Diagnostic Slack
The app subscribes to no events, so nothing is delivered
The bot is installed. It is in the channel, which somebody has checked four times. The token is valid, the scopes include app_mentions:read, the request URL is up and returns 200 to a browser. Someone types @deploybot status and nothing happens, and nothing appears in the logs, because nothing arrived.
The handler is fine. It has never been called. Being in a channel does not cause Slack to send you what happens in it, and the scope that makes an event available is not the switch that turns it on.
Event subscriptions are opt-in, one event type at a time, in the app configuration. Adding app_mentions:read to the scopes makes the app_mention event available to subscribe to. It does not subscribe you. Those are two separate screens and two separate steps, and an app that did the first and not the second is completely inert with nothing wrong anywhere in it.
The direct read needs an app configuration token, which is a different credential class from your bot token: apps.manifest.export returns settings.event_subscriptions.bot_events[], and an empty array beside a populated scope list is the finding, full stop. Without that token the script falls back to evidence: the bot is a member, humans are addressing it, and it has never replied, ever. That last word is the one that matters, because an app that used to reply and stopped is a different note.
The problem in plain words
Nothing about this failure looks like a failure. Every screen a developer thinks to open is green. The app is installed, the OAuth flow completed, auth.test returns ok: true with the right team, conversations.members lists the bot, and the scope list contains exactly the scope the documentation named. The hosting is healthy. The handler compiles. The only symptom is an absence.
An absence is the hardest symptom to work with, because it gives you nothing to search for. There is no error string, no status code, no log line, no ok: false. Teams spend days on the handler, adding print statements to a function that is never entered, before somebody thinks to prove that a request ever reached the process at all. When they do, the request URL is quiet, and then the search moves to networking and TLS and load balancers, which is another day.
The confusion underneath is a genuinely good-faith one. Slack's scope names are written in the language of events. app_mentions:read reads exactly like "receive mentions". channels:history reads like "receive channel messages". They do not mean that. They are permissions attached to the token, describing what the app is allowed to see; the subscription is a separate list in the app configuration describing what Slack should actually push to you. The two lists have to agree, and only one of them is in the place people look.
Why it happens
The subscription list is not visible to a bot token. No Web API method available to a runtime bot token returns which events an app subscribes to. That is why this note asks for an app configuration token: it is the only credential that reads the manifest, and the manifest is where the answer is written down.
An unreadable manifest must never be reported as an empty one. If the script cannot read the configuration, the honest answer is that it could not check. Reporting "no subscriptions" because it did not look is how an audit sends somebody to add subscriptions that were already there.
Never replied and stopped replying are two different findings with two different repairs. An app whose delivery Slack disabled after sustained failures has a history of replies and then a cliff, and the repair is a switch in the app configuration plus whatever caused the failures. An app that has never once replied since installation almost certainly never subscribed to anything. The script separates them explicitly and hands the second case to the note that owns it.
Adding an event can change the grant, so subscribing is often not one step either. Subscribing to an event whose scope the token does not hold requires adding that scope, and adding a scope requires a reinstall. That is the sibling failure to this one, and the script names it rather than pretending subscription alone is always enough.
A quiet channel is not evidence of anything. If nobody has addressed the app, its silence is correct. The script refuses to produce a finding in that case, because an audit that fires on an unused app in an unused channel will be switched off within a week.
The fix, as a flow
Every box in this chain is green except one, and the one that is not is an empty list rather than an error. The last step is an absence: no request, no log line, nothing to search for, which is what makes this the hardest of the four to file. The fix branch keeps a read apart from a reading, and the two plain rows are both refusals to conclude.
How to fix it
Get an app configuration token, or accept a weaker answer
App configuration tokens are issued from the app management screen and are separate from the bot token. Put one in SLACK_CONFIG_TOKEN. They expire after twelve hours, which is its own note. Without one the script still runs, and says out loud that its answer is inferred rather than read.
Read the subscription list and treat an empty one as the answer
subscription_state reduces the manifest to none, configured or unreadable. Those three are kept strictly apart: none is a finding, unreadable is a gap in the audit, and merging them is the one way this script could confidently mislead you.
Diff the scopes against the subscriptions, in that direction
scope_without_subscription takes each granted scope that exists to make events available and reports the events it enables that nobody subscribed to. A token holding app_mentions:read with no app_mention subscription is this note in one line, and it is the line that explains the confusion rather than just stating the outcome.
Establish that somebody is actually addressing the app
The behavioural half reads conversations.history for mentions of the bot's user ID. No mentions means no evidence, and the script says so rather than manufacturing a finding out of a quiet channel.
Separate never replied from stopped replying
reply_shape returns never-replied, stopped-replying, answering or not-addressed. The second one is not this note, and the script says which note it is instead. Getting that boundary wrong sends a team to re-enable a subscription that was never enabled.
Print the events to subscribe, then the reinstall
The repair is a list: app_mention for mentions, message.channels, message.groups, message.im and message.mpim per channel type, and whatever else the handler switches on. Then reinstall if any of them needed a scope you do not hold, because the subscription and the grant have to arrive together.
How to check it worked
After subscribing and reinstalling, run it again with the same configuration token. The manifest read should change state, and the behavioural half should stop being the only evidence you have.
python3 slack_event_subscription_audit.py --app-id A01ABCDE9 --channel C01ABCDE9
# identity U0APPBOT11 in acme
# manifest configured 3 subscribed event(s): app_mention, message.im, reaction_added
# scopes matched every event scope on this token has a subscription behind it
# history answering 12 mention(s), 12 reply(ies), most recent reply after the last mention
# verdict healthy events are subscribed and the app is answering them
The full code
Two credentials, three reads and four pure functions. subscription_state is nine lines and the entire point of the script, because it is the one place where could not check has to stay distinct from nothing is there. scope_without_subscription walks the scope table in the direction that explains the confusion. reply_shape is the boundary against the auto-disabled note, and finding is the small matrix that combines the read half with the inferred half.
"""Find out whether this app subscribes to any events at all.
Read only. auth.test and conversations.history on a bot token, plus one
apps.manifest.export on an app configuration token if you have one, which is a
read of the app's configuration and not a change to it. Nothing is subscribed,
enabled or installed by this script: it reports what the manifest says, what
the workspace shows, and prints the events you would add.
"""
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_event_subscription_audit")
API = "https://slack.com/api/"
# Scopes that exist to make an event available, and the events they unlock.
# This table is walked scope-first on purpose: the confusion this note is about
# runs in that direction, from "I granted app_mentions:read" to "so I will
# receive app_mention", and the table makes the missing step visible.
SCOPE_EVENTS = {
"app_mentions:read": ["app_mention"],
"channels:history": ["message.channels"],
"groups:history": ["message.groups"],
"im:history": ["message.im"],
"mpim:history": ["message.mpim"],
"reactions:read": ["reaction_added", "reaction_removed"],
"channels:read": ["channel_created", "channel_archive", "channel_rename",
"member_joined_channel", "member_left_channel"],
"files:read": ["file_created", "file_shared"],
"users:read": ["team_join", "user_change"],
"pins:read": ["pin_added", "pin_removed"],
"emoji:read": ["emoji_changed"],
}
def subscription_state(manifest):
"""Reduce an exported manifest to what it says about event subscriptions. Pure.
Returns (state, events, detail) where state is none, configured or
unreadable. Those three never collapse into two. "I could not look" is not
"there is nothing there", and an audit that confuses them will tell a team
to add subscriptions that already exist.
"""
if not manifest:
return ("unreadable", [],
"no manifest was available, so the subscription list was not read. "
"This is a gap in the audit and not a finding: an app configuration "
"token is what closes it")
settings = (manifest.get("settings") or {}).get("event_subscriptions") or {}
events = sorted({str(e).strip() for e in
list(settings.get("bot_events") or []) +
list(settings.get("user_events") or []) if str(e).strip()})
if not events:
return ("none", [],
"the manifest was read and subscribes to zero events. Nothing has "
"ever been delivered to this app, and no amount of scope, "
"membership or uptime changes that")
return ("configured", events,
"%d subscribed event(s): %s" % (len(events), ", ".join(events)))
def scope_without_subscription(scopes, events):
"""Granted scopes whose events nobody subscribed to. Pure.
Returns rows of (scope, [unsubscribed events]). A token holding
app_mentions:read with no app_mention subscription is this whole note in
one row, and it names the step that was skipped rather than only the
outcome.
"""
have = {str(s).strip() for s in scopes or [] if str(s).strip()}
subscribed = {str(e).strip() for e in events or [] if str(e).strip()}
rows = []
for scope in sorted(have):
enabled = SCOPE_EVENTS.get(scope)
if not enabled:
continue
idle = [e for e in enabled if e not in subscribed]
if idle:
rows.append((scope, idle))
return rows
def reply_shape(messages, bot_id, bot_user, min_unanswered=3):
"""Has this app never replied, or did it reply and then stop? Pure.
Returns (shape, detail). never-replied points at this note.
stopped-replying points at the auto-disabled note instead, and keeping
those two apart is the reason this function exists rather than a counter.
"""
mention = "<@%s>" % bot_user if bot_user else None
triggers, replies = [], []
for m in messages or []:
ts = float(m.get("ts") or 0)
ours = bool(bot_id and m.get("bot_id") == bot_id) or bool(
bot_user and m.get("user") == bot_user)
if ours:
replies.append(ts)
elif mention and mention in (m.get("text") or ""):
triggers.append(ts)
triggers.sort()
replies.sort()
if not triggers:
return ("not-addressed",
"nobody has addressed this app in the sampled history, so its "
"silence is correct and proves nothing")
if not replies:
return ("never-replied",
"%d mention(s) and not one reply, ever. An app that has never "
"answered is usually one that was never subscribed, rather than "
"one whose delivery was switched off" % len(triggers))
after = [t for t in triggers if t > replies[-1]]
if len(after) >= int(min_unanswered):
return ("stopped-replying",
"%d reply(ies) and then %d unanswered mention(s). This app worked "
"and stopped, which is the auto-disabled note rather than this "
"one" % (len(replies), len(after)))
return ("answering",
"%d mention(s), %d reply(ies), most recent reply after the last mention"
% (len(triggers), len(replies)))
def finding(state, shape):
"""Combine what was read with what was inferred. Pure.
Returns (verdict, action). The read half outranks the inferred half where
they overlap, because a manifest that says zero is evidence and a quiet
channel is not.
"""
if state == "none":
return ("no-subscriptions",
"subscribe the events this app handles under Event Subscriptions, "
"then reinstall if any of them needed a scope you do not hold")
if shape == "stopped-replying":
return ("delivery-stopped",
"this is not a subscription problem: the app answered and then "
"stopped, so read the note on Slack disabling event delivery")
if state == "configured":
if shape == "never-replied":
return ("subscribed-but-inert",
"events are subscribed and nothing has ever been answered, so "
"the gap is downstream: the request URL, or an event whose "
"scope the token was never granted")
if shape == "answering":
return ("healthy", "events are subscribed and the app is answering them")
return ("no-evidence",
"events are subscribed and nobody has addressed the app, so there "
"is nothing to judge")
if shape == "never-replied":
return ("probably-unsubscribed",
"the manifest was not read and the app has never once answered. "
"Get an app configuration token and check bot_events before "
"changing any code")
return ("not-assessed",
"the manifest was not read and the workspace evidence is inconclusive")
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("--channel", action="append", default=[],
help="a channel the app is expected to answer in; repeatable")
ap.add_argument("--limit", type=int, default=200, help="messages sampled per channel")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s (channels:history and channels:read are enough)",
args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
who, scope_header = get(s, "auth.test", {}, "auth.test")
if not who:
return 2
bot_user, bot_id = who.get("user_id") or "", who.get("bot_id") or ""
log.info("identity %s in %s", bot_user, who.get("team"))
scopes = [p.strip() for p in (scope_header or "").split(",") if p.strip()]
manifest = 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")
manifest = (body or {}).get("manifest")
elif not config_token:
log.info("manifest skipped %s is unset, so the subscription list "
"cannot be read directly", args.config_token_env)
state, events, detail = subscription_state(manifest)
(log.info if state == "configured" else log.warning)(
"manifest %-14s %s", state, detail)
idle = scope_without_subscription(scopes, events)
if state == "configured" and not idle:
log.info("scopes matched every event scope on this token has a "
"subscription behind it")
for scope, unsubscribed in idle:
log.warning("scopes idle %s is granted and none of %s is "
"subscribed; the scope makes the event available, it does not "
"turn it on", scope, ", ".join(unsubscribed))
shape, why = ("not-addressed", "no channel was sampled")
for channel in args.channel:
body, _ = get(s, "conversations.history",
{"channel": channel, "limit": str(args.limit)}, "history")
if not body:
continue
shape, why = reply_shape(body.get("messages") or [], bot_id, bot_user)
(log.info if shape in ("answering", "not-addressed") else log.warning)(
"history %-14s %s in %s", shape, why, channel)
if shape in ("never-replied", "stopped-replying"):
break
verdict, action = finding(state, shape)
(log.info if verdict == "healthy" else log.warning)(
"verdict %-14s %s", verdict, action)
if verdict in ("no-subscriptions", "probably-unsubscribed"):
log.warning(" repair: Event Subscriptions, Subscribe to bot events, add "
"app_mention for mentions and message.channels, message.groups, "
"message.im or message.mpim per channel type")
log.warning(" repair: in a manifest-managed app, populate "
"settings.event_subscriptions.bot_events and redeploy the manifest")
log.warning(" repair: reinstall afterwards if any added event needed a "
"scope this token does not already hold")
return 1
return 1 if verdict in ("subscribed-but-inert", "delivery-stopped") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find out whether this app subscribes to any events at all.
*
* Read only. auth.test and conversations.history on a bot token, plus one
* apps.manifest.export on an app configuration token if you have one, which is
* a read of the app's configuration and not a change to it. Nothing is
* subscribed, enabled or installed here: this reports what the manifest says,
* what the workspace shows, and prints the events you would add.
*/
const API = 'https://slack.com/api/';
// Walked scope-first on purpose: the confusion this note is about runs in that
// direction, from "I granted app_mentions:read" to "so I will receive
// app_mention", and the table makes the missing step visible.
const SCOPE_EVENTS = new Map([
['app_mentions:read', ['app_mention']],
['channels:history', ['message.channels']],
['groups:history', ['message.groups']],
['im:history', ['message.im']],
['mpim:history', ['message.mpim']],
['reactions:read', ['reaction_added', 'reaction_removed']],
['channels:read', ['channel_created', 'channel_archive', 'channel_rename',
'member_joined_channel', 'member_left_channel']],
['files:read', ['file_created', 'file_shared']],
['users:read', ['team_join', 'user_change']],
['pins:read', ['pin_added', 'pin_removed']],
['emoji:read', ['emoji_changed']],
]);
/**
* Reduce an exported manifest to what it says about event subscriptions. Pure.
* none, configured and unreadable never collapse into two states: "I could not
* look" is not "there is nothing there".
*/
export function subscriptionState(manifest) {
if (!manifest) {
return ['unreadable', [],
'no manifest was available, so the subscription list was not read. This is ' +
'a gap in the audit and not a finding: an app configuration token is what ' +
'closes it'];
}
const settings = manifest.settings?.event_subscriptions ?? {};
const events = [...new Set([
...(settings.bot_events ?? []), ...(settings.user_events ?? []),
].map((e) => String(e).trim()).filter(Boolean))].sort();
if (!events.length) {
return ['none', [],
'the manifest was read and subscribes to zero events. Nothing has ever been ' +
'delivered to this app, and no amount of scope, membership or uptime ' +
'changes that'];
}
return ['configured', events,
`${events.length} subscribed event(s): ${events.join(', ')}`];
}
/**
* Granted scopes whose events nobody subscribed to. Pure.
* Names the step that was skipped rather than only the outcome.
*/
export function scopeWithoutSubscription(scopes, events) {
const have = [...new Set((scopes ?? []).map((s) => String(s).trim()).filter(Boolean))];
const subscribed = new Set((events ?? []).map((e) => String(e).trim()).filter(Boolean));
const rows = [];
for (const scope of have.sort()) {
const enabled = SCOPE_EVENTS.get(scope);
if (!enabled) continue;
const idle = enabled.filter((e) => !subscribed.has(e));
if (idle.length) rows.push([scope, idle]);
}
return rows;
}
/**
* Has this app never replied, or did it reply and then stop? Pure.
* never-replied points here. stopped-replying points at the auto-disabled note.
*/
export function replyShape(messages, botId, botUser, minUnanswered = 3) {
const mention = botUser ? `<@${botUser}>` : null;
const triggers = [];
const replies = [];
for (const m of messages ?? []) {
const ts = Number(m.ts ?? 0);
const ours = (botId && m.bot_id === botId) || (botUser && m.user === botUser);
if (ours) replies.push(ts);
else if (mention && String(m.text ?? '').includes(mention)) triggers.push(ts);
}
triggers.sort((a, b) => a - b);
replies.sort((a, b) => a - b);
if (!triggers.length) {
return ['not-addressed',
'nobody has addressed this app in the sampled history, so its silence is ' +
'correct and proves nothing'];
}
if (!replies.length) {
return ['never-replied',
`${triggers.length} mention(s) and not one reply, ever. An app that has never ` +
'answered is usually one that was never subscribed, rather than one whose ' +
'delivery was switched off'];
}
const last = replies[replies.length - 1];
const after = triggers.filter((t) => t > last);
if (after.length >= Number(minUnanswered)) {
return ['stopped-replying',
`${replies.length} reply(ies) and then ${after.length} unanswered mention(s). ` +
'This app worked and stopped, which is the auto-disabled note rather than ' +
'this one'];
}
return ['answering',
`${triggers.length} mention(s), ${replies.length} reply(ies), most recent reply ` +
'after the last mention'];
}
/**
* Combine what was read with what was inferred. Pure.
* The read half outranks the inferred half where they overlap.
*/
export function finding(state, shape) {
if (state === 'none') {
return ['no-subscriptions',
'subscribe the events this app handles under Event Subscriptions, then ' +
'reinstall if any of them needed a scope you do not hold'];
}
if (shape === 'stopped-replying') {
return ['delivery-stopped',
'this is not a subscription problem: the app answered and then stopped, so ' +
'read the note on Slack disabling event delivery'];
}
if (state === 'configured') {
if (shape === 'never-replied') {
return ['subscribed-but-inert',
'events are subscribed and nothing has ever been answered, so the gap is ' +
'downstream: the request URL, or an event whose scope the token was never ' +
'granted'];
}
if (shape === 'answering') {
return ['healthy', 'events are subscribed and the app is answering them'];
}
return ['no-evidence',
'events are subscribed and nobody has addressed the app, so there is nothing ' +
'to judge'];
}
if (shape === 'never-replied') {
return ['probably-unsubscribed',
'the manifest was not read and the app has never once answered. Get an app ' +
'configuration token and check bot_events before changing any code'];
}
return ['not-assessed',
'the manifest was not read and the workspace evidence is inconclusive'];
}
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} (channels:history and channels:read are enough)`);
process.exitCode = 2;
return;
}
const configTokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_TOKEN');
const appId = arg(args, '--app-id', '');
const limit = arg(args, '--limit', '200');
const [who, scopeHeader] = await get(token, 'auth.test', {}, 'auth.test');
if (!who) { process.exitCode = 2; return; }
const botUser = who.user_id ?? '';
const botId = who.bot_id ?? '';
console.log(`identity ${botUser} in ${who.team}`);
const scopes = String(scopeHeader ?? '').split(',').map((p) => p.trim()).filter(Boolean);
let manifest = null;
const configToken = process.env[configTokenEnv];
if (configToken && appId) {
const [body] = await get(configToken, 'apps.manifest.export', { app_id: appId },
'manifest');
manifest = body?.manifest ?? null;
} else if (!configToken) {
console.log(`manifest skipped ${configTokenEnv} is unset, so the ` +
'subscription list cannot be read directly');
}
const [state, events, detail] = subscriptionState(manifest);
(state === 'configured' ? console.log : console.warn)(
`manifest ${state.padEnd(14)} ${detail}`);
const idle = scopeWithoutSubscription(scopes, events);
if (state === 'configured' && !idle.length) {
console.log('scopes matched every event scope on this token has a ' +
'subscription behind it');
}
for (const [scope, unsubscribed] of idle) {
console.warn(`scopes idle ${scope} is granted and none of ` +
`${unsubscribed.join(', ')} is subscribed; the scope makes the event ` +
'available, it does not turn it on');
}
let shape = 'not-addressed';
let why = 'no channel was sampled';
for (const channel of argAll(args, '--channel')) {
const [body] = await get(token, 'conversations.history',
{ channel, limit: String(limit) }, 'history');
if (!body) continue;
[shape, why] = replyShape(body.messages ?? [], botId, botUser);
(shape === 'answering' || shape === 'not-addressed' ? console.log : console.warn)(
`history ${shape.padEnd(14)} ${why} in ${channel}`);
if (shape === 'never-replied' || shape === 'stopped-replying') break;
}
const [verdict, action] = finding(state, shape);
(verdict === 'healthy' ? console.log : console.warn)(
`verdict ${verdict.padEnd(14)} ${action}`);
if (verdict === 'no-subscriptions' || verdict === 'probably-unsubscribed') {
console.warn(' repair: Event Subscriptions, Subscribe to bot events, add ' +
'app_mention for mentions and message.channels, message.groups, message.im ' +
'or message.mpim per channel type');
console.warn(' repair: in a manifest-managed app, populate ' +
'settings.event_subscriptions.bot_events and redeploy the manifest');
console.warn(' repair: reinstall afterwards if any added event needed a scope ' +
'this token does not already hold');
process.exitCode = 1;
} else if (verdict === 'subscribed-but-inert' || verdict === 'delivery-stopped') {
process.exitCode = 1;
}
}
// 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 test that keeps this script honest is the one about the manifest it could not read. unreadable and none have to stay two states, because collapsing them turns a missing credential into a confident instruction to add subscriptions that are already there. The other one that carries weight is the split between never-replied and stopped-replying: the same channel history, read with one extra reply in it, has to change which note the reader is sent to.
from slack_event_subscription_audit import (
finding, reply_shape, scope_without_subscription, subscription_state)
def manifest(bot_events=None, user_events=None):
return {"settings": {"event_subscriptions": {
"bot_events": bot_events or [], "user_events": user_events or []}}}
def msg(ts, text="", user="", bot_id=""):
return {"ts": str(ts), "text": text, "user": user, "bot_id": bot_id}
def test_an_empty_bot_events_list_is_the_finding():
state, events, detail = subscription_state(manifest([]))
assert state == "none"
assert events == []
assert "zero events" in detail
def test_a_manifest_that_could_not_be_read_is_never_reported_as_empty():
state, events, detail = subscription_state(None)
assert state == "unreadable"
assert events == []
assert "not a finding" in detail
def test_subscribed_events_are_deduplicated_and_sorted():
state, events, _ = subscription_state(
manifest(["message.im", "app_mention"], ["app_mention", " "]))
assert state == "configured"
assert events == ["app_mention", "message.im"]
def test_the_scope_that_reads_like_a_subscription_is_reported_as_idle():
rows = scope_without_subscription(["app_mentions:read", "chat:write"], [])
assert rows == [("app_mentions:read", ["app_mention"])]
def test_a_scope_whose_event_is_subscribed_is_not_a_row():
assert scope_without_subscription(["app_mentions:read"], ["app_mention"]) == []
def test_a_scope_with_several_events_reports_only_the_unsubscribed_ones():
rows = scope_without_subscription(["reactions:read"], ["reaction_added"])
assert rows == [("reactions:read", ["reaction_removed"])]
def test_scopes_that_unlock_no_events_are_left_out_of_the_diff():
assert scope_without_subscription(["chat:write", "commands", ""], []) == []
def test_mentions_with_no_reply_ever_is_the_shape_this_note_owns():
shape, detail = reply_shape(
[msg(10, "<@U1> status"), msg(20, "<@U1> status"), msg(30, "<@U1> ping")],
"B1", "U1")
assert shape == "never-replied"
assert "not one reply" in detail
def test_replies_followed_by_unanswered_mentions_belongs_to_the_other_note():
shape, detail = reply_shape(
[msg(10, "<@U1> a"), msg(11, bot_id="B1"),
msg(20, "<@U1> b"), msg(30, "<@U1> c"), msg(40, "<@U1> d")], "B1", "U1")
assert shape == "stopped-replying"
assert "auto-disabled" in detail
def test_one_unanswered_mention_after_a_reply_is_not_a_cliff():
shape, _ = reply_shape(
[msg(10, "<@U1> a"), msg(11, bot_id="B1"), msg(20, "<@U1> b")], "B1", "U1")
assert shape == "answering"
def test_a_channel_nobody_addressed_produces_no_evidence():
assert reply_shape([msg(10, "morning"), msg(11, "morning")], "B1", "U1")[0] \
== "not-addressed"
def test_the_apps_own_mention_of_itself_is_a_reply_not_a_trigger():
assert reply_shape([msg(10, "<@U1> hello", bot_id="B1")], "B1", "U1")[0] \
== "not-addressed"
def test_zero_subscriptions_outranks_every_behavioural_reading():
assert finding("none", "answering")[0] == "no-subscriptions"
assert finding("none", "not-addressed")[0] == "no-subscriptions"
def test_a_cliff_is_handed_to_the_note_that_owns_it():
verdict, action = finding("configured", "stopped-replying")
assert verdict == "delivery-stopped"
assert "not a subscription problem" in action
def test_subscribed_and_never_answering_points_downstream_instead():
assert finding("configured", "never-replied")[0] == "subscribed-but-inert"
assert finding("configured", "answering")[0] == "healthy"
def test_an_unread_manifest_gives_a_hedged_verdict_not_a_confident_one():
verdict, action = finding("unreadable", "never-replied")
assert verdict == "probably-unsubscribed"
assert "before changing any code" in action
assert finding("unreadable", "not-addressed")[0] == "not-assessed"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
finding, replyShape, scopeWithoutSubscription, subscriptionState,
} from './slack-event-subscription-audit.mjs';
const manifest = (botEvents = [], userEvents = []) => ({
settings: { event_subscriptions: { bot_events: botEvents, user_events: userEvents } },
});
const msg = (ts, text = '', user = '', botId = '') => ({
ts: String(ts), text, user, bot_id: botId,
});
test('an empty bot_events list is the finding', () => {
const [state, events, detail] = subscriptionState(manifest([]));
assert.equal(state, 'none');
assert.deepEqual(events, []);
assert.match(detail, /zero events/);
});
test('a manifest that could not be read is never reported as empty', () => {
const [state, events, detail] = subscriptionState(null);
assert.equal(state, 'unreadable');
assert.deepEqual(events, []);
assert.match(detail, /not a finding/);
});
test('subscribed events are deduplicated and sorted', () => {
const [state, events] = subscriptionState(
manifest(['message.im', 'app_mention'], ['app_mention', ' ']));
assert.equal(state, 'configured');
assert.deepEqual(events, ['app_mention', 'message.im']);
});
test('the scope that reads like a subscription is reported as idle', () => {
assert.deepEqual(scopeWithoutSubscription(['app_mentions:read', 'chat:write'], []),
[['app_mentions:read', ['app_mention']]]);
});
test('a scope whose event is subscribed is not a row', () => {
assert.deepEqual(scopeWithoutSubscription(['app_mentions:read'], ['app_mention']), []);
});
test('a scope with several events reports only the unsubscribed ones', () => {
assert.deepEqual(scopeWithoutSubscription(['reactions:read'], ['reaction_added']),
[['reactions:read', ['reaction_removed']]]);
});
test('scopes that unlock no events are left out of the diff', () => {
assert.deepEqual(scopeWithoutSubscription(['chat:write', 'commands', ''], []), []);
});
test('mentions with no reply ever is the shape this note owns', () => {
const [shape, detail] = replyShape(
[msg(10, '<@U1> status'), msg(20, '<@U1> status'), msg(30, '<@U1> ping')],
'B1', 'U1');
assert.equal(shape, 'never-replied');
assert.match(detail, /not one reply/);
});
test('replies followed by unanswered mentions belongs to the other note', () => {
const [shape, detail] = replyShape(
[msg(10, '<@U1> a'), msg(11, '', '', 'B1'),
msg(20, '<@U1> b'), msg(30, '<@U1> c'), msg(40, '<@U1> d')], 'B1', 'U1');
assert.equal(shape, 'stopped-replying');
assert.match(detail, /auto-disabled/);
});
test('one unanswered mention after a reply is not a cliff', () => {
const [shape] = replyShape(
[msg(10, '<@U1> a'), msg(11, '', '', 'B1'), msg(20, '<@U1> b')], 'B1', 'U1');
assert.equal(shape, 'answering');
});
test('a channel nobody addressed produces no evidence', () => {
assert.equal(replyShape([msg(10, 'morning'), msg(11, 'morning')], 'B1', 'U1')[0],
'not-addressed');
});
test('the apps own mention of itself is a reply not a trigger', () => {
assert.equal(replyShape([msg(10, '<@U1> hello', '', 'B1')], 'B1', 'U1')[0],
'not-addressed');
});
test('zero subscriptions outranks every behavioural reading', () => {
assert.equal(finding('none', 'answering')[0], 'no-subscriptions');
assert.equal(finding('none', 'not-addressed')[0], 'no-subscriptions');
});
test('a cliff is handed to the note that owns it', () => {
const [verdict, action] = finding('configured', 'stopped-replying');
assert.equal(verdict, 'delivery-stopped');
assert.match(action, /not a subscription problem/);
});
test('subscribed and never answering points downstream instead', () => {
assert.equal(finding('configured', 'never-replied')[0], 'subscribed-but-inert');
assert.equal(finding('configured', 'answering')[0], 'healthy');
});
test('an unread manifest gives a hedged verdict not a confident one', () => {
const [verdict, action] = finding('unreadable', 'never-replied');
assert.equal(verdict, 'probably-unsubscribed');
assert.match(action, /before changing any code/);
assert.equal(finding('unreadable', 'not-addressed')[0], 'not-assessed');
});
FAQ
I added app_mentions:read. Why is the bot still silent?
Because that scope is a permission, not a subscription. It makes the app_mention event available to your app; it does not ask Slack to send it. The subscription is a separate list under Event Subscriptions, Subscribe to bot events, and until app_mention appears there nothing is delivered. The naming is genuinely misleading and this is the most common way into this failure.
Do I really need an app configuration token to check this?
To read the answer, yes: no bot token can see which events an app subscribes to, so the manifest export is the only direct route. Without it the script still runs and gives you a hedged verdict from workspace evidence. It will say probably-unsubscribed rather than no-subscriptions, and the difference in wording is deliberate, because one is a reading and the other is a fact.
The bot is in the channel. Doesn't that mean it receives the messages?
No. Membership controls what the app is allowed to read through the Web API and where it can post. Delivery of events is a separate mechanism entirely, driven by the subscription list and the request URL. An app can be in a thousand channels and receive nothing at all, which is exactly the state this note is about.
How is this different from Slack disabling my event subscriptions?
History. An app whose delivery Slack disabled has a record of replies and then a cliff, because it worked until the failures crossed the threshold. An app that was never subscribed has no replies at all, from installation to now. The script reports those as stopped-replying and never-replied and sends the first case to the other note, because the repairs are unrelated: one re-enables a switch, the other adds a list that was always empty.
Once I subscribe the events, is that the whole fix?
Usually not quite. An event whose required scope the token does not hold will still never arrive, silently, even though the subscription is now present. That is the sibling failure to this one. Add the scope alongside the subscription, keep them together in the manifest so they cannot drift, and reinstall so the token actually carries the new grant.
Related field notes
- when delivery worked and then stopped
- when the subscription exists and the scope does not
- the credential this script needs, and how it dies
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.
- Events API - Slack Docs
- apps.manifest.export method reference - Slack Docs
- app_mention event reference - Slack Docs
- Scopes - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.