Diagnostic Slack
method_deprecated: still on channels.*, groups.* and im.*
Nothing broke. That is the whole difficulty. The integration was written in 2019 against a tutorial that was already a year old, it calls channels.list and channels.history, and it has run every fifteen minutes for six years without a single error. Then one morning it returns nothing at all, and the body says {"ok": false, "error": "method_deprecated"}.
The notice was there. It had been there for months, on responses that said ok: true and returned exactly the data they always returned, in a field nobody logs: warning. Slack tells you a method is going away by attaching a string to a successful response, and the entire industry standard for handling a successful response is to read the payload and throw the envelope away.
Slack replaced the per-type method families with one unified family. channels.*, groups.*, im.* and mpim.* all became conversations.*, distinguished by a types parameter rather than by the method name. rtm.start gave way to rtm.connect and then to Socket Mode. files.upload was replaced by a three-call external-upload sequence. Code written from an old tutorial starts on the retired path and stays there.
There are three states, and only one of them is an error. A method that has been removed answers unknown_method. A method that is dead answers method_deprecated or deprecated_endpoint. A method that is scheduled answers ok: true with a warning field or entries in response_metadata.warnings[] — and that third state is the only one you can act on before an outage rather than during one.
Two cautions shape the script below. Not every warning is a deprecation: missing_charset and superfluous_charset are about the Content-Type header you sent and say nothing about the method's future, so a sweep that counts them reports that everything is dying. And a deprecated write is never issued to find out whether it still works. files.upload, im.open, channels.create and rtm.start are named here, mapped to their replacements, and left alone.
The problem in plain words
The reason this is a whole-surface problem rather than a single-method one is that the migration was a family migration. If your code calls channels.list it almost certainly also calls channels.info, channels.history and probably groups.list for the private ones, because that is how the old API forced you to write it: one method per conversation type, four times over, with a branch to pick between them. The replacement collapses all of that into conversations.list with types=public_channel,private_channel,im,mpim. Finding one legacy call almost always means finding six.
The staged retirement is what turns this into a surprise. A method does not usually flip from working to dead. It works, then it works and warns, then it warns more loudly, then it fails — and the middle two stages are invisible to any client that treats HTTP 200 as done. The advance notice is delivered on the successful response, which is the one place production code is guaranteed not to look, because the successful response is the one where nothing needs investigating.
The families also diverge in behaviour before they diverge in availability, which produces bugs that look nothing like a deprecation. conversations.list paginates by cursor and defaults to public channels only; channels.list returned a big array. An app that migrates the method name and not the pagination gets the first hundred channels and silently stops. An app that migrates channels.list and forgets groups.list stops seeing private channels entirely, with no error, because the replacement simply was not asked for that type.
And there is a category boundary that catches teams mid-migration. Some of these methods are reads and can be checked by asking. Others are writes: im.open opens a conversation, channels.create creates a channel, files.upload uploads a file, rtm.start and rtm.connect mint a session. There is no read-only way to ask a write method whether it still works, because the only way to ask is to do it. Every audit script that "just tries them all" is quietly writing to the workspace it was hired to inspect.
Why it happens
The scheduled state is the finding; the dead state is the incident. By the time method_deprecated comes back, the outage has already happened and you do not need a script to find it. What a sweep is for is the response that succeeded and warned. So this check treats ok: true plus a deprecation warning as a finding of equal weight to an outright failure, and prints the migration for it in exactly the same words.
A warning is not automatically a deprecation, and conflating them makes the check useless. missing_charset and superfluous_charset arrive constantly on perfectly healthy calls and are about the request's Content-Type. A sweep that reports them as scheduled outages produces a list on which everything is on fire, which is the same information as a list on which nothing is. The filter is small, explicit, and the reason the output can be trusted.
Both warning channels are read, because Slack uses both. body.warning is a single string; body.response_metadata.warnings[] is an array, and structured deprecation messages arrive there. Reading only the first is how a sweep comes back clean on an app with a dated retirement notice sitting in the array.
Deprecated writes are named and never called, and that is not squeamishness. Calling im.open to see whether it still exists opens a DM. Calling channels.create creates a channel with whatever name the probe made up. Calling rtm.connect mints a session against your connection budget. The map in this script records, for every legacy method, both its replacement and whether issuing it is a read, and only the reads are issued. The rest are reported from the map.
Two of these methods have notes of their own and are handed over rather than duplicated. files.upload was sunset on its own timetable and its replacement is a three-call sequence with its own failure modes; the RTM transport requires a classic scope a modern app cannot request. Both are printed here with a pointer, because a sweep should tell you they are in your code, and neither is explained here, because a pointer is more useful than a summary.
The replacement is printed as a mapping, not as advice. “Migrate to the Conversations API” is not actionable at four in the afternoon. channels.history -> conversations.history is. Every row the script prints carries the exact method that replaces it and, where the parameters changed, the parameter that has to come with it.
The fix, as a flow
The red arrow is between two boxes that both look like successes, which is the whole character of this one. The call worked. The data came back. Slack attached a date to the response and the client did what every client does with a successful response, which is read the payload and throw the envelope away. Everything to the right of that arrow is the outage arriving on time, months later, with no deploy to bisect and no change to point at. The fix branch sorts methods rather than errors, and the row that matters most is the third: a deprecated write is named and never issued, because there is no way to ask a write method whether it still works except by doing it.
How to fix it
Take the method list from your own code, not from a guess
Pass --methods with the legacy names your source actually contains, from a grep for slack.com/api/ or for your SDK's call sites. With no argument the script sweeps the whole known map, which is a fine first pass and will report methods you never call as live.
Decide, per method, whether the sweep is allowed to issue it
probe_safety answers probe, report-only or needs-argument. A deprecated write is always report-only: named, mapped, never called. A read that needs a channel id is needs-argument until you pass --channel, because a probe without one returns channel_not_found and tells you nothing about the method.
Read three fields on every response, two of which are on successes
classify returns removed, dead, scheduled, live, not-assessed or failed. scheduled is the one to act on: the call worked, the data came back, and a warning says it will not next quarter.
Filter the charset noise out of the warnings
deprecation_warnings reads body.warning and body.response_metadata.warnings[] together and drops missing_charset and superfluous_charset. Those two are about the header you sent. Leaving them in turns a sweep of twenty methods into twenty findings.
Migrate the family, not the method
migration_for prints the replacement and the parameter that has to come with it: conversations.list needs types, and the four legacy list methods collapse into one call with four values. Migrating channels.list alone and leaving groups.list behind loses every private channel silently.
Log the warning field permanently, at WARN
The repair that stops this recurring is one line in your HTTP client: if body.warning or body.response_metadata.warnings is non-empty, log it. That is the only channel Slack has for telling you about the next retirement, and it costs nothing to keep open.
How to check it worked
Migrate, deploy, and run the sweep again with the same --methods list. Every row should read live, and the verdict line should be clean.
python3 slack_deprecated_methods.py \
--methods channels.list,groups.list,channels.history,im.open,files.upload
# method channels.list dead method_deprecated
# migrate to conversations.list, with types=public_channel
# method groups.list scheduled ok true, and warning: method_deprecated
# migrate to conversations.list, with types=private_channel
# method channels.history needs-argument
# note pass --channel C... to probe the history methods
# method im.open report-only this method writes; it opens a conversation,
# so the sweep names it and does not call it
# migrate to conversations.open
# method files.upload report-only this method writes; the sweep names it and
# does not call it
# see also /slack/files-upload-retired/
# verdict 2 finding(s) 1 dead, 1 scheduled, 2 named but not probed
# repair: migrate the whole family at once; the four legacy list methods are one
# conversations.list call with types=public_channel,private_channel,im,mpim
# repair: log body.warning and body.response_metadata.warnings at WARN, permanently
# note: no deprecated write was issued to establish any of this
The full code
The map is the script. replacement_for holds every legacy method against what replaced it and against whether issuing it is a read, and probe_safety is the guard that reads the second half of that pair: a deprecated write is named and never called. deprecation_warnings pulls both warning channels together and drops the two charset warnings that mean nothing here. classify turns one response into one of six states, of which scheduled is the valuable one, and sweep_verdict refuses to report clean on a sweep where most rows were never assessed.
"""Find the deprecated Slack methods your app still calls.
Read only, and deliberately stricter than the question requires. The map below
records, for every legacy method, both its replacement and whether issuing it
is a read. Only the reads are ever issued. files.upload, im.open,
channels.create, mpim.open and the RTM session methods are deprecated writes:
they are named here, mapped to their replacements, and never called, because
"does it still work?" cannot be asked of a write method without performing it.
Three fields carry the answer and two of them ride on responses that
succeeded. body.error holds method_deprecated once a method is dead;
body.warning and body.response_metadata.warnings[] hold the notice while it
still works. The second pair is the whole point of running this: it is the only
form of this finding that arrives before the outage rather than during it.
"""
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_deprecated_methods")
API = "https://slack.com/api/"
# Legacy method -> (replacement, kind). The kind is the safety half of the map
# and the reason this script can promise it never writes: "write" methods are
# reported from the map and never issued.
REPLACEMENTS = {
"channels.list": ("conversations.list, with types=public_channel", "read"),
"channels.info": ("conversations.info", "read"),
"channels.history": ("conversations.history", "read"),
"channels.replies": ("conversations.replies", "read"),
"channels.create": ("conversations.create", "write"),
"channels.invite": ("conversations.invite", "write"),
"channels.join": ("conversations.join", "write"),
"channels.archive": ("conversations.archive", "write"),
"groups.list": ("conversations.list, with types=private_channel", "read"),
"groups.info": ("conversations.info", "read"),
"groups.history": ("conversations.history", "read"),
"groups.replies": ("conversations.replies", "read"),
"groups.create": ("conversations.create, with is_private=true", "write"),
"im.list": ("conversations.list, with types=im", "read"),
"im.history": ("conversations.history", "read"),
"im.replies": ("conversations.replies", "read"),
"im.open": ("conversations.open", "write"),
"im.close": ("conversations.close", "write"),
"mpim.list": ("conversations.list, with types=mpim", "read"),
"mpim.history": ("conversations.history", "read"),
"mpim.open": ("conversations.open, with a comma separated users list", "write"),
"files.upload": ("files.getUploadURLExternal, then a plain upload, then "
"files.completeUploadExternal", "write"),
"rtm.start": ("Socket Mode", "write"),
"rtm.connect": ("Socket Mode", "write"),
}
# The methods that answer with no arguments at all. Everything else in the read
# half of the map needs a channel id, and probing it without one returns
# channel_not_found, which says nothing whatever about the method.
ARG_FREE = ("channels.list", "groups.list", "im.list", "mpim.list")
# Two methods here are large enough to have their own note. A sweep should tell
# you they are in your code; it should not try to explain them in a column.
OWNED_ELSEWHERE = {
"files.upload": "/slack/files-upload-retired/",
"rtm.start": "/slack/rtm-legacy-still-used/",
"rtm.connect": "/slack/rtm-legacy-still-used/",
}
# A warning field is not automatically a deprecation notice. These two are
# about the Content-Type header on the request you sent and say nothing about
# the method's future. Counting them produces a sweep on which every method is
# dying, which carries the same information as one on which none of them is.
NOISE_WARNINGS = ("missing_charset", "superfluous_charset")
DEAD_ERRORS = ("method_deprecated", "deprecated_endpoint")
UNASSESSABLE = ("missing_scope", "not_allowed_token_type", "invalid_auth",
"ratelimited", "token_revoked", "account_inactive")
def replacement_for(method):
"""What replaced this method, and whether issuing it is a read. Pure.
Returns (replacement, kind); kind is "read", "write" or "unknown". An
unknown method is not assumed safe: it is reported and not issued.
"""
entry = REPLACEMENTS.get(str(method or "").strip())
if not entry:
return (None, "unknown")
return entry
def probe_safety(method, channel_available=False):
"""May this sweep issue this method itself? Pure.
Returns (action, why); action is "probe", "report-only" or
"needs-argument". This is the guard the read-only promise rests on, so it
is a function with tests rather than a comment in the request loop.
"""
name = str(method or "").strip()
_replacement, kind = replacement_for(name)
if kind == "write":
return ("report-only", "this method writes; the sweep names it and does "
"not call it")
if kind == "unknown":
return ("report-only", "this method is not in the map, so the sweep will "
"not issue it on the chance that it is a read")
if name in ARG_FREE:
return ("probe", "a read that answers with no arguments")
if channel_available:
return ("probe", "a read, probed against the channel you passed")
return ("needs-argument", "pass --channel C... to probe the info and history "
"methods; without one the answer is channel_not_found "
"and says nothing about the method")
def deprecation_warnings(body):
"""The warnings in a response that are actually about deprecation. Pure.
Reads both channels, because Slack uses both: body.warning is one string
and body.response_metadata.warnings is an array carrying the structured
notices. Drops the two charset warnings, which are about the request header
and arrive constantly on healthy calls.
"""
doc = body or {}
raw = [doc.get("warning")]
raw.extend((doc.get("response_metadata") or {}).get("warnings") or [])
out = []
for item in raw:
text = str(item or "").strip()
if text and text not in NOISE_WARNINGS:
out.append(text)
return list(dict.fromkeys(out))
def classify(body):
"""Read one response body as a deprecation state. Pure.
Returns (state, detail). Six states, and the third is the one worth having:
removed unknown_method; the name is gone from the API entirely.
dead method_deprecated or deprecated_endpoint.
scheduled ok true, and a deprecation warning rides along with it.
live ok true and nothing said.
not-assessed the call was refused for a reason unrelated to deprecation,
so this method's state is simply not known.
failed any other ok false, which is a different note.
"""
doc = body or {}
error = str(doc.get("error") or "")
if doc.get("ok") is True:
warnings = deprecation_warnings(doc)
if warnings:
return ("scheduled", "ok true, and warning: %s" % ", ".join(warnings))
return ("live", "ok true, and no deprecation warning")
if error == "unknown_method":
return ("removed", "unknown_method: the name is gone from the API")
if error in DEAD_ERRORS:
return ("dead", error)
if error in UNASSESSABLE:
return ("not-assessed", "%s, which is about the token rather than the "
"method" % error)
if not error:
return ("not-assessed", "no ok field and no error, so nothing can be read "
"from this response")
return ("failed", "%s, which is a different problem" % error)
def migration_for(method):
"""The exact replacement line to print for one method. Pure."""
replacement, _kind = replacement_for(method)
return replacement
def sweep_verdict(rows):
"""rows: [(method, state), ...]. Returns (state, detail). Pure.
Refuses to report clean on a sweep in which nothing was actually assessed,
which is the failure mode of every audit tool: a token that cannot read
anything produces a spotless report.
"""
seen = [(str(m), str(st)) for m, st in (rows or [])]
if not seen:
return ("no-methods", "no method was checked, so there is nothing to say")
dead = [m for m, st in seen if st in ("dead", "removed")]
scheduled = [m for m, st in seen if st == "scheduled"]
named = [m for m, st in seen if st == "report-only"]
assessed = [m for m, st in seen if st in ("dead", "removed", "scheduled", "live")]
if dead and scheduled:
return ("dead", "%d method(s) already refused and %d more are warning"
% (len(dead), len(scheduled)))
if dead:
return ("dead", "%d method(s) already refused: %s"
% (len(dead), ", ".join(sorted(dead))))
if scheduled:
return ("scheduled", "%d method(s) still work and are warning: %s"
% (len(scheduled), ", ".join(sorted(scheduled))))
if not assessed:
return ("not-assessed", "nothing was assessed; %d method(s) were named from "
"the map and none was probed" % len(named))
return ("clean", "%d method(s) probed and none is deprecated" % len(assessed))
def get(session, method, params=None):
"""One GET against the Web API. Returns the parsed body."""
r = session.get(API + method, params=params or {}, timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding a read scoped bot token")
ap.add_argument("--methods", default="",
help="comma separated legacy method names from your own source; "
"the whole known map is swept when this is omitted")
ap.add_argument("--channel", default="",
help="a channel id, so the info and history methods can be probed")
args = ap.parse_args()
wanted = [m.strip() for m in args.methods.split(",") if m.strip()]
if not wanted:
wanted = sorted(REPLACEMENTS)
token = os.environ.get(args.token_env)
session = requests.Session()
if token:
session.headers.update({"Authorization": "Bearer " + token})
else:
log.warning("token missing set %s; every read becomes report-only",
args.token_env)
rows = []
for name in wanted:
action, why = probe_safety(name, channel_available=bool(args.channel))
if action != "probe" or not token:
state = "report-only" if action != "needs-argument" else "needs-argument"
log.warning("method %-16s %-11s %s", name, state, why)
rows.append((name, state))
else:
params = {} if name in ARG_FREE else {"channel": args.channel}
state, detail = classify(get(session, name, params))
level = log.info if state == "live" else log.warning
level("method %-16s %-11s %s", name, state, detail)
rows.append((name, state))
line = migration_for(name)
if line:
log.info(" migrate to %s", line)
if name in OWNED_ELSEWHERE:
log.info(" see also %s", OWNED_ELSEWHERE[name])
state, detail = sweep_verdict(rows)
if state == "clean":
log.info("verdict clean %s", detail)
return 0
log.warning("verdict %-14s %s", state, detail)
log.warning(" repair: migrate the whole family at once; the four legacy list "
"methods are one conversations.list call with "
"types=public_channel,private_channel,im,mpim")
log.warning(" repair: log body.warning and body.response_metadata.warnings at "
"WARN, permanently; that is the only channel Slack has for the next "
"retirement")
log.warning(" note: no deprecated write was issued to establish any of this")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Find the deprecated Slack methods your app still calls.
*
* Read only, and deliberately stricter than the question requires. The map
* below records, for every legacy method, both its replacement and whether
* issuing it is a read. Only the reads are ever issued. files.upload, im.open,
* channels.create, mpim.open and the RTM session methods are deprecated
* writes: they are named, mapped, and never called, because "does it still
* work?" cannot be asked of a write without performing it.
*
* Three fields carry the answer and two of them ride on responses that
* succeeded: body.warning and body.response_metadata.warnings[] are the only
* form of this finding that arrives before the outage.
*/
const API = 'https://slack.com/api/';
// Legacy method -> [replacement, kind]. The kind is the safety half of the map.
export const REPLACEMENTS = {
'channels.list': ['conversations.list, with types=public_channel', 'read'],
'channels.info': ['conversations.info', 'read'],
'channels.history': ['conversations.history', 'read'],
'channels.replies': ['conversations.replies', 'read'],
'channels.create': ['conversations.create', 'write'],
'channels.invite': ['conversations.invite', 'write'],
'channels.join': ['conversations.join', 'write'],
'channels.archive': ['conversations.archive', 'write'],
'groups.list': ['conversations.list, with types=private_channel', 'read'],
'groups.info': ['conversations.info', 'read'],
'groups.history': ['conversations.history', 'read'],
'groups.replies': ['conversations.replies', 'read'],
'groups.create': ['conversations.create, with is_private=true', 'write'],
'im.list': ['conversations.list, with types=im', 'read'],
'im.history': ['conversations.history', 'read'],
'im.replies': ['conversations.replies', 'read'],
'im.open': ['conversations.open', 'write'],
'im.close': ['conversations.close', 'write'],
'mpim.list': ['conversations.list, with types=mpim', 'read'],
'mpim.history': ['conversations.history', 'read'],
'mpim.open': ['conversations.open, with a comma separated users list', 'write'],
'files.upload': ['files.getUploadURLExternal, then a plain upload, then '
+ 'files.completeUploadExternal', 'write'],
'rtm.start': ['Socket Mode', 'write'],
'rtm.connect': ['Socket Mode', 'write'],
};
// The methods that answer with no arguments at all.
export const ARG_FREE = ['channels.list', 'groups.list', 'im.list', 'mpim.list'];
// Two methods here are large enough to have their own note.
export const OWNED_ELSEWHERE = {
'files.upload': '/slack/files-upload-retired/',
'rtm.start': '/slack/rtm-legacy-still-used/',
'rtm.connect': '/slack/rtm-legacy-still-used/',
};
// About the Content-Type header you sent, not about the method's future.
export const NOISE_WARNINGS = ['missing_charset', 'superfluous_charset'];
export const DEAD_ERRORS = ['method_deprecated', 'deprecated_endpoint'];
export const UNASSESSABLE = ['missing_scope', 'not_allowed_token_type',
'invalid_auth', 'ratelimited', 'token_revoked', 'account_inactive'];
/** What replaced this method, and whether issuing it is a read. Pure. */
export function replacementFor(method) {
const entry = REPLACEMENTS[String(method ?? '').trim()];
if (!entry) return [null, 'unknown'];
return entry;
}
/**
* May this sweep issue this method itself? Pure.
* Returns [action, why]; probe, report-only or needs-argument.
*/
export function probeSafety(method, channelAvailable = false) {
const name = String(method ?? '').trim();
const [, kind] = replacementFor(name);
if (kind === 'write') {
return ['report-only', 'this method writes; the sweep names it and does not '
+ 'call it'];
}
if (kind === 'unknown') {
return ['report-only', 'this method is not in the map, so the sweep will not '
+ 'issue it on the chance that it is a read'];
}
if (ARG_FREE.includes(name)) {
return ['probe', 'a read that answers with no arguments'];
}
if (channelAvailable) {
return ['probe', 'a read, probed against the channel you passed'];
}
return ['needs-argument', 'pass --channel C... to probe the info and history '
+ 'methods; without one the answer is channel_not_found and says nothing '
+ 'about the method'];
}
/** The warnings in a response that are actually about deprecation. Pure. */
export function deprecationWarnings(body) {
const doc = body ?? {};
const raw = [doc.warning, ...((doc.response_metadata ?? {}).warnings ?? [])];
const out = [];
for (const item of raw) {
const text = String(item ?? '').trim();
if (text && !NOISE_WARNINGS.includes(text)) out.push(text);
}
return [...new Set(out)];
}
/**
* Read one response body as a deprecation state. Pure.
* Returns [state, detail]; removed, dead, scheduled, live, not-assessed, failed.
*/
export function classify(body) {
const doc = body ?? {};
const error = String(doc.error ?? '');
if (doc.ok === true) {
const warnings = deprecationWarnings(doc);
if (warnings.length) {
return ['scheduled', `ok true, and warning: ${warnings.join(', ')}`];
}
return ['live', 'ok true, and no deprecation warning'];
}
if (error === 'unknown_method') {
return ['removed', 'unknown_method: the name is gone from the API'];
}
if (DEAD_ERRORS.includes(error)) return ['dead', error];
if (UNASSESSABLE.includes(error)) {
return ['not-assessed', `${error}, which is about the token rather than the method`];
}
if (!error) {
return ['not-assessed', 'no ok field and no error, so nothing can be read from '
+ 'this response'];
}
return ['failed', `${error}, which is a different problem`];
}
/** The exact replacement line to print for one method. Pure. */
export function migrationFor(method) {
const [replacement] = replacementFor(method);
return replacement ?? null;
}
/** rows: [[method, state], ...]. Returns [state, detail]. Pure. */
export function sweepVerdict(rows) {
const seen = (rows ?? []).map(([m, st]) => [String(m), String(st)]);
if (!seen.length) {
return ['no-methods', 'no method was checked, so there is nothing to say'];
}
const pick = (...states) => seen.filter(([, st]) => states.includes(st))
.map(([m]) => m);
const dead = pick('dead', 'removed');
const scheduled = pick('scheduled');
const named = pick('report-only');
const assessed = pick('dead', 'removed', 'scheduled', 'live');
if (dead.length && scheduled.length) {
return ['dead', `${dead.length} method(s) already refused and ${scheduled.length} `
+ 'more are warning'];
}
if (dead.length) {
return ['dead', `${dead.length} method(s) already refused: `
+ `${[...dead].sort().join(', ')}`];
}
if (scheduled.length) {
return ['scheduled', `${scheduled.length} method(s) still work and are warning: `
+ `${[...scheduled].sort().join(', ')}`];
}
if (!assessed.length) {
return ['not-assessed', `nothing was assessed; ${named.length} method(s) were `
+ 'named from the map and none was probed'];
}
return ['clean', `${assessed.length} method(s) probed and none is deprecated`];
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function read(token, method, params) {
const qs = new URLSearchParams(params ?? {}).toString();
const url = `${API}${method}${qs ? `?${qs}` : ''}`;
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
try {
return await r.json();
} catch {
return { ok: false, error: 'unparseable_body' };
}
}
async function main() {
const args = process.argv.slice(2);
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const channel = arg(args, '--channel');
let wanted = arg(args, '--methods').split(',').map((m) => m.trim()).filter(Boolean);
if (!wanted.length) wanted = Object.keys(REPLACEMENTS).sort();
const token = process.env[tokenEnv];
if (!token) {
console.warn(`token missing set ${tokenEnv}; every read becomes `
+ 'report-only');
}
const rows = [];
for (const name of wanted) {
const [action, why] = probeSafety(name, Boolean(channel));
const pad = name.padEnd(16);
if (action !== 'probe' || !token) {
const state = action === 'needs-argument' ? 'needs-argument' : 'report-only';
console.warn(`method ${pad} ${state.padEnd(11)} ${why}`);
rows.push([name, state]);
} else {
const params = ARG_FREE.includes(name) ? {} : { channel };
// eslint-disable-next-line no-await-in-loop
const [state, detail] = classify(await read(token, name, params));
const line = `method ${pad} ${state.padEnd(11)} ${detail}`;
if (state === 'live') console.log(line);
else console.warn(line);
rows.push([name, state]);
}
const line = migrationFor(name);
if (line) console.log(` migrate to ${line}`);
if (OWNED_ELSEWHERE[name]) {
console.log(` see also ${OWNED_ELSEWHERE[name]}`);
}
}
const [state, detail] = sweepVerdict(rows);
if (state === 'clean') {
console.log(`verdict clean ${detail}`);
return;
}
console.warn(`verdict ${state.padEnd(14)} ${detail}`);
console.warn(' repair: migrate the whole family at once; the four legacy list '
+ 'methods are one conversations.list call with '
+ 'types=public_channel,private_channel,im,mpim');
console.warn(' repair: log body.warning and body.response_metadata.warnings at '
+ 'WARN, permanently');
console.warn(' note: no deprecated write was issued to establish any of this');
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The assertions that carry the weight are the ones about restraint. Every deprecated write in the map has to come back report-only, and a method the map has never heard of has to come back report-only too rather than being assumed harmless. After that it is the warning filter: a response carrying only missing_charset is live, the same response carrying a real notice is scheduled, and a sweep in which nothing was probed refuses to say clean.
from slack_deprecated_methods import (
REPLACEMENTS, classify, deprecation_warnings, migration_for, probe_safety,
replacement_for, sweep_verdict,
)
def test_every_legacy_list_method_maps_to_the_one_replacement():
for name in ("channels.list", "groups.list", "im.list", "mpim.list"):
replacement, kind = replacement_for(name)
assert replacement.startswith("conversations.list")
assert kind == "read"
def test_an_unknown_method_is_not_assumed_to_be_a_read():
assert replacement_for("chat.postMessage") == (None, "unknown")
assert probe_safety("chat.postMessage")[0] == "report-only"
def test_every_deprecated_write_in_the_map_is_named_and_never_probed():
writes = [m for m, (_r, kind) in REPLACEMENTS.items() if kind == "write"]
assert "files.upload" in writes
assert "im.open" in writes
assert "rtm.connect" in writes
for name in writes:
action, why = probe_safety(name, channel_available=True)
assert action == "report-only"
assert "writes" in why
def test_the_argument_free_reads_are_probed_without_a_channel():
assert probe_safety("channels.list")[0] == "probe"
assert probe_safety("mpim.list")[0] == "probe"
def test_a_read_that_needs_a_channel_says_so_until_one_is_given():
assert probe_safety("channels.history")[0] == "needs-argument"
assert probe_safety("channels.history", channel_available=True)[0] == "probe"
def test_the_charset_warnings_are_not_deprecation_notices():
assert deprecation_warnings({"ok": True, "warning": "missing_charset"}) == []
assert deprecation_warnings(
{"ok": True, "response_metadata": {"warnings": ["superfluous_charset"]}}) == []
def test_both_warning_channels_are_read_and_deduplicated():
body = {"ok": True, "warning": "method_deprecated",
"response_metadata": {"warnings": ["method_deprecated", "use_conversations"]}}
assert deprecation_warnings(body) == ["method_deprecated", "use_conversations"]
def test_a_success_carrying_a_real_notice_is_the_finding_worth_having():
state, detail = classify({"ok": True, "warning": "method_deprecated"})
assert state == "scheduled"
assert "method_deprecated" in detail
def test_a_success_carrying_only_charset_noise_is_simply_live():
assert classify({"ok": True, "warning": "superfluous_charset"})[0] == "live"
def test_the_two_dead_errors_are_both_recognised():
assert classify({"ok": False, "error": "method_deprecated"})[0] == "dead"
assert classify({"ok": False, "error": "deprecated_endpoint"})[0] == "dead"
def test_unknown_method_is_removed_rather_than_merely_deprecated():
state, detail = classify({"ok": False, "error": "unknown_method"})
assert state == "removed"
assert "gone" in detail
def test_a_token_problem_is_not_assessed_rather_than_counted_as_healthy():
assert classify({"ok": False, "error": "missing_scope"})[0] == "not-assessed"
assert classify({"ok": False, "error": "ratelimited"})[0] == "not-assessed"
def test_an_unrelated_error_is_handed_on_rather_than_absorbed():
state, detail = classify({"ok": False, "error": "channel_not_found"})
assert state == "failed"
assert "different problem" in detail
def test_a_body_with_neither_ok_nor_error_is_not_assessed():
assert classify({})[0] == "not-assessed"
def test_the_migration_line_names_the_parameter_that_has_to_come_with_it():
assert "types=private_channel" in migration_for("groups.list")
assert migration_for("nope.nope") is None
def test_a_dead_method_outranks_a_scheduled_one_in_the_verdict():
state, detail = sweep_verdict([("channels.list", "dead"),
("groups.list", "scheduled")])
assert state == "dead"
assert "warning" in detail
def test_a_scheduled_method_is_a_finding_on_its_own():
state, detail = sweep_verdict([("groups.list", "scheduled"),
("im.list", "live")])
assert state == "scheduled"
assert "groups.list" in detail
def test_a_sweep_that_probed_nothing_refuses_to_report_clean():
state, detail = sweep_verdict([("files.upload", "report-only"),
("im.open", "report-only")])
assert state == "not-assessed"
assert "none was probed" in detail
def test_an_empty_sweep_says_so_instead_of_passing():
assert sweep_verdict([])[0] == "no-methods"
def test_all_reads_probed_and_healthy_is_the_only_clean_verdict():
state, detail = sweep_verdict([("channels.list", "live"), ("im.list", "live")])
assert state == "clean"
assert "2 method(s)" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
REPLACEMENTS, classify, deprecationWarnings, migrationFor, probeSafety,
replacementFor, sweepVerdict,
} from './slack-deprecated-methods.mjs';
test('every legacy list method maps to the one replacement', () => {
for (const name of ['channels.list', 'groups.list', 'im.list', 'mpim.list']) {
const [replacement, kind] = replacementFor(name);
assert.equal(replacement.startsWith('conversations.list'), true);
assert.equal(kind, 'read');
}
});
test('an unknown method is not assumed to be a read', () => {
assert.deepEqual(replacementFor('chat.postMessage'), [null, 'unknown']);
assert.equal(probeSafety('chat.postMessage')[0], 'report-only');
});
test('every deprecated write in the map is named and never probed', () => {
const writes = Object.entries(REPLACEMENTS)
.filter(([, [, kind]]) => kind === 'write').map(([m]) => m);
assert.equal(writes.includes('files.upload'), true);
assert.equal(writes.includes('im.open'), true);
assert.equal(writes.includes('rtm.connect'), true);
for (const name of writes) {
const [action, why] = probeSafety(name, true);
assert.equal(action, 'report-only');
assert.match(why, /writes/);
}
});
test('the argument free reads are probed without a channel', () => {
assert.equal(probeSafety('channels.list')[0], 'probe');
assert.equal(probeSafety('mpim.list')[0], 'probe');
});
test('a read that needs a channel says so until one is given', () => {
assert.equal(probeSafety('channels.history')[0], 'needs-argument');
assert.equal(probeSafety('channels.history', true)[0], 'probe');
});
test('the charset warnings are not deprecation notices', () => {
assert.deepEqual(deprecationWarnings({ ok: true, warning: 'missing_charset' }), []);
assert.deepEqual(deprecationWarnings(
{ ok: true, response_metadata: { warnings: ['superfluous_charset'] } }), []);
});
test('both warning channels are read and deduplicated', () => {
const body = {
ok: true,
warning: 'method_deprecated',
response_metadata: { warnings: ['method_deprecated', 'use_conversations'] },
};
assert.deepEqual(deprecationWarnings(body),
['method_deprecated', 'use_conversations']);
});
test('a success carrying a real notice is the finding worth having', () => {
const [state, detail] = classify({ ok: true, warning: 'method_deprecated' });
assert.equal(state, 'scheduled');
assert.match(detail, /method_deprecated/);
});
test('a success carrying only charset noise is simply live', () => {
assert.equal(classify({ ok: true, warning: 'superfluous_charset' })[0], 'live');
});
test('the two dead errors are both recognised', () => {
assert.equal(classify({ ok: false, error: 'method_deprecated' })[0], 'dead');
assert.equal(classify({ ok: false, error: 'deprecated_endpoint' })[0], 'dead');
});
test('unknown_method is removed rather than merely deprecated', () => {
const [state, detail] = classify({ ok: false, error: 'unknown_method' });
assert.equal(state, 'removed');
assert.match(detail, /gone/);
});
test('a token problem is not assessed rather than counted as healthy', () => {
assert.equal(classify({ ok: false, error: 'missing_scope' })[0], 'not-assessed');
assert.equal(classify({ ok: false, error: 'ratelimited' })[0], 'not-assessed');
});
test('an unrelated error is handed on rather than absorbed', () => {
const [state, detail] = classify({ ok: false, error: 'channel_not_found' });
assert.equal(state, 'failed');
assert.match(detail, /different problem/);
});
test('a body with neither ok nor error is not assessed', () => {
assert.equal(classify({})[0], 'not-assessed');
});
test('the migration line names the parameter that has to come with it', () => {
assert.match(migrationFor('groups.list'), /types=private_channel/);
assert.equal(migrationFor('nope.nope'), null);
});
test('a dead method outranks a scheduled one in the verdict', () => {
const [state, detail] = sweepVerdict([['channels.list', 'dead'],
['groups.list', 'scheduled']]);
assert.equal(state, 'dead');
assert.match(detail, /warning/);
});
test('a scheduled method is a finding on its own', () => {
const [state, detail] = sweepVerdict([['groups.list', 'scheduled'],
['im.list', 'live']]);
assert.equal(state, 'scheduled');
assert.match(detail, /groups\.list/);
});
test('a sweep that probed nothing refuses to report clean', () => {
const [state, detail] = sweepVerdict([['files.upload', 'report-only'],
['im.open', 'report-only']]);
assert.equal(state, 'not-assessed');
assert.match(detail, /none was probed/);
});
test('an empty sweep says so instead of passing', () => {
assert.equal(sweepVerdict([])[0], 'no-methods');
});
test('all reads probed and healthy is the only clean verdict', () => {
const [state, detail] = sweepVerdict([['channels.list', 'live'],
['im.list', 'live']]);
assert.equal(state, 'clean');
assert.match(detail, /2 method\(s\)/);
});
FAQ
Why does the script refuse to call files.upload or im.open when the whole point is to find out whether they still work?
Because there is no read-only way to ask a write method that question. The only way to find out whether im.open still works is to open a conversation; the only way to find out whether channels.create still works is to create a channel with whatever name the probe invented. A diagnostic that does either has written to the workspace it was hired to inspect, and it will do so every time it runs. The map in this script records whether each legacy method is a read or a write, and the write half is reported from the map: the name, the replacement, and nothing sent.
My sweep says everything is live. Am I safe?
Only for the methods it actually probed. Look at the verdict line rather than the rows: a sweep in which every row was report-only or needs-argument reports not-assessed, not clean, precisely because a token that can read nothing produces a spotless report. Pass --channel so the info and history methods can be probed, and pass --methods with the names your source really contains rather than sweeping the whole map, because a clean result on methods you never call is not evidence about the ones you do.
What is the difference between a warning and an error here?
Timing, and it is the whole value of the check. An error means the method is already refusing you and the outage has happened. A warning arrives on a response that succeeded, returned its data and did everything you asked, and it is Slack telling you the method has a date on it. That notice is the only advance warning the API gives, and it is delivered in the one place production code never looks, because nothing needs investigating when the call worked.
Is files.upload covered by this note?
It is named and handed over. files.upload was sunset on its own timetable and its replacement is a three-call sequence with failure modes of its own, so the sweep prints the method, the migration and a pointer, and the note linked below explains it properly. The same is true of the RTM transport: rtm.start and rtm.connect appear in the map because they belong in a sweep of your call sites, and the reason a modern app cannot use them at all is a different note about a scope that no longer exists.
I renamed channels.list to conversations.list and private channels disappeared. Why?
Because the replacement is one method with a types parameter, and its default is public channels only. The old API forced you to write four calls, one per conversation type; the new one is a single call that has to be told which types you want. Migrating the method names one at a time and leaving the parameters alone loses private channels, DMs and group DMs silently, with no error anywhere, which is why the script prints the parameter alongside the replacement rather than just the new name.
Related field notes
- the one method in the map with its own timetable
- the retired transport, and the scope it needs
- why a warning on a successful response goes unread
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.
- Web API method reference - Slack Docs
- conversations.list method reference - Slack Docs
- Changelog: retiring older methods and files.upload - Slack Docs
- The Conversations API - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.