Slack: log the token's scopes, and which optional ones are missing

Slack names a token's scopes in a header on every answer; the module
now logs them once the token is checked, and says plainly when
reactions:write or emoji:read is absent and how to add it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-14 16:58:35 -04:00
co-authored by Claude Fable 5.1
parent ca696a3931
commit cc148926be
+14
View File
@@ -53,6 +53,10 @@ const API_BASE = String(process.env.SLACK_API_BASE || DEFAULT_API_BASE).replace(
/** Abort a hung Slack request well before the poll cadence stacks up. */ /** Abort a hung Slack request well before the poll cadence stacks up. */
const REQUEST_TIMEOUT_MS = 10000; const REQUEST_TIMEOUT_MS = 10000;
/** The scopes on the token, as Slack's x-oauth-scopes header names them on every answer. */
let tokenScopes = '';
/** Scopes a feature needs beyond reading: what to tell the tech when one is missing. */
const OPTIONAL_SCOPES = [['reactions:write', 'emoji reactions'], ['emoji:read', 'custom emoji']];
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000; const HEARTBEAT_MS = 15000;
/** Fastest allowed history poll; the admin default is 2 s. */ /** Fastest allowed history poll; the admin default is 2 s. */
@@ -168,6 +172,8 @@ async function slackCall(token, method, params = {}, { json = false } = {}) {
} catch (err) { } catch (err) {
throw new SlackError('network', { network: true, detail: err?.message || String(err) }); throw new SlackError('network', { network: true, detail: err?.message || String(err) });
} }
const scopes = response.headers.get('x-oauth-scopes');
if (scopes) tokenScopes = scopes;
if (response.status === 429) { if (response.status === 429) {
const retryAfter = Math.max(1, Math.trunc(Number(response.headers.get('retry-after'))) || 5); const retryAfter = Math.max(1, Math.trunc(Number(response.headers.get('retry-after'))) || 5);
throw new SlackError('ratelimited', { retryAfter, status: 429 }); throw new SlackError('ratelimited', { retryAfter, status: 429 });
@@ -987,6 +993,14 @@ module.exports = {
authError = ''; authError = '';
invalidateAll(); invalidateAll();
log(`token belongs to ${identityLabel()} (${id.team || 'team unknown'})${id.kind === 'bot' && !sendAs ? ' — messages will read as the app; set "Send as" to name the location' : ''}`); log(`token belongs to ${identityLabel()} (${id.team || 'team unknown'})${id.kind === 'bot' && !sendAs ? ' — messages will read as the app; set "Send as" to name the location' : ''}`);
if (tokenScopes) {
const have = new Set(tokenScopes.split(',').map((x) => x.trim()));
const missing = OPTIONAL_SCOPES.filter(([scope]) => !have.has(scope));
log(`token scopes: ${tokenScopes}`);
if (missing.length) {
log(`without ${missing.map(([scope, what]) => `${scope} (${what})`).join(' and ')} — add under OAuth & Permissions → User Token Scopes, Reinstall to Workspace as this account, paste the token it shows`);
}
}
} }
/** Every poller wants the identity; the first to ask makes the one call. */ /** Every poller wants the identity; the first to ask makes the one call. */