Diagnostic Slack
Still on RTM: the retired transport and the next reinstall
The bot has been up for four years. It connects with rtm.connect, holds a websocket open, and has never once been the thing that broke. Nobody has touched it, which is the highest compliment this industry pays a service.
Then somebody adds a scope, or recreates the app after a workspace migration, and rtm.connect answers missing_scope with needed: client. There is no screen on which client can be granted to an app created after 2020. The transport did not break. It was withdrawn years ago, and this app has been living on a grandfather clause it never knew it had.
The Real Time Messaging API predates granular permissions and needs the classic client scope. Apps created before the 2020 migration hold coarse scopes like client, bot and read and can still use it. Apps created since hold namespaced scopes like channels:history, cannot request client at all, and get missing_scope the first time they try. Socket Mode is the supported replacement.
So the audit crosses three things a read-only token can establish: the vocabulary of the grant, from the X-OAuth-Scopes header; what the client opens, which you declare with --calls; and what is configured to take over, from settings.socket_mode_enabled and the subscribed events. The interesting state is not "RTM is deprecated" but cliff: code that opens an RTM session against a grant that can never authorise one. The script never calls rtm.connect, because that method mints a session, and a section that promises read-only scripts does not get to open connections to prove a point.
The problem in plain words
Nothing here fails gradually. RTM does not warn, does not deprecate at runtime, does not add a header telling you to move. It works exactly as well on the last day as on the first. The whole of the risk sits behind an event that is not a code change and is usually not even a deploy: somebody adds a scope, or an admin reinstalls the app, or a workspace is migrated into a Grid org and the app is recreated on the other side.
At that moment the grant is reissued under the current model, in the current vocabulary, and the current vocabulary has no client in it. rtm.connect starts answering missing_scope, and the team does what the error asks: they go to the app configuration to add the scope it named. It is not there. It cannot be added. That is the moment the four-year-old service becomes an unplanned migration, usually in the middle of whatever the reinstall was actually for.
The rewrite is also bigger than swapping a client library, and this is the part that gets underestimated. RTM delivered everything the token could see, implicitly, as a stream. Socket Mode and the Events API deliver only what you have subscribed to, event by event. So an app that moves the transport and not the subscriptions connects successfully, reports healthy, and receives nothing, which looks like a broken socket and is actually an empty bot_events list.
Why it happens
Working is not the same as supported, and only one of them is observable. There is no runtime signal that separates a transport Slack maintains from one it merely has not switched off. That is why this note is an audit rather than an incident: the finding has to be produced from configuration before the reinstall, because after the reinstall it produces itself.
The scope vocabulary dates the app precisely. A bare word like client or bot in X-OAuth-Scopes is a pre-2020 grant; namespaced scopes are the current model. The two do not mix, and the presence or absence of client tells you whether RTM is available to this token without any need to try it.
The cliff is the finding, not the deprecation. An old app on an old grant running RTM is stable. An app whose code opens RTM against a granular grant is already broken or one reinstall from it, and those two are different rows in a report. Collapsing them into "you use a deprecated API" wastes the reader's attention on the stable case.
RTM was implicit and its replacements are not. This is the single most expensive difference and it is invisible in a diff of the connection code. Socket Mode with an empty subscription list connects, stays connected, and delivers nothing, so the migration looks finished and the app is silent. The script reports that state by name rather than calling Socket Mode configured.
A probe here would be a write. Calling rtm.connect to see what it says opens a session against your workspace. It is the kind of thing that reads as harmless and is exactly the behaviour this section promises not to have, so the answer is derived from the grant instead. The scope header is not weaker evidence than the error string; it is the same fact, read earlier.
The fix, as a flow
The failure here sits on an event that is not a deploy and not a code change: somebody added a scope. That is the entire reason this one can be audited in advance, and the reason the chain reaches its error through four boxes in which nothing at all goes wrong. The fix branch separates stable from doomed, because an old app quietly running RTM and a modern app calling rtm.connect are the same deprecation and completely different mornings.
How to fix it
Read the grant vocabulary from the header
One auth.test and Slack returns X-OAuth-Scopes with the token's complete current grant. grant_era sorts it: a bare client, bot or read is the classic model, anything namespaced is the current one. That single distinction decides whether RTM is available at all.
Declare what your client actually opens
Pass --calls rtm.connect, or --calls apps.connections.open, or both, for the methods your code calls. No API can tell you this and pretending otherwise would be the wrong kind of confidence; it is one grep in your own repository and it is the input the rest of the audit turns on.
Find out what is configured to take over
replacement reads settings.socket_mode_enabled and the subscribed events from the manifest. Socket Mode with events is a destination. The Events API with events is a destination. Neither, and there is nothing for RTM to hand over to when the day comes.
Cross the three and let the cliff surface
transport_state is the whole report in one function. The state to act on is cliff: code that opens RTM against a granular grant, which cannot work and cannot be made to work by any change to the app configuration. Everything else is planning.
Watch for the socket that carries nothing
half-migrated is Socket Mode enabled with an empty bot_events list. The connection opens, the health check is green, and no event ever arrives, because RTM streamed everything implicitly and its replacements deliver only what you subscribed to.
Print the migration, not just the deprecation
Enable Socket Mode, mint an app-level token with connections:write, subscribe to the events the RTM stream was carrying implicitly, then swap the client. The script prints them in that order because reversing the last two is how an app arrives silent.
How to check it worked
After the cut-over, re-run with the new client declared. The grant should read granular, the replacement should name Socket Mode with a non-empty subscription list, and the transport should be clear.
python3 slack_rtm_migration_audit.py --app-id A01ABCDE9 --calls apps.connections.open
# identity U0APPBOT11 in acme
# grant granular every scope is namespaced, so rtm.connect is not available
# client socket the code opens a Socket Mode connection
# replace socket-mode Socket Mode is on and 4 event(s) are subscribed
# verdict clear the app is on socket-mode with a granular grant
The full code
Four pure functions and one read. grant_era dates the app from the shape of its scope names, which is the only fact here that does not need you to know anything about your own code. client_intent is the one input the API cannot supply, so it is declared rather than guessed. replacement is where the expensive detail lives, because Socket Mode with no subscriptions is not a destination. transport_state crosses all three, and cliff is the row worth waking somebody for.
"""Decide whether a Slack app is still riding RTM, and what happens if it is.
Read only, and specifically without opening anything. This never calls
rtm.connect: that method mints a session, and a section whose scripts promise
never to change your workspace does not get to open a connection to prove a
point. The same fact is available earlier, in the X-OAuth-Scopes header, which
says whether this token could authorise an RTM session at all.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_rtm_migration_audit")
API = "https://slack.com/api/"
# The classic vocabulary. A pre-2020 app holds bare words like these; RTM needs
# client, and no app created since the granular migration can request it.
CLASSIC_SCOPES = {"client", "bot", "read", "post", "identify", "admin"}
# Bare words that survived the migration and therefore date nothing.
NEUTRAL_SCOPES = {"commands", "incoming-webhook"}
RTM_METHODS = {"rtm.connect", "rtm.start"}
SOCKET_METHODS = {"apps.connections.open"}
# States that should fail the run. legacy-grant-idle and no-transport are real
# observations and neither is breaking anything today.
FAILING = ("cliff", "retired-transport", "dual-transport", "half-migrated",
"mixed-grant")
def grant_era(scopes):
"""Which permission model this token was issued under. Pure. -> (state, detail)
The vocabulary dates the app exactly, with no call to make and nothing to
open. A bare word is the old model; a colon is the new one.
"""
have = {str(s).strip() for s in scopes or [] if str(s).strip()}
if not have:
return ("unknown",
"no scopes were read, so the permission model of this token is "
"unknown and every conclusion below would be a guess")
classic = sorted(have & CLASSIC_SCOPES)
granular = sorted(s for s in have if ":" in s)
if classic and granular:
return ("mixed",
"the grant holds classic scopes (%s) and namespaced ones at the "
"same time. The two vocabularies are not supposed to coexist, so "
"read this twice before acting on it" % ", ".join(classic))
if classic:
return ("rtm-capable",
"the grant holds %s, which is the pre-2020 vocabulary. RTM is "
"available to this token and to no token issued today"
% ", ".join(classic))
if granular:
return ("granular",
"every scope is namespaced, so this is a modern app. rtm.connect "
"answers missing_scope with needed: client, and there is no screen "
"on which client can be granted")
named = ", ".join(sorted(have & NEUTRAL_SCOPES)) or "unrecognised scopes"
return ("unknown",
"the grant holds only %s, which survived the migration and says "
"nothing about which model this app was created under" % named)
def client_intent(calls):
"""What the client code opens, as declared by the caller. Pure.
No API reports which methods your code calls, and inferring it from workspace
state would be a guess dressed as a finding. One grep in your own repository
is the honest input.
"""
named = {str(c).strip().lower() for c in calls or [] if str(c).strip()}
rtm = bool(named & RTM_METHODS)
socket = bool(named & SOCKET_METHODS)
if rtm and socket:
return ("both",
"the code opens an RTM session and a Socket Mode connection")
if rtm:
return ("rtm", "the code opens an RTM session")
if socket:
return ("socket", "the code opens a Socket Mode connection")
if named:
return ("http",
"none of the methods named opens a persistent connection, so "
"events reach this app over HTTP if they reach it at all")
return ("unknown",
"no client methods were named. Pass --calls rtm.connect or --calls "
"apps.connections.open for what your code actually opens")
def replacement(socket_enabled, subscribed):
"""What is configured to take over from RTM. Pure. -> (state, detail)
RTM delivered everything the token could see, implicitly, as a stream. Both
replacements are subscription based, so an empty event list is not a
destination: it is a connection that will open and carry nothing.
"""
if socket_enabled is None and subscribed is None:
return ("unknown",
"no manifest was read, so what is configured to receive events is "
"unknown")
events = [str(e).strip() for e in subscribed or [] if str(e).strip()]
if socket_enabled and events:
return ("socket-mode",
"Socket Mode is on and %d event(s) are subscribed" % len(events))
if socket_enabled:
return ("socket-unsubscribed",
"Socket Mode is on and no events are subscribed. The socket will "
"connect and carry nothing, because RTM streamed everything "
"implicitly and Socket Mode does not")
if events:
return ("http-events",
"%d event(s) are subscribed for delivery over HTTP" % len(events))
return ("none",
"neither Socket Mode nor any event subscription is configured, so "
"there is nothing for RTM to hand over to")
def transport_state(era, intent, repl):
"""Cross the grant, the client and the configuration. Pure. -> (state, detail)
The state worth acting on is cliff. An old app on an old grant running RTM is
stable and has been for years; an app whose code opens RTM against a granular
grant is already broken or one reinstall away, and reporting those two at the
same volume wastes the only attention this report will get.
"""
if era == "unknown" or intent == "unknown":
return ("unknown",
"the grant or the client is unknown, so no transport conclusion "
"is available")
if era == "mixed":
return ("mixed-grant",
"classic and granular scopes on one token. Establish which app "
"this token belongs to before reading anything else here")
if intent == "rtm" and era == "granular":
return ("cliff",
"the code opens an RTM session and the token holds only granular "
"scopes. rtm.connect answers missing_scope with needed: client, "
"and no reinstall can grant client to a modern app")
if intent == "both":
return ("dual-transport",
"the code opens RTM and a socket at once. Both carry the same "
"events, so everything subscribed is handled twice for as long as "
"the cut-over lasts")
if repl == "socket-unsubscribed":
return ("half-migrated",
"the transport moved and the subscriptions did not. The socket "
"connects, the health check passes, and no event arrives")
if intent == "rtm" and repl in ("none", "unknown"):
return ("retired-transport",
"the app runs on RTM with nothing configured to take over. It "
"works today on a grandfather clause and stops on the day the app "
"is recreated or its scopes change")
if intent == "rtm":
return ("migration-ready",
"the app still opens RTM and %s is already configured. What is "
"left is the client, not the configuration" % repl)
if era == "rtm-capable":
return ("legacy-grant-idle",
"the token still holds classic scopes and nothing here opens an "
"RTM session. That is a grant to tidy rather than a transport to "
"migrate")
if repl == "none":
return ("no-transport",
"nothing opens a connection and nothing is subscribed, so no "
"event reaches this app by any route")
return ("clear",
"the app is on %s with a granular grant, which is where RTM was "
"going" % repl)
def get(session, method, params, label):
"""One GET, asserting on the body rather than the status line."""
r = session.get(API + method, params=params or {}, timeout=30)
body = r.json()
if body.get("ok") is not True:
log.warning("%-10s %-14s %s", label, "unavailable", body.get("error"))
return None, r.headers.get("X-OAuth-Scopes", "")
return body, r.headers.get("X-OAuth-Scopes", "")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
ap.add_argument("--config-token-env", default="SLACK_CONFIG_TOKEN",
help="environment variable holding an app configuration token")
ap.add_argument("--app-id", default="", help="app ID, for the manifest read")
ap.add_argument("--calls", action="append", default=[],
help="a method your client code calls, such as rtm.connect or "
"apps.connections.open; repeatable")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s; any read scope works, the header is what matters",
args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
who, header = get(s, "auth.test", {}, "auth.test")
if not who:
return 2
scopes = {p.strip() for p in str(header or "").split(",") if p.strip()}
log.info("identity %s in %s", who.get("user_id"), who.get("team"))
era, era_detail = grant_era(scopes)
log.info("grant %-14s %s", era, era_detail)
intent, intent_detail = client_intent(args.calls)
log.info("client %-14s %s", intent, intent_detail)
socket_enabled, subscribed = None, None
config_token = os.environ.get(args.config_token_env)
if config_token and args.app_id:
c = requests.Session()
c.headers.update({"Authorization": "Bearer " + config_token})
body, _ = get(c, "apps.manifest.export", {"app_id": args.app_id}, "manifest")
if body:
settings = ((body or {}).get("manifest") or {}).get("settings") or {}
socket_enabled = bool(settings.get("socket_mode_enabled"))
subscribed = list(
(settings.get("event_subscriptions") or {}).get("bot_events") or [])
repl, repl_detail = replacement(socket_enabled, subscribed)
log.info("replace %-14s %s", repl, repl_detail)
state, detail = transport_state(era, intent, repl)
if state in FAILING:
log.warning("verdict %-14s %s", state, detail)
log.warning(" repair: enable Socket Mode and mint an app-level token with "
"connections:write on the Basic Information page")
log.warning(" repair: subscribe to the events the RTM stream was carrying "
"implicitly, before you swap the client. Reversing those two is "
"how an app arrives connected and silent")
log.warning(" repair: then move the client to SocketModeClient, or to the "
"Events API with a public Request URL if you would rather not "
"hold a connection open")
return 1
log.info("verdict %-14s %s", state, detail)
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Decide whether a Slack app is still riding RTM, and what happens if it is.
*
* Read only, and specifically without opening anything. This never calls
* rtm.connect: that method mints a session, and a section whose scripts promise
* never to change your workspace does not get to open a connection to prove a
* point. The same fact is available earlier, in the X-OAuth-Scopes header.
*/
const API = 'https://slack.com/api/';
// The classic vocabulary. A pre-2020 app holds bare words like these; RTM needs
// client, and no app created since the granular migration can request it.
const CLASSIC_SCOPES = new Set(['client', 'bot', 'read', 'post', 'identify',
'admin']);
// Bare words that survived the migration and therefore date nothing.
const NEUTRAL_SCOPES = new Set(['commands', 'incoming-webhook']);
const RTM_METHODS = new Set(['rtm.connect', 'rtm.start']);
const SOCKET_METHODS = new Set(['apps.connections.open']);
const FAILING = ['cliff', 'retired-transport', 'dual-transport', 'half-migrated',
'mixed-grant'];
/**
* Which permission model this token was issued under. Pure.
* A bare word is the old model; a colon is the new one.
*/
export function grantEra(scopes) {
const have = new Set([...(scopes ?? [])].map((s) => String(s).trim())
.filter(Boolean));
if (!have.size) {
return ['unknown',
'no scopes were read, so the permission model of this token is unknown and ' +
'every conclusion below would be a guess'];
}
const classic = [...have].filter((s) => CLASSIC_SCOPES.has(s)).sort();
const granular = [...have].filter((s) => s.includes(':')).sort();
if (classic.length && granular.length) {
return ['mixed',
`the grant holds classic scopes (${classic.join(', ')}) and namespaced ones ` +
'at the same time. The two vocabularies are not supposed to coexist, so read ' +
'this twice before acting on it'];
}
if (classic.length) {
return ['rtm-capable',
`the grant holds ${classic.join(', ')}, which is the pre-2020 vocabulary. ` +
'RTM is available to this token and to no token issued today'];
}
if (granular.length) {
return ['granular',
'every scope is namespaced, so this is a modern app. rtm.connect answers ' +
'missing_scope with needed: client, and there is no screen on which client ' +
'can be granted'];
}
const neutral = [...have].filter((s) => NEUTRAL_SCOPES.has(s)).sort();
return ['unknown',
`the grant holds only ${neutral.join(', ') || 'unrecognised scopes'}, which ` +
'survived the migration and says nothing about which model this app was ' +
'created under'];
}
/**
* What the client code opens, as declared by the caller. Pure.
* No API reports which methods your code calls, and inferring it from workspace
* state would be a guess dressed as a finding.
*/
export function clientIntent(calls) {
const named = new Set((calls ?? []).map((c) => String(c).trim().toLowerCase())
.filter(Boolean));
const rtm = [...named].some((m) => RTM_METHODS.has(m));
const socket = [...named].some((m) => SOCKET_METHODS.has(m));
if (rtm && socket) {
return ['both', 'the code opens an RTM session and a Socket Mode connection'];
}
if (rtm) return ['rtm', 'the code opens an RTM session'];
if (socket) return ['socket', 'the code opens a Socket Mode connection'];
if (named.size) {
return ['http',
'none of the methods named opens a persistent connection, so events reach ' +
'this app over HTTP if they reach it at all'];
}
return ['unknown',
'no client methods were named. Pass --calls rtm.connect or --calls ' +
'apps.connections.open for what your code actually opens'];
}
/**
* What is configured to take over from RTM. Pure.
* RTM delivered everything the token could see, implicitly. Both replacements
* are subscription based, so an empty event list is not a destination.
*/
export function replacement(socketEnabled, subscribed) {
if ((socketEnabled === null || socketEnabled === undefined)
&& (subscribed === null || subscribed === undefined)) {
return ['unknown',
'no manifest was read, so what is configured to receive events is unknown'];
}
const events = (subscribed ?? []).map((e) => String(e).trim()).filter(Boolean);
if (socketEnabled && events.length) {
return ['socket-mode',
`Socket Mode is on and ${events.length} event(s) are subscribed`];
}
if (socketEnabled) {
return ['socket-unsubscribed',
'Socket Mode is on and no events are subscribed. The socket will connect ' +
'and carry nothing, because RTM streamed everything implicitly and Socket ' +
'Mode does not'];
}
if (events.length) {
return ['http-events',
`${events.length} event(s) are subscribed for delivery over HTTP`];
}
return ['none',
'neither Socket Mode nor any event subscription is configured, so there is ' +
'nothing for RTM to hand over to'];
}
/**
* Cross the grant, the client and the configuration. Pure.
* The state worth acting on is cliff: an old app on an old grant running RTM is
* stable, and an app whose code opens RTM against a granular grant is not.
*/
export function transportState(era, intent, repl) {
if (era === 'unknown' || intent === 'unknown') {
return ['unknown',
'the grant or the client is unknown, so no transport conclusion is available'];
}
if (era === 'mixed') {
return ['mixed-grant',
'classic and granular scopes on one token. Establish which app this token ' +
'belongs to before reading anything else here'];
}
if (intent === 'rtm' && era === 'granular') {
return ['cliff',
'the code opens an RTM session and the token holds only granular scopes. ' +
'rtm.connect answers missing_scope with needed: client, and no reinstall can ' +
'grant client to a modern app'];
}
if (intent === 'both') {
return ['dual-transport',
'the code opens RTM and a socket at once. Both carry the same events, so ' +
'everything subscribed is handled twice for as long as the cut-over lasts'];
}
if (repl === 'socket-unsubscribed') {
return ['half-migrated',
'the transport moved and the subscriptions did not. The socket connects, ' +
'the health check passes, and no event arrives'];
}
if (intent === 'rtm' && (repl === 'none' || repl === 'unknown')) {
return ['retired-transport',
'the app runs on RTM with nothing configured to take over. It works today ' +
'on a grandfather clause and stops on the day the app is recreated or its ' +
'scopes change'];
}
if (intent === 'rtm') {
return ['migration-ready',
`the app still opens RTM and ${repl} is already configured. What is left is ` +
'the client, not the configuration'];
}
if (era === 'rtm-capable') {
return ['legacy-grant-idle',
'the token still holds classic scopes and nothing here opens an RTM session. ' +
'That is a grant to tidy rather than a transport to migrate'];
}
if (repl === 'none') {
return ['no-transport',
'nothing opens a connection and nothing is subscribed, so no event reaches ' +
'this app by any route'];
}
return ['clear',
`the app is on ${repl} with a granular grant, which is where RTM was going`];
}
async function get(token, method, params, label) {
const qs = new URLSearchParams(params ?? {});
const res = await fetch(`${API}${method}?${qs}`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
if (body.ok !== true) {
console.warn(`${label.padEnd(10)} ${'unavailable'.padEnd(14)} ${body.error}`);
return [null, res.headers.get('x-oauth-scopes') ?? ''];
}
return [body, res.headers.get('x-oauth-scopes') ?? ''];
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
function argAll(args, name) {
const out = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
}
return out;
}
async function main() {
const args = process.argv.slice(2);
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv}; any read scope works, the header is what matters`);
process.exitCode = 2;
return;
}
const configTokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_TOKEN');
const appId = arg(args, '--app-id', '');
const [who, header] = await get(token, 'auth.test', {}, 'auth.test');
if (!who) { process.exitCode = 2; return; }
const scopes = String(header ?? '').split(',').map((p) => p.trim()).filter(Boolean);
console.log(`identity ${who.user_id} in ${who.team}`);
const [era, eraDetail] = grantEra(scopes);
console.log(`grant ${era.padEnd(14)} ${eraDetail}`);
const [intent, intentDetail] = clientIntent(argAll(args, '--calls'));
console.log(`client ${intent.padEnd(14)} ${intentDetail}`);
let socketEnabled = null;
let subscribed = null;
const configToken = process.env[configTokenEnv];
if (configToken && appId) {
const [body] = await get(configToken, 'apps.manifest.export', { app_id: appId },
'manifest');
if (body) {
const settings = body?.manifest?.settings ?? {};
socketEnabled = Boolean(settings.socket_mode_enabled);
subscribed = settings.event_subscriptions?.bot_events ?? [];
}
}
const [repl, replDetail] = replacement(socketEnabled, subscribed);
console.log(`replace ${repl.padEnd(14)} ${replDetail}`);
const [state, detail] = transportState(era, intent, repl);
if (FAILING.includes(state)) {
console.warn(`verdict ${state.padEnd(14)} ${detail}`);
console.warn(' repair: enable Socket Mode and mint an app-level token with ' +
'connections:write on the Basic Information page');
console.warn(' repair: subscribe to the events the RTM stream was carrying ' +
'implicitly, before you swap the client. Reversing those two is how an app ' +
'arrives connected and silent');
console.warn(' repair: then move the client to SocketModeClient, or to the ' +
'Events API with a public Request URL if you would rather not hold a ' +
'connection open');
process.exitCode = 1;
} else {
console.log(`verdict ${state.padEnd(14)} ${detail}`);
}
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The tests defend the two distinctions this note is built on. A classic app quietly running RTM has to come back as stable, and a modern app whose code calls rtm.connect has to come back as cliff, because treating those two the same is what turns a useful audit into a deprecation notice nobody reads. And Socket Mode with an empty subscription list has to be half-migrated rather than migrated, since that is the state where every dashboard is green and nothing arrives.
from slack_rtm_migration_audit import (client_intent, grant_era, replacement,
transport_state)
def test_a_bare_client_scope_dates_the_app_to_the_classic_model():
state, detail = grant_era(["client", "bot"])
assert state == "rtm-capable"
assert "pre-2020" in detail
def test_namespaced_scopes_are_the_modern_model_and_cannot_reach_rtm():
state, detail = grant_era(["channels:history", "chat:write"])
assert state == "granular"
assert "needed: client" in detail
def test_both_vocabularies_at_once_is_reported_rather_than_resolved():
state, detail = grant_era(["client", "channels:history"])
assert state == "mixed"
assert "read this twice" in detail
def test_scopes_that_survived_the_migration_date_nothing():
assert grant_era(["commands", "incoming-webhook"])[0] == "unknown"
assert grant_era([])[0] == "unknown"
def test_the_client_is_declared_and_never_inferred():
assert client_intent(["rtm.connect"])[0] == "rtm"
assert client_intent(["rtm.start"])[0] == "rtm"
assert client_intent(["apps.connections.open"])[0] == "socket"
assert client_intent(["rtm.connect", "apps.connections.open"])[0] == "both"
assert client_intent(["chat.postMessage"])[0] == "http"
assert client_intent([])[0] == "unknown"
def test_socket_mode_with_subscriptions_is_a_destination():
state, detail = replacement(True, ["app_mention", "message.channels"])
assert state == "socket-mode"
assert "2 event(s)" in detail
def test_socket_mode_with_no_subscriptions_is_not_a_destination():
state, detail = replacement(True, [])
assert state == "socket-unsubscribed"
assert "connect and carry nothing" in detail
def test_subscriptions_without_socket_mode_are_the_http_route():
assert replacement(False, ["app_mention"])[0] == "http-events"
def test_neither_leaves_rtm_with_nowhere_to_go():
assert replacement(False, [])[0] == "none"
assert replacement(None, None)[0] == "unknown"
def test_rtm_code_on_a_granular_grant_is_the_cliff():
state, detail = transport_state("granular", "rtm", "socket-mode")
assert state == "cliff"
assert "no reinstall can grant client" in detail
def test_a_classic_app_on_rtm_with_no_replacement_is_a_plan_not_an_outage():
state, detail = transport_state("rtm-capable", "rtm", "none")
assert state == "retired-transport"
assert "grandfather clause" in detail
def test_a_classic_app_on_rtm_with_socket_mode_ready_is_a_client_swap():
assert transport_state("rtm-capable", "rtm", "socket-mode")[0] == "migration-ready"
def test_two_transports_at_once_deliver_everything_twice():
state, detail = transport_state("rtm-capable", "both", "socket-mode")
assert state == "dual-transport"
assert "handled twice" in detail
def test_a_socket_with_no_subscriptions_beats_the_other_readings():
assert transport_state("granular", "socket", "socket-unsubscribed")[0] == \
"half-migrated"
def test_a_classic_grant_with_no_rtm_client_is_the_scope_note_next_door():
state, detail = transport_state("rtm-capable", "http", "http-events")
assert state == "legacy-grant-idle"
assert "grant to tidy" in detail
def test_nothing_connecting_and_nothing_subscribed_receives_nothing():
assert transport_state("granular", "http", "none")[0] == "no-transport"
def test_a_migrated_app_is_clear():
state, detail = transport_state("granular", "socket", "socket-mode")
assert state == "clear"
assert "where RTM was going" in detail
def test_an_undeclared_client_produces_no_conclusion():
assert transport_state("granular", "unknown", "socket-mode")[0] == "unknown"
assert transport_state("unknown", "rtm", "none")[0] == "unknown"
def test_a_mixed_grant_is_settled_before_anything_else_is_read():
assert transport_state("mixed", "rtm", "none")[0] == "mixed-grant"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
clientIntent, grantEra, replacement, transportState,
} from './slack-rtm-migration-audit.mjs';
test('a bare client scope dates the app to the classic model', () => {
const [state, detail] = grantEra(['client', 'bot']);
assert.equal(state, 'rtm-capable');
assert.match(detail, /pre-2020/);
});
test('namespaced scopes are the modern model and cannot reach rtm', () => {
const [state, detail] = grantEra(['channels:history', 'chat:write']);
assert.equal(state, 'granular');
assert.match(detail, /needed: client/);
});
test('both vocabularies at once is reported rather than resolved', () => {
const [state, detail] = grantEra(['client', 'channels:history']);
assert.equal(state, 'mixed');
assert.match(detail, /read this twice/);
});
test('scopes that survived the migration date nothing', () => {
assert.equal(grantEra(['commands', 'incoming-webhook'])[0], 'unknown');
assert.equal(grantEra([])[0], 'unknown');
});
test('the client is declared and never inferred', () => {
assert.equal(clientIntent(['rtm.connect'])[0], 'rtm');
assert.equal(clientIntent(['rtm.start'])[0], 'rtm');
assert.equal(clientIntent(['apps.connections.open'])[0], 'socket');
assert.equal(clientIntent(['rtm.connect', 'apps.connections.open'])[0], 'both');
assert.equal(clientIntent(['chat.postMessage'])[0], 'http');
assert.equal(clientIntent([])[0], 'unknown');
});
test('socket mode with subscriptions is a destination', () => {
const [state, detail] = replacement(true, ['app_mention', 'message.channels']);
assert.equal(state, 'socket-mode');
assert.match(detail, /2 event\(s\)/);
});
test('socket mode with no subscriptions is not a destination', () => {
const [state, detail] = replacement(true, []);
assert.equal(state, 'socket-unsubscribed');
assert.match(detail, /connect and carry nothing/);
});
test('subscriptions without socket mode are the http route', () => {
assert.equal(replacement(false, ['app_mention'])[0], 'http-events');
});
test('neither leaves rtm with nowhere to go', () => {
assert.equal(replacement(false, [])[0], 'none');
assert.equal(replacement(null, null)[0], 'unknown');
});
test('rtm code on a granular grant is the cliff', () => {
const [state, detail] = transportState('granular', 'rtm', 'socket-mode');
assert.equal(state, 'cliff');
assert.match(detail, /no reinstall can grant client/);
});
test('a classic app on rtm with no replacement is a plan not an outage', () => {
const [state, detail] = transportState('rtm-capable', 'rtm', 'none');
assert.equal(state, 'retired-transport');
assert.match(detail, /grandfather clause/);
});
test('a classic app on rtm with socket mode ready is a client swap', () => {
assert.equal(transportState('rtm-capable', 'rtm', 'socket-mode')[0],
'migration-ready');
});
test('two transports at once deliver everything twice', () => {
const [state, detail] = transportState('rtm-capable', 'both', 'socket-mode');
assert.equal(state, 'dual-transport');
assert.match(detail, /handled twice/);
});
test('a socket with no subscriptions beats the other readings', () => {
assert.equal(transportState('granular', 'socket', 'socket-unsubscribed')[0],
'half-migrated');
});
test('a classic grant with no rtm client is the scope note next door', () => {
const [state, detail] = transportState('rtm-capable', 'http', 'http-events');
assert.equal(state, 'legacy-grant-idle');
assert.match(detail, /grant to tidy/);
});
test('nothing connecting and nothing subscribed receives nothing', () => {
assert.equal(transportState('granular', 'http', 'none')[0], 'no-transport');
});
test('a migrated app is clear', () => {
const [state, detail] = transportState('granular', 'socket', 'socket-mode');
assert.equal(state, 'clear');
assert.match(detail, /where RTM was going/);
});
test('an undeclared client produces no conclusion', () => {
assert.equal(transportState('granular', 'unknown', 'socket-mode')[0], 'unknown');
assert.equal(transportState('unknown', 'rtm', 'none')[0], 'unknown');
});
test('a mixed grant is settled before anything else is read', () => {
assert.equal(transportState('mixed', 'rtm', 'none')[0], 'mixed-grant');
});
FAQ
RTM still works for us. Is this actually urgent?
For a classic app on a classic grant, no, and the script says so by returning retired-transport rather than an error. It works and will keep working until something reissues the grant. What makes it worth scheduling is that the trigger is not a code change: adding a scope, reinstalling, or recreating the app after a workspace migration all reissue it, and at that point the migration happens on somebody else's timetable rather than yours.
Why can I not just add the client scope the error asks for?
Because client belongs to the pre-2020 permission model and the app configuration screens for a modern app do not offer it. Slack kept RTM working for apps that already held those coarse scopes rather than making it grantable again. If your grant is namespaced, the error naming client is telling you which era the method belongs to, not which checkbox to tick.
Is Socket Mode just RTM with a new name?
No, and the difference is the part that costs time. RTM delivered everything the token could see as a single implicit stream. Socket Mode delivers only the events you have subscribed to, in the Events API payload shape, over a connection you obtain with an app-level token holding connections:write. The transport is similar; the delivery model, the payloads and the setup are not.
The socket connects and nothing arrives. What did we miss?
The subscriptions, almost certainly. That is the half-migrated state: Socket Mode is enabled, the client connects, every health check is green, and bot_events is empty, so there is nothing for the socket to carry. RTM never made you list events, so a team migrating the transport has no reason to think a list is missing. Subscribe first, then swap the client.
Why does this script not just call rtm.connect and read the error?
Because rtm.connect mints a session. It is a small write rather than a large one, but it is still the script changing something on your account to answer a question, and every script in this section promises not to. The scope header carries the same fact earlier: a token with no client scope could not have opened that session, so there is nothing the call would add except a connection nobody asked for.
Related field notes
- the scope vocabulary that makes RTM possible at all
- the credential the replacement needs
- what RTM delivered implicitly and its replacements do not
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Legacy RTM API - Slack Docs
- Using Socket Mode - Slack Docs
- Scopes - Slack Docs
- apps.manifest.export method reference - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.