Diagnostic Slack
too_many_attachments: the legacy array has its own ceiling
The alert integration has posted one coloured bar per failing check for four years. This afternoon a dependency went down, 140 checks failed at once, and the alert came back {"ok": false, "error": "too_many_attachments"}. Nothing was deployed. The message that would have explained the outage is the one the outage suppressed.
The obvious answer is to move off attachments, because Slack has described them as legacy for years. That answer is right and it is also a trap: the surface everybody migrates to has a ceiling of 50, and you were refused at 100.
A message takes at most 100 attachments. Past that, chat.postMessage answers too_many_attachments, and a serialized attachment payload that is large enough answers attachment_payload_limit_exceeded at a lower count. There is a parallel ceiling of 10 on contact_cards.
This is a different ceiling on a different surface from the 50-block limit, and the two coexist in one message. An audit that measures blocks reports nothing at all about the message that was just refused, which is why an integration built out of attachments can fail a limit check and pass a limit audit on the same payload.
Attachments still exist because they still do one thing Block Kit does not: the coloured bar down the left edge. Every monitoring tool that emits red for critical and yellow for warning emits attachments, and there is no Block Kit equivalent to move that to. So this script measures the ceilings, and then prices the migration honestly - which usually means one attachment becomes more than one block, against a ceiling half the size.
The problem in plain words
The count is the least of it. Attachments are legacy in the specific sense that they are frozen: they are still delivered, still rendered, and no longer developed, so everything about them is slightly out of step with the rest of the API. Formatting inside an attachment is opt-in through mrkdwn_in, and a field not named there renders its own asterisks, which is why so many bot messages in so many workspaces show *Critical* with the punctuation visible. Nobody files that as a bug. It just looks slightly broken forever.
The migration advice is also given without a number attached, and the number is what decides whether the advice works. An attachment with a title, a body and eight fields is not one block. It is a section for the body, a section for each group of ten fields, sometimes a context row for the footer, and a divider if you want to keep the visual separation the coloured bar was providing. Sixty attachments do not become sixty blocks; they become something like a hundred and twenty, against a ceiling of fifty. A team that rewrites the generator over an afternoon discovers this at the end of the afternoon.
And the failure fires on the same schedule as every other capacity failure: on the busiest day, in the message that mattered most, with an error string nobody in the room has seen before. The generator maps a list to attachments, one per item, and the list is a list of things that went wrong. It is longest exactly when somebody is waiting for it.
Underneath all of it, the attachment payload has a size ceiling that Slack does not publish as a number. A hundred short bars are fine and forty long ones are not, and the error is a different string, which makes the two failures look like unrelated incidents when they are the same generator hitting two edges of the same box.
Why it happens
This is a second ceiling, not a second reading of the first one. Blocks cap at 50 and attachments cap at 100, in the same message, on the same call. A script that measures one and reports on the other is worse than no script, because it returns a clean verdict on a payload that was refused ten minutes ago.
The migration cost has to be computed, or the advice is wrong half the time. Telling somebody to move to blocks without saying how many blocks their content becomes is telling them to swap a ceiling of 100 for a ceiling of 50 and hope. The arithmetic is not hard and it changes the plan, so the script does it.
Two of the smells are reasons to keep an attachment. The coloured bar has no replacement, and content already expressed as blocks inside an attachment is already migrated. A report that lists those as faults is a report that gets read once and ignored, so they are printed as findings without being printed as problems.
mrkdwn_in is opt-in, and nothing tells you when you forgot. The message posts, the formatting does not happen, and the asterisks are right there in the channel. This is the only fault in the note with no error string and no ceiling, and it is the one most likely to already be true of your workspace.
The byte ceiling is not published, so the script reports the measurement. It prints the serialized size, names the line it compared against, and says plainly that the line is a default rather than a documented figure. A verdict built on a guessed threshold is worth less than the number it was computed from.
Nothing is sent. Finding an attachment ceiling by walking into it means posting a hundred coloured bars into a channel, twice, because the first attempt is how you learn the count and the second is how you learn the size. With --payload this holds no token at all.
The fix, as a flow
The only chain in this section that goes red twice, and the second red box is the repair. A monitoring tool emits one attachment per result, the results grow past a hundred, and the obvious fix is to move to blocks, which is a ceiling of fifty. The fix branch is therefore a sort by what each attachment is actually doing, because one of the things it does has no replacement and two of them cost nothing to move.
How to fix it
Measure the payload you were about to send
Write the failing message to a file and run the script with --payload. No token, no network, nothing posted. budget_report returns the count, the serialized size, the contact_cards count and which ceiling each one is against.
Read the byte line as a measurement, not a verdict
Slack does not publish the size behind attachment_payload_limit_exceeded, so --payload-bytes sets the line and the report always prints the number it measured. Adjust the line to whatever your workspace actually refuses; the measurement is the part that transfers.
Price the migration before you plan it
migration_cost converts the same content into the blocks it would need: a section per body, one per ten fields, a divider between items to keep the separation the coloured bar gave you. If that number is over 50, moving to blocks makes the problem worse and the real repair is capping the list.
Sort the attachments by what they are actually doing
legacy_smells names it per attachment. A coloured bar has no Block Kit equivalent and is a reason to stay. Text only maps to a section and costs nothing. Legacy actions are a retired interactive style. These are three different decisions, not one migration.
Fix the formatting you did not know was broken
Any attachment whose text, pretext or field values contain mrkdwn without naming that field in mrkdwn_in is rendering its own punctuation right now. This has nothing to do with the ceiling and it is usually the finding people act on first.
Run the same measurement over what you have already posted
With a token and --channel, the script reads conversations.history and measures the attachments Slack stored for your app's own messages. The distribution is the useful part: a fleet whose tail sits at 80 is one bad afternoon from the ceiling.
How to check it worked
Cap the generator, run the same fixture again, and the verdict should move without anything being sent.
python3 slack_attachment_budget.py --payload failing-alert.json
# payload failing-alert.json
# budget reject payload 140 attachment(s), 0 contact_card(s), 6441 bytes
# reason too_many_attachments 140 attachments, and the ceiling is 100
# migration 140 attachment(s) become about 279 block(s); the block ceiling is 50, so a
# straight migration lands over it
# legacy colour-bar color '#e01e5a' is the one thing an attachment does
# that Block Kit has no equivalent for; a reason to keep it
# legacy mrkdwn-not-enabled text contains mrkdwn and mrkdwn_in does not list 'text',
# so the asterisks and link syntax render literally
The full code
Four pure functions and no network in the path that matters. attachment_size serializes the array the way the API counts it. legacy_smells names what one attachment is doing rather than whether it is wrong, because two of the answers are reasons to keep it. migration_cost is the one that earns the note: it turns "just move to blocks" into a block count you can hold against a ceiling of fifty before you spend an afternoon on it. budget_report holds one message against every ceiling at once and prints the numbers beside the verdict.
"""Measure the legacy attachments array against the ceilings that are its own.
Read only. With --payload it holds no token and calls no Slack method at all;
with --channel it reads conversations.history and measures the attachments
Slack stored for messages your app already posted. Nothing is ever sent from
here: finding an attachment ceiling by walking into it puts a hundred coloured
bars in a real channel.
"""
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_attachment_budget")
API = "https://slack.com/api/"
# Published, and the one number in this script that is not a judgement call.
MAX_ATTACHMENTS = 100
# The parallel ceiling on the contact_cards field, which travels beside
# attachments and is counted separately.
MAX_CONTACT_CARDS = 10
# Slack does not publish the byte ceiling behind attachment_payload_limit_exceeded,
# so this is a deliberately conservative default rather than a documented number,
# and --payload-bytes exists because the figure that matters is the one your own
# workspace refuses at. The measurement is printed either way, so the report is
# useful even when the threshold is wrong.
DEFAULT_PAYLOAD_BYTES = 12000
# Belongs to the note next door, and is here only for the migration arithmetic:
# the point of that arithmetic is that moving off attachments lands you on this.
BLOCK_CEILING = 50
# A section holds at most ten fields, which is what an attachment fields grid
# turns into.
FIELDS_PER_SECTION = 10
# Formatting that only renders when the field is named in mrkdwn_in. Inside an
# attachment this is opt in, which is the single most surprising thing about
# the legacy surface and the reason so many attachments show their own asterisks.
MRKDWN = re.compile(r"\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|<https?://[^>|]+\|[^>]+>")
# Fields whose content mrkdwn_in can name.
MRKDWN_FIELDS = ("text", "pretext", "fields")
def attachment_size(attachments):
"""Serialized bytes of the attachments array, counted as the API counts it.
Pure. Returns -1 for a structure that will not serialize at all, which is a
different fault and belongs to whoever built it.
"""
try:
encoded = json.dumps(attachments, separators=(",", ":"), ensure_ascii=False)
except (TypeError, ValueError):
return -1
return len(encoded.encode("utf-8"))
def legacy_smells(attachment):
"""Name what one attachment is doing that Block Kit does another way. Pure.
Returns a list of (smell, detail). This is not a list of faults. Two of
these are reasons to keep an attachment, and reporting them as problems is
how a migration report gets ignored.
"""
out = []
if not isinstance(attachment, dict):
return [("not-an-object", "an attachment must be an object; this is a %s"
% type(attachment).__name__)]
declared = attachment.get("mrkdwn_in")
declared = set(declared) if isinstance(declared, list) else set()
for field in MRKDWN_FIELDS:
value = attachment.get(field)
if field == "fields":
body = " ".join(str(f.get("value", "")) for f in value or []
if isinstance(f, dict))
else:
body = str(value or "")
if body and MRKDWN.search(body) and field not in declared:
out.append(("mrkdwn-not-enabled",
"%s contains mrkdwn and mrkdwn_in does not list %r, so the "
"asterisks and link syntax render literally" % (field, field)))
if attachment.get("color"):
out.append(("colour-bar",
"color %r is the one thing an attachment does that Block Kit has "
"no equivalent for; this is a reason to keep it"
% str(attachment["color"])[:16]))
if isinstance(attachment.get("fields"), list) and attachment["fields"]:
out.append(("fields-grid",
"%d field(s), which become section fields ten at a time"
% len(attachment["fields"])))
if isinstance(attachment.get("blocks"), list) and attachment["blocks"]:
out.append(("blocks-inside",
"%d block(s) already inside the attachment, so the content is "
"migrated and only the colour bar is still legacy"
% len(attachment["blocks"])))
if isinstance(attachment.get("actions"), list) and attachment["actions"]:
out.append(("legacy-actions",
"%d interactive action(s) in the retired attachment style rather "
"than a Block Kit actions block" % len(attachment["actions"])))
if not out:
out.append(("plain", "text only, so this maps to one section and nothing is "
"lost by moving it"))
return out
def migration_cost(attachments, separator=True):
"""How many blocks the same content needs once it stops being attachments.
Pure. The answer matters because it is usually larger than the attachment
count, and the ceiling on the other side is 50 rather than 100. A fleet at
70 attachments is not one refactor away from safety; it is one refactor away
from a tighter ceiling.
"""
items = attachments if isinstance(attachments, list) else []
blocks = 0
for a in items:
if not isinstance(a, dict):
continue
inner = a.get("blocks")
if isinstance(inner, list) and inner:
blocks += len(inner)
continue
if str(a.get("pretext") or "").strip():
blocks += 1
if str(a.get("title") or "").strip() or str(a.get("text") or "").strip():
blocks += 1
fields = a.get("fields")
if isinstance(fields, list) and fields:
blocks += -(-len(fields) // FIELDS_PER_SECTION)
if str(a.get("footer") or "").strip():
blocks += 1
if isinstance(a.get("actions"), list) and a["actions"]:
blocks += 1
if separator and len(items) > 1:
blocks += len(items) - 1
return {
"attachments": len(items),
"blocks_needed": blocks,
"block_ceiling": BLOCK_CEILING,
"over_block_ceiling": blocks > BLOCK_CEILING,
"detail": ("%d attachment(s) become about %d block(s); the block ceiling is "
"%d, so %s" % (len(items), blocks, BLOCK_CEILING,
"a straight migration lands over it"
if blocks > BLOCK_CEILING
else "a straight migration fits")),
}
def budget_report(attachments, contact_cards=None, payload_bytes=DEFAULT_PAYLOAD_BYTES):
"""Hold one message's attachments against every ceiling at once. Pure.
Returns a verdict of reject, at-risk or ok, plus the numbers, because the
numbers are what somebody acts on and the verdict is only how they are
sorted.
"""
items = attachments if isinstance(attachments, list) else []
cards = contact_cards if isinstance(contact_cards, list) else []
size = attachment_size(items)
reasons = []
verdict = "ok"
if len(items) > MAX_ATTACHMENTS:
verdict = "reject"
reasons.append(("too_many_attachments",
"%d attachments, and the ceiling is %d"
% (len(items), MAX_ATTACHMENTS)))
if len(cards) > MAX_CONTACT_CARDS:
verdict = "reject"
reasons.append(("too_many_contact_cards",
"%d contact_cards, and the ceiling is %d"
% (len(cards), MAX_CONTACT_CARDS)))
if size > payload_bytes:
verdict = "reject"
reasons.append(("attachment_payload_limit_exceeded",
"%d bytes serialized, past the %d byte line this run used; "
"Slack does not publish the real figure, so treat this as a "
"measurement rather than a certainty" % (size, payload_bytes)))
if verdict == "ok":
near_count = len(items) >= MAX_ATTACHMENTS * 0.8
near_size = size >= payload_bytes * 0.8
if near_count or near_size:
verdict = "at-risk"
reasons.append(("inside-the-last-fifth",
"%d of %d attachments and %d of %d bytes; this fails on a "
"busier day and nothing will have changed"
% (len(items), MAX_ATTACHMENTS, size, payload_bytes)))
if not items and not cards:
reasons.append(("no-attachments", "nothing legacy here"))
return {
"count": len(items),
"contact_cards": len(cards),
"bytes": size,
"payload_bytes": payload_bytes,
"verdict": verdict,
"reasons": reasons,
}
def _load_payload(path):
body = json.loads(open(path, encoding="utf-8").read())
if isinstance(body, list):
return body, []
if isinstance(body, dict):
return body.get("attachments") or [], body.get("contact_cards") or []
return [], []
def _report(label, attachments, cards, args):
b = budget_report(attachments, cards, args.payload_bytes)
line = ("%s %d attachment(s), %d contact_card(s), %d bytes"
% (label, b["count"], b["contact_cards"], b["bytes"]))
(log.warning if b["verdict"] != "ok" else log.info)(
"budget %-10s %s", b["verdict"], line)
for code, detail in b["reasons"]:
if code == "no-attachments":
continue
(log.info if code == "inside-the-last-fifth" else log.warning)(
"reason %-34s %s", code, detail)
if not attachments:
return b["verdict"] != "ok"
m = migration_cost(attachments)
(log.warning if m["over_block_ceiling"] else log.info)(
"migration %s", m["detail"])
seen = {}
for a in attachments:
for smell, detail in legacy_smells(a):
seen.setdefault(smell, detail)
for smell in sorted(seen):
(log.info if smell in ("colour-bar", "plain", "blocks-inside")
else log.warning)("legacy %-20s %s", smell, seen[smell])
return b["verdict"] != "ok"
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--payload", help="a JSON file holding a message payload or a "
"bare attachments array; needs no token")
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 your app posts into; repeatable")
ap.add_argument("--limit", type=int, default=200, help="messages read per channel")
ap.add_argument("--payload-bytes", type=int, default=DEFAULT_PAYLOAD_BYTES,
help="serialized byte line to report against")
args = ap.parse_args()
if args.payload:
attachments, cards = _load_payload(args.payload)
log.info("payload %s", args.payload)
return 1 if _report("payload", attachments, cards, args) else 0
if not args.channel:
log.error("pass --payload FILE, or --channel with a token in %s", args.token_env)
return 2
token = os.environ.get(args.token_env)
if not token:
log.error("set %s (channels:history is enough)", args.token_env)
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, bot_user = who.get("bot_id") or "", who.get("user_id") or ""
log.info("identity %s in %s", bot_user, who.get("team"))
findings, using = 0, 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
for m in body.get("messages") or []:
mine = ((bot_id and m.get("bot_id") == bot_id)
or (bot_user and m.get("user") == bot_user))
if not mine:
continue
attachments = m.get("attachments")
if not (isinstance(attachments, list) and attachments):
continue
using += 1
if _report("%s ts=%s" % (channel, m.get("ts")), attachments,
m.get("contact_cards"), args):
findings += 1
if not using:
log.info("verdict clear nothing of ours posts attachments here")
return 0
log.warning("using %d of our messages still post attachments", using)
log.warning(" repair: cap the attachment count in the generator and summarize the "
"overflow, the same way you would cap any unbounded list")
log.warning(" repair: keep attachments only for the coloured side bar, and put the "
"content in blocks inside the attachment")
log.warning(" repair: name every mrkdwn bearing field in mrkdwn_in, or the "
"formatting renders as its own punctuation")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Measure the legacy attachments array against the ceilings that are its own.
*
* Read only. With --payload it holds no token and calls no Slack method at
* all; with --channel it reads conversations.history and measures the
* attachments Slack stored for messages your app already posted. Nothing is
* ever sent from here.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Published, and the one number here that is not a judgement call.
export const MAX_ATTACHMENTS = 100;
// The parallel ceiling on contact_cards, which is counted separately.
export const MAX_CONTACT_CARDS = 10;
// Not a documented figure. Slack does not publish the byte ceiling behind
// attachment_payload_limit_exceeded, so this is conservative and adjustable,
// and the measurement is printed either way.
export const DEFAULT_PAYLOAD_BYTES = 12000;
// Belongs to the note next door, and is here only for the migration
// arithmetic: the point of that arithmetic is that you land on this.
export const BLOCK_CEILING = 50;
const FIELDS_PER_SECTION = 10;
// Formatting that only renders when the field is named in mrkdwn_in.
const MRKDWN = /\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|<https?:\/\/[^>|]+\|[^>]+>/;
const MRKDWN_FIELDS = ['text', 'pretext', 'fields'];
/** Serialized bytes of the attachments array, counted as the API counts it. Pure. */
export function attachmentSize(attachments) {
let encoded;
try {
encoded = JSON.stringify(attachments);
} catch {
return -1;
}
if (encoded === undefined) return -1;
return Buffer.byteLength(encoded, 'utf8');
}
/**
* Name what one attachment is doing that Block Kit does another way. Pure.
* Not a list of faults: two of these are reasons to keep an attachment, and
* reporting them as problems is how a migration report gets ignored.
*/
export function legacySmells(attachment) {
const out = [];
if (attachment === null || typeof attachment !== 'object' || Array.isArray(attachment)) {
return [['not-an-object',
`an attachment must be an object; this is a ${typeof attachment}`]];
}
const declared = new Set(Array.isArray(attachment.mrkdwn_in) ? attachment.mrkdwn_in : []);
for (const field of MRKDWN_FIELDS) {
let body;
if (field === 'fields') {
body = (attachment.fields ?? [])
.filter((f) => f && typeof f === 'object')
.map((f) => String(f.value ?? '')).join(' ');
} else {
body = String(attachment[field] ?? '');
}
if (body && MRKDWN.test(body) && !declared.has(field)) {
out.push(['mrkdwn-not-enabled',
`${field} contains mrkdwn and mrkdwn_in does not list "${field}", so the ` +
'asterisks and link syntax render literally']);
}
}
if (attachment.color) {
out.push(['colour-bar',
`color "${String(attachment.color).slice(0, 16)}" is the one thing an ` +
'attachment does that Block Kit has no equivalent for; this is a reason to keep it']);
}
if (Array.isArray(attachment.fields) && attachment.fields.length) {
out.push(['fields-grid',
`${attachment.fields.length} field(s), which become section fields ten at a time`]);
}
if (Array.isArray(attachment.blocks) && attachment.blocks.length) {
out.push(['blocks-inside',
`${attachment.blocks.length} block(s) already inside the attachment, so the ` +
'content is migrated and only the colour bar is still legacy']);
}
if (Array.isArray(attachment.actions) && attachment.actions.length) {
out.push(['legacy-actions',
`${attachment.actions.length} interactive action(s) in the retired attachment ` +
'style rather than a Block Kit actions block']);
}
if (!out.length) {
out.push(['plain',
'text only, so this maps to one section and nothing is lost by moving it']);
}
return out;
}
/**
* How many blocks the same content needs once it stops being attachments. Pure.
* Usually more than the attachment count, against a ceiling of 50 rather than
* 100, which is why a fleet at 70 attachments is not one refactor from safety.
*/
export function migrationCost(attachments, separator = true) {
const items = Array.isArray(attachments) ? attachments : [];
let blocks = 0;
for (const a of items) {
if (!a || typeof a !== 'object') continue;
if (Array.isArray(a.blocks) && a.blocks.length) {
blocks += a.blocks.length;
continue;
}
if (String(a.pretext ?? '').trim()) blocks += 1;
if (String(a.title ?? '').trim() || String(a.text ?? '').trim()) blocks += 1;
if (Array.isArray(a.fields) && a.fields.length) {
blocks += Math.ceil(a.fields.length / FIELDS_PER_SECTION);
}
if (String(a.footer ?? '').trim()) blocks += 1;
if (Array.isArray(a.actions) && a.actions.length) blocks += 1;
}
if (separator && items.length > 1) blocks += items.length - 1;
const over = blocks > BLOCK_CEILING;
return {
attachments: items.length,
blocksNeeded: blocks,
blockCeiling: BLOCK_CEILING,
overBlockCeiling: over,
detail: `${items.length} attachment(s) become about ${blocks} block(s); the block ` +
`ceiling is ${BLOCK_CEILING}, so ${over ? 'a straight migration lands over it'
: 'a straight migration fits'}`,
};
}
/**
* Hold one message's attachments against every ceiling at once. Pure.
* Returns reject, at-risk or ok plus the numbers, because the numbers are what
* somebody acts on and the verdict is only how they are sorted.
*/
export function budgetReport(attachments, contactCards = null,
payloadBytes = DEFAULT_PAYLOAD_BYTES) {
const items = Array.isArray(attachments) ? attachments : [];
const cards = Array.isArray(contactCards) ? contactCards : [];
const size = attachmentSize(items);
const reasons = [];
let verdict = 'ok';
if (items.length > MAX_ATTACHMENTS) {
verdict = 'reject';
reasons.push(['too_many_attachments',
`${items.length} attachments, and the ceiling is ${MAX_ATTACHMENTS}`]);
}
if (cards.length > MAX_CONTACT_CARDS) {
verdict = 'reject';
reasons.push(['too_many_contact_cards',
`${cards.length} contact_cards, and the ceiling is ${MAX_CONTACT_CARDS}`]);
}
if (size > payloadBytes) {
verdict = 'reject';
reasons.push(['attachment_payload_limit_exceeded',
`${size} bytes serialized, past the ${payloadBytes} byte line this run used; ` +
'Slack does not publish the real figure, so treat this as a measurement rather ' +
'than a certainty']);
}
if (verdict === 'ok'
&& (items.length >= MAX_ATTACHMENTS * 0.8 || size >= payloadBytes * 0.8)) {
verdict = 'at-risk';
reasons.push(['inside-the-last-fifth',
`${items.length} of ${MAX_ATTACHMENTS} attachments and ${size} of ` +
`${payloadBytes} bytes; this fails on a busier day and nothing will have changed`]);
}
if (!items.length && !cards.length) reasons.push(['no-attachments', 'nothing legacy here']);
return {
count: items.length,
contactCards: cards.length,
bytes: size,
payloadBytes,
verdict,
reasons,
};
}
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;
}
function report(label, attachments, cards, payloadBytes) {
const b = budgetReport(attachments, cards, payloadBytes);
const line = `${label} ${b.count} attachment(s), ${b.contactCards} contact_card(s), ` +
`${b.bytes} bytes`;
(b.verdict === 'ok' ? console.log : console.warn)(
`budget ${b.verdict.padEnd(10)} ${line}`);
for (const [code, detail] of b.reasons) {
if (code === 'no-attachments') continue;
(code === 'inside-the-last-fifth' ? console.log : console.warn)(
`reason ${code.padEnd(34)} ${detail}`);
}
if (!attachments || !attachments.length) return b.verdict !== 'ok';
const m = migrationCost(attachments);
(m.overBlockCeiling ? console.warn : console.log)(`migration ${m.detail}`);
const seen = new Map();
for (const a of attachments) {
for (const [smell, detail] of legacySmells(a)) {
if (!seen.has(smell)) seen.set(smell, detail);
}
}
for (const smell of [...seen.keys()].sort()) {
(['colour-bar', 'plain', 'blocks-inside'].includes(smell) ? console.log : console.warn)(
`legacy ${smell.padEnd(20)} ${seen.get(smell)}`);
}
return b.verdict !== 'ok';
}
async function main() {
const args = process.argv.slice(2);
const payloadBytes = Number(arg(args, '--payload-bytes', String(DEFAULT_PAYLOAD_BYTES)));
const payload = arg(args, '--payload');
if (payload) {
const body = JSON.parse(await readFile(payload, 'utf8'));
const attachments = Array.isArray(body) ? body : (body.attachments ?? []);
const cards = Array.isArray(body) ? [] : (body.contact_cards ?? []);
console.log(`payload ${payload}`);
if (report('payload', attachments, cards, payloadBytes)) process.exitCode = 1;
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const channels = argAll(args, '--channel');
if (!channels.length) {
console.error(`pass --payload FILE, or --channel with a token in ${tokenEnv}`);
process.exitCode = 2;
return;
}
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} (channels:history is enough)`);
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 ?? '';
const botUser = who.user_id ?? '';
console.log(`identity ${botUser} in ${who.team}`);
let findings = 0;
let using = 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;
}
for (const m of body.messages ?? []) {
const mine = (botId && m.bot_id === botId) || (botUser && m.user === botUser);
if (!mine) continue;
if (!Array.isArray(m.attachments) || !m.attachments.length) continue;
using += 1;
if (report(`${channel} ts=${m.ts}`, m.attachments, m.contact_cards, payloadBytes)) {
findings += 1;
}
}
}
if (!using) {
console.log('verdict clear nothing of ours posts attachments here');
return;
}
console.warn(`using ${using} of our messages still post attachments`);
console.warn(' repair: cap the attachment count in the generator and summarize the ' +
'overflow, the same way you would cap any unbounded list');
console.warn(' repair: keep attachments only for the coloured side bar, and put the ' +
'content in blocks inside the attachment');
console.warn(' repair: name every mrkdwn bearing field in mrkdwn_in, or the ' +
'formatting renders as its own punctuation');
if (findings) process.exitCode = 1;
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The assertion the note rests on is that sixty attachments become more than sixty blocks, because that is the arithmetic that makes the obvious migration a worse position rather than a better one. The rest are about not crying wolf: a coloured bar has to come back as a reason to keep an attachment rather than as a fault, an attachment that already holds blocks has to cost what it holds rather than what it looks like, and a two-item payload has to be able to fail on bytes while passing on count, since that is the case a check on the length alone was written for and does not catch.
from slack_attachment_budget import (attachment_size, budget_report, legacy_smells,
migration_cost)
def bar(i):
return {"color": "#e01e5a", "text": "check %d failed" % i, "mrkdwn_in": ["text"]}
def test_a_hundred_attachments_pass_and_a_hundred_and_one_do_not():
assert budget_report([bar(i) for i in range(100)])["verdict"] != "reject"
b = budget_report([bar(i) for i in range(101)])
assert b["verdict"] == "reject"
assert b["reasons"][0][0] == "too_many_attachments"
assert "101" in b["reasons"][0][1]
def test_the_contact_cards_ceiling_is_counted_separately():
b = budget_report([bar(0)], [{"name": "n%d" % i} for i in range(11)])
assert b["verdict"] == "reject"
assert [c for c, _ in b["reasons"]] == ["too_many_contact_cards"]
assert b["count"] == 1
def test_a_small_count_can_still_be_over_on_bytes():
fat = [{"text": "x" * 4000}, {"text": "y" * 4000}]
b = budget_report(fat, None, payload_bytes=5000)
assert b["count"] == 2
assert b["verdict"] == "reject"
assert b["reasons"][0][0] == "attachment_payload_limit_exceeded"
assert "not publish" in b["reasons"][0][1]
def test_the_byte_line_is_reported_as_the_line_this_run_used():
b = budget_report([{"text": "x" * 100}], None, payload_bytes=9999)
assert b["payload_bytes"] == 9999
assert b["bytes"] > 100
def test_inside_the_last_fifth_is_a_warning_and_not_a_rejection():
b = budget_report([bar(i) for i in range(85)])
assert b["verdict"] == "at-risk"
assert b["reasons"][0][0] == "inside-the-last-fifth"
assert "busier day" in b["reasons"][0][1]
def test_a_message_with_no_attachments_is_not_a_finding():
b = budget_report(None)
assert b["verdict"] == "ok"
assert b["count"] == 0
assert b["reasons"] == [("no-attachments", "nothing legacy here")]
def test_attachment_size_counts_bytes_rather_than_characters():
assert attachment_size([{"text": "abc"}]) == len('[{"text":"abc"}]')
assert attachment_size([{"text": "é"}]) == len('[{"text":"x"}]') + 1
assert attachment_size([{"bad": {1, 2}}]) == -1
def test_mrkdwn_without_mrkdwn_in_renders_its_own_punctuation():
smells = dict(legacy_smells({"text": "*3 deploys* failed"}))
assert "mrkdwn-not-enabled" in smells
assert "render literally" in smells["mrkdwn-not-enabled"]
assert "mrkdwn-not-enabled" not in dict(
legacy_smells({"text": "*3 deploys* failed", "mrkdwn_in": ["text"]}))
assert "mrkdwn-not-enabled" not in dict(legacy_smells({"text": "3 deploys failed"}))
def test_a_link_in_slack_syntax_counts_as_mrkdwn():
smells = dict(legacy_smells({"pretext": "see <https://ci.example.com/1|build 1>"}))
assert "mrkdwn-not-enabled" in smells
assert "pretext" in smells["mrkdwn-not-enabled"]
def test_the_colour_bar_is_reported_as_a_reason_to_keep_it():
smells = dict(legacy_smells({"color": "good", "text": "ok"}))
assert "colour-bar" in smells
assert "reason to keep it" in smells["colour-bar"]
def test_blocks_inside_an_attachment_are_already_migrated():
smells = dict(legacy_smells({"color": "#36a64f", "blocks": [{"type": "divider"}]}))
assert "blocks-inside" in smells
assert "only the colour bar is still legacy" in smells["blocks-inside"]
def test_legacy_actions_are_named_separately_from_the_rest():
smells = dict(legacy_smells({"actions": [{"type": "button", "text": "Retry"}]}))
assert "legacy-actions" in smells
assert "retired" in smells["legacy-actions"]
def test_a_plain_attachment_is_reported_as_costing_nothing_to_move():
assert dict(legacy_smells({"text": "3 deploys failed"})).get("plain")
assert legacy_smells("not a dict")[0][0] == "not-an-object"
def test_the_migration_lands_on_the_tighter_ceiling():
m = migration_cost([bar(i) for i in range(60)])
assert m["attachments"] == 60
assert m["blocks_needed"] > 60
assert m["over_block_ceiling"] is True
assert "lands over it" in m["detail"]
def test_a_small_fleet_migrates_without_hitting_the_block_ceiling():
m = migration_cost([bar(i) for i in range(4)])
assert m["blocks_needed"] == 7
assert m["over_block_ceiling"] is False
assert "fits" in m["detail"]
def test_a_fields_grid_costs_one_section_per_ten_fields():
a = {"text": "summary", "fields": [{"title": str(i), "value": str(i)}
for i in range(25)]}
assert migration_cost([a])["blocks_needed"] == 1 + 3
def test_an_attachment_that_already_holds_blocks_costs_what_it_holds():
a = {"color": "danger", "text": "ignored", "blocks": [{"type": "divider"}] * 5}
assert migration_cost([a])["blocks_needed"] == 5
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
attachmentSize, budgetReport, legacySmells, migrationCost,
} from './slack-attachment-budget.mjs';
const bar = (i) => ({ color: '#e01e5a', text: `check ${i} failed`, mrkdwn_in: ['text'] });
const many = (n) => Array.from({ length: n }, (_, i) => bar(i));
const smells = (a) => new Map(legacySmells(a));
test('a hundred attachments pass and a hundred and one do not', () => {
assert.notEqual(budgetReport(many(100)).verdict, 'reject');
const b = budgetReport(many(101));
assert.equal(b.verdict, 'reject');
assert.equal(b.reasons[0][0], 'too_many_attachments');
assert.match(b.reasons[0][1], /101/);
});
test('the contact cards ceiling is counted separately', () => {
const cards = Array.from({ length: 11 }, (_, i) => ({ name: `n${i}` }));
const b = budgetReport([bar(0)], cards);
assert.equal(b.verdict, 'reject');
assert.deepEqual(b.reasons.map(([c]) => c), ['too_many_contact_cards']);
assert.equal(b.count, 1);
});
test('a small count can still be over on bytes', () => {
const fat = [{ text: 'x'.repeat(4000) }, { text: 'y'.repeat(4000) }];
const b = budgetReport(fat, null, 5000);
assert.equal(b.count, 2);
assert.equal(b.verdict, 'reject');
assert.equal(b.reasons[0][0], 'attachment_payload_limit_exceeded');
assert.match(b.reasons[0][1], /not publish/);
});
test('the byte line is reported as the line this run used', () => {
const b = budgetReport([{ text: 'x'.repeat(100) }], null, 9999);
assert.equal(b.payloadBytes, 9999);
assert.ok(b.bytes > 100);
});
test('inside the last fifth is a warning and not a rejection', () => {
const b = budgetReport(many(85));
assert.equal(b.verdict, 'at-risk');
assert.equal(b.reasons[0][0], 'inside-the-last-fifth');
assert.match(b.reasons[0][1], /busier day/);
});
test('a message with no attachments is not a finding', () => {
const b = budgetReport(null);
assert.equal(b.verdict, 'ok');
assert.equal(b.count, 0);
assert.deepEqual(b.reasons, [['no-attachments', 'nothing legacy here']]);
});
test('attachment size counts bytes rather than characters', () => {
assert.equal(attachmentSize([{ text: 'abc' }]), '[{"text":"abc"}]'.length);
assert.equal(attachmentSize([{ text: 'é' }]), '[{"text":"x"}]'.length + 1);
const loop = [{}];
loop[0].self = loop;
assert.equal(attachmentSize(loop), -1);
});
test('mrkdwn without mrkdwn_in renders its own punctuation', () => {
const found = smells({ text: '*3 deploys* failed' });
assert.ok(found.has('mrkdwn-not-enabled'));
assert.match(found.get('mrkdwn-not-enabled'), /render literally/);
assert.ok(!smells({ text: '*3 deploys* failed', mrkdwn_in: ['text'] })
.has('mrkdwn-not-enabled'));
assert.ok(!smells({ text: '3 deploys failed' }).has('mrkdwn-not-enabled'));
});
test('a link in slack syntax counts as mrkdwn', () => {
const found = smells({ pretext: 'see <https://ci.example.com/1|build 1>' });
assert.ok(found.has('mrkdwn-not-enabled'));
assert.match(found.get('mrkdwn-not-enabled'), /pretext/);
});
test('the colour bar is reported as a reason to keep it', () => {
const found = smells({ color: 'good', text: 'ok' });
assert.ok(found.has('colour-bar'));
assert.match(found.get('colour-bar'), /reason to keep it/);
});
test('blocks inside an attachment are already migrated', () => {
const found = smells({ color: '#36a64f', blocks: [{ type: 'divider' }] });
assert.ok(found.has('blocks-inside'));
assert.match(found.get('blocks-inside'), /only the colour bar is still legacy/);
});
test('legacy actions are named separately from the rest', () => {
const found = smells({ actions: [{ type: 'button', text: 'Retry' }] });
assert.ok(found.has('legacy-actions'));
assert.match(found.get('legacy-actions'), /retired/);
});
test('a plain attachment is reported as costing nothing to move', () => {
assert.ok(smells({ text: '3 deploys failed' }).has('plain'));
assert.equal(legacySmells('not an object')[0][0], 'not-an-object');
});
test('the migration lands on the tighter ceiling', () => {
const m = migrationCost(many(60));
assert.equal(m.attachments, 60);
assert.ok(m.blocksNeeded > 60);
assert.equal(m.overBlockCeiling, true);
assert.match(m.detail, /lands over it/);
});
test('a small fleet migrates without hitting the block ceiling', () => {
const m = migrationCost(many(4));
assert.equal(m.blocksNeeded, 7);
assert.equal(m.overBlockCeiling, false);
assert.match(m.detail, /fits/);
});
test('a fields grid costs one section per ten fields', () => {
const fields = Array.from({ length: 25 }, (_, i) => ({ title: `${i}`, value: `${i}` }));
assert.equal(migrationCost([{ text: 'summary', fields }]).blocksNeeded, 4);
});
test('an attachment that already holds blocks costs what it holds', () => {
const a = { color: 'danger', text: 'ignored', blocks: Array(5).fill({ type: 'divider' }) };
assert.equal(migrationCost([a]).blocksNeeded, 5);
});
FAQ
Are attachments actually deprecated, or just old?
Slack describes them as legacy and steers new work to Block Kit, and rendering of attachments is deprioritised in modern clients. They are not removed and are unlikely to be: an enormous amount of existing integration code emits them, and the coloured side bar has no Block Kit equivalent. Treat them as frozen rather than dying, which is why this note prices the migration instead of assuming it.
Why does my formatting inside an attachment not work?
Because mrkdwn inside an attachment is opt-in. Slack only parses the fields you name in mrkdwn_in, so a bold marker in text renders as an asterisk unless you send mrkdwn_in with text in it. The script flags every attachment where a field contains mrkdwn syntax and is not named, which in most workspaces is the finding people fix first because it is visible in the channel right now.
Can I keep the coloured bar and still use Block Kit?
Yes, and it is the usual answer. Put a blocks array inside the attachment: the content is then Block Kit and the attachment is doing nothing except carrying the colour. The script reports that as blocks-inside rather than as a fault, because it is the migrated state rather than a step short of it. The block ceiling still applies to what is inside.
Is the byte ceiling a real number I can code against?
Not a published one. Slack returns attachment_payload_limit_exceeded without documenting the threshold, and the figure this script compares against is a conservative default rather than a documented limit. That is why the report always prints the measured size and names the line it used: the measurement is what transfers between workspaces, and the threshold is what you adjust once you have seen your own refusal.
Does the 50-block limit apply to my attachments too?
The ceilings are separate and both are live in the same message. That is the whole reason this note exists beside the block one: a payload can be refused for attachments while sitting at four blocks, and an audit that only counts blocks reports a clean bill of health on it. Once you migrate, the block ceiling is the one that binds, and it is half the size.
Related field notes
- the ceiling on the surface you would migrate to, and it is smaller
- the fallback string, one level down inside an attachment
- when the payload is refused for its shape rather than its size
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.
- chat.postMessage method reference - Slack Docs
- Formatting message text - Slack Docs
- Blocks reference - Slack Docs
- conversations.history method 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.