Skip to content

Diagnostic Slack

no_text: the message that rendered to nothing

The digest has run every morning for a year. This morning it returned {"ok": false, "error": "no_text"}, and the only thing different about this morning is that the query it runs returned zero rows.

The formatter joins those rows into a string. Zero rows join into an empty string. The empty string is passed to chat.postMessage, which requires the message to carry something, and refuses it. Nothing is delivered, and the symptom is a report that did not arrive, which is a symptom nobody notices until they go looking for a report they had stopped expecting.

Read-only token Python and Node.js Tests included
Person using calculator at desk with coffee mug
Photo by Towfiqu barbhuiya on Unsplash
The short answer

chat.postMessage requires at least one of text, blocks or attachments to actually carry content. An empty text beside an empty blocks: [] is refused with no_text, and nothing is delivered at all.

This is a rejection, not a degradation. The message never reaches the channel, never appears in history, and never notifies anybody, so unlike most message-quality problems there is no artefact left behind to inspect. The evidence is in your logs and in the absence of a post.

The path is almost always the same: a builder concatenates results, the result set is empty, and the "no results" case is the one nobody wrote a fixture for. So the script decides the question from the payload before the call, and separates it from the neighbouring case that does get through - a payload that is structurally present and holds no words, which Slack accepts and a reader sees as a heading over nothing.

The problem in plain words

The reason this survives review is that the empty case is invisible in every environment where anybody looks. In development the query has fixtures in it. In staging somebody seeded data. In production it has rows on 364 days out of 365. The one day it does not is a Sunday of a long weekend, and the failure is a message that did not appear in a channel nobody is reading that day.

When it is noticed, it is noticed as a gap. "Did the digest run?" is not a bug report, and the answer is usually that the digest ran perfectly, computed correctly that there was nothing to report, and was refused when it tried to say so. The exit code is non-zero and the log line exists, but a cron job that fails one morning in three hundred is a cron job whose failures are filtered.

The repairs people reach for make it worse in an interesting way. Putting a space in the field silences the error, and now a blank message posts every quiet morning. Putting the word update in the field silences the error and posts the word update. Emitting a header block with nothing under it silences the error, because a header carries text, and posts a heading over nothing - a message that is delivered, occupies the channel, and says less than nothing. Each of these turns a loud failure into a quiet one, which is the wrong direction.

And underneath, the question nobody asks: should this message exist at all? A daily digest that posts "no results" every quiet day teaches its channel that most of its messages are noise, which is exactly how a channel stops being read before the day the digest has something important to say.

Digest joinsthe rowsinto one stringZero rows todaynothing upstreambrokeThe string isemptyand is sent anywayno_textnothing isdeliveredNobody noticesan absent report
The quiet case is the one nobody writes a fixture for, so the first time the template renders nothing is in production, and the failure is a report that simply does not appear.

Why it happens

A rejection and a degradation are two different notes and the script has to say which. Nothing delivered is a different afternoon from delivered and unreadable. The census returns rejected, hollow or carries, and only the first of those is no_text. Collapsing them into "empty" sends somebody looking for an error that, for the hollow case, was never raised.

Structural presence is not content. blocks: [{"type": "divider"}] is a valid, accepted, delivered message that says nothing. So is a header with no body. The script walks the blocks for readable characters rather than checking whether the array is non-empty, because the empty-render bug frequently escapes by being structurally correct.

Invisible characters are the standard workaround and have to be stripped first. A zero-width space in text makes no_text stop happening. It is present, it is non-empty, it passes every check anybody writes, and it makes the message worse. The census removes those characters before it decides anything.

Skipping is a legitimate output, and usually the right one. The guard returns skip by default rather than a substitute string, because a channel that receives "nothing happened" every quiet morning stops reading the channel. Making the builder return None and the sender treat None as a no-op closes the whole class.

Your own history holds the proof. A hollow post is the empty path having already run and got through by a hair. Finding three of them last month is evidence that the generator can emit nothing, which is a much stronger argument for a guard than an error string from one morning.

Nothing is sent. Reproducing no_text by sending it means running the generator against a live workspace on the day it renders empty, and that is the same day it would post the empty message if the guard were wrong. With --payload the script holds no token.

The fix, as a flow

This chain ends in silence rather than in a symptom, which is the point: the query returns nothing, the template renders nothing, the send is refused, and the only person who could notice is waiting for a message that was never going to arrive. The fix branch sorts by what Slack does with the payload rather than by how empty it is, because a payload that is refused and one that posts a bare horizontal rule need opposite repairs.

Text, blocks, attachmentswhich one carries anythingNothing anywhererefused with no_textInvisible characterspresent, and not a messageA heading over nothingaccepted, and unreadableDividers and no wordsa rule, posted on purposeSkip the send insteadsilence is the right output
Refused and delivered empty are two different afternoons. One is an error in a log, the other is a heading over nothing that a reader quietly stops opening.

How to fix it

Take the census of the payload you were about to send

content_census asks which of text, blocks and attachments is carrying readable characters, and returns rejected, hollow or carries with the reason. Run it with --payload and no token.

Separate the refusal from the message that gets through empty

rejected means Slack answers no_text and delivers nothing. hollow means Slack accepts it and the reader gets a rule or a heading over nothing. These want different repairs and only one of them appears in your error log.

Strip the invisible characters before deciding anything

A zero-width space, a word joiner and a byte order mark are all present and all silent. The census removes them first, which is why a payload that passes every presence check in your codebase can still come back rejected here.

Look for the heading with nothing under it

A header block carries text, so it satisfies the API. It is also the exact shape of a zero-row render when the heading was appended before the loop that never ran. The census names that case specifically rather than calling it content.

Let the guard choose to say nothing

guard_decision returns skip, substitute or send. skip is the default because it is usually correct: nobody wants a daily ping that says nothing happened. substitute exists for the cases where the quiet day genuinely is the news.

Prove the empty path exists using your own history

With a token and --channel, hollow_history counts the posts your app has already made that carry nothing a reader can read. Each one is the same code path, on a day it produced slightly more than nothing.

How to check it worked

Add the guard, run the same empty fixture, and the script should report the skip rather than the rejection - without a call being made.

python3 slack_empty_message.py --payload monday-digest.json
# census     rejected   no text field, no blocks and no attachments; chat.postMessage
#                       answers no_text and nothing is delivered
# guard      skip       nothing to say, so say nothing; the builder should return None
#                       and the sender should treat None as a no-op
#   repair: return None from the message builder when the body renders empty, and have
#           the sender treat None as a no-op

The full code

Three pure functions and a recursive walk. content_census is the whole note: it asks which carrier holds readable characters and returns the three outcomes that need three different repairs. guard_decision is the repair expressed as a function, and its default is to send nothing at all, which is the answer people arrive at last and should have arrived at first. hollow_history finds the successful siblings of the failing send in your own channel, because an error from one morning argues less well than three posts that say nothing.

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_empty_message.py
"""Decide whether a message carries anything at all before you try to send it.

Read only. With --payload it holds no token and calls no Slack method; with
--channel it reads conversations.history and looks for the successful siblings
of the failing case, which are the posts your generator emitted on a day it had
almost nothing to say. Nothing is sent from here: reproducing no_text by sending
it is how you learn that the day the template renders empty is also the day it
renders empty into a customer 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_empty_message")

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

# Occupies the array, is accepted by the API, and shows the reader a rule.
WORDLESS = {"divider"}
# A label for content rather than content. A heading with nothing under it is
# the shape a zero row render takes when the heading was built before the loop,
# and it is the one empty message the API is perfectly happy with.
LABEL_ONLY = {"header"}

# Present, and not a message. The "no results" render arrives as one of these
# more often than as a bare empty string, because somebody put a character in
# the field to make the rejection stop.
INVISIBLE = "\u200b\u200c\u200d\u2060\ufeff"

# Where a text object hides. Walked by key rather than by block shape so a new
# block type does not silently read as empty.
TEXT_KEYS = ("text", "value", "alt_text", "title")


def _clean(text):
    """Collapse to what is left after the invisible characters go. Pure."""
    if text is None:
        return ""
    out = str(text)
    for ch in INVISIBLE:
        out = out.replace(ch, " ")
    return re.sub(r"\s+", " ", out).strip()


def _words(node):
    """Every readable character under a block or a list of them. Pure."""
    found = []

    def walk(item):
        if isinstance(item, dict):
            for key, value in item.items():
                if key in TEXT_KEYS and isinstance(value, str):
                    found.append(value)
                else:
                    walk(value)
        elif isinstance(item, list):
            for entry in item:
                walk(entry)

    walk(node)
    return _clean(" ".join(found))


def _body_words(blocks):
    """The words outside the headings and the rules. Pure.

    A heading is a label for a body. If the body is missing, the message reads
    as a title with nothing under it, which is exactly what a zero row render
    looks like when the heading was appended before the loop that never ran.
    """
    body = [b for b in (blocks or [])
            if isinstance(b, dict) and b.get("type") not in (LABEL_ONLY | WORDLESS)]
    return _words(body)


def content_census(text=None, blocks=None, attachments=None):
    """Say which of the three carriers is carrying something. Pure.

    Returns (verdict, carriers, detail). The three verdicts are outcomes rather
    than degrees of one thing:

      rejected  no carrier holds content, so chat.postMessage answers no_text
                and nothing is delivered at all
      hollow    a carrier is present and holds no body, so the send succeeds
                and the reader is shown a rule or a heading over nothing
      carries   there is something to read
    """
    body = _clean(text)
    block_list = blocks if isinstance(blocks, list) else []
    attach_list = attachments if isinstance(attachments, list) else []
    block_text = _words(block_list)
    attach_text = _clean(" ".join(
        " ".join(str(a.get(k) or "") for k in ("text", "pretext", "title", "fallback"))
        for a in attach_list if isinstance(a, dict)))

    carriers = []
    if body:
        carriers.append("text")
    if block_text:
        carriers.append("blocks")
    if attach_text:
        carriers.append("attachments")

    if not carriers:
        if not block_list and not attach_list:
            if text is None:
                got = "no text field"
            elif str(text) == "":
                got = "an empty text field"
            else:
                got = ("text of %d invisible or whitespace character(s)"
                       % len(str(text)))
            return ("rejected", [],
                    "%s, no blocks and no attachments; chat.postMessage answers "
                    "no_text and nothing is delivered" % got)
        present = ", ".join(x for x in
                            ("blocks[%d]" % len(block_list) if block_list else "",
                             "attachments[%d]" % len(attach_list) if attach_list else "")
                            if x)
        return ("hollow", [],
                "%s present and holding no words; the send succeeds and there is "
                "nothing to read" % present)

    if block_list and not _body_words(block_list):
        kinds = sorted({b.get("type") for b in block_list if isinstance(b, dict)})
        if set(kinds) <= LABEL_ONLY:
            return ("hollow", carriers,
                    "a heading and nothing under it, which is what a zero row render "
                    "looks like when the heading was built before the loop")
        return ("hollow", carriers,
                "%d block(s), all of them %s; the send succeeds and the reader is "
                "shown a rule" % (len(block_list), " and ".join(kinds)))

    return ("carries", carriers, "carried by %s" % ", ".join(carriers))


def guard_decision(rendered=None, blocks=None, window=None, announce_empty=False):
    """The three honest things to do with a body that came out empty. Pure.

    skip is the default and is usually right. A digest that posts "nothing
    happened" every quiet morning teaches a channel to ignore it, and that
    costs more than the message it was trying not to miss.
    """
    verdict, _carriers, _detail = content_census(rendered, blocks, None)
    if verdict == "carries":
        return ("send", "the body has content")
    if announce_empty:
        return ("substitute", "No results for %s" % (window or "the window"))
    return ("skip", "nothing to say, so say nothing; the builder should return None "
                    "and the sender should treat None as a no-op")


def hollow_history(messages, bot_id="", bot_user=""):
    """Find the successful siblings of the send that failed. Pure.

    A hollow post is proof, in your own channel, that the generator has a path
    that produces nothing. The no_text rejection is that same path on a day it
    produced slightly less, so this count is a prediction rather than a
    complaint about the posts it found.
    """
    authored, hollow = 0, 0
    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
        authored += 1
        verdict, carriers, detail = content_census(
            m.get("text"), m.get("blocks"), m.get("attachments"))
        if verdict == "carries":
            continue
        hollow += 1
        rows.append({"ts": m.get("ts"), "verdict": verdict, "detail": detail,
                     "carriers": carriers})
    proof = ("%d of %d posts carry nothing a reader can read, so the generator has an "
             "empty path and no_text is that path on a quieter day" % (hollow, authored)
             ) if hollow else ("%d post(s), all of them carrying something" % authored)
    return {"authored": authored, "hollow": hollow, "rows": rows, "proof": proof}


def _load(path):
    body = json.loads(open(path, encoding="utf-8").read())
    return body if isinstance(body, dict) else {}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--payload", help="a JSON file holding the message payload you "
                                      "were about to send; 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("--announce-empty", action="store_true",
                    help="show the substitute string rather than the skip")
    ap.add_argument("--window", default="the last 24 hours",
                    help="label for the substitute string")
    args = ap.parse_args()

    if args.payload:
        payload = _load(args.payload)
        verdict, carriers, detail = content_census(
            payload.get("text"), payload.get("blocks"), payload.get("attachments"))
        (log.info if verdict == "carries" else log.warning)(
            "census     %-10s %s", verdict, detail)
        if verdict == "carries":
            log.info("carriers   %s", ", ".join(carriers))
            return 0
        action, message = guard_decision(payload.get("text"), payload.get("blocks"),
                                         args.window, args.announce_empty)
        log.warning("guard      %-10s %s", action, message)
        log.warning("  repair: return None from the message builder when the body "
                    "renders empty, and have the sender treat None as a no-op")
        log.warning("  repair: if the quiet day is worth reporting, substitute an "
                    "explicit string; never send an empty one")
        return 1

    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 = 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
        h = hollow_history(body.get("messages") or [], bot_id, bot_user)
        (log.warning if h["hollow"] else log.info)("history    %s: %s", channel,
                                                   h["proof"])
        for row in h["rows"][:10]:
            log.warning("empty      ts=%-18s %-9s %s", row["ts"], row["verdict"],
                        row["detail"])
        findings += h["hollow"]

    if findings:
        log.warning("  repair: guard the send. A builder that can render an empty body "
                    "should return None, and the sender should not call the API at all")
        log.warning("  repair: add the zero row case to the tests. It is the one case "
                    "nobody writes a fixture for, and the only one that fails")
        return 1
    log.info("verdict    clear          every post of ours carries something to read")
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-empty-message.mjs
/**
 * Decide whether a message carries anything at all before you try to send it.
 *
 * Read only. With --payload it holds no token and calls no Slack method; with
 * --channel it reads conversations.history and looks for the successful
 * siblings of the failing case. Nothing is sent from here: reproducing no_text
 * by sending it is how you learn that the day the template renders empty is
 * also the day it renders empty into a customer channel.
 */
import { readFile } from 'node:fs/promises';

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

// Occupies the array, is accepted by the API, and shows the reader a rule.
const WORDLESS = new Set(['divider']);
// A label for content rather than content. A heading with nothing under it is
// the shape a zero row render takes, and the one empty message the API likes.
const LABEL_ONLY = new Set(['header']);

// Present, and not a message. The "no results" render arrives as one of these
// more often than as a bare empty string.
const INVISIBLE = /[\u200b\u200c\u200d\u2060\ufeff]/g;

// Where a text object hides. Walked by key rather than by block shape so a new
// block type does not silently read as empty.
const TEXT_KEYS = new Set(['text', 'value', 'alt_text', 'title']);

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

/** Every readable character under a block or a list of them. Pure. */
function words(node) {
  const found = [];
  const walk = (item) => {
    if (Array.isArray(item)) {
      for (const entry of item) walk(entry);
    } else if (item && typeof item === 'object') {
      for (const [key, value] of Object.entries(item)) {
        if (TEXT_KEYS.has(key) && typeof value === 'string') found.push(value);
        else walk(value);
      }
    }
  };
  walk(node);
  return clean(found.join(' '));
}

/**
 * The words outside the headings and the rules. Pure.
 * A heading is a label for a body; with the body missing the message reads as
 * a title over nothing, which is what a zero row render looks like when the
 * heading was appended before the loop that never ran.
 */
function bodyWords(blocks) {
  const body = (blocks ?? []).filter(
    (b) => b && typeof b === 'object'
      && !LABEL_ONLY.has(b.type) && !WORDLESS.has(b.type));
  return words(body);
}

/**
 * Say which of the three carriers is carrying something. Pure.
 * Returns [verdict, carriers, detail]: rejected (no_text, nothing delivered),
 * hollow (delivered and unreadable) or carries.
 */
export function contentCensus(text = null, blocks = null, attachments = null) {
  const body = clean(text);
  const blockList = Array.isArray(blocks) ? blocks : [];
  const attachList = Array.isArray(attachments) ? attachments : [];
  const blockText = words(blockList);
  const attachText = clean(attachList
    .filter((a) => a && typeof a === 'object')
    .map((a) => ['text', 'pretext', 'title', 'fallback']
      .map((k) => String(a[k] ?? '')).join(' '))
    .join(' '));

  const carriers = [];
  if (body) carriers.push('text');
  if (blockText) carriers.push('blocks');
  if (attachText) carriers.push('attachments');

  if (!carriers.length) {
    if (!blockList.length && !attachList.length) {
      let got = 'no text field';
      if (text !== null && text !== undefined) {
        got = String(text) === ''
          ? 'an empty text field'
          : `text of ${String(text).length} invisible or whitespace character(s)`;
      }
      return ['rejected', [],
        `${got}, no blocks and no attachments; chat.postMessage answers no_text ` +
        'and nothing is delivered'];
    }
    const present = [
      blockList.length ? `blocks[${blockList.length}]` : '',
      attachList.length ? `attachments[${attachList.length}]` : '',
    ].filter(Boolean).join(', ');
    return ['hollow', [],
      `${present} present and holding no words; the send succeeds and there is ` +
      'nothing to read'];
  }

  if (blockList.length && !bodyWords(blockList)) {
    const kinds = [...new Set(blockList.filter((b) => b && typeof b === 'object')
      .map((b) => b.type))].sort();
    if (kinds.every((k) => LABEL_ONLY.has(k))) {
      return ['hollow', carriers,
        'a heading and nothing under it, which is what a zero row render looks ' +
        'like when the heading was built before the loop'];
    }
    return ['hollow', carriers,
      `${blockList.length} block(s), all of them ${kinds.join(' and ')}; the send ` +
      'succeeds and the reader is shown a rule'];
  }

  return ['carries', carriers, `carried by ${carriers.join(', ')}`];
}

/**
 * The three honest things to do with a body that came out empty. Pure.
 * skip is the default and is usually right: a digest that posts "nothing
 * happened" every quiet morning teaches a channel to ignore it.
 */
export function guardDecision(rendered = null, blocks = null, window = null,
  announceEmpty = false) {
  const [verdict] = contentCensus(rendered, blocks, null);
  if (verdict === 'carries') return ['send', 'the body has content'];
  if (announceEmpty) return ['substitute', `No results for ${window || 'the window'}`];
  return ['skip', 'nothing to say, so say nothing; the builder should return null '
    + 'and the sender should treat null as a no-op'];
}

/**
 * Find the successful siblings of the send that failed. Pure.
 * A hollow post is proof, in your own channel, that the generator has a path
 * that produces nothing; no_text is that path on a quieter day.
 */
export function hollowHistory(messages, botId = '', botUser = '') {
  let authored = 0;
  let hollow = 0;
  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 [verdict, carriers, detail] = contentCensus(m.text, m.blocks, m.attachments);
    if (verdict === 'carries') continue;
    hollow += 1;
    rows.push({ ts: m.ts, verdict, detail, carriers });
  }
  const proof = hollow
    ? `${hollow} of ${authored} posts carry nothing a reader can read, so the `
      + 'generator has an empty path and no_text is that path on a quieter day'
    : `${authored} post(s), all of them carrying something`;
  return { authored, hollow, rows, proof };
}

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 payloadPath = arg(args, '--payload');
  const windowLabel = arg(args, '--window', 'the last 24 hours');
  const announce = args.includes('--announce-empty');

  if (payloadPath) {
    const payload = JSON.parse(await readFile(payloadPath, 'utf8'));
    const [verdict, carriers, detail] = contentCensus(
      payload.text, payload.blocks, payload.attachments);
    (verdict === 'carries' ? console.log : console.warn)(
      `census     ${verdict.padEnd(10)} ${detail}`);
    if (verdict === 'carries') {
      console.log(`carriers   ${carriers.join(', ')}`);
      return;
    }
    const [action, message] = guardDecision(
      payload.text, payload.blocks, windowLabel, announce);
    console.warn(`guard      ${action.padEnd(10)} ${message}`);
    console.warn('  repair: return null from the message builder when the body renders '
      + 'empty, and have the sender treat null as a no-op');
    console.warn('  repair: if the quiet day is worth reporting, substitute an explicit '
      + 'string; never send an empty one');
    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;
  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 h = hollowHistory(body.messages ?? [], botId, botUser);
    (h.hollow ? console.warn : console.log)(`history    ${channel}: ${h.proof}`);
    for (const row of h.rows.slice(0, 10)) {
      console.warn(`empty      ts=${String(row.ts).padEnd(18)} `
        + `${row.verdict.padEnd(9)} ${row.detail}`);
    }
    findings += h.hollow;
  }

  if (findings) {
    console.warn('  repair: guard the send. A builder that can render an empty body '
      + 'should return null, and the sender should not call the API at all');
    console.warn('  repair: add the zero row case to the tests. It is the one case '
      + 'nobody writes a fixture for, and the only one that fails');
    process.exitCode = 1;
    return;
  }
  console.log('verdict    clear          every post of ours carries something to read');
}

// 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 that keeps this note distinct from its neighbour is that a heading over nothing comes back hollow while a payload with nothing anywhere comes back rejected: one is delivered and unreadable, the other never existed, and a checker that calls both of them empty has merged two different afternoons. The rest are about the workarounds. A zero-width space has to be stripped before the decision, an empty attachment object has to be hollow rather than a rejection, and a divider on its own has to be reported as a message that posts and says nothing rather than one that fails.

test_slack_empty_message.py
from slack_empty_message import content_census, guard_decision, hollow_history

SECTION = {"type": "section", "text": {"type": "mrkdwn", "text": "3 deploys failed"}}
HEADER = {"type": "header", "text": {"type": "plain_text", "text": "Nightly digest"}}
DIVIDER = {"type": "divider"}


def test_nothing_anywhere_is_the_rejection_this_note_is_about():
    verdict, carriers, detail = content_census(None, None, None)
    assert verdict == "rejected"
    assert carriers == []
    assert "no_text" in detail
    assert "nothing is delivered" in detail


def test_an_empty_string_beside_an_empty_blocks_array_is_still_rejected():
    verdict, _carriers, detail = content_census("", [], [])
    assert verdict == "rejected"
    assert detail.startswith("an empty text field")
    assert content_census("   ", [])[0] == "rejected"
    assert content_census(None)[2].startswith("no text field")


def test_a_field_holding_only_invisible_characters_is_reported_as_such():
    verdict, _carriers, detail = content_census("\u200b\ufeff")
    assert verdict == "rejected"
    assert "invisible or whitespace character(s)" in detail


def test_a_real_message_carries_and_names_its_carrier():
    verdict, carriers, detail = content_census("3 deploys failed", [SECTION])
    assert verdict == "carries"
    assert carriers == ["text", "blocks"]
    assert detail == "carried by text, blocks"
    assert content_census(None, [SECTION])[1] == ["blocks"]


def test_blocks_present_and_wordless_are_delivered_and_unreadable():
    verdict, carriers, detail = content_census(None, [DIVIDER, DIVIDER])
    assert verdict == "hollow"
    assert carriers == []
    assert "blocks[2]" in detail
    assert "the send succeeds" in detail


def test_a_heading_with_nothing_under_it_is_the_zero_row_render():
    verdict, carriers, detail = content_census("Nightly digest", [HEADER])
    assert verdict == "hollow"
    assert carriers == ["text", "blocks"]
    assert "heading and nothing under it" in detail


def test_a_heading_over_a_rule_is_hollow_and_named_by_its_types():
    verdict, _carriers, detail = content_census("Nightly digest", [HEADER, DIVIDER])
    assert verdict == "hollow"
    assert "divider and header" in detail


def test_a_heading_over_a_real_section_is_a_message():
    assert content_census("Nightly digest", [HEADER, SECTION])[0] == "carries"


def test_an_attachment_can_be_the_only_carrier():
    verdict, carriers, _detail = content_census(
        None, None, [{"fallback": "3 deploys failed"}])
    assert verdict == "carries"
    assert carriers == ["attachments"]


def test_an_empty_attachment_object_is_hollow_rather_than_rejected():
    verdict, _carriers, detail = content_census(None, None, [{}])
    assert verdict == "hollow"
    assert "attachments[1]" in detail


def test_the_guard_skips_by_default_because_silence_is_usually_right():
    action, message = guard_decision("", None)
    assert action == "skip"
    assert "no-op" in message
    assert guard_decision("   \u200b  ")[0] == "skip"


def test_the_guard_substitutes_only_when_the_quiet_day_is_worth_saying():
    action, message = guard_decision("", None, "the last 24 hours", True)
    assert action == "substitute"
    assert message == "No results for the last 24 hours"
    assert guard_decision("", None, None, True)[1] == "No results for the window"


def test_the_guard_sends_a_body_that_has_something_in_it():
    action, message = guard_decision("3 deploys failed")
    assert action == "send"
    assert message == "the body has content"
    assert guard_decision(None, [SECTION])[0] == "send"


def test_history_counts_the_hollow_posts_as_proof_of_the_empty_path():
    messages = [
        {"ts": "1", "bot_id": "B1", "text": "3 deploys failed", "blocks": [SECTION]},
        {"ts": "2", "bot_id": "B1", "text": "Nightly digest", "blocks": [HEADER]},
        {"ts": "3", "bot_id": "B1", "blocks": [DIVIDER]},
        {"ts": "4", "user": "U9", "text": "a human"},
    ]
    h = hollow_history(messages, bot_id="B1")
    assert h["authored"] == 3
    assert h["hollow"] == 2
    assert [r["ts"] for r in h["rows"]] == ["2", "3"]
    assert "empty path" in h["proof"]


def test_history_with_nothing_hollow_says_so_without_a_finding():
    h = hollow_history([{"ts": "1", "bot_id": "B1", "text": "3 deploys failed"}],
                       bot_id="B1")
    assert h["hollow"] == 0
    assert h["rows"] == []
    assert "all of them carrying something" in h["proof"]


def test_an_empty_channel_is_not_a_finding():
    h = hollow_history([], bot_id="B1")
    assert h["authored"] == 0
    assert h["hollow"] == 0
slack-empty-message.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  contentCensus, guardDecision, hollowHistory,
} from './slack-empty-message.mjs';

const SECTION = { type: 'section', text: { type: 'mrkdwn', text: '3 deploys failed' } };
const HEADER = { type: 'header', text: { type: 'plain_text', text: 'Nightly digest' } };
const DIVIDER = { type: 'divider' };

test('nothing anywhere is the rejection this note is about', () => {
  const [verdict, carriers, detail] = contentCensus(null, null, null);
  assert.equal(verdict, 'rejected');
  assert.deepEqual(carriers, []);
  assert.match(detail, /no_text/);
  assert.match(detail, /nothing is delivered/);
});

test('an empty string beside an empty blocks array is still rejected', () => {
  const [verdict, , detail] = contentCensus('', [], []);
  assert.equal(verdict, 'rejected');
  assert.ok(detail.startsWith('an empty text field'));
  assert.equal(contentCensus('   ', [])[0], 'rejected');
  assert.ok(contentCensus(null)[2].startsWith('no text field'));
});

test('a field holding only invisible characters is reported as such', () => {
  const [verdict, , detail] = contentCensus('\u200b\ufeff');
  assert.equal(verdict, 'rejected');
  assert.match(detail, /invisible or whitespace character\(s\)/);
});

test('a real message carries and names its carrier', () => {
  const [verdict, carriers, detail] = contentCensus('3 deploys failed', [SECTION]);
  assert.equal(verdict, 'carries');
  assert.deepEqual(carriers, ['text', 'blocks']);
  assert.equal(detail, 'carried by text, blocks');
  assert.deepEqual(contentCensus(null, [SECTION])[1], ['blocks']);
});

test('blocks present and wordless are delivered and unreadable', () => {
  const [verdict, carriers, detail] = contentCensus(null, [DIVIDER, DIVIDER]);
  assert.equal(verdict, 'hollow');
  assert.deepEqual(carriers, []);
  assert.match(detail, /blocks\[2\]/);
  assert.match(detail, /the send succeeds/);
});

test('a heading with nothing under it is the zero row render', () => {
  const [verdict, carriers, detail] = contentCensus('Nightly digest', [HEADER]);
  assert.equal(verdict, 'hollow');
  assert.deepEqual(carriers, ['text', 'blocks']);
  assert.match(detail, /heading and nothing under it/);
});

test('a heading over a rule is hollow and named by its types', () => {
  const [verdict, , detail] = contentCensus('Nightly digest', [HEADER, DIVIDER]);
  assert.equal(verdict, 'hollow');
  assert.match(detail, /divider and header/);
});

test('a heading over a real section is a message', () => {
  assert.equal(contentCensus('Nightly digest', [HEADER, SECTION])[0], 'carries');
});

test('an attachment can be the only carrier', () => {
  const [verdict, carriers] = contentCensus(null, null, [{ fallback: '3 deploys failed' }]);
  assert.equal(verdict, 'carries');
  assert.deepEqual(carriers, ['attachments']);
});

test('an empty attachment object is hollow rather than rejected', () => {
  const [verdict, , detail] = contentCensus(null, null, [{}]);
  assert.equal(verdict, 'hollow');
  assert.match(detail, /attachments\[1\]/);
});

test('the guard skips by default because silence is usually right', () => {
  const [action, message] = guardDecision('', null);
  assert.equal(action, 'skip');
  assert.match(message, /no-op/);
  assert.equal(guardDecision('   \u200b  ')[0], 'skip');
});

test('the guard substitutes only when the quiet day is worth saying', () => {
  const [action, message] = guardDecision('', null, 'the last 24 hours', true);
  assert.equal(action, 'substitute');
  assert.equal(message, 'No results for the last 24 hours');
  assert.equal(guardDecision('', null, null, true)[1], 'No results for the window');
});

test('the guard sends a body that has something in it', () => {
  const [action, message] = guardDecision('3 deploys failed');
  assert.equal(action, 'send');
  assert.equal(message, 'the body has content');
  assert.equal(guardDecision(null, [SECTION])[0], 'send');
});

test('history counts the hollow posts as proof of the empty path', () => {
  const messages = [
    { ts: '1', bot_id: 'B1', text: '3 deploys failed', blocks: [SECTION] },
    { ts: '2', bot_id: 'B1', text: 'Nightly digest', blocks: [HEADER] },
    { ts: '3', bot_id: 'B1', blocks: [DIVIDER] },
    { ts: '4', user: 'U9', text: 'a human' },
  ];
  const h = hollowHistory(messages, 'B1');
  assert.equal(h.authored, 3);
  assert.equal(h.hollow, 2);
  assert.deepEqual(h.rows.map((r) => r.ts), ['2', '3']);
  assert.match(h.proof, /empty path/);
});

test('history with nothing hollow says so without a finding', () => {
  const h = hollowHistory([{ ts: '1', bot_id: 'B1', text: '3 deploys failed' }], 'B1');
  assert.equal(h.hollow, 0);
  assert.deepEqual(h.rows, []);
  assert.match(h.proof, /all of them carrying something/);
});

test('an empty channel is not a finding', () => {
  const h = hollowHistory([], 'B1');
  assert.equal(h.authored, 0);
  assert.equal(h.hollow, 0);
});

FAQ

Is no_text the same problem as sending blocks with no text field?

No, and they are worth keeping apart. no_text is a rejection: nothing in the payload carries content, so nothing is delivered. Blocks without a text fallback is a success: the message is delivered and rendered correctly, and only the notification surfaces degrade. The first leaves an error and no message; the second leaves a message and no error. They have separate notes for that reason.

Can I just put a space in the text field to make the error stop?

It does make the error stop, and that is the problem. A space or a zero-width space satisfies the API, so an empty digest now posts successfully and shows a reader nothing at all. The script strips those characters before it judges anything precisely because they are the standard workaround, and a payload that passes every presence check in your codebase can still come back rejected here.

Should the job post something on a quiet day, or nothing?

Usually nothing. A channel that receives a daily message saying nothing happened learns that most messages from that app are noise, and stops reading it before the day something does happen. Skip the send, and let the absence be the signal. Substitute an explicit string only where the quiet day is genuinely news, such as a compliance check that has to record that it ran.

Why is a header block with nothing under it flagged?

Because it is the shape a zero-row render takes when the heading was built before the loop. Slack accepts it, since a header carries text, so it never appears in your error log; a reader gets a title over nothing. It is the one empty message the API is perfectly happy with, and the only place it shows up is in the channel.

How can a message be empty and still be in my history?

Because only the total absence of content is refused. A divider is a valid block, an empty attachment object is a valid attachment, and a header is a valid block that carries text, so all three post successfully. Those posts are the same code path as the failing one on a day it produced marginally more, which is why the script counts them: they are proof the empty path exists.

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.