Skip to content

Diagnostic Slack

Blocks with no fallback text, so notifications are blank

Somebody says the bot's alerts are useless on their phone. You open the channel and the messages are perfect: headers, coloured context lines, a button. You open the API logs and every call returned ok: true. Nothing has ever errored.

Then you look at your own lock screen. The push says the app's name and This content can't be displayed. The sidebar preview beside the channel is empty. Search finds nothing. The message is fine everywhere it is rendered as Block Kit, and it is blank everywhere it is not, which is most of the places anybody actually notices a message.

Read-only token Python and Node.js Tests included
A person holding a credit card in front of a machine
Photo by Nathana Rebouças on Unsplash
The short answer

text is not the legacy alternative to blocks. It is the fallback string, and Slack uses it on every surface that cannot render Block Kit: mobile and desktop push notifications, the channel list preview, notification emails, search results, and screen readers. Send blocks without text and Slack accepts the message, renders it correctly in the channel, and has nothing to show anywhere else.

This is the only note in this batch where nothing fails. There is no error code, no ok: false, no rejected payload, no log line, and no metric that moves. The message succeeded. What degraded is a surface your sending code never sees, and the only place the evidence exists is in what your app has already posted.

So the detector is not looking for a failure - there isn't one. It reads your own messages back and renders the notification each of them produced, so you can look at the string a person on a phone was given. A payload with a zero-width space in text, or the literal word message, passes every check that tests for presence and fails the only test that matters.

The problem in plain words

The reason this survives for years is that every feedback loop points away from it. The developer builds the message, looks at it in the channel, and it is beautiful. QA checks the channel. The screenshot in the pull request is of the channel. The monitoring checks that the call returned ok. Every one of those observations is of the one surface where the fallback is never used.

Meanwhile the failure is reported in language nobody can search for. "The alerts are useless on mobile." "I never know what the notification is about." "I stopped looking at them." That is not a bug report anybody files against a message builder, and when it is raised it sounds like a complaint about notification settings, so it gets routed to whoever owns notification settings and dies there.

The workarounds make it permanent. A team that hits the related no_text error - which Slack does return when attachments are in play and there is no text anywhere - fixes it by putting something in the field. A space. A zero-width space. The word message. The error goes away, the send succeeds, and the notification now says message instead of nothing, which is arguably worse because it looks deliberate.

And the cost is asymmetric. The people harmed are the ones reading on a phone during an incident, which is exactly the population and exactly the moment when a one-line summary is worth the most. An alerting system whose alerts do not survive the trip to a lock screen is not alerting anybody.

blocks, no textthe builder omitsitok: truenothing to logRenders inchannelthe screenshotlooks rightPush is blankand search findsnothingUseless onmobilereported as acomplaint
Every feedback loop a team has points at the one surface where the fallback is never used. Nobody checks their own lock screen, and the complaint arrives as a comment about the product being bad.

Why it happens

The finding is not an error, so it has to be a rendering. There is no failure to report and no code to look up. The only output that changes anybody's mind is the actual notification string their users received, printed beside the message that looks perfect in the channel. That is why the central function returns a preview rather than a verdict.

Presence is not the test. Every naive checker asks whether text is set. A zero-width space is set. The word alert is set. A JSON dump of the blocks is set. Each of those passes a presence check and produces a useless notification, so the classifier sorts a present string into good, bulky, thin, placeholder or markup and only one of those is fine.

Slack has a preference order and the script has to model it. Message text comes first; an attachment's fallback is used when there is no message text. A checker that ignores attachments will report a degraded notification for a message whose notification is fine, and a finding that is wrong once is a finding nobody trusts again.

A ratio is the argument, not an example. One bad message is a curiosity. "Ninety-one percent of what this app posted last week notifies as nothing" is a sentence that gets a builder function changed. The script reports the share and then shows the previews, in that order.

The repair is an invariant, not an edit. Adding text to the seven call sites you can find fixes seven call sites. Making text a required positional argument of the function that builds messages fixes the eighth one, which somebody will write next month. The script prints the second repair because the first one does not hold.

It costs nothing visually, which removes the only objection. The fallback is never rendered when the blocks display, so there is no design tradeoff to weigh and no reason to leave it out. This is unusually rare among quality findings and worth saying plainly.

The fix, as a flow

The only chain in this section with no red box in it, and that is the note. Every step succeeds. The send returns ok, the channel renders correctly, the screenshot in the pull request looks right, and the push notification is blank. There is no failure to colour. The fix branch is the only one here that classifies a string rather than an error, because presence is not the test.

Your history, read backand the push it producedNo text beside blocksthe notification is blankA zero width spacepresent, and says nothingThe literal word messageput there to stop no_textAttachment fallback setSlack uses it, so this is fineA one line summaryuseful on a lock screen
A presence check passes a zero width space, the word message, and a JSON dump. The output that changes anybody's mind is the string a person on a phone was actually shown.

How to fix it

Read back what your app actually posted

conversations.history returns each message's stored text, blocks and attachments exactly as Slack holds them. Filter to your own bot_id from auth.test. This is the only place the evidence lives, because nothing in the send path recorded anything.

Judge the string, not its presence

fallback_quality strips zero-width characters before it decides anything, then sorts what is left. absent, placeholder and markup are the three that notify as nothing useful, and a presence check calls all three of them fine.

Let an attachment fallback count, because Slack does

A message with no text but a real fallback on its attachment notifies correctly. notification_preview falls through to it and says where the string came from, so the report does not flag a message that is actually fine.

Render the notification instead of describing it

The output is the string a person on a phone was shown: one line, whitespace collapsed, truncated the way a lock screen truncates. Seeing This content can't be displayed beside a screenshot of a perfectly rendered message is the thing that ends the argument.

Report the share before the examples

coverage returns how many of your own messages degrade and what fraction that is. Lead with the fraction. A list of twelve message timestamps invites somebody to fix twelve messages; a percentage invites them to fix the builder.

Make the summary a required argument

The repair is one line of human text per message: 3 deploys failed in prod. Put it in the signature of the function that builds your messages so it cannot be omitted, and the class of bug closes rather than shrinking.

How to check it worked

Nothing about the send changes, so verification is the same read done again. The share should fall and the previews should start saying something.

python3 slack_fallback_text.py --channel C01ABCDE9
# identity   U0APPBOT11 in acme
# coverage   34 of ours, 34 with blocks, 31 notify as nothing (91.2%)
# quality    absent           28  no text field at all beside a rendered blocks array
# quality    placeholder       3  the literal string "message"
# quality    good              3  a one line summary a reader can act on
# preview    ts=1735689600.001  degraded  "This content can't be displayed."
# preview    ts=1735689612.004  fine      "3 deploys failed in prod, oldest 14m ago"
#   repair: send text beside blocks, set to a one line human summary of the message

The full code

Two pure functions and a reducer, and the interesting one does not return a verdict at all. fallback_quality sorts a present string into the five things it can be, because a zero-width space and a JSON dump both pass a presence check. notification_preview renders what the reader was actually shown, following Slack's own preference order down to an attachment fallback. coverage turns a page of history into the one number that changes a builder function.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_fallback_text.py
"""Show the notification each of your app's messages actually produced.

Read only. auth.test for identity and conversations.history for what your app
posted. Nothing here failed and nothing is sent: every message this script
reports came back ok true, rendered correctly in the channel, and notified as
nothing. The finding is the notification string, printed beside a message that
looks perfect everywhere anybody thought to look.
"""
import argparse
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_fallback_text")

API = "https://slack.com/api/"

# What a reader is shown when Slack has blocks it cannot render on this surface
# and no fallback string to use instead. Reproduced so the report can show the
# notification rather than describe it.
DEGRADED = "This content can't be displayed."

# Characters that satisfy a presence check and say nothing. A zero-width space
# in text is the usual way a team makes a no_text error go away.
INVISIBLE = "\u200b\u200c\u200d\u2060\ufeff"

# Strings that are present, non-empty, and tell a reader nothing at all. These
# are what people put in the field to silence the error rather than to inform
# anybody, and a presence check calls every one of them a pass.
PLACEHOLDERS = {
    "message", "new message", "notification", "alert", "update", "notice",
    "bot", "bot message", "attachment", "see message", "no text", "(no text)",
    "n/a", "na", "none", "null", "undefined", "-", ".", "..", "...",
}

# Below this a fallback cannot carry a fact. "Build" is present and useless.
THIN = 12
# Above this, or past this many lines, it is a document rather than a summary
# and the notification surface will cut it anyway.
BULKY = 400
BULKY_LINES = 4


def _clean(text):
    """Strip the characters that are present but invisible. Pure."""
    if text is None:
        return ""
    out = str(text)
    for ch in INVISIBLE:
        out = out.replace(ch, "")
    return out.strip()


def _first_fallback(attachments):
    """The first usable attachment fallback, which Slack will use. Pure."""
    for a in attachments or []:
        if isinstance(a, dict):
            candidate = _clean(a.get("fallback"))
            if candidate and candidate.lower() not in PLACEHOLDERS:
                return candidate
    return ""


def fallback_quality(text, blocks=None, attachments=None):
    """Judge the string Slack will use when Block Kit cannot be rendered. Pure.

    Returns (quality, detail): no-blocks, good, bulky, thin, placeholder,
    markup or absent. Presence is not the test. A zero-width space is present,
    the word "message" is present, and a JSON dump of the blocks is present,
    and all three notify as nothing worth reading.
    """
    has_blocks = isinstance(blocks, list) and len(blocks) > 0
    has_attachments = isinstance(attachments, list) and len(attachments) > 0
    clean = _clean(text)
    if not has_blocks and not has_attachments:
        if not clean:
            return ("absent", "no blocks and no text; there is no message here")
        return ("no-blocks", "no blocks, so text is the message itself and every "
                             "surface shows the same thing")
    if not clean:
        return ("absent", "blocks with no text field. Slack has nothing to put in a "
                          "push notification, a sidebar preview, a notification email "
                          "or a screen reader")
    if clean.lower() in PLACEHOLDERS:
        return ("placeholder", "text is %r, which is present, silences the no_text "
                               "error and tells a reader nothing" % clean[:40])
    if clean.startswith(("{", "[")) or '"type"' in clean or "\\u003c" in clean:
        return ("markup", "text looks like serialised block markup rather than a "
                          "sentence; the notification will show JSON")
    if len(clean) > BULKY or clean.count("\n") >= BULKY_LINES:
        return ("bulky", "text is %d characters over %d line(s); a notification shows "
                         "one line, so most of this is cut and the first line has to "
                         "stand alone" % (len(clean), clean.count("\n") + 1))
    if len(clean) < THIN:
        return ("thin", "text is %d characters (%r), which is present but too short "
                        "to carry what happened" % (len(clean), clean[:40]))
    return ("good", "a one line summary a reader can act on without opening Slack")


def notification_preview(text, blocks=None, attachments=None, width=110):
    """Render what the reader was actually shown. Pure.

    Returns (preview, degraded, source). Slack prefers the message text and
    falls through to an attachment's fallback, so this does too: flagging a
    message whose notification is fine is how a report stops being trusted.
    """
    quality, _ = fallback_quality(text, blocks, attachments)
    clean = _clean(text)
    if quality in ("good", "bulky", "thin", "no-blocks"):
        return (_one_line(clean, width), quality == "thin", "text")
    fallback = _first_fallback(attachments)
    if fallback:
        return (_one_line(fallback, width), False, "attachment.fallback")
    if quality == "placeholder":
        return (_one_line(clean, width), True, "text")
    if quality == "markup":
        return (_one_line(clean, width), True, "text")
    return (DEGRADED, True, "none")


def _one_line(text, width):
    """Collapse to the single line a lock screen has room for. Pure."""
    line = re.sub(r"\s+", " ", str(text or "")).strip()
    if len(line) <= width:
        return line
    return line[:max(1, width - 3)].rstrip() + "..."


def coverage(messages, bot_id="", bot_user="", width=110):
    """Reduce a page of history to the share that notifies as nothing. Pure.

    The share is the argument. A list of timestamps invites somebody to fix
    twelve messages; a percentage invites them to fix the function that built
    all of them.
    """
    ours, carrying, degraded = 0, 0, 0
    by_quality = {}
    rows = []
    for m in messages or []:
        mine = (bot_id and m.get("bot_id") == bot_id) or (
            bot_user and m.get("user") == bot_user) or not (bot_id or bot_user)
        if not mine:
            continue
        ours += 1
        blocks, attachments = m.get("blocks"), m.get("attachments")
        if not (isinstance(blocks, list) and blocks):
            continue
        carrying += 1
        quality, detail = fallback_quality(m.get("text"), blocks, attachments)
        entry = by_quality.setdefault(quality, {"count": 0, "detail": detail})
        entry["count"] += 1
        preview, is_degraded, source = notification_preview(
            m.get("text"), blocks, attachments, width)
        if is_degraded:
            degraded += 1
        rows.append({"ts": m.get("ts"), "quality": quality, "preview": preview,
                     "degraded": is_degraded, "source": source})
    return {"authored": ours, "with_blocks": carrying, "degraded": degraded,
            "share_percent": round(degraded * 100.0 / carrying, 1) if carrying else 0.0,
            "by_quality": by_quality, "rows": rows}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--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("--width", type=int, default=110,
                    help="characters a notification surface has room for")
    ap.add_argument("--show", type=int, default=8,
                    help="how many rendered notifications to print per channel")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s (channels:history is enough)", args.token_env)
        return 2
    if not args.channel:
        log.error("pass at least one --channel your app actually posts into")
        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 = 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
        c = coverage(body.get("messages") or [], bot_id, bot_user, args.width)
        if not c["with_blocks"]:
            log.info("coverage   %s: nothing of ours used blocks here", channel)
            continue
        (log.warning if c["degraded"] else log.info)(
            "coverage   %s: %d of ours, %d with blocks, %d notify as nothing (%.1f%%)",
            channel, c["authored"], c["with_blocks"], c["degraded"], c["share_percent"])
        for quality, info in sorted(c["by_quality"].items(),
                                    key=lambda kv: (-kv[1]["count"], kv[0])):
            (log.info if quality in ("good", "no-blocks") else log.warning)(
                "quality    %-14s %4d  %s", quality, info["count"], info["detail"])
        for row in c["rows"][:max(0, args.show)]:
            (log.warning if row["degraded"] else log.info)(
                "preview    ts=%-18s %-8s from %-18s %r",
                row["ts"], "degraded" if row["degraded"] else "fine", row["source"],
                row["preview"])
        findings += c["degraded"]

    if findings:
        log.warning("  repair: send text beside blocks on every call, set to a one line "
                    "human summary such as '3 deploys failed in prod'")
        log.warning("  repair: make that summary a required argument of the function "
                    "that builds your messages, so the next call site cannot omit it")
        log.warning("  repair: for messages still using attachments, set fallback on "
                    "each one; it is the same idea one level down")
        log.warning("  note: nothing here failed. Every one of these messages returned "
                    "ok true and renders correctly in the channel")
        return 1
    log.info("verdict    clear          every message of ours carries a usable fallback")
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-fallback-text.mjs
/**
 * Show the notification each of your app's messages actually produced.
 *
 * Read only. auth.test for identity and conversations.history for what your
 * app posted. Nothing here failed and nothing is sent: every message this
 * script reports came back ok true, rendered correctly in the channel, and
 * notified as nothing.
 */

const API = 'https://slack.com/api/';

// What a reader is shown when Slack has blocks it cannot render on this
// surface and no fallback string to use instead.
const DEGRADED = "This content can't be displayed.";

// Present, invisible, and enough to silence a no_text error.
const INVISIBLE = /[\u200b\u200c\u200d\u2060\ufeff]/g;

// Present, non-empty, and useless. A presence check passes every one of these.
const PLACEHOLDERS = new Set(['message', 'new message', 'notification', 'alert',
  'update', 'notice', 'bot', 'bot message', 'attachment', 'see message', 'no text',
  '(no text)', 'n/a', 'na', 'none', 'null', 'undefined', '-', '.', '..', '...']);

const THIN = 12;
const BULKY = 400;
const BULKY_LINES = 4;

function clean(text) {
  if (text === null || text === undefined) return '';
  return String(text).replace(INVISIBLE, '').trim();
}

function firstFallback(attachments) {
  for (const a of attachments ?? []) {
    if (a && typeof a === 'object') {
      const candidate = clean(a.fallback);
      if (candidate && !PLACEHOLDERS.has(candidate.toLowerCase())) return candidate;
    }
  }
  return '';
}

/**
 * Judge the string Slack will use when Block Kit cannot be rendered. Pure.
 * Presence is not the test: a zero-width space is present, the word "message"
 * is present, and a JSON dump of the blocks is present.
 */
export function fallbackQuality(text, blocks = null, attachments = null) {
  const hasBlocks = Array.isArray(blocks) && blocks.length > 0;
  const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
  const body = clean(text);
  if (!hasBlocks && !hasAttachments) {
    if (!body) return ['absent', 'no blocks and no text; there is no message here'];
    return ['no-blocks', 'no blocks, so text is the message itself and every surface ' +
      'shows the same thing'];
  }
  if (!body) {
    return ['absent', 'blocks with no text field. Slack has nothing to put in a push ' +
      'notification, a sidebar preview, a notification email or a screen reader'];
  }
  if (PLACEHOLDERS.has(body.toLowerCase())) {
    return ['placeholder', `text is "${body.slice(0, 40)}", which is present, silences ` +
      'the no_text error and tells a reader nothing'];
  }
  if (body.startsWith('{') || body.startsWith('[') || body.includes('"type"')) {
    return ['markup', 'text looks like serialised block markup rather than a sentence; ' +
      'the notification will show JSON'];
  }
  const lines = body.split('\n').length;
  if (body.length > BULKY || lines > BULKY_LINES) {
    return ['bulky', `text is ${body.length} characters over ${lines} line(s); a ` +
      'notification shows one line, so most of this is cut and the first line has to ' +
      'stand alone'];
  }
  if (body.length < THIN) {
    return ['thin', `text is ${body.length} characters ("${body.slice(0, 40)}"), which ` +
      'is present but too short to carry what happened'];
  }
  return ['good', 'a one line summary a reader can act on without opening Slack'];
}

function oneLine(text, width) {
  const line = String(text ?? '').replace(/\s+/g, ' ').trim();
  if (line.length <= width) return line;
  return `${line.slice(0, Math.max(1, width - 3)).trimEnd()}...`;
}

/**
 * Render what the reader was actually shown. Pure.
 * Returns [preview, degraded, source]. Slack prefers the message text and
 * falls through to an attachment's fallback, so this does too.
 */
export function notificationPreview(text, blocks = null, attachments = null, width = 110) {
  const [quality] = fallbackQuality(text, blocks, attachments);
  const body = clean(text);
  if (['good', 'bulky', 'thin', 'no-blocks'].includes(quality)) {
    return [oneLine(body, width), quality === 'thin', 'text'];
  }
  const fallback = firstFallback(attachments);
  if (fallback) return [oneLine(fallback, width), false, 'attachment.fallback'];
  if (quality === 'placeholder' || quality === 'markup') {
    return [oneLine(body, width), true, 'text'];
  }
  return [DEGRADED, true, 'none'];
}

/**
 * Reduce a page of history to the share that notifies as nothing. Pure.
 * The share is the argument; a list of timestamps invites somebody to fix
 * twelve messages instead of the function that built all of them.
 */
export function coverage(messages, botId = '', botUser = '', width = 110) {
  let authored = 0;
  let withBlocks = 0;
  let degraded = 0;
  const byQuality = new Map();
  const rows = [];
  for (const m of messages ?? []) {
    const mine = (botId && m.bot_id === botId) || (botUser && m.user === botUser)
      || !(botId || botUser);
    if (!mine) continue;
    authored += 1;
    const { blocks, attachments } = m;
    if (!Array.isArray(blocks) || !blocks.length) continue;
    withBlocks += 1;
    const [quality, detail] = fallbackQuality(m.text, blocks, attachments);
    if (!byQuality.has(quality)) byQuality.set(quality, { count: 0, detail });
    byQuality.get(quality).count += 1;
    const [preview, isDegraded, source] = notificationPreview(
      m.text, blocks, attachments, width);
    if (isDegraded) degraded += 1;
    rows.push({ ts: m.ts, quality, preview, degraded: isDegraded, source });
  }
  return { authored,
    withBlocks,
    degraded,
    sharePercent: withBlocks ? Math.round((degraded * 1000) / withBlocks) / 10 : 0,
    byQuality,
    rows };
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

function argAll(args, name) {
  const out = [];
  for (let i = 0; i < args.length; i += 1) {
    if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
  }
  return out;
}

async function main() {
  const args = process.argv.slice(2);
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} (channels:history is enough)`);
    process.exitCode = 2;
    return;
  }
  const channels = argAll(args, '--channel');
  if (!channels.length) {
    console.error('pass at least one --channel your app actually posts into');
    process.exitCode = 2;
    return;
  }
  const limit = arg(args, '--limit', '200');
  const width = Number(arg(args, '--width', '110'));
  const show = Number(arg(args, '--show', '8'));
  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;
  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 c = coverage(body.messages ?? [], botId, botUser, width);
    if (!c.withBlocks) {
      console.log(`coverage   ${channel}: nothing of ours used blocks here`);
      continue;
    }
    (c.degraded ? console.warn : console.log)(
      `coverage   ${channel}: ${c.authored} of ours, ${c.withBlocks} with blocks, ` +
      `${c.degraded} notify as nothing (${c.sharePercent}%)`);
    const ordered = [...c.byQuality.entries()].sort(
      (a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]));
    for (const [quality, info] of ordered) {
      (['good', 'no-blocks'].includes(quality) ? console.log : console.warn)(
        `quality    ${quality.padEnd(14)} ${String(info.count).padStart(4)}  ${info.detail}`);
    }
    for (const row of c.rows.slice(0, Math.max(0, show))) {
      (row.degraded ? console.warn : console.log)(
        `preview    ts=${String(row.ts).padEnd(18)} ` +
        `${(row.degraded ? 'degraded' : 'fine').padEnd(8)} from ` +
        `${row.source.padEnd(18)} "${row.preview}"`);
    }
    findings += c.degraded;
  }

  if (findings) {
    console.warn("  repair: send text beside blocks on every call, set to a one line " +
      "human summary such as '3 deploys failed in prod'");
    console.warn('  repair: make that summary a required argument of the function that ' +
      'builds your messages, so the next call site cannot omit it');
    console.warn('  repair: for messages still using attachments, set fallback on each ' +
      'one; it is the same idea one level down');
    console.warn('  note: nothing here failed. Every one of these messages returned ok ' +
      'true and renders correctly in the channel');
    process.exitCode = 1;
  } else {
    console.log('verdict    clear          every message of ours carries a usable fallback');
  }
}

// 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

These tests are mostly about the ways a message passes a presence check and still notifies as nothing: a zero-width space, the literal word message, a JSON dump. Each is asserted separately because each is a real thing a team has typed into that field to make an error go away. The other half protects the report's credibility in the other direction: a message with no text but a genuine attachment fallback must come back as fine, because a checker that cries wolf once is a checker nobody runs twice.

test_slack_fallback_text.py
from slack_fallback_text import coverage, fallback_quality, notification_preview

BLOCKS = [{"type": "section", "text": {"type": "mrkdwn", "text": "*3 deploys failed*"}}]


def test_a_real_summary_beside_blocks_is_good():
    quality, _ = fallback_quality("3 deploys failed in prod, oldest 14m ago", BLOCKS)
    assert quality == "good"
    preview, degraded, source = notification_preview(
        "3 deploys failed in prod, oldest 14m ago", BLOCKS)
    assert degraded is False
    assert source == "text"
    assert preview.startswith("3 deploys failed")


def test_blocks_with_no_text_notify_as_nothing():
    quality, detail = fallback_quality(None, BLOCKS)
    assert quality == "absent"
    assert "push notification" in detail
    preview, degraded, source = notification_preview(None, BLOCKS)
    assert degraded is True
    assert source == "none"
    assert preview == "This content can't be displayed."


def test_a_zero_width_space_is_not_a_fallback():
    assert fallback_quality("\u200b", BLOCKS)[0] == "absent"
    assert fallback_quality("   \ufeff  ", BLOCKS)[0] == "absent"
    assert notification_preview("\u200b", BLOCKS)[1] is True


def test_a_placeholder_word_is_present_and_useless():
    quality, detail = fallback_quality("message", BLOCKS)
    assert quality == "placeholder"
    assert "no_text" in detail
    preview, degraded, _ = notification_preview("Alert", BLOCKS)
    assert degraded is True
    assert preview == "Alert"


def test_a_json_dump_in_the_text_field_is_caught():
    dumped = '[{"type": "section", "text": {"type": "mrkdwn", "text": "hi"}}]'
    assert fallback_quality(dumped, BLOCKS)[0] == "markup"
    assert notification_preview(dumped, BLOCKS)[1] is True


def test_a_string_too_short_to_carry_a_fact_is_thin():
    quality, detail = fallback_quality("Build", BLOCKS)
    assert quality == "thin"
    assert "too short" in detail


def test_a_wall_of_text_is_bulky_rather_than_good():
    quality, detail = fallback_quality("x" * 500, BLOCKS)
    assert quality == "bulky"
    assert "one line" in detail
    assert fallback_quality("a\nb\nc\nd\ne\nf", BLOCKS)[0] == "bulky"


def test_a_message_with_no_blocks_is_not_this_note():
    quality, detail = fallback_quality("just a plain message", None, None)
    assert quality == "no-blocks"
    assert "the message itself" in detail
    assert notification_preview("just a plain message")[1] is False


def test_an_attachment_fallback_rescues_a_message_with_no_text():
    attachments = [{"fallback": "3 deploys failed in prod"}]
    preview, degraded, source = notification_preview(None, BLOCKS, attachments)
    assert degraded is False
    assert source == "attachment.fallback"
    assert preview == "3 deploys failed in prod"


def test_a_placeholder_attachment_fallback_rescues_nothing():
    assert notification_preview(None, BLOCKS, [{"fallback": "message"}])[1] is True
    assert notification_preview(None, BLOCKS, [{"fallback": "  "}])[1] is True


def test_the_preview_is_one_line_and_truncated_the_way_a_lock_screen_truncates():
    preview, _, _ = notification_preview("a" * 200, BLOCKS, None, width=40)
    assert len(preview) == 40
    assert preview.endswith("...")
    assert notification_preview("two\n\nlines here now", BLOCKS)[0] == "two lines here now"


def test_coverage_reports_the_share_and_not_just_the_rows():
    messages = [
        {"ts": "1", "bot_id": "B1", "blocks": BLOCKS},
        {"ts": "2", "bot_id": "B1", "blocks": BLOCKS, "text": "message"},
        {"ts": "3", "bot_id": "B1", "blocks": BLOCKS,
         "text": "3 deploys failed in prod"},
        {"ts": "4", "bot_id": "B1", "text": "a plain message with no blocks"},
    ]
    c = coverage(messages, bot_id="B1")
    assert c["authored"] == 4
    assert c["with_blocks"] == 3
    assert c["degraded"] == 2
    assert c["share_percent"] == 66.7
    assert c["by_quality"]["absent"]["count"] == 1


def test_coverage_ignores_messages_that_are_not_ours():
    messages = [{"ts": "1", "bot_id": "B2", "blocks": BLOCKS},
                {"ts": "2", "user": "U9", "blocks": BLOCKS}]
    c = coverage(messages, bot_id="B1", bot_user="U1")
    assert c["authored"] == 0
    assert c["share_percent"] == 0.0


def test_an_empty_channel_is_not_a_finding():
    c = coverage([], bot_id="B1")
    assert c["with_blocks"] == 0
    assert c["degraded"] == 0
    assert c["share_percent"] == 0.0
slack-fallback-text.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  coverage, fallbackQuality, notificationPreview,
} from './slack-fallback-text.mjs';

const BLOCKS = [{ type: 'section', text: { type: 'mrkdwn', text: '*3 deploys failed*' } }];

test('a real summary beside blocks is good', () => {
  assert.equal(fallbackQuality('3 deploys failed in prod, oldest 14m ago', BLOCKS)[0],
    'good');
  const [preview, degraded, source] = notificationPreview(
    '3 deploys failed in prod, oldest 14m ago', BLOCKS);
  assert.equal(degraded, false);
  assert.equal(source, 'text');
  assert.match(preview, /^3 deploys failed/);
});

test('blocks with no text notify as nothing', () => {
  const [quality, detail] = fallbackQuality(null, BLOCKS);
  assert.equal(quality, 'absent');
  assert.match(detail, /push notification/);
  const [preview, degraded, source] = notificationPreview(null, BLOCKS);
  assert.equal(degraded, true);
  assert.equal(source, 'none');
  assert.equal(preview, "This content can't be displayed.");
});

test('a zero width space is not a fallback', () => {
  assert.equal(fallbackQuality('\u200b', BLOCKS)[0], 'absent');
  assert.equal(fallbackQuality('   \ufeff  ', BLOCKS)[0], 'absent');
  assert.equal(notificationPreview('\u200b', BLOCKS)[1], true);
});

test('a placeholder word is present and useless', () => {
  const [quality, detail] = fallbackQuality('message', BLOCKS);
  assert.equal(quality, 'placeholder');
  assert.match(detail, /no_text/);
  const [preview, degraded] = notificationPreview('Alert', BLOCKS);
  assert.equal(degraded, true);
  assert.equal(preview, 'Alert');
});

test('a json dump in the text field is caught', () => {
  const dumped = '[{"type": "section", "text": {"type": "mrkdwn", "text": "hi"}}]';
  assert.equal(fallbackQuality(dumped, BLOCKS)[0], 'markup');
  assert.equal(notificationPreview(dumped, BLOCKS)[1], true);
});

test('a string too short to carry a fact is thin', () => {
  const [quality, detail] = fallbackQuality('Build', BLOCKS);
  assert.equal(quality, 'thin');
  assert.match(detail, /too short/);
});

test('a wall of text is bulky rather than good', () => {
  const [quality, detail] = fallbackQuality('x'.repeat(500), BLOCKS);
  assert.equal(quality, 'bulky');
  assert.match(detail, /one line/);
  assert.equal(fallbackQuality('a\nb\nc\nd\ne\nf', BLOCKS)[0], 'bulky');
});

test('a message with no blocks is not this note', () => {
  const [quality, detail] = fallbackQuality('just a plain message', null, null);
  assert.equal(quality, 'no-blocks');
  assert.match(detail, /the message itself/);
  assert.equal(notificationPreview('just a plain message')[1], false);
});

test('an attachment fallback rescues a message with no text', () => {
  const [preview, degraded, source] = notificationPreview(
    null, BLOCKS, [{ fallback: '3 deploys failed in prod' }]);
  assert.equal(degraded, false);
  assert.equal(source, 'attachment.fallback');
  assert.equal(preview, '3 deploys failed in prod');
});

test('a placeholder attachment fallback rescues nothing', () => {
  assert.equal(notificationPreview(null, BLOCKS, [{ fallback: 'message' }])[1], true);
  assert.equal(notificationPreview(null, BLOCKS, [{ fallback: '  ' }])[1], true);
});

test('the preview is one line and truncated the way a lock screen truncates', () => {
  const [preview] = notificationPreview('a'.repeat(200), BLOCKS, null, 40);
  assert.equal(preview.length, 40);
  assert.ok(preview.endsWith('...'));
  assert.equal(notificationPreview('two\n\nlines here now', BLOCKS)[0],
    'two lines here now');
});

test('coverage reports the share and not just the rows', () => {
  const messages = [
    { ts: '1', bot_id: 'B1', blocks: BLOCKS },
    { ts: '2', bot_id: 'B1', blocks: BLOCKS, text: 'message' },
    { ts: '3', bot_id: 'B1', blocks: BLOCKS, text: '3 deploys failed in prod' },
    { ts: '4', bot_id: 'B1', text: 'a plain message with no blocks' },
  ];
  const c = coverage(messages, 'B1');
  assert.equal(c.authored, 4);
  assert.equal(c.withBlocks, 3);
  assert.equal(c.degraded, 2);
  assert.equal(c.sharePercent, 66.7);
  assert.equal(c.byQuality.get('absent').count, 1);
});

test('coverage ignores messages that are not ours', () => {
  const c = coverage([{ ts: '1', bot_id: 'B2', blocks: BLOCKS },
    { ts: '2', user: 'U9', blocks: BLOCKS }], 'B1', 'U1');
  assert.equal(c.authored, 0);
  assert.equal(c.sharePercent, 0);
});

test('an empty channel is not a finding', () => {
  const c = coverage([], 'B1');
  assert.equal(c.withBlocks, 0);
  assert.equal(c.degraded, 0);
  assert.equal(c.sharePercent, 0);
});

FAQ

If nothing fails, is this actually a bug?

It is a bug with no failure signal, which is a category worth naming. The message is delivered and rendered, so nothing in your monitoring can see it, and the people affected experience it as the product being bad rather than as something being broken. The absence of an error is what makes it last for years, not evidence that it does not matter.

Will the text field show up twice if I send both?

No. When Slack renders the blocks, the text field is not displayed at all. It is used only on surfaces that cannot render Block Kit. That is the unusual thing about this fix: there is no visual tradeoff to weigh, so the only reason to leave it out is not knowing what the field is for.

We put a zero-width space in text to stop a no_text error. Is that a problem?

It is the specific case this script was written to catch. The zero-width space satisfies the API, so the error stops, and the notification is now empty rather than being rejected. The script strips those characters before it judges anything, which is why a message that passes every presence check in your codebase can still come back as absent here.

What should the fallback actually say?

One line that would be useful on a lock screen with Slack closed: the fact and the count. &quot;3 deploys failed in prod&quot; rather than &quot;Deploy notification&quot; and rather than the whole message repeated. If a reader could decide whether to open their laptop from that line alone, it is doing its job.

Does the same thing apply to attachments?

Yes, one level down. An attachment's fallback string plays the same role for that attachment, and Slack will use it when there is no message-level text, which is why this script checks it before reporting a message as degraded. If you are still on attachments, set fallback on each one, and treat that as a stopgap while you move to blocks.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.