Diagnostic Slack
invalid_blocks: the whole message dies on one block
The payload renders perfectly in Block Kit Builder. The JSON parses. The same generator produced a hundred messages that worked. This one comes back {"ok": false, "error": "invalid_blocks"} and Slack does not say which block, which field, or which rule.
So the message did not land, and the only information you have is that something in an array of eleven objects is wrong. This is a payload problem rather than a delivery problem: the channel is fine, the bot is in it, the token is valid, and the thing you handed Slack is not a thing Slack will accept.
Slack validates the whole blocks array server-side and rejects the entire message on the first violation, returning one opaque string. invalid_blocks means a block broke a rule; invalid_blocks_format means the array itself was not what the API expected, which is almost always an object where a JSON array should be, or a string that was encoded twice.
The structural rules are documented and your payload is in your hands, so this check needs no token and no network: hand the script the JSON and it names the block index, the field and the rule. The one rule it cannot check locally is the one that causes most of these, which is that an image block's image_url must be fetchable by Slack, unauthenticated, returning an image content type. A chart URL behind your SSO works flawlessly in the browser where you tested it, because your browser had a session cookie. Slack does not, and an image Slack cannot fetch does not degrade to alt_text - it kills the message.
The problem in plain words
The debugging loop this creates is the expensive part. There is one error string for perhaps twenty distinct rules, no block index, no field name, and no partial success to reason from. So the loop becomes bisection by sending: comment out half the blocks, post, see if it works, repeat. Every iteration is a real message into a real channel, which means either a scratch channel that does not have the data that reproduces the bug, or a customer-facing channel that now has six half-rendered diagnostic messages in it.
Worse, the payload usually validates everywhere you look. Block Kit Builder renders it. Your schema tests pass. A colleague pastes it into their own workspace and it posts. That last one is genuinely maddening and it is the tell: the difference is not the payload, it is whether the thing the payload points at can be reached from where Slack is standing.
Image blocks are the reason this note leads with images. image_url is fetched by Slack's own infrastructure at post time. If the URL needs a cookie, a bearer token, a VPN, or resolves to localhost on the developer's machine, Slack gets a 401, a 403 or a connection refused, and refuses the message rather than dropping the image. A developer who opens the same URL in the browser they are logged into sees a chart and concludes the URL is fine. It is fine for them. The check has to be made from a client with no credentials, which is the whole point of the unauthenticated fetch this script makes.
The rest is a long tail of shapes that look right and are not: a section whose text came out empty because a template variable was blank, a static_select whose options array is empty because the query returned nothing, two buttons that ended up with the same action_id because the loop that built them reused a constant, a block type from an SDK newer than the workspace. Each is obvious once named, and Slack names none of them.
Why it happens
Rejection is all-or-nothing, so one bad block costs the whole message. There is no partial render and no fallback path. That is why a nineteen-block digest can be destroyed by the twentieth block containing an image nobody looks at, and why the fix has to be pre-send validation rather than post-send inspection.
The structural rules are checkable without Slack, and should be checked without Slack. Every rule in this script's table comes from the published block reference. Running them locally turns a send-and-see loop into a function call, which is the difference between a fix that takes ten minutes and one that takes an afternoon and leaves debris in a channel.
An unauthenticated fetch is the only honest test of an image URL. Your browser, your terminal on the VPN, and your CI runner inside the VPC all have access Slack does not. The script sends no Authorization header deliberately, and reports a 401 or 403 as the finding rather than as an error in the check.
A private or loopback host is a certainty, not a suspicion. http://localhost:3000/chart.png and http://10.0.1.14/graph.svg cannot be fetched by Slack from anywhere, ever. The script decides those from the URL alone and does not bother making a request, because a request from inside your network would succeed and tell you the wrong thing.
Unknown is not the same as wrong. Slack adds block types. An element this script's table does not recognise is reported as unknown and never as a fault, because an audit that fails on a new block type gets switched off the first time somebody upgrades an SDK.
Length ceilings are a different rule with the same error string. A 3,001-character section text also produces invalid_blocks, and it is measured rather than inspected, so it lives in its own note and this script deliberately does not duplicate it. Two audits reporting the same row is how a team fixes one problem twice.
The fix, as a flow
The only chain in this section with a loop drawn under it, because the method this note replaces is a loop: comment out half the blocks, post, look, repeat, and leave the debris in a channel. The cause is three steps back and did not look like a cause, since the URL opened perfectly in the browser that had the session cookie. The fix branch is a sort by severity, and the two plain rows are both refusals to call something a fault.
How to fix it
Check the payload before you check anything else
Write the failing payload to a file and run the script with --payload. No token, no network, no message. audit_blocks walks the array and names the index, the field and the rule for every violation it can decide from the JSON alone, which is most of them.
Separate the format error from the block error
invalid_blocks_format and invalid_blocks are not the same finding. The first means the array is not an array: an object came back from the builder, or the SDK encoded an already-encoded string. The script returns that as a fault on blocks itself rather than on any block, because no amount of staring at block seven will fix it.
Fetch every image URL with nothing attached
Each image_url in the payload gets one unauthenticated HEAD. image_verdict sorts the answer into public-image, auth-required, not-found, not-an-image, unreachable or not-public. Only the first of those is a URL Slack can use.
Decide loopback and private hosts from the URL, without asking
A request to localhost from your own machine answers 200 and proves nothing. The script classifies those from the hostname and skips the fetch, because the only useful answer here is the one Slack would get, and you cannot get it from inside.
Run the same audit over what the app has already posted
With a token and --channel, the script reads conversations.history and audits the blocks Slack stored for your app's own messages. The same generator produced those and the failing one, so a rule that is nearly broken across a hundred successful messages is the rule about to break on the next one.
Read the severities as three different instructions
fatal means Slack will refuse this. risky means it will post and will not do what you meant. unknown means the script does not have a rule and you should read the block reference. Collapsing the three into a pass or fail is how a real fault gets buried under a list of shrugs.
How to check it worked
Fix the block the script names, run it again on the same file, and the verdict should move from reject to clean before you send anything.
python3 slack_invalid_blocks.py --payload failing-digest.json
# payload 11 block(s) read from failing-digest.json
# verdict reject 1 fatal, 0 risky, 0 unknown
# fatal blocks[7](image) https://charts.internal/deploys.png answered 403 to an
# unauthenticated request: Slack fetches this itself and
# has no session; your browser does
# images 1 checked, 1 unusable
# repair: host the image at a URL that answers 200 with an image content type to a
# client holding no credentials, or upload it and reference the returned file
The full code
Three pure functions and one deliberately unauthenticated request. block_fault is the block reference turned into a table, and returns severities rather than booleans. audit_blocks adds the two rules that only exist across blocks: the array must be an array, and an action_id must be unique within one message. image_verdict is the one that earns the note, because it turns 403 into a sentence about session cookies rather than a status code nobody connects to a rejected message.
"""Find the block that would make Slack reject the whole message.
Read only, and with --payload it holds no token and calls no Slack method at
all. Nothing is posted from here: bisecting a Block Kit payload by sending it
is how a scratch message ends up in a customer channel, so this validates the
JSON you hand it, or the payloads conversations.history says your app already
sent, and issues one unauthenticated HEAD per image URL to see what Slack's
own fetcher would see.
"""
import argparse
import json
import logging
import os
import sys
from urllib.parse import urlsplit
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_invalid_blocks")
API = "https://slack.com/api/"
# Block types whose rules are in the table below. A type that is not here is
# reported as unknown and never as a fault: Slack ships new block types, and an
# audit that fails on an SDK upgrade gets switched off within a week.
KNOWN_BLOCKS = {"actions", "context", "divider", "file", "header", "image",
"input", "rich_text", "section", "video"}
# Elements that must carry at least one option. An empty options array is what
# a query returning no rows produces, and it kills the entire message.
OPTION_ELEMENTS = {"static_select", "multi_static_select", "radio_buttons",
"checkboxes", "overflow"}
TEXT_TYPES = {"plain_text", "mrkdwn"}
# Slack fetches image_url from its own infrastructure. These never resolve to
# anything it can reach, so they are decided from the hostname and the fetch is
# skipped: a request to localhost from your own machine answers 200 and tells
# you the opposite of the truth.
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"}
def _is_private_host(host):
"""Is this a host Slack could never reach from the public internet? Pure."""
h = str(host or "").strip().lower().strip("[]")
if not h:
return True
if h in LOOPBACK_HOSTS or h.endswith(".local") or h.endswith(".internal"):
return True
if "." not in h and ":" not in h:
return True # a bare hostname only your resolver knows
parts = h.split(".")
if len(parts) == 4 and all(p.isdigit() for p in parts):
a, b = int(parts[0]), int(parts[1])
if a == 10 or a == 127 or (a == 192 and b == 168) or (a == 169 and b == 254):
return True
if a == 172 and 16 <= b <= 31:
return True
return False
def _text_ok(obj):
"""Is this a usable text composition object? Pure."""
if not isinstance(obj, dict):
return False
if obj.get("type") not in TEXT_TYPES:
return False
return bool(str(obj.get("text") or "").strip())
def _element_fault(el, path):
"""Faults in one interactive or context element. Pure."""
if not isinstance(el, dict):
return [("fatal", path, "an element must be an object; this is a %s"
% type(el).__name__)]
etype = str(el.get("type") or "").strip()
if not etype:
return [("fatal", path, "an element with no type field")]
where = "%s(%s)" % (path, etype)
rows = []
if etype in OPTION_ELEMENTS:
options = el.get("options")
groups = el.get("option_groups")
has_options = isinstance(options, list) and len(options) > 0
has_groups = isinstance(groups, list) and any(
isinstance(g, dict) and g.get("options") for g in groups)
if not has_options and not has_groups:
rows.append(("fatal", where, "%s with no options; an empty options array "
"is what a query returning no rows produces "
"and it rejects the whole message" % etype))
if etype == "button":
if not _text_ok(el.get("text")):
rows.append(("fatal", where, "a button needs a non-empty plain_text label"))
url = str(el.get("url") or "")
if url and not url.startswith(("http://", "https://")):
rows.append(("risky", where, "button url %r is not an http or https URL"
% url[:60]))
if etype in TEXT_TYPES and not _text_ok(el):
rows.append(("fatal", where, "an empty text object; a blank template variable "
"produces exactly this"))
return rows
def _child_elements(block, path):
"""Yield (path, element) for everything inside a block. Pure."""
for key in ("accessory", "element"):
child = block.get(key)
if child is not None:
yield ("%s.%s" % (path, key), child)
elements = block.get("elements")
if isinstance(elements, list):
for i, el in enumerate(elements):
yield ("%s.elements[%d]" % (path, i), el)
def _action_ids(block, path):
"""Yield (action_id, where) for every element in one block. Pure."""
if not isinstance(block, dict):
return
for where, el in _child_elements(block, path):
if isinstance(el, dict) and str(el.get("action_id") or "").strip():
yield (str(el["action_id"]), where)
def block_fault(block, index=0):
"""Return the structural faults in one block. Pure.
Rows are (severity, path, detail). fatal means Slack refuses the message,
risky means it posts and does not do what you meant, unknown means this
table has no rule and you should read the block reference.
Character ceilings are deliberately absent. A 3001 character section text
produces the same error string from a different rule, it is measured rather
than inspected, and it has its own note; two audits reporting one row is
how a team fixes one problem twice.
"""
path = "blocks[%d]" % index
if not isinstance(block, dict):
return [("fatal", path, "a block must be an object; this is a %s"
% type(block).__name__)]
btype = str(block.get("type") or "").strip()
if not btype:
return [("fatal", path, "no type field, so Slack cannot dispatch this block")]
where = "%s(%s)" % (path, btype)
rows = []
if btype not in KNOWN_BLOCKS:
rows.append(("unknown", where, "%s is not in this table; it is either newer "
"than the table or a typo, and only one of "
"those is a fault" % btype))
if btype == "section":
fields = block.get("fields")
has_fields = isinstance(fields, list) and any(_text_ok(f) for f in fields)
if not _text_ok(block.get("text")) and not has_fields:
rows.append(("fatal", where, "a section needs text or fields and this has "
"neither usable; an empty template variable "
"lands here"))
if isinstance(fields, list) and len(fields) > 10:
rows.append(("fatal", where, "%d fields; a section takes at most 10"
% len(fields)))
elif btype == "header":
head = block.get("text")
if not _text_ok(head):
rows.append(("fatal", where, "a header needs a non-empty text object"))
elif head.get("type") != "plain_text":
rows.append(("fatal", where, "a header text object must be plain_text, "
"not %s" % head.get("type")))
elif btype == "image":
if not block.get("image_url") and not block.get("slack_file"):
rows.append(("fatal", where, "an image block needs image_url or slack_file"))
if not str(block.get("alt_text") or "").strip():
rows.append(("fatal", where, "an image block needs alt_text, which is also "
"the only thing a screen reader is given"))
elif btype == "actions":
elements = block.get("elements")
if not isinstance(elements, list) or not elements:
rows.append(("fatal", where, "an actions block with no elements"))
elif len(elements) > 25:
rows.append(("fatal", where, "%d elements; an actions block takes at most 25"
% len(elements)))
elif btype == "context":
elements = block.get("elements")
if not isinstance(elements, list) or not elements:
rows.append(("fatal", where, "a context block with no elements"))
elif len(elements) > 10:
rows.append(("fatal", where, "%d elements; a context block takes at most 10"
% len(elements)))
elif btype == "input":
if not _text_ok(block.get("label")):
rows.append(("fatal", where, "an input block needs a label"))
if block.get("element") is None:
rows.append(("fatal", where, "an input block needs an element"))
for sub, el in _child_elements(block, path):
rows.extend(_element_fault(el, sub))
return rows
def audit_blocks(blocks):
"""Validate a whole payload, including the rules that span blocks. Pure.
Returns (verdict, rows) where verdict is reject, suspect or clean. The
difference matters: reject names something Slack will refuse, suspect names
something this script cannot decide from the payload alone and will not
pretend to have decided.
"""
if isinstance(blocks, str):
return ("reject", [("fatal", "blocks", "blocks is a string. It was encoded to "
"JSON once too often, which Slack "
"answers with invalid_blocks_format")])
if not isinstance(blocks, list):
return ("reject", [("fatal", "blocks", "blocks must be a JSON array and this "
"is a %s. Passing the object your "
"builder returned produces "
"invalid_blocks_format"
% type(blocks).__name__)])
rows = []
if not blocks:
rows.append(("unknown", "blocks", "an empty blocks array. Slack accepts it "
"beside a text field, but it usually means "
"the generator produced nothing"))
if len(blocks) > 50:
rows.append(("unknown", "blocks", "%d blocks is past the 50 a message takes. "
"That is a different error and its own note"
% len(blocks)))
for i, block in enumerate(blocks):
rows.extend(block_fault(block, i))
seen = {}
for i, block in enumerate(blocks):
for action_id, where in _action_ids(block, "blocks[%d]" % i):
seen.setdefault(action_id, []).append(where)
for action_id, wheres in sorted(seen.items()):
if len(wheres) > 1:
rows.append(("fatal", ", ".join(wheres),
"action_id %s appears %d times in one message and must be "
"unique within the payload; a loop reusing a constant is the "
"usual cause" % (action_id, len(wheres))))
if any(r[0] == "fatal" for r in rows):
return ("reject", rows)
return ("suspect", rows) if rows else ("clean", rows)
def image_verdict(url, status=None, content_type=None, error=None):
"""Could Slack's own fetcher have loaded this image? Pure.
Returns (verdict, detail). Only public-image is a URL Slack can use, and an
image Slack cannot fetch does not fall back to alt_text: it rejects the
entire message.
"""
text = str(url or "").strip()
if not text:
return ("missing", "no image_url on an image block")
if not text.startswith(("http://", "https://")):
return ("not-http", "%s is not an http or https URL; a data: URI or a bare "
"path is not something Slack can fetch" % text[:80])
host = urlsplit(text).hostname
if _is_private_host(host):
return ("not-public", "%s resolves only inside your own network. Slack fetches "
"this from its infrastructure, so it can never load; "
"testing it from here would answer 200 and mislead you"
% (host or text[:60]))
if error:
return ("unreachable", "%s did not answer an unauthenticated request (%s)"
% (text[:80], error))
code = int(status or 0)
if code in (401, 403):
return ("auth-required", "%s answered %d to a request carrying no credentials. "
"Slack has no session cookie and your browser does, "
"which is why this looks fine when you open it"
% (text[:80], code))
if code == 404:
return ("not-found", "%s answered 404 unauthenticated" % text[:80])
if code >= 400:
return ("error-status", "%s answered %d unauthenticated" % (text[:80], code))
if code == 0:
return ("unchecked", "%s was not fetched" % text[:80])
ctype = str(content_type or "").split(";")[0].strip().lower()
if not ctype.startswith("image/"):
return ("not-an-image", "%s answered %d with content type %r. An HTML error "
"page returned with a 200 looks like success to "
"everything except Slack's image fetcher"
% (text[:80], code, ctype or "none"))
return ("public-image", "%s answered %d as %s to a client holding no credentials"
% (text[:80], code, ctype))
def image_urls(blocks):
"""Every image_url in a payload, with where it was found. Pure."""
out = []
for i, block in enumerate(blocks or []):
if not isinstance(block, dict):
continue
if block.get("type") == "image" and block.get("image_url"):
out.append(("blocks[%d](image)" % i, str(block["image_url"])))
for where, el in _child_elements(block, "blocks[%d]" % i):
if isinstance(el, dict) and el.get("type") == "image" and el.get("image_url"):
out.append((where, str(el["image_url"])))
return out
def probe(url, timeout=10):
"""One unauthenticated HEAD, so the answer is the one Slack would get.
No Authorization header, no cookie jar, on purpose: every credential you
hold is a credential Slack does not, and the whole question is what a
stranger sees.
"""
try:
r = requests.head(url, timeout=timeout, allow_redirects=True)
if r.status_code in (405, 501):
r = requests.get(url, timeout=timeout, allow_redirects=True, stream=True)
r.close()
return (r.status_code, r.headers.get("Content-Type", ""), None)
except requests.RequestException as exc:
return (None, "", str(exc))
def report(verdict, rows, label):
counts = {"fatal": 0, "risky": 0, "unknown": 0}
for severity, _, _ in rows:
counts[severity] = counts.get(severity, 0) + 1
(log.warning if verdict == "reject" else log.info)(
"verdict %-14s %s: %d fatal, %d risky, %d unknown", verdict, label,
counts.get("fatal", 0), counts.get("risky", 0), counts.get("unknown", 0))
for severity, where, detail in rows:
(log.warning if severity == "fatal" else log.info)(
"%-10s %s %s", severity, where, detail)
return counts.get("fatal", 0)
def check_images(blocks, timeout, skip):
bad = 0
urls = image_urls(blocks)
checked = 0
for where, url in urls:
host = urlsplit(url).hostname if url.startswith(("http://", "https://")) else None
if skip or (host and _is_private_host(host)) or not url.startswith(("http://", "https://")):
verdict, detail = image_verdict(url, status=0 if skip else None)
else:
status, ctype, err = probe(url, timeout)
checked += 1
verdict, detail = image_verdict(url, status, ctype, err)
if verdict == "public-image":
log.info("image %-14s %s %s", verdict, where, detail)
else:
bad += 1
log.warning("image %-14s %s %s", verdict, where, detail)
if urls:
log.info("images %d found, %d fetched, %d unusable", len(urls), checked, bad)
return bad
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--payload", default="",
help="a JSON file holding a blocks array or a whole message; "
"with this the script needs no Slack token at all")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
ap.add_argument("--channel", action="append", default=[],
help="a channel to audit your app's own stored blocks in; repeatable")
ap.add_argument("--limit", type=int, default=200, help="messages read per channel")
ap.add_argument("--timeout", type=float, default=10.0, help="seconds per image fetch")
ap.add_argument("--skip-images", action="store_true",
help="structure only; useful on a machine with no egress")
args = ap.parse_args()
if args.payload:
raw = json.loads(open(args.payload, encoding="utf-8").read())
blocks = raw if isinstance(raw, (list, str)) else raw.get("blocks")
count = len(blocks) if isinstance(blocks, list) else "an unusable"
log.info("payload %s block(s) read from %s", count, args.payload)
verdict, rows = audit_blocks(blocks)
fatal = report(verdict, rows, args.payload)
fatal += check_images(blocks if isinstance(blocks, list) else [],
args.timeout, args.skip_images)
if fatal:
log.warning(" repair: fix the block named above and run this again before "
"you send anything; bisecting by posting leaves debris")
log.warning(" repair: an image_url must answer 200 with an image content "
"type to a client holding no credentials, or upload the file "
"and reference what Slack gives you back")
return 1
return 0
token = os.environ.get(args.token_env)
if not token:
log.error("set %s, or pass --payload to check a file with no token at all",
args.token_env)
return 2
if not args.channel:
log.error("pass at least one --channel, or --payload")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
who = s.get(API + "auth.test", timeout=30).json()
if who.get("ok") is not True:
log.error("auth.test unavailable %s", who.get("error"))
return 2
bot_id = who.get("bot_id") or ""
log.info("identity %s in %s", who.get("user_id"), who.get("team"))
fatal = 0
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
ours = [m for m in body.get("messages") or []
if m.get("blocks") and (not bot_id or m.get("bot_id") == bot_id)]
log.info("history %s: %d message(s) of ours carrying blocks", channel,
len(ours))
for m in ours:
verdict, rows = audit_blocks(m.get("blocks"))
if verdict != "clean":
fatal += report(verdict, rows, "%s ts=%s" % (channel, m.get("ts")))
fatal += check_images(m.get("blocks"), args.timeout, args.skip_images)
if fatal:
log.warning(" repair: validate the generated payload in the message builder, "
"before the call, and log the payload beside any invalid_blocks")
log.warning(" repair: host every image at a publicly fetchable URL with an "
"image content type, or upload it to Slack and use the file")
return 1
log.info("verdict clean nothing in these payloads breaks a structural rule")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find the block that would make Slack reject the whole message.
*
* Read only, and with --payload it holds no token and calls no Slack method at
* all. Nothing is posted from here: bisecting a Block Kit payload by sending it
* is how a scratch message ends up in a customer channel, so this validates the
* JSON you hand it, or the payloads conversations.history says your app already
* sent, and issues one unauthenticated HEAD per image URL.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// A type that is not here is reported as unknown and never as a fault: Slack
// ships new block types, and an audit that fails on an SDK upgrade gets
// switched off within a week.
const KNOWN_BLOCKS = new Set(['actions', 'context', 'divider', 'file', 'header',
'image', 'input', 'rich_text', 'section', 'video']);
const OPTION_ELEMENTS = new Set(['static_select', 'multi_static_select',
'radio_buttons', 'checkboxes', 'overflow']);
const TEXT_TYPES = new Set(['plain_text', 'mrkdwn']);
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1',
'host.docker.internal']);
/** Is this a host Slack could never reach from the public internet? Pure. */
export function isPrivateHost(host) {
const h = String(host ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!h) return true;
if (LOOPBACK_HOSTS.has(h) || h.endsWith('.local') || h.endsWith('.internal')) return true;
if (!h.includes('.') && !h.includes(':')) return true;
const parts = h.split('.');
if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p))) {
const [a, b] = parts.map(Number);
if (a === 10 || a === 127 || (a === 192 && b === 168) || (a === 169 && b === 254)) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) return true;
}
return false;
}
function textOk(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
if (!TEXT_TYPES.has(obj.type)) return false;
return String(obj.text ?? '').trim().length > 0;
}
function elementFault(el, path) {
if (!el || typeof el !== 'object' || Array.isArray(el)) {
return [['fatal', path, `an element must be an object; this is a ${typeof el}`]];
}
const etype = String(el.type ?? '').trim();
if (!etype) return [['fatal', path, 'an element with no type field']];
const where = `${path}(${etype})`;
const rows = [];
if (OPTION_ELEMENTS.has(etype)) {
const hasOptions = Array.isArray(el.options) && el.options.length > 0;
const hasGroups = Array.isArray(el.option_groups)
&& el.option_groups.some((g) => g && Array.isArray(g.options) && g.options.length);
if (!hasOptions && !hasGroups) {
rows.push(['fatal', where, `${etype} with no options; an empty options array is ` +
'what a query returning no rows produces and it rejects the whole message']);
}
}
if (etype === 'button') {
if (!textOk(el.text)) {
rows.push(['fatal', where, 'a button needs a non-empty plain_text label']);
}
const url = String(el.url ?? '');
if (url && !/^https?:\/\//.test(url)) {
rows.push(['risky', where, `button url ${url.slice(0, 60)} is not an http or https URL`]);
}
}
if (TEXT_TYPES.has(etype) && !textOk(el)) {
rows.push(['fatal', where, 'an empty text object; a blank template variable ' +
'produces exactly this']);
}
return rows;
}
function* childElements(block, path) {
for (const key of ['accessory', 'element']) {
if (block[key] !== undefined && block[key] !== null) yield [`${path}.${key}`, block[key]];
}
if (Array.isArray(block.elements)) {
for (let i = 0; i < block.elements.length; i += 1) {
yield [`${path}.elements[${i}]`, block.elements[i]];
}
}
}
function* actionIds(block, path) {
if (!block || typeof block !== 'object') return;
for (const [where, el] of childElements(block, path)) {
if (el && typeof el === 'object' && String(el.action_id ?? '').trim()) {
yield [String(el.action_id), where];
}
}
}
/**
* Return the structural faults in one block. Pure.
*
* Rows are [severity, path, detail]. Character ceilings are deliberately
* absent: they produce the same error string from a different rule and have
* their own note.
*/
export function blockFault(block, index = 0) {
let path = `blocks[${index}]`;
if (!block || typeof block !== 'object' || Array.isArray(block)) {
return [['fatal', path, `a block must be an object; this is a ${typeof block}`]];
}
const btype = String(block.type ?? '').trim();
if (!btype) {
return [['fatal', path, 'no type field, so Slack cannot dispatch this block']];
}
const where = `${path}(${btype})`;
const rows = [];
if (!KNOWN_BLOCKS.has(btype)) {
rows.push(['unknown', where, `${btype} is not in this table; it is either newer ` +
'than the table or a typo, and only one of those is a fault']);
}
if (btype === 'section') {
const fields = block.fields;
const hasFields = Array.isArray(fields) && fields.some(textOk);
if (!textOk(block.text) && !hasFields) {
rows.push(['fatal', where, 'a section needs text or fields and this has neither ' +
'usable; an empty template variable lands here']);
}
if (Array.isArray(fields) && fields.length > 10) {
rows.push(['fatal', where, `${fields.length} fields; a section takes at most 10`]);
}
} else if (btype === 'header') {
if (!textOk(block.text)) {
rows.push(['fatal', where, 'a header needs a non-empty text object']);
} else if (block.text.type !== 'plain_text') {
rows.push(['fatal', where,
`a header text object must be plain_text, not ${block.text.type}`]);
}
} else if (btype === 'image') {
if (!block.image_url && !block.slack_file) {
rows.push(['fatal', where, 'an image block needs image_url or slack_file']);
}
if (!String(block.alt_text ?? '').trim()) {
rows.push(['fatal', where, 'an image block needs alt_text, which is also the ' +
'only thing a screen reader is given']);
}
} else if (btype === 'actions') {
if (!Array.isArray(block.elements) || !block.elements.length) {
rows.push(['fatal', where, 'an actions block with no elements']);
} else if (block.elements.length > 25) {
rows.push(['fatal', where,
`${block.elements.length} elements; an actions block takes at most 25`]);
}
} else if (btype === 'context') {
if (!Array.isArray(block.elements) || !block.elements.length) {
rows.push(['fatal', where, 'a context block with no elements']);
} else if (block.elements.length > 10) {
rows.push(['fatal', where,
`${block.elements.length} elements; a context block takes at most 10`]);
}
} else if (btype === 'input') {
if (!textOk(block.label)) rows.push(['fatal', where, 'an input block needs a label']);
if (block.element === undefined || block.element === null) {
rows.push(['fatal', where, 'an input block needs an element']);
}
}
for (const [sub, el] of childElements(block, path)) {
rows.push(...elementFault(el, sub));
}
return rows;
}
/**
* Validate a whole payload, including the rules that span blocks. Pure.
* Returns [verdict, rows] where verdict is reject, suspect or clean.
*/
export function auditBlocks(blocks) {
if (typeof blocks === 'string') {
return ['reject', [['fatal', 'blocks', 'blocks is a string. It was encoded to JSON ' +
'once too often, which Slack answers with invalid_blocks_format']]];
}
if (!Array.isArray(blocks)) {
return ['reject', [['fatal', 'blocks', `blocks must be a JSON array and this is a ` +
`${typeof blocks}. Passing the object your builder returned produces ` +
'invalid_blocks_format']]];
}
const rows = [];
if (!blocks.length) {
rows.push(['unknown', 'blocks', 'an empty blocks array. Slack accepts it beside a ' +
'text field, but it usually means the generator produced nothing']);
}
if (blocks.length > 50) {
rows.push(['unknown', 'blocks', `${blocks.length} blocks is past the 50 a message ` +
'takes. That is a different error and its own note']);
}
blocks.forEach((block, i) => rows.push(...blockFault(block, i)));
const seen = new Map();
blocks.forEach((block, i) => {
for (const [id, where] of actionIds(block, `blocks[${i}]`)) {
if (!seen.has(id)) seen.set(id, []);
seen.get(id).push(where);
}
});
for (const [id, wheres] of [...seen.entries()].sort()) {
if (wheres.length > 1) {
rows.push(['fatal', wheres.join(', '), `action_id ${id} appears ${wheres.length} ` +
'times in one message and must be unique within the payload; a loop reusing a ' +
'constant is the usual cause']);
}
}
if (rows.some((r) => r[0] === 'fatal')) return ['reject', rows];
return rows.length ? ['suspect', rows] : ['clean', rows];
}
/**
* Could Slack's own fetcher have loaded this image? Pure.
* Only public-image is a URL Slack can use, and an image Slack cannot fetch
* does not fall back to alt_text: it rejects the entire message.
*/
export function imageVerdict(url, status = null, contentType = null, error = null) {
const text = String(url ?? '').trim();
if (!text) return ['missing', 'no image_url on an image block'];
if (!/^https?:\/\//.test(text)) {
return ['not-http', `${text.slice(0, 80)} is not an http or https URL; a data: URI ` +
'or a bare path is not something Slack can fetch'];
}
let host = '';
try { host = new URL(text).hostname; } catch { host = ''; }
if (isPrivateHost(host)) {
return ['not-public', `${host || text.slice(0, 60)} resolves only inside your own ` +
'network. Slack fetches this from its infrastructure, so it can never load; ' +
'testing it from here would answer 200 and mislead you'];
}
if (error) {
return ['unreachable',
`${text.slice(0, 80)} did not answer an unauthenticated request (${error})`];
}
const code = Number(status ?? 0);
if (code === 401 || code === 403) {
return ['auth-required', `${text.slice(0, 80)} answered ${code} to a request ` +
'carrying no credentials. Slack has no session cookie and your browser does, ' +
'which is why this looks fine when you open it'];
}
if (code === 404) return ['not-found', `${text.slice(0, 80)} answered 404 unauthenticated`];
if (code >= 400) {
return ['error-status', `${text.slice(0, 80)} answered ${code} unauthenticated`];
}
if (code === 0) return ['unchecked', `${text.slice(0, 80)} was not fetched`];
const ctype = String(contentType ?? '').split(';')[0].trim().toLowerCase();
if (!ctype.startsWith('image/')) {
return ['not-an-image', `${text.slice(0, 80)} answered ${code} with content type ` +
`${ctype || 'none'}. An HTML error page returned with a 200 looks like success ` +
"to everything except Slack's image fetcher"];
}
return ['public-image',
`${text.slice(0, 80)} answered ${code} as ${ctype} to a client holding no credentials`];
}
/** Every image_url in a payload, with where it was found. Pure. */
export function imageUrls(blocks) {
const out = [];
(blocks ?? []).forEach((block, i) => {
if (!block || typeof block !== 'object') return;
if (block.type === 'image' && block.image_url) {
out.push([`blocks[${i}](image)`, String(block.image_url)]);
}
for (const [where, el] of childElements(block, `blocks[${i}]`)) {
if (el && typeof el === 'object' && el.type === 'image' && el.image_url) {
out.push([where, String(el.image_url)]);
}
}
});
return out;
}
// No Authorization header and no cookie jar, on purpose: every credential you
// hold is one Slack does not, and the whole question is what a stranger sees.
async function probe(url) {
try {
const res = await fetch(url, { method: 'HEAD', redirect: 'follow' });
return [res.status, res.headers.get('content-type') ?? '', null];
} catch (err) {
return [null, '', err.message];
}
}
function report(verdict, rows, label) {
const counts = { fatal: 0, risky: 0, unknown: 0 };
for (const [severity] of rows) counts[severity] = (counts[severity] ?? 0) + 1;
const line = `verdict ${verdict.padEnd(14)} ${label}: ${counts.fatal} fatal, ` +
`${counts.risky} risky, ${counts.unknown} unknown`;
(verdict === 'reject' ? console.warn : console.log)(line);
for (const [severity, where, detail] of rows) {
(severity === 'fatal' ? console.warn : console.log)(
`${severity.padEnd(10)} ${where} ${detail}`);
}
return counts.fatal;
}
async function checkImages(blocks, skip) {
const urls = imageUrls(blocks);
let bad = 0;
let checked = 0;
for (const [where, url] of urls) {
let host = '';
try { host = new URL(url).hostname; } catch { host = ''; }
let verdict;
let detail;
if (skip || isPrivateHost(host) || !/^https?:\/\//.test(url)) {
[verdict, detail] = imageVerdict(url, skip ? 0 : null);
} else {
const [status, ctype, err] = await probe(url);
checked += 1;
[verdict, detail] = imageVerdict(url, status, ctype, err);
}
if (verdict === 'public-image') {
console.log(`image ${verdict.padEnd(14)} ${where} ${detail}`);
} else {
bad += 1;
console.warn(`image ${verdict.padEnd(14)} ${where} ${detail}`);
}
}
if (urls.length) {
console.log(`images ${urls.length} found, ${checked} fetched, ${bad} unusable`);
}
return bad;
}
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 skipImages = args.includes('--skip-images');
const payload = arg(args, '--payload', '');
if (payload) {
const raw = JSON.parse(await readFile(payload, 'utf8'));
const blocks = Array.isArray(raw) || typeof raw === 'string' ? raw : raw.blocks;
const count = Array.isArray(blocks) ? blocks.length : 'an unusable';
console.log(`payload ${count} block(s) read from ${payload}`);
const [verdict, rows] = auditBlocks(blocks);
let fatal = report(verdict, rows, payload);
fatal += await checkImages(Array.isArray(blocks) ? blocks : [], skipImages);
if (fatal) {
console.warn(' repair: fix the block named above and run this again before you ' +
'send anything; bisecting by posting leaves debris');
console.warn(' repair: an image_url must answer 200 with an image content type ' +
'to a client holding no credentials, or upload the file and reference what ' +
'Slack gives you back');
process.exitCode = 1;
}
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv}, or pass --payload to check a file with no token at all`);
process.exitCode = 2;
return;
}
const channels = argAll(args, '--channel');
if (!channels.length) {
console.error('pass at least one --channel, or --payload');
process.exitCode = 2;
return;
}
const limit = arg(args, '--limit', '200');
const headers = { Authorization: `Bearer ${token}` };
const who = await (await fetch(`${API}auth.test`, { headers })).json();
if (who.ok !== true) {
console.error(`auth.test unavailable ${who.error}`);
process.exitCode = 2;
return;
}
const botId = who.bot_id ?? '';
console.log(`identity ${who.user_id} in ${who.team}`);
let fatal = 0;
for (const channel of channels) {
const url = `${API}conversations.history?channel=${encodeURIComponent(channel)}` +
`&limit=${encodeURIComponent(limit)}`;
const body = await (await fetch(url, { headers })).json();
if (body.ok !== true) {
console.warn(`history unavailable ${channel}: ${body.error}`);
continue;
}
const ours = (body.messages ?? []).filter(
(m) => m.blocks && (!botId || m.bot_id === botId));
console.log(`history ${channel}: ${ours.length} message(s) of ours carrying blocks`);
for (const m of ours) {
const [verdict, rows] = auditBlocks(m.blocks);
if (verdict !== 'clean') fatal += report(verdict, rows, `${channel} ts=${m.ts}`);
fatal += await checkImages(m.blocks, skipImages);
}
}
if (fatal) {
console.warn(' repair: validate the generated payload in the message builder, ' +
'before the call, and log the payload beside any invalid_blocks');
console.warn(' repair: host every image at a publicly fetchable URL with an image ' +
'content type, or upload it to Slack and use the file');
process.exitCode = 1;
} else {
console.log('verdict clean nothing in these payloads breaks a structural rule');
}
}
// 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 assertions that matter are the ones about restraint. A block type this table has never heard of must come back unknown and must not push the verdict to reject, because an audit that fails on an SDK upgrade is an audit nobody runs twice. A 401 from an image URL must be reported as a session problem rather than as a broken link, since that is the reading that changes what somebody does next. And a URL on localhost must be decided without a request at all, because the request would succeed and the answer would be wrong.
from slack_invalid_blocks import (audit_blocks, block_fault, image_urls,
image_verdict)
def section(text="hello"):
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
def test_a_healthy_payload_is_clean():
verdict, rows = audit_blocks([section(), {"type": "divider"}])
assert verdict == "clean"
assert rows == []
def test_a_section_with_neither_text_nor_fields_is_fatal():
rows = block_fault({"type": "section"}, 3)
assert rows[0][0] == "fatal"
assert "blocks[3](section)" == rows[0][1]
assert "text or fields" in rows[0][2]
def test_a_section_whose_text_is_an_empty_string_is_also_fatal():
assert block_fault(section(""), 0)[0][0] == "fatal"
assert block_fault(section(" "), 0)[0][0] == "fatal"
def test_more_than_ten_fields_is_fatal():
block = {"type": "section",
"fields": [{"type": "mrkdwn", "text": "x"} for _ in range(11)]}
assert any("at most 10" in r[2] for r in block_fault(block))
def test_an_unknown_block_type_is_unknown_and_never_reject():
verdict, rows = audit_blocks([{"type": "quantum_carousel"}])
assert rows[0][0] == "unknown"
assert verdict == "suspect"
def test_a_select_with_no_options_is_fatal():
block = {"type": "actions",
"elements": [{"type": "static_select", "options": []}]}
assert audit_blocks([block])[0] == "reject"
def test_a_select_with_option_groups_is_accepted():
block = {"type": "actions", "elements": [
{"type": "static_select",
"option_groups": [{"options": [{"text": {"type": "plain_text", "text": "a"},
"value": "a"}]}]}]}
assert audit_blocks([block])[0] == "clean"
def test_an_image_block_needs_a_url_and_alt_text():
rows = block_fault({"type": "image"}, 0)
assert len(rows) == 2
assert all(r[0] == "fatal" for r in rows)
assert any("alt_text" in r[2] for r in rows)
def test_a_header_must_be_plain_text():
block = {"type": "header", "text": {"type": "mrkdwn", "text": "Deploys"}}
assert "plain_text" in block_fault(block)[0][2]
def test_blocks_as_an_object_or_a_string_is_the_format_error():
verdict, rows = audit_blocks({"blocks": []})
assert verdict == "reject"
assert "invalid_blocks_format" in rows[0][2]
assert "once too often" in audit_blocks("[]")[1][0][2]
def test_a_duplicate_action_id_across_two_blocks_is_fatal():
button = {"type": "button", "action_id": "approve",
"text": {"type": "plain_text", "text": "Approve"}}
verdict, rows = audit_blocks([{"type": "actions", "elements": [button]},
{"type": "actions", "elements": [dict(button)]}])
assert verdict == "reject"
assert any("appears 2 times" in r[2] for r in rows)
def test_the_same_action_id_in_different_messages_is_fine():
button = {"type": "button", "action_id": "approve",
"text": {"type": "plain_text", "text": "Approve"}}
assert audit_blocks([{"type": "actions", "elements": [button]}])[0] == "clean"
def test_more_than_fifty_blocks_is_handed_to_the_other_note():
verdict, rows = audit_blocks([{"type": "divider"} for _ in range(51)])
assert verdict == "suspect"
assert "its own note" in rows[0][2]
def test_an_image_that_wants_credentials_is_named_as_a_session_problem():
verdict, detail = image_verdict("https://charts.example.com/a.png", 403, "image/png")
assert verdict == "auth-required"
assert "session cookie" in detail
def test_a_two_hundred_that_is_not_an_image_is_caught():
verdict, detail = image_verdict("https://example.com/a.png", 200, "text/html")
assert verdict == "not-an-image"
assert "error page" in detail
def test_a_usable_image_is_the_only_clean_verdict():
assert image_verdict("https://example.com/a.png", 200, "image/png; charset=utf-8")[0] \
== "public-image"
def test_loopback_and_private_hosts_are_decided_without_a_request():
for url in ("http://localhost:3000/a.png", "http://127.0.0.1/a.png",
"http://10.0.1.14/a.png", "http://192.168.1.5/a.png",
"http://172.20.0.3/a.png", "https://charts.internal/a.png"):
assert image_verdict(url)[0] == "not-public", url
def test_a_public_address_is_not_mistaken_for_a_private_one():
assert image_verdict("https://203.0.113.10/a.png", 200, "image/png")[0] == "public-image"
def test_a_data_uri_and_a_bare_path_are_not_fetchable():
assert image_verdict("data:image/png;base64,AAAA")[0] == "not-http"
assert image_verdict("/static/a.png")[0] == "not-http"
assert image_verdict("")[0] == "missing"
def test_a_transport_error_is_unreachable_rather_than_a_status():
assert image_verdict("https://example.com/a.png", None, None, "timed out")[0] \
== "unreachable"
def test_image_urls_finds_accessories_as_well_as_image_blocks():
blocks = [{"type": "image", "image_url": "https://a/1.png", "alt_text": "a"},
{"type": "section", "text": {"type": "mrkdwn", "text": "x"},
"accessory": {"type": "image", "image_url": "https://a/2.png",
"alt_text": "b"}}]
found = image_urls(blocks)
assert [u for _, u in found] == ["https://a/1.png", "https://a/2.png"]
assert found[1][0].endswith(".accessory")
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
auditBlocks, blockFault, imageUrls, imageVerdict, isPrivateHost,
} from './slack-invalid-blocks.mjs';
const section = (text = 'hello') => ({ type: 'section', text: { type: 'mrkdwn', text } });
test('a healthy payload is clean', () => {
const [verdict, rows] = auditBlocks([section(), { type: 'divider' }]);
assert.equal(verdict, 'clean');
assert.equal(rows.length, 0);
});
test('a section with neither text nor fields is fatal', () => {
const rows = blockFault({ type: 'section' }, 3);
assert.equal(rows[0][0], 'fatal');
assert.equal(rows[0][1], 'blocks[3](section)');
assert.match(rows[0][2], /text or fields/);
});
test('a section whose text is an empty string is also fatal', () => {
assert.equal(blockFault(section(''), 0)[0][0], 'fatal');
assert.equal(blockFault(section(' '), 0)[0][0], 'fatal');
});
test('more than ten fields is fatal', () => {
const block = { type: 'section', fields: Array.from({ length: 11 },
() => ({ type: 'mrkdwn', text: 'x' })) };
assert.ok(blockFault(block).some((r) => /at most 10/.test(r[2])));
});
test('an unknown block type is unknown and never reject', () => {
const [verdict, rows] = auditBlocks([{ type: 'quantum_carousel' }]);
assert.equal(rows[0][0], 'unknown');
assert.equal(verdict, 'suspect');
});
test('a select with no options is fatal', () => {
const block = { type: 'actions', elements: [{ type: 'static_select', options: [] }] };
assert.equal(auditBlocks([block])[0], 'reject');
});
test('a select with option groups is accepted', () => {
const block = { type: 'actions', elements: [{ type: 'static_select',
option_groups: [{ options: [{ text: { type: 'plain_text', text: 'a' }, value: 'a' }] }] }] };
assert.equal(auditBlocks([block])[0], 'clean');
});
test('an image block needs a url and alt text', () => {
const rows = blockFault({ type: 'image' }, 0);
assert.equal(rows.length, 2);
assert.ok(rows.every((r) => r[0] === 'fatal'));
assert.ok(rows.some((r) => /alt_text/.test(r[2])));
});
test('a header must be plain_text', () => {
const block = { type: 'header', text: { type: 'mrkdwn', text: 'Deploys' } };
assert.match(blockFault(block)[0][2], /plain_text/);
});
test('blocks as an object or a string is the format error', () => {
const [verdict, rows] = auditBlocks({ blocks: [] });
assert.equal(verdict, 'reject');
assert.match(rows[0][2], /invalid_blocks_format/);
assert.match(auditBlocks('[]')[1][0][2], /once too often/);
});
test('a duplicate action_id across two blocks is fatal', () => {
const button = { type: 'button', action_id: 'approve',
text: { type: 'plain_text', text: 'Approve' } };
const [verdict, rows] = auditBlocks([
{ type: 'actions', elements: [button] },
{ type: 'actions', elements: [{ ...button }] },
]);
assert.equal(verdict, 'reject');
assert.ok(rows.some((r) => /appears 2 times/.test(r[2])));
});
test('the same action_id in different messages is fine', () => {
const button = { type: 'button', action_id: 'approve',
text: { type: 'plain_text', text: 'Approve' } };
assert.equal(auditBlocks([{ type: 'actions', elements: [button] }])[0], 'clean');
});
test('more than fifty blocks is handed to the other note', () => {
const [verdict, rows] = auditBlocks(Array.from({ length: 51 }, () => ({ type: 'divider' })));
assert.equal(verdict, 'suspect');
assert.match(rows[0][2], /its own note/);
});
test('an image that wants credentials is named as a session problem', () => {
const [verdict, detail] = imageVerdict('https://charts.example.com/a.png', 403, 'image/png');
assert.equal(verdict, 'auth-required');
assert.match(detail, /session cookie/);
});
test('a two hundred that is not an image is caught', () => {
const [verdict, detail] = imageVerdict('https://example.com/a.png', 200, 'text/html');
assert.equal(verdict, 'not-an-image');
assert.match(detail, /error page/);
});
test('a usable image is the only clean verdict', () => {
assert.equal(
imageVerdict('https://example.com/a.png', 200, 'image/png; charset=utf-8')[0],
'public-image');
});
test('loopback and private hosts are decided without a request', () => {
for (const url of ['http://localhost:3000/a.png', 'http://127.0.0.1/a.png',
'http://10.0.1.14/a.png', 'http://192.168.1.5/a.png', 'http://172.20.0.3/a.png',
'https://charts.internal/a.png']) {
assert.equal(imageVerdict(url)[0], 'not-public', url);
}
});
test('a public address is not mistaken for a private one', () => {
assert.equal(imageVerdict('https://203.0.113.10/a.png', 200, 'image/png')[0],
'public-image');
assert.equal(isPrivateHost('example.com'), false);
});
test('a data uri and a bare path are not fetchable', () => {
assert.equal(imageVerdict('data:image/png;base64,AAAA')[0], 'not-http');
assert.equal(imageVerdict('/static/a.png')[0], 'not-http');
assert.equal(imageVerdict('')[0], 'missing');
});
test('a transport error is unreachable rather than a status', () => {
assert.equal(imageVerdict('https://example.com/a.png', null, null, 'timed out')[0],
'unreachable');
});
test('imageUrls finds accessories as well as image blocks', () => {
const blocks = [
{ type: 'image', image_url: 'https://a/1.png', alt_text: 'a' },
{ type: 'section', text: { type: 'mrkdwn', text: 'x' },
accessory: { type: 'image', image_url: 'https://a/2.png', alt_text: 'b' } },
];
const found = imageUrls(blocks);
assert.deepEqual(found.map((f) => f[1]), ['https://a/1.png', 'https://a/2.png']);
assert.ok(found[1][0].endsWith('.accessory'));
});
FAQ
Slack said invalid_blocks. Why will it not say which block?
The API returns a single error code for the whole call, and Block Kit validation happens as one server-side pass over the array. There is no field in the response that carries a block index, and there is no verbose mode that adds one. That asymmetry is the entire reason this script exists: the rules are published, so the index Slack will not give you can be computed on your side before the call is made.
The payload renders fine in Block Kit Builder. How can it be invalid?
Block Kit Builder validates the structure, and structure is only part of the contract. It does not fetch your image URLs from Slack's network, it does not know whether an action_id collides with one in another block you pasted separately, and it renders with whatever preview it has rather than with the workspace's supported types. An image URL that needs a cookie is the classic case: the builder is running in your browser, which has the cookie.
Is invalid_blocks_format different from invalid_blocks?
Yes, and the distinction saves time. invalid_blocks means the array was read and a block inside it broke a rule. invalid_blocks_format means the array could not be read as an array of blocks at all, which in practice is an object that should have been a list, or a value that was JSON-encoded twice on its way through an SDK. No amount of examining individual blocks will fix the second one.
Why check my own posted messages if those are the ones that worked?
Because the same generator produced them and the failing one. A hundred successful messages that each carry nine of the ten permitted section fields tell you the tenth is arriving next week. The historical audit is a leading indicator; the payload check is the incident. They answer different questions and the script does both because you will want both at different times.
Can I just retry without the blocks and send plain text instead?
As a fallback in the send path, yes, and it is a reasonable safety net so an alert still reaches somebody. As a fix, no: it converts a loud failure into a permanent silent downgrade, and nobody will notice that the rich message stopped being rich. Log the rejected payload, send the plain-text fallback, and treat the log line as a bug rather than as the resolution.
Related field notes
- the same error string, from a length rule instead
- when every block is valid and the message is not
- why the rejection arrived looking like a success
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.
- Blocks reference - Slack Docs
- Composition objects - Slack Docs
- chat.postMessage method reference - Slack Docs
- Block Kit - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.