Diagnostic Slack
link_shared never fires: no domain was ever registered
The handler is written, deployed and covered by tests. Somebody pastes a link to your product into #support and it posts as a bare blue URL with no preview at all. Nothing appears in the logs, because nothing arrived. The handler was never called.
There is no error to search for. Slack did not reject anything, your endpoint did not time out, and the app is in the channel with a valid token. Unfurling is gated by four separate switches and only one of them lives anywhere near your code, so the app sits there behaving perfectly and receiving nothing.
Slack sends link_shared only for domains the app has explicitly registered under Event Subscriptions → App unfurl domains. Not for every link. Not for links in channels the bot is in. Only for registered domains, and only to an app that also holds links:read, and only if the link_shared event is subscribed. Answering then needs chat.unfurl, which needs links:write.
Four preconditions, and every one of them fails silently. There is no ok: false, no rejected event, no warning on install. The app simply never hears about the link, and from inside the handler that is indistinguishable from nobody having posted one.
Two more rules make this harder to test than it should be. Slack suppresses link_shared for links the app posts itself, so the obvious way to check — have the bot post its own link — proves nothing. And unfurls honour per-user preferences, so a link that unfurls for you may not for a colleague who turned previews off.
Which precondition is missing is decidable without guessing. The domain list and the event subscription come from the app manifest, the scopes come from the X-OAuth-Scopes header on any response, and the evidence that it is not working comes from conversations.history: your links, sitting in a channel, with no unfurl attached.
The problem in plain words
The reason this eats a day is that everything you can check looks correct. The bot is installed. The bot is in the channel. The Request URL is verified, and other events arrive through it, so the endpoint is definitely reachable. The code has a branch for link_shared. Every hypothesis you can form from inside the application is already disproved, and the actual answer is a text box on a web page nobody opened.
The domain registration is the switch that catches almost everyone, because it does not resemble anything else in the Slack app model. Scopes are a list, events are a list, and both are visible in the manifest next to code that mentions them. The unfurl domain list is a third list, in a different place, with its own rules: Slack matches a registered domain and its subdomains, so registering the apex is what you want — and registering www.example.com instead means every link without the www is invisible, because example.com is not a subdomain of www.example.com. Pasting in a full URL with a scheme or a path fails the same quiet way.
The scope pair is the second trap, and the two scopes fail at opposite ends. Without links:read the event never arrives, so you see nothing. With links:read but without links:write the event arrives, your handler runs happily, and chat.unfurl comes back {"ok": false, "error": "missing_scope"} — which is at least a visible failure, and is a completely different debugging session from the silent one.
Then there is the self-post rule. The natural test is to have the bot post a link to your own product and watch. Slack does not fire link_shared for links posted by the app itself, so that test returns a negative result no matter how correct the configuration is, and people conclude the configuration is wrong when it may be fine.
Finally, adding the domain and the scopes changes the token grant, which means a reinstall. Apps get all four things right, skip the reinstall, and stay broken with a configuration page that says everything is set.
Why it happens
Silence is not evidence of anything, so the script goes looking for the evidence. A handler that never runs writes no logs, so the only proof that unfurling is not happening lives in the workspace: messages your users already posted, carrying links on your domain, with no unfurl attached to them. That is a read, and it turns "it does not seem to work" into a count.
The four preconditions have an order, and reporting them out of order wastes the fix. If no domain is registered, the scopes do not matter yet. If the event is not subscribed, links:write does not matter yet. The script names the first missing one rather than listing all four and leaving you to work out which to do first.
A registered domain is matched literally, and the ways to write it wrong all look fine. A scheme, a path, a port, a leading www., a wildcard someone added because wildcards feel right: each one is a string that reads as a domain and matches nothing. The script checks the shape of what is registered before it checks whether anything matched.
Links the app posted itself are excluded from the evidence, not counted against it. Slack suppresses the event for those. Counting them as failures inflates the finding and, worse, sends people to re-check a handler that was never going to be called for that message.
A link on an unregistered domain is a different row from a link on a registered one. The first will never fire whatever you do to the code. The second should have fired and did not, which is the row that means the remaining preconditions are worth checking. Merging them produces a number that cannot be acted on.
The two scopes fail at opposite ends and want to be named separately. Missing links:read is silence. Missing links:write is a loud missing_scope after your handler already did its work. Same feature, same page, entirely different symptom.
The fix, as a flow
The red box here is the second one and everything after it is grey, which is unusual and is the note. Nothing failed. No request was refused, no handler timed out, no scope was checked and found wanting. Slack looked at a list, did not find your app on it, and created no event, so the three boxes downstream are not failures but consequences of an absence. The fix branch is ordered rather than sorted: it names the first missing switch, because the ones after it cannot be tested until that one is flipped.
How to fix it
Read the domain list and the subscription out of the manifest
Export the manifest from the app configuration page and pass it with --manifest. features.unfurl_domains is the registered list and settings.event_subscriptions.bot_events is where link_shared has to appear. A bot token cannot read either of these; this is app configuration, not workspace state.
Check the shape of every registered domain before you check anything else
registration_fault returns scheme-included, path-included, port-included, wildcard, www-prefixed or usable. Each of the failing ones is a string that reads perfectly as a domain and matches no link at all, and finding one here ends the investigation before any workspace read.
Take the granted scopes from the response header, not from the manifest
X-OAuth-Scopes on any Web API response lists what the token actually holds, which is what matters. The manifest lists what the app asks for. An app whose manifest requests links:read and whose token was granted before that line was added has a manifest that says yes and a token that says no.
Name the first missing precondition rather than all of them
precondition_gap returns no-domains, not-subscribed, no-links-read, no-links-write or ready, in that order. The order is the fix order. Anything after the first missing one is unknowable until that one is done, because the failures mask each other.
Prove it from the channel, using links other people posted
conversations.history returns what is already there. link_urls pulls the URLs out of Slack's <url|label> markup, domain_match decides which registered domain covers each one, and unfurl_state sorts each message into unfurled, not-unfurled, off-domain, self-posted or no-links.
Discard the app's own links from the evidence
self-posted exists because Slack does not fire link_shared for links the app posted itself. Those messages tell you nothing about whether unfurling works, so they are excluded from the count rather than reported as failures. If every link in your test channel was posted by the bot, the run has no evidence in it and says so.
How to check it worked
Run it against a channel where people paste your links. The finding is one missing precondition and a count of links that went unadorned.
python3 slack_unfurl_silence.py --manifest app-manifest.json --channel C024BE91L
# identity U024BE7LH in Acme
# scopes chat:write,channels:history,channels:read,links:read
# manifest 1 unfurl domain(s), link_shared subscribed
# domain www-prefixed www.acme.dev Slack matches this domain and its
# subdomains, and acme.dev is not a subdomain of it
# gap no-links-write links:read is granted so the event can arrive, but
# chat.unfurl needs links:write to answer it
# history 182 message(s) read from C024BE91L
# links not-unfurled 31 link(s) on a registered domain, none with an unfurl
# links off-domain 12 link(s) on acme.dev, which matches nothing registered
# links self-posted 4 link(s) posted by this app; Slack suppresses the event
# for those, so they are not evidence either way
# verdict unfurling cannot work as configured
# repair: register the apex acme.dev under Event Subscriptions, App unfurl domains
# repair: add links:write to the bot scopes and reinstall the app
The full code
Five pure functions and one read. registration_fault is the cheapest useful thing here and catches the majority of real cases without touching the network. domain_match implements the matching rule literally, apex plus subdomains, because implementing it generously would hide the exact mistake the note is about. link_urls unpacks Slack's link markup. unfurl_state classifies one posted message, and precondition_gap answers the only question that decides what you do next: which switch to flip first.
"""Find out why your links never unfurl, when nothing anywhere reports an error.
Read only. This reads the app manifest you exported, the scopes on the token,
and messages that are already in a channel. It never posts a link, never calls
chat.unfurl, and never changes the app configuration.
Unfurling has four preconditions: the domain is registered under App unfurl
domains, link_shared is subscribed, the token holds links:read to receive the
event and links:write to answer it. Every one of them fails silently, so the
useful output is which one is missing first.
"""
import argparse
import json
import logging
import os
import re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_unfurl_silence")
API = "https://slack.com/api/"
# Slack rewrites links in message text as <url> or <url|label>. Anything left
# after those are removed is a bare URL somebody typed.
LINK_MARKUP = re.compile(r"<(https?://[^>|\s]+)(?:\|[^>]*)?>")
BARE_URL = re.compile(r"https?://[^\s<>|]+")
def link_urls(text):
"""Every URL in a message's text, unwrapped from Slack's markup. Pure."""
body = str(text or "")
found = list(LINK_MARKUP.findall(body))
found.extend(BARE_URL.findall(LINK_MARKUP.sub(" ", body)))
out = []
for url in found:
cleaned = url.rstrip(".,;:)]>")
if cleaned and cleaned not in out:
out.append(cleaned)
return out
def host_of(url):
"""The host part of a URL, lowercased and stripped of everything else. Pure."""
text = str(url or "").strip()
if "://" in text:
text = text.split("://", 1)[1]
for sep in ("/", "?", "#"):
text = text.split(sep, 1)[0]
if "@" in text:
text = text.split("@", 1)[1]
text = text.split(":", 1)[0]
return text.strip().lower().rstrip(".")
def registration_fault(value):
"""Is this registered entry a domain Slack can match? Pure.
Returns (verdict, detail). Every failing verdict is a string that reads
perfectly well as a domain to a human and matches nothing at all in Slack,
which is why this runs before any network call.
"""
raw = str(value or "").strip()
if not raw:
return ("empty", "an empty entry in the unfurl domain list")
if "://" in raw:
return ("scheme-included", "%s carries a scheme. The field takes a domain, so "
"the scheme becomes part of the string being "
"matched and nothing matches it" % raw)
if "/" in raw:
return ("path-included", "%s carries a path. Slack matches on the domain; a "
"path here matches no link" % raw)
if raw.startswith("*."):
return ("wildcard", "%s is a wildcard. Slack already matches the registered "
"domain and every subdomain of it, so the literal *. is "
"just a domain that does not exist" % raw)
if ":" in raw:
return ("port-included", "%s carries a port, which is not part of the domain "
"Slack compares against" % raw)
low = raw.lower()
if "." not in low or low.startswith(".") or low.endswith("."):
return ("not-a-domain", "%s is not shaped like a domain" % raw)
if low.startswith("www."):
return ("www-prefixed", "%s matches this domain and its subdomains, and %s is "
"not a subdomain of it. Every link without the www "
"prefix is invisible to the app" % (low, low[4:]))
return ("usable", "%s is registered and will match itself and its subdomains" % low)
def domain_match(url, registered):
"""Which registered domain, if any, covers this URL? Pure.
Implemented literally: a registered domain matches itself and anything
ending in a dot plus itself. Being generous here would hide the exact
mistake this note exists for, so it is not.
"""
host = host_of(url)
if not host:
return ""
best = ""
for entry in registered or []:
domain = str(entry or "").strip().lower().rstrip(".")
if not domain:
continue
if host == domain or host.endswith("." + domain):
if len(domain) > len(best):
best = domain
return best
def unfurl_state(message, own_ids, registered):
"""What does this posted message tell you about unfurling? Pure.
Returns (verdict, detail, urls). self-posted is excluded from the evidence
rather than counted as a failure, because Slack does not fire link_shared
for links the app posted itself and the message could never have unfurled
however correct the configuration is.
"""
msg = message or {}
urls = link_urls(msg.get("text"))
if not urls:
return ("no-links", "nothing to unfurl in this message", [])
author = {str(msg.get("user") or ""), str(msg.get("bot_id") or ""),
str(msg.get("app_id") or "")} - {""}
if author & {str(x) for x in (own_ids or set()) if x}:
return ("self-posted", "this app posted the link. Slack suppresses "
"link_shared for an app's own links, so this message "
"is not evidence either way", urls)
matched = [u for u in urls if domain_match(u, registered)]
if not matched:
return ("off-domain", "no URL here falls under a registered domain, so no "
"link_shared would ever fire for this message", urls)
attached = []
for att in (msg.get("attachments") or []):
attached.append(str((att or {}).get("app_unfurl_url")
or (att or {}).get("from_url") or ""))
for url in matched:
if url in attached:
return ("unfurled", "%s came back with an unfurl attached" % url, matched)
if msg.get("attachments"):
return ("not-unfurled", "%d attachment(s) on this message and none of them is "
"an unfurl of %s" % (len(msg["attachments"]),
matched[0]), matched)
return ("not-unfurled", "%s is on a registered domain and posted with no unfurl "
"at all" % matched[0], matched)
def precondition_gap(domains, events, scopes):
"""Which of the four preconditions is missing, in fix order? Pure.
The order is the point. A missing domain makes the scopes unknowable, and a
missing links:read makes links:write academic, so reporting all four at once
produces a list nobody can start on.
"""
usable = [d for d in (domains or [])
if registration_fault(d)[0] == "usable"]
if not usable:
return ("no-domains", "no usable domain is registered under App unfurl "
"domains, so link_shared cannot fire for any link at "
"all. This is the first thing to fix and nothing after "
"it can be tested until it is done")
if events is not None and "link_shared" not in set(events or []):
return ("not-subscribed", "%d domain(s) are registered and link_shared is not "
"in the subscribed bot events. Registering the "
"domain does not subscribe the event"
% len(usable))
if scopes is None:
return ("scopes-unread", "the domain and the subscription are in place; the "
"granted scopes were not read, so links:read and "
"links:write are unverified")
held = {s.strip() for s in scopes}
if "links:read" not in held:
return ("no-links-read", "links:read is not granted, so the event will not be "
"delivered however the domain is registered. This is "
"the silent one: no error appears anywhere")
if "links:write" not in held:
return ("no-links-write", "links:read is granted so the event can arrive, but "
"chat.unfurl needs links:write to answer it. This "
"one fails loudly, after your handler has already "
"done its work")
return ("ready", "all four preconditions are in place: a registered domain, the "
"link_shared subscription, links:read and links:write")
def manifest_facts(manifest):
"""Pull the unfurl domains and subscribed bot events out of a manifest."""
doc = manifest or {}
doc = doc.get("manifest", doc)
features = doc.get("features") or {}
settings = doc.get("settings") or {}
events = ((settings.get("event_subscriptions") or {}).get("bot_events"))
return list(features.get("unfurl_domains") or []), events
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--manifest", default="",
help="path to a manifest exported from the app configuration page")
ap.add_argument("--domain", action="append", default=[],
help="a registered unfurl domain, if you have no manifest to hand")
ap.add_argument("--channel", action="append", default=[],
help="a channel to read for evidence; repeatable")
ap.add_argument("--limit", type=int, default=200,
help="messages to read per channel")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
args = ap.parse_args()
domains, events = list(args.domain), None
if args.manifest:
domains_from_file, events = manifest_facts(
json.loads(open(args.manifest, encoding="utf-8").read()))
domains.extend(domains_from_file)
token = os.environ.get(args.token_env)
if not token:
log.error("set %s; the scopes and the channel evidence both need one",
args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
r = s.get(API + "auth.test", timeout=30)
who = r.json()
if who.get("ok") is not True:
log.error("auth.test unavailable %s", who.get("error"))
return 2
scopes = None
header = r.headers.get("X-OAuth-Scopes")
if header:
scopes = [p.strip() for p in header.split(",") if p.strip()]
log.info("identity %s in %s", who.get("user_id"), who.get("team"))
log.info("scopes %s", ",".join(scopes) if scopes else "not reported")
log.info("manifest %d unfurl domain(s), link_shared %s", len(domains),
"subscribed" if events and "link_shared" in events
else "not subscribed" if events is not None else "unknown")
broken = 0
for entry in domains:
verdict, detail = registration_fault(entry)
if verdict != "usable":
broken += 1
log.warning("domain %-14s %s", verdict, detail)
gap, detail = precondition_gap(domains, events, scopes)
(log.info if gap == "ready" else log.warning)("gap %-14s %s", gap, detail)
own = {who.get("user_id"), who.get("bot_id"), who.get("app_id")}
tally = {}
for channel in args.channel:
body = s.get(API + "conversations.history", timeout=30,
params={"channel": channel, "limit": str(args.limit)}).json()
if body.get("ok") is not True:
log.warning("history unavailable %s: %s", channel, body.get("error"))
continue
messages = body.get("messages") or []
log.info("history %d message(s) read from %s", len(messages), channel)
for msg in messages:
verdict, _, urls = unfurl_state(msg, own, domains)
if verdict == "no-links":
continue
tally[verdict] = tally.get(verdict, 0) + len(urls)
for verdict in ("not-unfurled", "off-domain", "self-posted", "unfurled"):
if verdict in tally:
(log.info if verdict == "unfurled" else log.warning)(
"links %-14s %d link(s)", verdict, tally[verdict])
if gap != "ready" or broken or tally.get("not-unfurled"):
log.warning("verdict unfurling cannot work as configured")
if broken or gap == "no-domains":
log.warning(" repair: register the apex domain under Event Subscriptions, "
"App unfurl domains. Slack matches it and every subdomain")
if gap == "not-subscribed":
log.warning(" repair: subscribe to link_shared under Subscribe to bot "
"events; registering the domain does not do it for you")
if gap in ("no-links-read", "no-links-write"):
log.warning(" repair: add links:read and links:write to the bot scopes "
"and reinstall the app; a scope change needs a fresh grant")
if not tally:
log.warning(" note: no evidence was read. Pass --channel with a channel "
"where people paste your links, and note that links the app "
"posts itself never fire the event")
return 1
log.info("verdict clean the configuration allows unfurling")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find out why your links never unfurl, when nothing reports an error anywhere.
*
* Read only. This reads the app manifest you exported, the scopes on the
* token, and messages already in a channel. It never posts a link, never calls
* chat.unfurl, and never changes the app configuration.
*
* Unfurling has four preconditions: the domain is registered under App unfurl
* domains, link_shared is subscribed, the token holds links:read to receive
* the event and links:write to answer it. Every one fails silently.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Slack rewrites links in message text as <url> or <url|label>. Anything left
// after those are removed is a bare URL somebody typed.
const LINK_MARKUP = /<(https?:\/\/[^>|\s]+)(?:\|[^>]*)?>/g;
const BARE_URL = /https?:\/\/[^\s<>|]+/g;
/** Every URL in a message's text, unwrapped from Slack's markup. Pure. */
export function linkUrls(text) {
const body = String(text ?? '');
const found = [...body.matchAll(LINK_MARKUP)].map((m) => m[1]);
found.push(...(body.replace(LINK_MARKUP, ' ').match(BARE_URL) ?? []));
const out = [];
for (const url of found) {
const cleaned = url.replace(/[.,;:)\]>]+$/, '');
if (cleaned && !out.includes(cleaned)) out.push(cleaned);
}
return out;
}
/** The host part of a URL, lowercased and stripped of everything else. Pure. */
export function hostOf(url) {
let text = String(url ?? '').trim();
if (text.includes('://')) [, text] = text.split('://');
for (const sep of ['/', '?', '#']) [text] = text.split(sep);
if (text.includes('@')) text = text.slice(text.indexOf('@') + 1);
[text] = text.split(':');
return text.trim().toLowerCase().replace(/\.+$/, '');
}
/**
* Is this registered entry a domain Slack can match? Pure.
* Returns [verdict, detail]; every failing verdict reads fine to a human.
*/
export function registrationFault(value) {
const raw = String(value ?? '').trim();
if (!raw) return ['empty', 'an empty entry in the unfurl domain list'];
if (raw.includes('://')) {
return ['scheme-included', `${raw} carries a scheme. The field takes a domain, so `
+ 'the scheme becomes part of the string being matched and nothing matches it'];
}
if (raw.includes('/')) {
return ['path-included', `${raw} carries a path. Slack matches on the domain; a `
+ 'path here matches no link'];
}
if (raw.startsWith('*.')) {
return ['wildcard', `${raw} is a wildcard. Slack already matches the registered `
+ 'domain and every subdomain of it, so the literal *. is just a domain that '
+ 'does not exist'];
}
if (raw.includes(':')) {
return ['port-included', `${raw} carries a port, which is not part of the domain `
+ 'Slack compares against'];
}
const low = raw.toLowerCase();
if (!low.includes('.') || low.startsWith('.') || low.endsWith('.')) {
return ['not-a-domain', `${raw} is not shaped like a domain`];
}
if (low.startsWith('www.')) {
return ['www-prefixed', `${low} matches this domain and its subdomains, and `
+ `${low.slice(4)} is not a subdomain of it. Every link without the www prefix `
+ 'is invisible to the app'];
}
return ['usable', `${low} is registered and will match itself and its subdomains`];
}
/** Which registered domain, if any, covers this URL? Pure. */
export function domainMatch(url, registered) {
const host = hostOf(url);
if (!host) return '';
let best = '';
for (const entry of registered ?? []) {
const domain = String(entry ?? '').trim().toLowerCase().replace(/\.+$/, '');
if (!domain) continue;
if ((host === domain || host.endsWith(`.${domain}`)) && domain.length > best.length) {
best = domain;
}
}
return best;
}
/**
* What does this posted message tell you about unfurling? Pure.
* Returns [verdict, detail, urls].
*/
export function unfurlState(message, ownIds, registered) {
const msg = message ?? {};
const urls = linkUrls(msg.text);
if (!urls.length) return ['no-links', 'nothing to unfurl in this message', []];
const author = new Set([String(msg.user ?? ''), String(msg.bot_id ?? ''),
String(msg.app_id ?? '')].filter(Boolean));
const mine = [...(ownIds ?? [])].filter(Boolean).map(String);
if (mine.some((id) => author.has(id))) {
return ['self-posted', 'this app posted the link. Slack suppresses link_shared for '
+ 'an app\u2019s own links, so this message is not evidence either way', urls];
}
const matched = urls.filter((u) => domainMatch(u, registered));
if (!matched.length) {
return ['off-domain', 'no URL here falls under a registered domain, so no '
+ 'link_shared would ever fire for this message', urls];
}
const attached = (msg.attachments ?? []).map(
(a) => String((a ?? {}).app_unfurl_url ?? (a ?? {}).from_url ?? ''));
for (const url of matched) {
if (attached.includes(url)) {
return ['unfurled', `${url} came back with an unfurl attached`, matched];
}
}
if ((msg.attachments ?? []).length) {
return ['not-unfurled', `${msg.attachments.length} attachment(s) on this message `
+ `and none of them is an unfurl of ${matched[0]}`, matched];
}
return ['not-unfurled', `${matched[0]} is on a registered domain and posted with no `
+ 'unfurl at all', matched];
}
/** Which of the four preconditions is missing, in fix order? Pure. */
export function preconditionGap(domains, events, scopes) {
const usable = (domains ?? []).filter((d) => registrationFault(d)[0] === 'usable');
if (!usable.length) {
return ['no-domains', 'no usable domain is registered under App unfurl domains, so '
+ 'link_shared cannot fire for any link at all. This is the first thing to fix '
+ 'and nothing after it can be tested until it is done'];
}
if (events !== null && events !== undefined && !new Set(events).has('link_shared')) {
return ['not-subscribed', `${usable.length} domain(s) are registered and `
+ 'link_shared is not in the subscribed bot events. Registering the domain does '
+ 'not subscribe the event'];
}
if (scopes === null || scopes === undefined) {
return ['scopes-unread', 'the domain and the subscription are in place; the granted '
+ 'scopes were not read, so links:read and links:write are unverified'];
}
const held = new Set(scopes.map((s) => String(s).trim()));
if (!held.has('links:read')) {
return ['no-links-read', 'links:read is not granted, so the event will not be '
+ 'delivered however the domain is registered. This is the silent one: no error '
+ 'appears anywhere'];
}
if (!held.has('links:write')) {
return ['no-links-write', 'links:read is granted so the event can arrive, but '
+ 'chat.unfurl needs links:write to answer it. This one fails loudly, after your '
+ 'handler has already done its work'];
}
return ['ready', 'all four preconditions are in place: a registered domain, the '
+ 'link_shared subscription, links:read and links:write'];
}
/** Pull the unfurl domains and subscribed bot events out of a manifest. */
export function manifestFacts(manifest) {
const doc = (manifest ?? {}).manifest ?? manifest ?? {};
const events = ((doc.settings ?? {}).event_subscriptions ?? {}).bot_events;
return [[...((doc.features ?? {}).unfurl_domains ?? [])], events];
}
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 domains = argAll(args, '--domain');
let events = null;
const manifestFile = arg(args, '--manifest', '');
if (manifestFile) {
const [fromFile, subscribed] = manifestFacts(
JSON.parse(await readFile(manifestFile, 'utf8')));
domains.push(...fromFile);
events = subscribed ?? null;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv}; the scopes and the channel evidence both need one`);
process.exitCode = 2;
return;
}
const headers = { Authorization: `Bearer ${token}` };
const r = await fetch(`${API}auth.test`, { headers });
const who = await r.json();
if (who.ok !== true) {
console.error(`auth.test unavailable ${who.error}`);
process.exitCode = 2;
return;
}
const header = r.headers.get('x-oauth-scopes');
const scopes = header ? header.split(',').map((p) => p.trim()).filter(Boolean) : null;
console.log(`identity ${who.user_id} in ${who.team}`);
console.log(`scopes ${scopes ? scopes.join(',') : 'not reported'}`);
console.log(`manifest ${domains.length} unfurl domain(s), link_shared `
+ `${events === null ? 'unknown' : (new Set(events).has('link_shared')
? 'subscribed' : 'not subscribed')}`);
let broken = 0;
for (const entry of domains) {
const [verdict, detail] = registrationFault(entry);
if (verdict !== 'usable') {
broken += 1;
console.warn(`domain ${verdict.padEnd(14)} ${detail}`);
}
}
const [gap, gapDetail] = preconditionGap(domains, events, scopes);
const gapLine = `gap ${gap.padEnd(14)} ${gapDetail}`;
if (gap === 'ready') console.log(gapLine); else console.warn(gapLine);
const own = new Set([who.user_id, who.bot_id, who.app_id]);
const tally = {};
const limit = arg(args, '--limit', '200');
for (const channel of argAll(args, '--channel')) {
const qs = new URLSearchParams({ channel, limit: String(limit) }).toString();
// eslint-disable-next-line no-await-in-loop
const body = await (await fetch(`${API}conversations.history?${qs}`, { headers })).json();
if (body.ok !== true) {
console.warn(`history unavailable ${channel}: ${body.error}`);
continue;
}
const messages = body.messages ?? [];
console.log(`history ${messages.length} message(s) read from ${channel}`);
for (const msg of messages) {
const [verdict, , urls] = unfurlState(msg, own, domains);
if (verdict === 'no-links') continue;
tally[verdict] = (tally[verdict] ?? 0) + urls.length;
}
}
for (const verdict of ['not-unfurled', 'off-domain', 'self-posted', 'unfurled']) {
if (verdict in tally) {
const line = `links ${verdict.padEnd(14)} ${tally[verdict]} link(s)`;
if (verdict === 'unfurled') console.log(line); else console.warn(line);
}
}
if (gap !== 'ready' || broken || tally['not-unfurled']) {
console.warn('verdict unfurling cannot work as configured');
if (broken || gap === 'no-domains') {
console.warn(' repair: register the apex domain under Event Subscriptions, App '
+ 'unfurl domains. Slack matches it and every subdomain');
}
if (gap === 'not-subscribed') {
console.warn(' repair: subscribe to link_shared under Subscribe to bot events; '
+ 'registering the domain does not do it for you');
}
if (gap === 'no-links-read' || gap === 'no-links-write') {
console.warn(' repair: add links:read and links:write to the bot scopes and '
+ 'reinstall the app; a scope change needs a fresh grant');
}
if (!Object.keys(tally).length) {
console.warn(' note: no evidence was read. Pass --channel with a channel where '
+ 'people paste your links, and note that links the app posts itself never '
+ 'fire the event');
}
process.exitCode = 1;
} else {
console.log('verdict clean the configuration allows unfurling');
}
}
// 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 that matter are the ones that keep the matching rule honest. domain_match has to match a subdomain of a registered apex and refuse the reverse, because that asymmetry is the entire www. mistake. precondition_gap has to return the first missing switch rather than a list, and has to keep no-links-read and no-links-write apart, since one is silence and the other is a loud error at the far end of the handler. And unfurl_state has to call the app's own links self-posted rather than counting them as failures, which is the assertion that stops the script from lying about its evidence.
from slack_unfurl_silence import (domain_match, host_of, link_urls, manifest_facts,
precondition_gap, registration_fault, unfurl_state)
OWN = {"U024BE7LH", "B024BE7LH", "A024BE7LH"}
REG = ["acme.dev"]
def msg(text, **kw):
row = {"text": text, "user": "U07J4K2QT"}
row.update(kw)
return row
def test_slack_link_markup_is_unwrapped():
assert link_urls("see <https://acme.dev/docs|the docs>") == ["https://acme.dev/docs"]
assert link_urls("see <https://acme.dev/docs>") == ["https://acme.dev/docs"]
def test_a_bare_url_is_found_too_and_duplicates_collapse():
assert link_urls("https://acme.dev/a and https://acme.dev/a") == \
["https://acme.dev/a"]
def test_trailing_punctuation_is_not_part_of_the_url():
assert link_urls("try https://acme.dev/x.") == ["https://acme.dev/x"]
def test_a_message_with_no_link_yields_nothing():
assert link_urls("no links here") == []
assert link_urls(None) == []
def test_the_host_is_stripped_of_everything_that_is_not_the_host():
assert host_of("https://Acme.dev:8443/docs?a=1#b") == "acme.dev"
assert host_of("http://user@acme.dev/") == "acme.dev"
assert host_of("acme.dev.") == "acme.dev"
def test_an_apex_matches_itself_and_its_subdomains():
assert domain_match("https://acme.dev/x", REG) == "acme.dev"
assert domain_match("https://docs.acme.dev/x", REG) == "acme.dev"
assert domain_match("https://a.b.acme.dev/x", REG) == "acme.dev"
def test_a_subdomain_registration_does_not_match_the_apex():
assert domain_match("https://acme.dev/x", ["www.acme.dev"]) == ""
assert domain_match("https://www.acme.dev/x", ["www.acme.dev"]) == "www.acme.dev"
def test_a_lookalike_domain_does_not_match():
assert domain_match("https://notacme.dev/x", REG) == ""
assert domain_match("https://acme.dev.evil.example/x", REG) == ""
def test_the_most_specific_registered_domain_wins():
assert domain_match("https://docs.acme.dev/x",
["acme.dev", "docs.acme.dev"]) == "docs.acme.dev"
def test_a_plain_apex_is_the_only_usable_registration():
assert registration_fault("acme.dev")[0] == "usable"
assert registration_fault("ACME.dev")[0] == "usable"
def test_every_way_of_writing_a_domain_wrong_gets_its_own_verdict():
assert registration_fault("https://acme.dev")[0] == "scheme-included"
assert registration_fault("acme.dev/docs")[0] == "path-included"
assert registration_fault("*.acme.dev")[0] == "wildcard"
assert registration_fault("acme.dev:443")[0] == "port-included"
assert registration_fault("localhost")[0] == "not-a-domain"
assert registration_fault("")[0] == "empty"
def test_a_www_registration_is_named_as_the_trap_it_is():
verdict, detail = registration_fault("www.acme.dev")
assert verdict == "www-prefixed"
assert "acme.dev is not a subdomain" in detail
def test_no_registered_domain_is_the_first_thing_reported():
verdict, detail = precondition_gap([], ["link_shared"],
["links:read", "links:write"])
assert verdict == "no-domains"
assert "nothing after it can be tested" in detail
def test_an_unusable_domain_counts_as_no_domain_at_all():
assert precondition_gap(["https://acme.dev"], ["link_shared"], ["links:read"])[0] \
== "no-domains"
def test_a_registered_domain_with_no_subscription_names_the_subscription():
verdict, detail = precondition_gap(REG, ["app_mention"], ["links:read"])
assert verdict == "not-subscribed"
assert "does not subscribe the event" in detail
def test_the_two_link_scopes_fail_at_opposite_ends():
silent, detail = precondition_gap(REG, ["link_shared"], ["chat:write"])
assert silent == "no-links-read"
assert "no error appears anywhere" in detail
loud, detail = precondition_gap(REG, ["link_shared"], ["links:read"])
assert loud == "no-links-write"
assert "fails loudly" in detail
def test_unread_scopes_are_not_reported_as_granted():
assert precondition_gap(REG, ["link_shared"], None)[0] == "scopes-unread"
def test_all_four_in_place_is_ready():
assert precondition_gap(REG, ["link_shared"],
["links:read", "links:write"])[0] == "ready"
def test_a_registered_link_with_no_attachment_is_the_finding():
verdict, detail, urls = unfurl_state(msg("look at https://acme.dev/x"), OWN, REG)
assert verdict == "not-unfurled"
assert urls == ["https://acme.dev/x"]
assert "no unfurl at all" in detail
def test_an_unfurled_link_is_recognised_by_its_attachment():
message = msg("look at https://acme.dev/x",
attachments=[{"app_unfurl_url": "https://acme.dev/x"}])
assert unfurl_state(message, OWN, REG)[0] == "unfurled"
def test_an_unrelated_attachment_does_not_count_as_an_unfurl():
message = msg("look at https://acme.dev/x",
attachments=[{"from_url": "https://example.com/other"}])
verdict, detail, _ = unfurl_state(message, OWN, REG)
assert verdict == "not-unfurled"
assert "none of them is an unfurl" in detail
def test_the_apps_own_link_is_excluded_from_the_evidence():
message = msg("here is https://acme.dev/x", user="U024BE7LH")
verdict, detail, _ = unfurl_state(message, OWN, REG)
assert verdict == "self-posted"
assert "not evidence either way" in detail
def test_a_link_on_an_unregistered_domain_is_its_own_row():
verdict, detail, _ = unfurl_state(msg("see https://example.com/x"), OWN, REG)
assert verdict == "off-domain"
assert "would ever fire" in detail
def test_off_domain_and_not_unfurled_are_never_collapsed():
assert unfurl_state(msg("https://example.com/x"), OWN, REG)[0] \
!= unfurl_state(msg("https://acme.dev/x"), OWN, REG)[0]
def test_the_manifest_gives_up_both_lists_through_either_wrapper():
doc = {"features": {"unfurl_domains": ["acme.dev"]},
"settings": {"event_subscriptions": {"bot_events": ["link_shared"]}}}
assert manifest_facts(doc) == (["acme.dev"], ["link_shared"])
assert manifest_facts({"manifest": doc}) == (["acme.dev"], ["link_shared"])
assert manifest_facts({}) == ([], None)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
domainMatch, hostOf, linkUrls, manifestFacts, preconditionGap, registrationFault,
unfurlState,
} from './slack-unfurl-silence.mjs';
const OWN = new Set(['U024BE7LH', 'B024BE7LH', 'A024BE7LH']);
const REG = ['acme.dev'];
const msg = (text, over = {}) => ({ text, user: 'U07J4K2QT', ...over });
test('Slack link markup is unwrapped', () => {
assert.deepEqual(linkUrls('see <https://acme.dev/docs|the docs>'),
['https://acme.dev/docs']);
assert.deepEqual(linkUrls('see <https://acme.dev/docs>'), ['https://acme.dev/docs']);
});
test('a bare url is found too and duplicates collapse', () => {
assert.deepEqual(linkUrls('https://acme.dev/a and https://acme.dev/a'),
['https://acme.dev/a']);
});
test('trailing punctuation is not part of the url', () => {
assert.deepEqual(linkUrls('try https://acme.dev/x.'), ['https://acme.dev/x']);
});
test('a message with no link yields nothing', () => {
assert.deepEqual(linkUrls('no links here'), []);
assert.deepEqual(linkUrls(null), []);
});
test('the host is stripped of everything that is not the host', () => {
assert.equal(hostOf('https://Acme.dev:8443/docs?a=1#b'), 'acme.dev');
assert.equal(hostOf('http://user@acme.dev/'), 'acme.dev');
assert.equal(hostOf('acme.dev.'), 'acme.dev');
});
test('an apex matches itself and its subdomains', () => {
assert.equal(domainMatch('https://acme.dev/x', REG), 'acme.dev');
assert.equal(domainMatch('https://docs.acme.dev/x', REG), 'acme.dev');
assert.equal(domainMatch('https://a.b.acme.dev/x', REG), 'acme.dev');
});
test('a subdomain registration does not match the apex', () => {
assert.equal(domainMatch('https://acme.dev/x', ['www.acme.dev']), '');
assert.equal(domainMatch('https://www.acme.dev/x', ['www.acme.dev']), 'www.acme.dev');
});
test('a lookalike domain does not match', () => {
assert.equal(domainMatch('https://notacme.dev/x', REG), '');
assert.equal(domainMatch('https://acme.dev.evil.example/x', REG), '');
});
test('the most specific registered domain wins', () => {
assert.equal(domainMatch('https://docs.acme.dev/x', ['acme.dev', 'docs.acme.dev']),
'docs.acme.dev');
});
test('a plain apex is the only usable registration', () => {
assert.equal(registrationFault('acme.dev')[0], 'usable');
assert.equal(registrationFault('ACME.dev')[0], 'usable');
});
test('every way of writing a domain wrong gets its own verdict', () => {
assert.equal(registrationFault('https://acme.dev')[0], 'scheme-included');
assert.equal(registrationFault('acme.dev/docs')[0], 'path-included');
assert.equal(registrationFault('*.acme.dev')[0], 'wildcard');
assert.equal(registrationFault('acme.dev:443')[0], 'port-included');
assert.equal(registrationFault('localhost')[0], 'not-a-domain');
assert.equal(registrationFault('')[0], 'empty');
});
test('a www registration is named as the trap it is', () => {
const [verdict, detail] = registrationFault('www.acme.dev');
assert.equal(verdict, 'www-prefixed');
assert.match(detail, /acme\.dev is not a subdomain/);
});
test('no registered domain is the first thing reported', () => {
const [verdict, detail] = preconditionGap([], ['link_shared'],
['links:read', 'links:write']);
assert.equal(verdict, 'no-domains');
assert.match(detail, /nothing after it can be tested/);
});
test('an unusable domain counts as no domain at all', () => {
assert.equal(preconditionGap(['https://acme.dev'], ['link_shared'],
['links:read'])[0], 'no-domains');
});
test('a registered domain with no subscription names the subscription', () => {
const [verdict, detail] = preconditionGap(REG, ['app_mention'], ['links:read']);
assert.equal(verdict, 'not-subscribed');
assert.match(detail, /does not subscribe the event/);
});
test('the two link scopes fail at opposite ends', () => {
const [silent, silentDetail] = preconditionGap(REG, ['link_shared'], ['chat:write']);
assert.equal(silent, 'no-links-read');
assert.match(silentDetail, /no error appears anywhere/);
const [loud, loudDetail] = preconditionGap(REG, ['link_shared'], ['links:read']);
assert.equal(loud, 'no-links-write');
assert.match(loudDetail, /fails loudly/);
});
test('unread scopes are not reported as granted', () => {
assert.equal(preconditionGap(REG, ['link_shared'], null)[0], 'scopes-unread');
});
test('all four in place is ready', () => {
assert.equal(preconditionGap(REG, ['link_shared'], ['links:read', 'links:write'])[0],
'ready');
});
test('a registered link with no attachment is the finding', () => {
const [verdict, detail, urls] = unfurlState(msg('look at https://acme.dev/x'),
OWN, REG);
assert.equal(verdict, 'not-unfurled');
assert.deepEqual(urls, ['https://acme.dev/x']);
assert.match(detail, /no unfurl at all/);
});
test('an unfurled link is recognised by its attachment', () => {
const message = msg('look at https://acme.dev/x',
{ attachments: [{ app_unfurl_url: 'https://acme.dev/x' }] });
assert.equal(unfurlState(message, OWN, REG)[0], 'unfurled');
});
test('an unrelated attachment does not count as an unfurl', () => {
const message = msg('look at https://acme.dev/x',
{ attachments: [{ from_url: 'https://example.com/other' }] });
const [verdict, detail] = unfurlState(message, OWN, REG);
assert.equal(verdict, 'not-unfurled');
assert.match(detail, /none of them is an unfurl/);
});
test('the app\u2019s own link is excluded from the evidence', () => {
const [verdict, detail] = unfurlState(
msg('here is https://acme.dev/x', { user: 'U024BE7LH' }), OWN, REG);
assert.equal(verdict, 'self-posted');
assert.match(detail, /not evidence either way/);
});
test('a link on an unregistered domain is its own row', () => {
const [verdict, detail] = unfurlState(msg('see https://example.com/x'), OWN, REG);
assert.equal(verdict, 'off-domain');
assert.match(detail, /would ever fire/);
});
test('off-domain and not-unfurled are never collapsed', () => {
assert.notEqual(unfurlState(msg('https://example.com/x'), OWN, REG)[0],
unfurlState(msg('https://acme.dev/x'), OWN, REG)[0]);
});
test('the manifest gives up both lists through either wrapper', () => {
const doc = {
features: { unfurl_domains: ['acme.dev'] },
settings: { event_subscriptions: { bot_events: ['link_shared'] } },
};
assert.deepEqual(manifestFacts(doc), [['acme.dev'], ['link_shared']]);
assert.deepEqual(manifestFacts({ manifest: doc }), [['acme.dev'], ['link_shared']]);
assert.deepEqual(manifestFacts({}), [[], undefined]);
});
FAQ
Why is there no error anywhere when the domain is not registered?
Because nothing failed. Slack decides which apps to notify about a shared link, your app is not on the list for that domain, and so no event is created. There is no request to reject and no response to carry an error. This is the same reason a missing event subscription is silent: the failure is an absence, and absences do not have error codes.
Do I register the apex domain or every subdomain?
The apex. Slack matches a registered domain and its subdomains, so registering acme.dev covers docs.acme.dev and app.acme.dev. The mistake worth avoiding is the reverse: registering www.acme.dev covers only that host and its subdomains, and acme.dev is not one of them, so every link without the www prefix stays silent.
The bot posts its own links and they never unfurl. Is that the same bug?
No, and it is why testing this is awkward. Slack deliberately suppresses link_shared for links an app posts itself, so a link the bot posted would not unfurl even with everything configured correctly. Test with a link a human posts, which is why the script separates the app's own links out of its evidence rather than counting them.
We have links:read. Is that enough?
It is enough to receive the event and not enough to answer it. chat.unfurl needs links:write, and without it your handler runs, does its work, and fails on the last line with missing_scope. The two scopes produce completely different symptoms, so it is worth knowing which one you are missing before you start reading handler code.
We added the domain and the scopes and it still does nothing. What is left?
A reinstall. Adding a scope changes the token grant, and the grant your app is running with was issued before the change. Also check that the event is actually subscribed rather than only available: registering the domain and holding the scope makes link_shared subscribable, and subscribing to it is a separate click.
Related field notes
- the same silence, one switch further back
- an event that is subscribed and still never arrives
- how to read what the token was actually granted
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.
- link_shared event reference - Slack Docs
- chat.unfurl method reference - Slack Docs
- Unfurling links in messages - Slack Docs
- App manifest 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.