Skip to content

Diagnostic Slack

Buttons render, clicks vanish: interactivity is switched off

The approval flow demoed beautifully. A deploy request arrives in #releases as a tidy message with two buttons, Approve and Reject, rendered exactly as they were designed, in the right colours, with the right text. Everyone agreed it looked finished.

In production nobody can approve anything. The button depresses, the client shows a brief spinner, and then nothing — sometimes a small red toast, more often no feedback at all. The handler has a log line on its first statement and that line has never printed. The bot answers @deploybot status in the same channel, instantly, every time. Events work. Clicks are not arriving, and they are not arriving because the payload a click produces is delivered to a URL on a screen nobody opened.

Read-only token Python and Node.js Tests included
A supermarket aisle
Photo by Dominik on Unsplash
The short answer

Posting an interactive message and receiving the interaction are two different permissions on two different screens. Anything with chat:write can post a Block Kit message full of buttons, and Slack will render them perfectly. The payload a click produces is delivered to the Interactivity Request URL, which is configured under Interactivity & Shortcuts — a separate toggle, with a separate URL field, from the Events Request URL. With the toggle off, clicks produce nothing at all.

The read is settings.interactivity.is_enabled and settings.interactivity.request_url from apps.manifest.export, held against settings.socket_mode_enabled, because Socket Mode carries interactions as well as events and makes both fields moot. Three ways to be wrong: the toggle is off, the toggle is on with an empty URL, or the toggle is on with a URL that is not the one your app serves.

The workspace confirms it with an asymmetry, and the asymmetry is what makes this note different from a dead transport. Read the channel: the app answers mentions within seconds, and the app has posted forty messages carrying type: "actions" blocks without ever posting a single follow-up after one. One surface alive, the other silent. If both surfaces are silent you have a transport problem and a different note; if both are alive the switch is fine and the bug is in your handler.

One extra field catches a subtler version: an external select menu makes Slack call back for its options at message_menu_options_url, which is a third URL. Enabled interactivity with external selects and no options URL is a menu that opens empty forever.

The problem in plain words

Every part of the visible evidence points away from the answer. The message rendered, so Block Kit is valid. The bot is in the channel, so membership is fine. The token posts successfully, so the scopes are fine. Mentions get answered within a second, so the app is up, the endpoint is reachable, the certificate is good and the deploy is healthy. Every check an on-call engineer runs comes back green, because every check is aimed at the surface that works.

The error, when there is one, is unhelpful in a specific way. A click on an app that cannot receive interactions surfaces as dispatch_failed or as a generic “something went wrong” toast in the Slack client, with no correlation id and nothing at all in your logs, because nothing reached your logs. The failure is entirely on Slack's side of the boundary: it produced a payload, looked up where to send it, found no destination, and stopped.

The configuration screens encourage it. Event Subscriptions and Interactivity & Shortcuts are two separate pages, each with its own verified-URL box, and there is no cross-reference between them. A team that stood up an events endpoint, pasted the URL, watched it go green and moved on has completed a perfectly coherent task. Nothing on that page mentions that clicks go somewhere else. Bolt makes it worse in a friendly way: it serves both on /slack/events by default, so the URL you need is a URL you already have, and the only missing step is telling Slack about it in the second place.

Then there is the Socket Mode variant, which is how this reaches production after working locally for a month. A developer runs the app on Socket Mode, where one connection carries events and interactions and neither URL field is consulted, so buttons work end to end on a laptop. The production app is the same code with Socket Mode off and an events URL configured. Interactivity was never enabled there because it was never needed anywhere it was tested.

A message withbuttonschat:write is allit takesThe clientdraws themno configurationconsultedA person clicksa payload isproducedSlack looks upthe URLon the otherscreenThere is notoneand your handlernever ran
Posting an interactive message and receiving the interaction are two separate capabilities. The first needs a scope, the second needs a switch, and only the first one is visible.

Why it happens

The asymmetry is the finding, not the silence. Any number of failures make an app stop answering. Exactly one class of failure makes an app answer mentions promptly while ignoring every click, and that is a route that exists for one surface and not the other. So the script measures both surfaces separately and reports the pair. interactions-dead with events-alive is this note. Both dead is a transport with nowhere to deliver, and the script says so rather than blaming the switch it happens to be looking at.

What counts as an interactive message has to be read out of the blocks, not guessed. A button is not the only thing that produces a block_actions payload: static and external selects, multi-selects, overflow menus, date and time pickers, checkboxes and radio buttons all do, and they appear both inside an actions block and as a section accessory. The script walks the blocks of the app's own posted messages and inventories them, so “this app has posted forty things that can be clicked” is a counted fact rather than an impression.

Socket Mode has to be checked first or the whole reading inverts. With Socket Mode on, interactions arrive over the WebSocket and both interactivity URL fields are ignored entirely. An empty request_url beside socket_mode_enabled: true is completely healthy, and a check that reported it as a fault would be wrong on every correctly configured Socket Mode app in the workspace.

The options URL is a third destination and it fails on its own. An external select asks Slack to fetch its choices at request time from message_menu_options_url. Interactivity can be enabled, its main URL correct, clicks handled fine — and every external menu still open empty, because that one field was never filled in. The script only raises it when the app has actually posted an external select, so it stays quiet for the apps it does not apply to.

The behavioural half is honest about what it cannot see. A click that opens a modal and nothing else leaves no trace in conversations.history, so the absence of a follow-up message is evidence and not proof. The script reports no-evidence when the app has posted nothing clickable, and it reports counts alongside every state so you can see the sample size it reached its conclusion from. A confident answer from four messages would be worse than no answer.

Nothing here clicks anything. There is no read-only way to make Slack produce an interaction payload, and the write that would — posting a message with a button and pressing it — is a message in somebody's channel. The configuration read plus the two surface counts settle it without the audit appearing in the workspace at all.

The fix, as a flow

The red arrow is the last one, later than anywhere else in this batch, and that lateness is the note. Four of the five steps work perfectly: the message posts with nothing more than chat:write, the client renders the buttons without consulting any configuration, a person clicks, and Slack genuinely produces a payload. Only the lookup at the end has nothing to find. The fix branch is the only one here that sorts two things at once, because a single count of silence cannot tell a missing switch from a missing transport, and those live on different screens with different repairs.

Two surfaces, apartmentions, and then clicksMentions answered, clicks notthe switch, and this noteEnabled, field left emptyproduced and then droppedMenus that open emptythe third URL, for optionsNeither surface answersa transport, not a switchBoth answerone route serving both
The finding is an asymmetry rather than a silence. One surface answering while the other never does is what a missing route looks like; both silent is a transport and a different note.

How to fix it

Find out where a click would go, if one happened

interaction_route reads socket_mode_enabled, interactivity.is_enabled and interactivity.request_url in that order of precedence and returns socket, url, enabled-no-url or disabled. Socket Mode is checked first because it makes the URL fields irrelevant, and reporting an empty field as a fault on a Socket Mode app would be a false positive on every one of them.

Inventory what the app has actually posted that can be clicked

interactive_elements walks a message's blocks and returns every element that can produce a block_actions payload, with its kind and action_id. It looks inside actions blocks, at section accessory elements, and at input blocks that set dispatch_action. Without this the note is an opinion; with it, the count is a fact.

Check whether any of them need the third URL

needs_options_url looks for external selects among those elements and options_route holds that against message_menu_options_url. This is the failure where interactivity is configured correctly and menus still open empty, and it is silent for every app that does not use external data sources.

Measure the two surfaces against each other

surface_split counts mentions that got a reply within the window and interactive messages that got a plain app follow-up within the window, and returns interactions-dead, all-dead, events-dead, healthy or no-evidence. One surface alive and one silent is the shape this note owns; both silent belongs to the transport note.

Combine the configuration and the evidence into one verdict

verdict puts the route and the split together, because either alone is weak. A disabled switch on an app that has never posted a button is not an incident. A disabled switch on an app that has posted forty of them and never followed one up is the answer, and it should be stated with the counts attached.

Point interactivity at the endpoint you already run

The printed repair is usually one paste: enable Interactivity & Shortcuts and set its Request URL to the same route that already serves events, since Bolt and most frameworks handle both there. Then remember the interactivity endpoint carries the same three-second acknowledgement budget as events, and that a modal has its own separate timing constraint on the trigger it was opened with.

How to check it worked

Enable the switch, paste the URL, and run it again against the same channel. The line that should change is split: interactions-dead becomes healthy once one click has been handled and left a trace.

python3 slack_interactivity_route.py --app-id A05DEPB0T --channel C05REL9QT
# manifest   socket mode off
# route      disabled        Interactivity is switched off, so a click produces no
#                            payload at all and nothing is delivered anywhere
# blocks     41 app message(s) read, 38 carrying something clickable
# element    actions[0]      button           approve_deploy
# element    actions[0]      button           reject_deploy
# element    section[2]      overflow         deploy_overflow
# options    not-needed      no external select has been posted by this app
# identity   U07DEP9QD (deploybot) in Northwind
# history    512 message(s) from C05REL9QT
# split      interactions-dead  62 of 66 mention(s) answered within 120s, and 0 of 38
#                            interactive message(s) followed by anything
# verdict    switch-off      the app posts buttons it has no way of hearing about
#   repair: enable Interactivity & Shortcuts and set its Request URL to the route that
#           already serves events; Bolt serves both on /slack/events
#   repair: budget the same three seconds to acknowledge an interaction as an event

The full code

Two halves that are only worth anything together. interaction_route is the configuration read and it is four lines, with Socket Mode checked first so a correctly configured socket app is never reported as broken. surface_split is the evidence, and it is written to return a pair of surface states rather than one silence count, because the entire distinction between this note and a dead transport is whether the other surface is still alive. interactive_elements is what makes the counts real: it walks the blocks the app itself posted and names every element a person could click.

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_interactivity_route.py
"""Find out where a click on this app's buttons goes, and whether it goes.

Read only. apps.manifest.export, auth.test and conversations.history are reads.
Nothing here posts a message, presses a button, or sends anything to an
interactivity endpoint - there is no read-only way to make Slack produce an
interaction payload, and manufacturing one would mean writing into somebody's
channel.

Posting an interactive message and receiving the interaction are two different
things. chat:write posts the button and Slack renders it; the payload a click
produces goes to the Interactivity Request URL, which is a separate switch with
a separate field on a separate screen, or to the Socket Mode connection when
that is on.
"""
import argparse
import json
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_interactivity_route")

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

# Every element type that produces a block_actions payload when a person uses
# it. Buttons are the ones everybody thinks of and the menus are the ones that
# quietly stop working with exactly the same cause.
ELEMENT_KINDS = {
    "button", "workflow_button", "overflow", "datepicker", "timepicker",
    "datetimepicker", "checkboxes", "radio_buttons",
    "static_select", "external_select", "users_select", "conversations_select",
    "channels_select", "multi_static_select", "multi_external_select",
    "multi_users_select", "multi_conversations_select", "multi_channels_select",
    "rich_text_input", "plain_text_input", "number_input", "email_text_input",
    "url_text_input", "file_input",
}

# The two that make Slack call back for their choices at a third URL, which is
# configured separately from the interactivity URL and fails on its own.
EXTERNAL_KINDS = {"external_select", "multi_external_select"}

# How long after a trigger an app response still counts as a response to it.
# Generous, because counting a slow handler as a dead one would point at the
# configuration screen when the problem is a queue.
WINDOW = 120.0


def interaction_route(manifest):
    """Where does a click on this app's buttons go? Pure.

    Returns (state, detail, facts). States:

      socket         Socket Mode is on and carries interactions as well as
                     events. Both URL fields are ignored, so an empty one is
                     not a fault - checking this first is what stops the whole
                     reading inverting on a correctly configured socket app.
      url            interactivity is enabled and has a URL.
      enabled-no-url the switch is on and the field is empty.
      disabled       the switch is off. No payload is produced at all.
      unknown        no manifest was available.
    """
    if manifest is None:
        return ("unknown", "no manifest was read, so the interactivity route cannot be "
                           "established; an app configuration token is what reads it",
                {})
    settings = manifest.get("settings") or {}
    socket = bool(settings.get("socket_mode_enabled"))
    inter = settings.get("interactivity") or {}
    enabled = bool(inter.get("is_enabled"))
    url = str(inter.get("request_url") or "").strip()
    options_url = str(inter.get("message_menu_options_url") or "").strip()
    facts = {"socket_mode": socket, "is_enabled": enabled, "request_url": url,
             "message_menu_options_url": options_url}
    if socket:
        return ("socket", "Socket Mode carries interactions as well as events, so "
                          "neither interactivity URL field is consulted", facts)
    if not enabled:
        return ("disabled", "Interactivity is switched off, so a click produces no "
                            "payload at all and nothing is delivered anywhere", facts)
    if not url:
        return ("enabled-no-url", "Interactivity is enabled with an empty request_url, "
                                  "so the payload is produced and has no destination",
                facts)
    return ("url", "interactions are delivered to %s" % url, facts)


def interactive_elements(message):
    """Everything in this posted message that a person can act on. Pure.

    Returns [(location, kind, action_id), ...]. Three places produce a
    block_actions payload and only the first is obvious: elements inside an
    actions block, an accessory hanging off a section, and an input block that
    sets dispatch_action. A count built from buttons alone misses the menus,
    which fail for exactly the same reason and are harder to notice.
    """
    out = []
    for index, block in enumerate(((message or {}).get("blocks") or [])):
        block = block or {}
        kind = str(block.get("type") or "")
        if kind == "actions":
            for element in block.get("elements") or []:
                element = element or {}
                if str(element.get("type") or "") in ELEMENT_KINDS:
                    out.append(("actions[%d]" % index, str(element.get("type")),
                                str(element.get("action_id") or "")))
        accessory = block.get("accessory") or {}
        if str(accessory.get("type") or "") in ELEMENT_KINDS:
            out.append(("%s[%d]" % (kind or "block", index),
                        str(accessory.get("type")),
                        str(accessory.get("action_id") or "")))
        if kind == "input" and block.get("dispatch_action"):
            element = block.get("element") or {}
            if str(element.get("type") or "") in ELEMENT_KINDS:
                out.append(("input[%d]" % index, str(element.get("type")),
                            str(element.get("action_id") or "")))
    return out


def needs_options_url(elements):
    """Do any of these elements make Slack call back for their choices? Pure.

    Returns (needed, kinds). External selects are fetched at request time from
    a third URL, so an app can have interactivity working perfectly and still
    open every menu empty.
    """
    kinds = sorted({kind for _where, kind, _action in (elements or [])
                    if kind in EXTERNAL_KINDS})
    return (bool(kinds), kinds)


def options_route(facts, needed):
    """Is the options URL there, and does this app need it? Pure.

    Returns (state, detail). States: not-needed, socket, missing, configured.
    """
    if facts.get("socket_mode"):
        return ("socket", "external select options arrive over the socket like "
                          "everything else")
    if not needed:
        return ("not-needed", "no external select has been posted by this app")
    if not facts.get("message_menu_options_url"):
        return ("missing", "an external select has been posted and "
                           "message_menu_options_url is empty, so the menu opens empty")
    return ("configured", "options are fetched from %s"
            % facts.get("message_menu_options_url"))


def surface_split(messages, bot_user_id, window=WINDOW):
    """Is one surface of this app alive while the other is silent? Pure.

    Returns (state, detail, counts). States:

      interactions-dead  mentions are answered and no interactive message the
                         app posted was ever followed by anything. This note.
      all-dead           neither surface responds. That is a transport with
                         nowhere to deliver, and a different note.
      events-dead        clicks are handled and mentions are not, which is the
                         mirror case and belongs to the events subscription.
      healthy            both surfaces respond.
      no-evidence        the app has posted nothing clickable, so there is
                         nothing to conclude and this says so.

    The pair is the point. A single silence count cannot tell a missing
    interactivity route from a missing transport, and those have different
    repairs on different screens.
    """
    rows = []
    marker = "<@%s>" % bot_user_id if bot_user_id else None
    for m in messages or []:
        ts = (m or {}).get("ts")
        if ts is None:
            continue
        mine = bool((m or {}).get("bot_id")) or (
            bot_user_id and (m or {}).get("user") == bot_user_id)
        rows.append((float(ts), bool(mine), m or {}))
    rows.sort(key=lambda r: r[0])
    own_ts = [ts for ts, mine, _m in rows if mine]
    # A reply that is itself another prompt is not evidence that a click was
    # handled, so only the app's plain messages count as a follow-up.
    own_plain = [ts for ts, mine, m in rows if mine and not interactive_elements(m)]

    mentions = [ts for ts, mine, m in rows
                if not mine and marker and marker in str(m.get("text") or "")]
    answered = sum(1 for ts in mentions
                   if any(ts < o <= ts + window for o in own_ts))

    interactive = [ts for ts, mine, m in rows if mine and interactive_elements(m)]
    followed = sum(1 for ts in interactive
                   if any(ts < o <= ts + window for o in own_plain))

    counts = {"mentions": len(mentions), "answered_mentions": answered,
              "interactive_messages": len(interactive),
              "followed_interactive": followed}
    if not interactive:
        return ("no-evidence", "the app has posted nothing clickable in the messages "
                               "read, so its interactivity cannot be judged from here",
                counts)
    events_alive = answered > 0
    clicks_alive = followed > 0
    if clicks_alive and events_alive:
        return ("healthy", "both surfaces show responses", counts)
    if clicks_alive:
        return ("events-dead", "clicks are handled and %d mention(s) are not, which is "
                               "the other surface" % len(mentions), counts)
    if events_alive:
        return ("interactions-dead",
                "%d of %d mention(s) answered within %.0fs, and 0 of %d interactive "
                "message(s) followed by a plain reply"
                % (answered, len(mentions), window, len(interactive)), counts)
    return ("all-dead", "neither surface responds, which is a transport problem rather "
                        "than an interactivity switch", counts)


def verdict(route, split):
    """Put the configuration and the evidence together. Pure.

    Returns (state, detail). Either half alone is weak: a switch that is off on
    an app which has never posted a button is not an incident, and an app that
    ignores clicks with the switch on has a different problem.
    """
    if route == "unknown":
        return ("unproven", "the manifest could not be read, so the route is unknown "
                            "and no finding is claimed")
    if split == "all-dead":
        return ("transport", "no surface responds; read the note on an app with no "
                             "transport configured before blaming interactivity")
    if route == "disabled":
        if split in ("interactions-dead", "no-evidence"):
            return ("switch-off", "the app posts buttons it has no way of hearing "
                                  "about")
        return ("contradiction", "interactivity reads as disabled and clicks appear to "
                                 "be handled; the manifest may be stale")
    if route == "enabled-no-url":
        return ("no-url", "the switch is on and the field is empty, so payloads are "
                          "produced and dropped")
    if split == "interactions-dead":
        return ("route-set-still-dead", "a route is configured and clicks still produce "
                                        "nothing; the endpoint or the handler is next, "
                                        "not the switch")
    if split == "events-dead":
        return ("events", "interactions work and events do not; that is the other "
                          "surface and the other screen")
    if split == "healthy":
        return ("clean", "both surfaces respond")
    return ("unproven", "the route is configured and the workspace shows nothing to "
                        "judge it by")


def load_manifest(args):
    """Read the live manifest. A read: export returns it and changes nothing."""
    if args.manifest:
        return json.loads(open(args.manifest, encoding="utf-8").read())
    token = os.environ.get(args.config_token_env)
    if not token or not args.app_id:
        log.warning("manifest   unavailable     set %s and --app-id, or pass "
                    "--manifest", args.config_token_env)
        return None
    body = requests.get(API + "apps.manifest.export",
                        headers={"Authorization": "Bearer " + token},
                        params={"app_id": args.app_id}, timeout=30).json()
    if body.get("ok") is not True:
        log.error("manifest   unavailable     %s", body.get("error"))
        return None
    return body.get("manifest") or {}


def page_history(session, channel, limit, max_pages):
    """Page conversations.history. A read."""
    out, cursor, pages = [], "", 0
    while pages < max_pages:
        params = {"channel": channel, "limit": str(limit)}
        if cursor:
            params["cursor"] = cursor
        body = session.get(API + "conversations.history", params=params,
                           timeout=30).json()
        if body.get("ok") is not True:
            log.error("history    unavailable     %s", body.get("error"))
            return out
        out.extend(body.get("messages") or [])
        cursor = ((body.get("response_metadata") or {}).get("next_cursor") or "")
        pages += 1
        if not cursor:
            break
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--manifest", default="", help="path to an exported manifest")
    ap.add_argument("--config-token-env", default="SLACK_CONFIG_TOKEN",
                    help="environment variable holding an app configuration token")
    ap.add_argument("--app-id", default="", help="app id, for the manifest read")
    ap.add_argument("--channel", default="",
                    help="a channel the app posts interactive messages into")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding a bot token, for the history")
    ap.add_argument("--window", type=float, default=WINDOW,
                    help="seconds after a trigger in which a response still counts")
    ap.add_argument("--limit", type=int, default=200)
    ap.add_argument("--max-pages", type=int, default=4)
    args = ap.parse_args()

    manifest = load_manifest(args)
    route, detail, facts = interaction_route(manifest)
    if facts:
        log.info("manifest   socket mode %s", "on" if facts.get("socket_mode") else "off")
    (log.warning if route in ("disabled", "enabled-no-url") else log.info)(
        "route      %-15s %s", route, detail)

    split, split_detail, counts = ("no-evidence", "no channel was read", {})
    elements = []
    if args.channel:
        token = os.environ.get(args.token_env)
        if not token:
            log.warning("history    set %s to read the app's own blocks", args.token_env)
        else:
            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
            messages = page_history(s, args.channel, args.limit, args.max_pages)
            mine = [m for m in messages
                    if m.get("bot_id") or m.get("user") == who.get("user_id")]
            clickable = [m for m in mine if interactive_elements(m)]
            log.info("blocks     %d app message(s) read, %d carrying something "
                     "clickable", len(mine), len(clickable))
            for m in clickable[:1]:
                elements = interactive_elements(m)
                for where, kind, action in elements:
                    log.info("element    %-15s %-16s %s", where, kind, action or "-")
            log.info("identity   %s (%s) in %s", who.get("user_id"), who.get("user"),
                     who.get("team"))
            log.info("history    %d message(s) from %s", len(messages), args.channel)
            split, split_detail, counts = surface_split(messages, who.get("user_id"),
                                                        args.window)
            (log.warning if split in ("interactions-dead", "all-dead") else log.info)(
                "split      %-15s %s", split, split_detail)
            log.info("split      counts          %s", counts)

    needed, kinds = needs_options_url(elements)
    options, options_detail = options_route(facts, needed)
    (log.warning if options == "missing" else log.info)(
        "options    %-15s %s", options, options_detail)
    if kinds:
        log.info("options    kinds           %s", ", ".join(kinds))

    state, why = verdict(route, split)
    findings = ("switch-off", "no-url", "route-set-still-dead", "transport",
                "contradiction")
    (log.warning if state in findings else log.info)("verdict    %-15s %s", state, why)
    if state in ("switch-off", "no-url"):
        log.warning("  repair: enable Interactivity & Shortcuts and set its Request URL "
                    "to the route that already serves events")
        log.warning("  repair: budget the same three seconds to acknowledge an "
                    "interaction as an event; the deadline is identical")
        if options == "missing":
            log.warning("  repair: set message_menu_options_url as well, or the "
                        "external menus will open empty after the switch is on")
        return 1
    return 1 if state in findings else 0


if __name__ == "__main__":
    sys.exit(main())
slack-interactivity-route.mjs
/**
 * Find out where a click on this app's buttons goes, and whether it goes.
 *
 * Read only. apps.manifest.export, auth.test and conversations.history are
 * reads. Nothing here posts a message, presses a button, or sends anything to
 * an interactivity endpoint.
 *
 * Posting an interactive message and receiving the interaction are two
 * different things: chat:write posts the button, and the payload a click
 * produces goes to the Interactivity Request URL, a separate switch with a
 * separate field on a separate screen.
 */

import { readFileSync } from 'node:fs';

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

// Every element type that produces a block_actions payload when a person uses
// it. Buttons are the obvious ones; the menus fail identically and quietly.
export const ELEMENT_KINDS = new Set([
  'button', 'workflow_button', 'overflow', 'datepicker', 'timepicker',
  'datetimepicker', 'checkboxes', 'radio_buttons',
  'static_select', 'external_select', 'users_select', 'conversations_select',
  'channels_select', 'multi_static_select', 'multi_external_select',
  'multi_users_select', 'multi_conversations_select', 'multi_channels_select',
  'rich_text_input', 'plain_text_input', 'number_input', 'email_text_input',
  'url_text_input', 'file_input',
]);

// The two that make Slack call back for their choices at a third URL.
export const EXTERNAL_KINDS = new Set(['external_select', 'multi_external_select']);

export const WINDOW = 120.0;

/**
 * Where does a click on this app's buttons go? Pure.
 * Returns [state, detail, facts]; socket, url, enabled-no-url, disabled, unknown.
 */
export function interactionRoute(manifest) {
  if (manifest === null || manifest === undefined) {
    return ['unknown', 'no manifest was read, so the interactivity route cannot be '
      + 'established; an app configuration token is what reads it', {}];
  }
  const settings = manifest.settings ?? {};
  const socket = Boolean(settings.socket_mode_enabled);
  const inter = settings.interactivity ?? {};
  const enabled = Boolean(inter.is_enabled);
  const url = String(inter.request_url ?? '').trim();
  const optionsUrl = String(inter.message_menu_options_url ?? '').trim();
  const facts = {
    socket_mode: socket,
    is_enabled: enabled,
    request_url: url,
    message_menu_options_url: optionsUrl,
  };
  if (socket) {
    return ['socket', 'Socket Mode carries interactions as well as events, so neither '
      + 'interactivity URL field is consulted', facts];
  }
  if (!enabled) {
    return ['disabled', 'Interactivity is switched off, so a click produces no payload '
      + 'at all and nothing is delivered anywhere', facts];
  }
  if (!url) {
    return ['enabled-no-url', 'Interactivity is enabled with an empty request_url, so '
      + 'the payload is produced and has no destination', facts];
  }
  return ['url', `interactions are delivered to ${url}`, facts];
}

/**
 * Everything in this posted message that a person can act on. Pure.
 * Returns [[location, kind, actionId], ...].
 */
export function interactiveElements(message) {
  const out = [];
  const blocks = (message ?? {}).blocks ?? [];
  blocks.forEach((rawBlock, index) => {
    const block = rawBlock ?? {};
    const kind = String(block.type ?? '');
    if (kind === 'actions') {
      for (const rawElement of block.elements ?? []) {
        const element = rawElement ?? {};
        if (ELEMENT_KINDS.has(String(element.type ?? ''))) {
          out.push([`actions[${index}]`, String(element.type),
            String(element.action_id ?? '')]);
        }
      }
    }
    const accessory = block.accessory ?? {};
    if (ELEMENT_KINDS.has(String(accessory.type ?? ''))) {
      out.push([`${kind || 'block'}[${index}]`, String(accessory.type),
        String(accessory.action_id ?? '')]);
    }
    if (kind === 'input' && block.dispatch_action) {
      const element = block.element ?? {};
      if (ELEMENT_KINDS.has(String(element.type ?? ''))) {
        out.push([`input[${index}]`, String(element.type),
          String(element.action_id ?? '')]);
      }
    }
  });
  return out;
}

/**
 * Do any of these elements make Slack call back for their choices? Pure.
 * Returns [needed, kinds].
 */
export function needsOptionsUrl(elements) {
  const kinds = [...new Set((elements ?? [])
    .map(([, kind]) => kind)
    .filter((kind) => EXTERNAL_KINDS.has(kind)))].sort();
  return [kinds.length > 0, kinds];
}

/**
 * Is the options URL there, and does this app need it? Pure.
 * Returns [state, detail]; not-needed, socket, missing, configured.
 */
export function optionsRoute(facts, needed) {
  if ((facts ?? {}).socket_mode) {
    return ['socket', 'external select options arrive over the socket like everything '
      + 'else'];
  }
  if (!needed) return ['not-needed', 'no external select has been posted by this app'];
  if (!(facts ?? {}).message_menu_options_url) {
    return ['missing', 'an external select has been posted and '
      + 'message_menu_options_url is empty, so the menu opens empty'];
  }
  return ['configured',
    `options are fetched from ${(facts ?? {}).message_menu_options_url}`];
}

/**
 * Is one surface of this app alive while the other is silent? Pure.
 * Returns [state, detail, counts]; interactions-dead, all-dead, events-dead,
 * healthy, no-evidence. The pair is the point.
 */
export function surfaceSplit(messages, botUserId, window = WINDOW) {
  const marker = botUserId ? `<@${botUserId}>` : null;
  const rows = [];
  for (const m of messages ?? []) {
    const ts = (m ?? {}).ts;
    if (ts === null || ts === undefined) continue;
    const mine = Boolean((m ?? {}).bot_id)
      || Boolean(botUserId && (m ?? {}).user === botUserId);
    rows.push([Number(ts), mine, m ?? {}]);
  }
  rows.sort((a, b) => a[0] - b[0]);
  const ownTs = rows.filter(([, mine]) => mine).map(([ts]) => ts);
  // A reply that is itself another prompt is not evidence that a click was
  // handled, so only the app plain messages count as a follow-up.
  const ownPlain = rows.filter(([, mine, m]) => mine && !interactiveElements(m).length)
    .map(([ts]) => ts);

  const mentions = rows
    .filter(([, mine, m]) => !mine && marker && String(m.text ?? '').includes(marker))
    .map(([ts]) => ts);
  const answered = mentions
    .filter((ts) => ownTs.some((o) => o > ts && o <= ts + window)).length;

  const interactive = rows
    .filter(([, mine, m]) => mine && interactiveElements(m).length)
    .map(([ts]) => ts);
  const followed = interactive
    .filter((ts) => ownPlain.some((o) => o > ts && o <= ts + window)).length;

  const counts = {
    mentions: mentions.length,
    answered_mentions: answered,
    interactive_messages: interactive.length,
    followed_interactive: followed,
  };
  if (!interactive.length) {
    return ['no-evidence', 'the app has posted nothing clickable in the messages read, '
      + 'so its interactivity cannot be judged from here', counts];
  }
  const eventsAlive = answered > 0;
  const clicksAlive = followed > 0;
  if (clicksAlive && eventsAlive) return ['healthy', 'both surfaces show responses', counts];
  if (clicksAlive) {
    return ['events-dead', `clicks are handled and ${mentions.length} mention(s) are `
      + 'not, which is the other surface', counts];
  }
  if (eventsAlive) {
    return ['interactions-dead',
      `${answered} of ${mentions.length} mention(s) answered within `
      + `${window.toFixed(0)}s, and 0 of ${interactive.length} interactive message(s) `
      + 'followed by a plain reply', counts];
  }
  return ['all-dead', 'neither surface responds, which is a transport problem rather '
    + 'than an interactivity switch', counts];
}

/**
 * Put the configuration and the evidence together. Pure.
 * Returns [state, detail].
 */
export function verdict(route, split) {
  if (route === 'unknown') {
    return ['unproven', 'the manifest could not be read, so the route is unknown and no '
      + 'finding is claimed'];
  }
  if (split === 'all-dead') {
    return ['transport', 'no surface responds; read the note on an app with no '
      + 'transport configured before blaming interactivity'];
  }
  if (route === 'disabled') {
    if (split === 'interactions-dead' || split === 'no-evidence') {
      return ['switch-off', 'the app posts buttons it has no way of hearing about'];
    }
    return ['contradiction', 'interactivity reads as disabled and clicks appear to be '
      + 'handled; the manifest may be stale'];
  }
  if (route === 'enabled-no-url') {
    return ['no-url', 'the switch is on and the field is empty, so payloads are '
      + 'produced and dropped'];
  }
  if (split === 'interactions-dead') {
    return ['route-set-still-dead', 'a route is configured and clicks still produce '
      + 'nothing; the endpoint or the handler is next, not the switch'];
  }
  if (split === 'events-dead') {
    return ['events', 'interactions work and events do not; that is the other surface '
      + 'and the other screen'];
  }
  if (split === 'healthy') return ['clean', 'both surfaces respond'];
  return ['unproven', 'the route is configured and the workspace shows nothing to judge '
    + 'it by'];
}

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

async function loadManifest(args) {
  const path = arg(args, '--manifest', '');
  if (path) return JSON.parse(readFileSync(path, 'utf8'));
  const tokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_TOKEN');
  const appId = arg(args, '--app-id', '');
  const token = process.env[tokenEnv];
  if (!token || !appId) {
    console.warn(`manifest   unavailable     set ${tokenEnv} and --app-id, or pass `
      + '--manifest');
    return null;
  }
  const params = new URLSearchParams({ app_id: appId });
  const body = await (await fetch(`${API}apps.manifest.export?${params}`,
    { headers: { Authorization: `Bearer ${token}` } })).json();
  if (body.ok !== true) {
    console.error(`manifest   unavailable     ${body.error}`);
    return null;
  }
  return body.manifest ?? {};
}

async function pageHistory(headers, channel, limit, maxPages) {
  const out = [];
  let cursor = '';
  for (let page = 0; page < maxPages; page += 1) {
    const params = new URLSearchParams({ channel, limit: String(limit) });
    if (cursor) params.set('cursor', cursor);
    const body = await (await fetch(`${API}conversations.history?${params}`,
      { headers })).json();
    if (body.ok !== true) {
      console.error(`history    unavailable     ${body.error}`);
      return out;
    }
    out.push(...(body.messages ?? []));
    cursor = (body.response_metadata ?? {}).next_cursor ?? '';
    if (!cursor) break;
  }
  return out;
}

async function main() {
  const args = process.argv.slice(2);
  const manifest = await loadManifest(args);
  const [route, detail, facts] = interactionRoute(manifest);
  if (Object.keys(facts).length) {
    console.log(`manifest   socket mode ${facts.socket_mode ? 'on' : 'off'}`);
  }
  const routeLine = `route      ${route.padEnd(15)} ${detail}`;
  if (route === 'disabled' || route === 'enabled-no-url') console.warn(routeLine);
  else console.log(routeLine);

  let split = 'no-evidence';
  let elements = [];
  const channel = arg(args, '--channel', '');
  if (channel) {
    const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
    const token = process.env[tokenEnv];
    if (!token) console.warn(`history    set ${tokenEnv} to read the app blocks`);
    else {
      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 messages = await pageHistory(headers, channel,
        Number(arg(args, '--limit', '200')), Number(arg(args, '--max-pages', '4')));
      const mine = messages.filter((m) => m.bot_id || m.user === who.user_id);
      const clickable = mine.filter((m) => interactiveElements(m).length);
      console.log(`blocks     ${mine.length} app message(s) read, ${clickable.length} `
        + 'carrying something clickable');
      if (clickable.length) {
        elements = interactiveElements(clickable[0]);
        for (const [where, kind, action] of elements) {
          console.log(`element    ${where.padEnd(15)} ${kind.padEnd(16)} ${action || '-'}`);
        }
      }
      console.log(`identity   ${who.user_id} (${who.user}) in ${who.team}`);
      console.log(`history    ${messages.length} message(s) from ${channel}`);
      const window = Number(arg(args, '--window', String(WINDOW)));
      const [splitState, splitDetail, counts] = surfaceSplit(messages, who.user_id,
        window);
      split = splitState;
      const row = `split      ${splitState.padEnd(15)} ${splitDetail}`;
      if (splitState === 'interactions-dead' || splitState === 'all-dead') console.warn(row);
      else console.log(row);
      console.log(`split      counts          ${JSON.stringify(counts)}`);
    }
  }

  const [needed, kinds] = needsOptionsUrl(elements);
  const [options, optionsDetail] = optionsRoute(facts, needed);
  const optionsLine = `options    ${options.padEnd(15)} ${optionsDetail}`;
  if (options === 'missing') console.warn(optionsLine); else console.log(optionsLine);
  if (kinds.length) console.log(`options    kinds           ${kinds.join(', ')}`);

  const [state, why] = verdict(route, split);
  const findings = ['switch-off', 'no-url', 'route-set-still-dead', 'transport',
    'contradiction'];
  const verdictLine = `verdict    ${state.padEnd(15)} ${why}`;
  if (findings.includes(state)) console.warn(verdictLine); else console.log(verdictLine);
  if (state === 'switch-off' || state === 'no-url') {
    console.warn('  repair: enable Interactivity & Shortcuts and set its Request URL to '
      + 'the route that already serves events');
    console.warn('  repair: budget the same three seconds to acknowledge an interaction '
      + 'as an event; the deadline is identical');
    if (options === 'missing') {
      console.warn('  repair: set message_menu_options_url as well, or the external '
        + 'menus will open empty after the switch is on');
    }
  }
  if (findings.includes(state)) process.exitCode = 1;
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The tests that earn their place are the ones that stop this note taking credit for other people's failures. interaction_route is asserted to return socket before it looks at either URL field, because an empty interactivity URL on a Socket Mode app is correct and flagging it would be a false positive on every one of them. surface_split is asserted to return all-dead when neither surface answers, which is the transport note rather than this one. And interactive_elements is checked against an accessory and a dispatching input as well as a plain button, because a count built from buttons alone misses the menus.

test_slack_interactivity_route.py
from slack_interactivity_route import (
    interaction_route, interactive_elements, needs_options_url, options_route,
    surface_split, verdict,
)


def manifest(socket=False, enabled=False, url="", options_url=""):
    return {"settings": {
        "socket_mode_enabled": socket,
        "interactivity": {"is_enabled": enabled, "request_url": url,
                          "message_menu_options_url": options_url},
    }}


BUTTONS = {"blocks": [
    {"type": "section", "text": {"type": "mrkdwn", "text": "deploy 4.2?"}},
    {"type": "actions", "elements": [
        {"type": "button", "action_id": "approve_deploy"},
        {"type": "button", "action_id": "reject_deploy"}]},
]}


def test_the_switch_being_off_is_the_route_this_note_owns():
    state, detail, facts = interaction_route(manifest())
    assert state == "disabled"
    assert facts["is_enabled"] is False
    assert "no payload at all" in detail


def test_socket_mode_is_checked_before_either_url_field():
    state, _detail, _facts = interaction_route(manifest(socket=True, enabled=False))
    assert state == "socket"


def test_an_empty_url_on_a_socket_app_is_not_a_fault():
    assert interaction_route(manifest(socket=True, enabled=True, url=""))[0] == "socket"


def test_enabled_with_an_empty_url_is_its_own_state():
    assert interaction_route(manifest(enabled=True))[0] == "enabled-no-url"
    assert interaction_route(manifest(enabled=True, url="  "))[0] == "enabled-no-url"


def test_enabled_with_a_url_reports_where_clicks_go():
    state, detail, _facts = interaction_route(
        manifest(enabled=True, url="https://ops.example.com/slack/events"))
    assert state == "url"
    assert "ops.example.com" in detail


def test_no_manifest_is_unknown_rather_than_disabled():
    assert interaction_route(None)[0] == "unknown"


def test_buttons_in_an_actions_block_are_found_with_their_action_ids():
    found = interactive_elements(BUTTONS)
    assert [f[1] for f in found] == ["button", "button"]
    assert [f[2] for f in found] == ["approve_deploy", "reject_deploy"]


def test_a_section_accessory_counts_even_though_it_is_not_in_an_actions_block():
    msg = {"blocks": [{"type": "section", "accessory": {"type": "overflow",
                                                        "action_id": "more"}}]}
    assert interactive_elements(msg) == [("section[0]", "overflow", "more")]


def test_an_input_block_only_counts_when_it_dispatches():
    quiet = {"blocks": [{"type": "input", "element": {"type": "plain_text_input",
                                                      "action_id": "note"}}]}
    loud = {"blocks": [{"type": "input", "dispatch_action": True,
                        "element": {"type": "plain_text_input", "action_id": "note"}}]}
    assert interactive_elements(quiet) == []
    assert interactive_elements(loud) == [("input[0]", "plain_text_input", "note")]


def test_a_message_of_plain_text_has_nothing_to_click():
    assert interactive_elements({"text": "deploy finished"}) == []
    assert interactive_elements({}) == []


def test_an_unknown_element_type_is_not_counted():
    msg = {"blocks": [{"type": "actions", "elements": [{"type": "image"}]}]}
    assert interactive_elements(msg) == []


def test_external_selects_are_the_ones_that_need_the_third_url():
    elements = [("actions[0]", "external_select", "pick")]
    assert needs_options_url(elements) == (True, ["external_select"])
    assert needs_options_url([("actions[0]", "button", "go")]) == (False, [])


def test_the_options_url_is_only_a_finding_when_something_needs_it():
    _state, _detail, facts = interaction_route(manifest(enabled=True, url="https://x/y"))
    assert options_route(facts, False)[0] == "not-needed"
    assert options_route(facts, True)[0] == "missing"


def test_the_socket_carries_options_too():
    _state, _detail, facts = interaction_route(manifest(socket=True))
    assert options_route(facts, True)[0] == "socket"


def test_answered_mentions_beside_ignored_buttons_is_this_note():
    msgs = [{"ts": "100", "user": "U1", "text": "<@UBOT> status"},
            {"ts": "105", "user": "UBOT", "text": "all green"},
            {"ts": "200", "user": "UBOT", **BUTTONS},
            {"ts": "300", "user": "UBOT", **BUTTONS}]
    state, _detail, counts = surface_split(msgs, "UBOT")
    assert state == "interactions-dead"
    assert counts == {"mentions": 1, "answered_mentions": 1,
                      "interactive_messages": 2, "followed_interactive": 0}


def test_both_surfaces_silent_is_the_transport_note_and_not_this_one():
    msgs = [{"ts": "50", "user": "UBOT", **BUTTONS},
            {"ts": "100", "user": "U1", "text": "<@UBOT> status"}]
    assert surface_split(msgs, "UBOT")[0] == "all-dead"


def test_a_follow_up_after_a_button_message_means_clicks_are_arriving():
    msgs = [{"ts": "100", "user": "U1", "text": "<@UBOT> status"},
            {"ts": "105", "user": "UBOT", "text": "all green"},
            {"ts": "200", "user": "UBOT", **BUTTONS},
            {"ts": "210", "user": "UBOT", "text": "approved by <@U1>"}]
    assert surface_split(msgs, "UBOT")[0] == "healthy"


def test_a_follow_up_outside_the_window_does_not_count():
    msgs = [{"ts": "100", "user": "U1", "text": "<@UBOT> status"},
            {"ts": "105", "user": "UBOT", "text": "all green"},
            {"ts": "200", "user": "UBOT", **BUTTONS},
            {"ts": "9000", "user": "UBOT", "text": "unrelated"}]
    assert surface_split(msgs, "UBOT", window=60.0)[0] == "interactions-dead"


def test_an_app_that_posts_nothing_clickable_proves_nothing():
    msgs = [{"ts": "100", "user": "U1", "text": "<@UBOT> status"},
            {"ts": "105", "user": "UBOT", "text": "all green"}]
    assert surface_split(msgs, "UBOT")[0] == "no-evidence"


def test_the_switch_and_the_evidence_are_only_a_verdict_together():
    assert verdict("disabled", "interactions-dead")[0] == "switch-off"
    assert verdict("disabled", "no-evidence")[0] == "switch-off"
    assert verdict("url", "interactions-dead")[0] == "route-set-still-dead"
    assert verdict("socket", "healthy")[0] == "clean"


def test_a_dead_transport_is_handed_over_rather_than_claimed():
    state, detail = verdict("disabled", "all-dead")
    assert state == "transport"
    assert "no transport configured" in detail


def test_an_unreadable_manifest_claims_nothing():
    assert verdict("unknown", "interactions-dead")[0] == "unproven"
slack-interactivity-route.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  interactionRoute, interactiveElements, needsOptionsUrl, optionsRoute, surfaceSplit,
  verdict,
} from './slack-interactivity-route.mjs';

function manifest({
  socket = false, enabled = false, url = '', optionsUrl = '',
} = {}) {
  return {
    settings: {
      socket_mode_enabled: socket,
      interactivity: {
        is_enabled: enabled,
        request_url: url,
        message_menu_options_url: optionsUrl,
      },
    },
  };
}

const BUTTONS = {
  blocks: [
    { type: 'section', text: { type: 'mrkdwn', text: 'deploy 4.2?' } },
    {
      type: 'actions',
      elements: [
        { type: 'button', action_id: 'approve_deploy' },
        { type: 'button', action_id: 'reject_deploy' },
      ],
    },
  ],
};

test('the switch being off is the route this note owns', () => {
  const [state, detail, facts] = interactionRoute(manifest());
  assert.equal(state, 'disabled');
  assert.equal(facts.is_enabled, false);
  assert.match(detail, /no payload at all/);
});

test('socket mode is checked before either url field', () => {
  assert.equal(interactionRoute(manifest({ socket: true }))[0], 'socket');
});

test('an empty url on a socket app is not a fault', () => {
  assert.equal(interactionRoute(manifest({ socket: true, enabled: true }))[0], 'socket');
});

test('enabled with an empty url is its own state', () => {
  assert.equal(interactionRoute(manifest({ enabled: true }))[0], 'enabled-no-url');
  assert.equal(interactionRoute(manifest({ enabled: true, url: '  ' }))[0],
    'enabled-no-url');
});

test('enabled with a url reports where clicks go', () => {
  const [state, detail] = interactionRoute(
    manifest({ enabled: true, url: 'https://ops.example.com/slack/events' }));
  assert.equal(state, 'url');
  assert.match(detail, /ops.example.com/);
});

test('no manifest is unknown rather than disabled', () => {
  assert.equal(interactionRoute(null)[0], 'unknown');
});

test('buttons in an actions block are found with their action ids', () => {
  const found = interactiveElements(BUTTONS);
  assert.deepEqual(found.map((f) => f[1]), ['button', 'button']);
  assert.deepEqual(found.map((f) => f[2]), ['approve_deploy', 'reject_deploy']);
});

test('a section accessory counts even though it is not in an actions block', () => {
  const msg = {
    blocks: [{ type: 'section', accessory: { type: 'overflow', action_id: 'more' } }],
  };
  assert.deepEqual(interactiveElements(msg), [['section[0]', 'overflow', 'more']]);
});

test('an input block only counts when it dispatches', () => {
  const quiet = {
    blocks: [{ type: 'input', element: { type: 'plain_text_input', action_id: 'note' } }],
  };
  const loud = {
    blocks: [{
      type: 'input',
      dispatch_action: true,
      element: { type: 'plain_text_input', action_id: 'note' },
    }],
  };
  assert.deepEqual(interactiveElements(quiet), []);
  assert.deepEqual(interactiveElements(loud),
    [['input[0]', 'plain_text_input', 'note']]);
});

test('a message of plain text has nothing to click', () => {
  assert.deepEqual(interactiveElements({ text: 'deploy finished' }), []);
  assert.deepEqual(interactiveElements({}), []);
});

test('an unknown element type is not counted', () => {
  const msg = { blocks: [{ type: 'actions', elements: [{ type: 'image' }] }] };
  assert.deepEqual(interactiveElements(msg), []);
});

test('external selects are the ones that need the third url', () => {
  assert.deepEqual(needsOptionsUrl([['actions[0]', 'external_select', 'pick']]),
    [true, ['external_select']]);
  assert.deepEqual(needsOptionsUrl([['actions[0]', 'button', 'go']]), [false, []]);
});

test('the options url is only a finding when something needs it', () => {
  const [, , facts] = interactionRoute(
    manifest({ enabled: true, url: 'https://ops.example.com/slack' }));
  assert.equal(optionsRoute(facts, false)[0], 'not-needed');
  assert.equal(optionsRoute(facts, true)[0], 'missing');
});

test('the socket carries options too', () => {
  const [, , facts] = interactionRoute(manifest({ socket: true }));
  assert.equal(optionsRoute(facts, true)[0], 'socket');
});

test('answered mentions beside ignored buttons is this note', () => {
  const msgs = [{ ts: '100', user: 'U1', text: '<@UBOT> status' },
    { ts: '105', user: 'UBOT', text: 'all green' },
    { ts: '200', user: 'UBOT', ...BUTTONS },
    { ts: '300', user: 'UBOT', ...BUTTONS }];
  const [state, , counts] = surfaceSplit(msgs, 'UBOT');
  assert.equal(state, 'interactions-dead');
  assert.deepEqual(counts, {
    mentions: 1, answered_mentions: 1, interactive_messages: 2, followed_interactive: 0,
  });
});

test('both surfaces silent is the transport note and not this one', () => {
  const msgs = [{ ts: '50', user: 'UBOT', ...BUTTONS },
    { ts: '100', user: 'U1', text: '<@UBOT> status' }];
  assert.equal(surfaceSplit(msgs, 'UBOT')[0], 'all-dead');
});

test('a follow up after a button message means clicks are arriving', () => {
  const msgs = [{ ts: '100', user: 'U1', text: '<@UBOT> status' },
    { ts: '105', user: 'UBOT', text: 'all green' },
    { ts: '200', user: 'UBOT', ...BUTTONS },
    { ts: '210', user: 'UBOT', text: 'approved by <@U1>' }];
  assert.equal(surfaceSplit(msgs, 'UBOT')[0], 'healthy');
});

test('a follow up outside the window does not count', () => {
  const msgs = [{ ts: '100', user: 'U1', text: '<@UBOT> status' },
    { ts: '105', user: 'UBOT', text: 'all green' },
    { ts: '200', user: 'UBOT', ...BUTTONS },
    { ts: '9000', user: 'UBOT', text: 'unrelated' }];
  assert.equal(surfaceSplit(msgs, 'UBOT', 60)[0], 'interactions-dead');
});

test('an app that posts nothing clickable proves nothing', () => {
  const msgs = [{ ts: '100', user: 'U1', text: '<@UBOT> status' },
    { ts: '105', user: 'UBOT', text: 'all green' }];
  assert.equal(surfaceSplit(msgs, 'UBOT')[0], 'no-evidence');
});

test('the switch and the evidence are only a verdict together', () => {
  assert.equal(verdict('disabled', 'interactions-dead')[0], 'switch-off');
  assert.equal(verdict('disabled', 'no-evidence')[0], 'switch-off');
  assert.equal(verdict('url', 'interactions-dead')[0], 'route-set-still-dead');
  assert.equal(verdict('socket', 'healthy')[0], 'clean');
});

test('a dead transport is handed over rather than claimed', () => {
  const [state, detail] = verdict('disabled', 'all-dead');
  assert.equal(state, 'transport');
  assert.match(detail, /no transport configured/);
});

test('an unreadable manifest claims nothing', () => {
  assert.equal(verdict('unknown', 'interactions-dead')[0], 'unproven');
});

FAQ

The buttons render perfectly. Doesn't that prove the app is configured?

It proves the app can post, which needs chat:write and nothing else. Rendering happens in the Slack client from the blocks in your message; Slack does not consult your app's configuration to draw a button, and it will happily render one on an app that has no way to receive the click. The two capabilities are independent, and the visible one is the one that does not need the switch.

Why is there a separate URL for interactivity at all?

Historically because interactions arrived long after events and were designed as their own product surface, with their own screen, their own payload shape and their own verification. Practically it means an app can serve one and not the other, which is occasionally useful and mostly a trap. Most frameworks collapse the distinction again at the receiving end: Bolt serves events, interactions, commands and options on one route, so the fix is usually pasting the URL you already have into a second box.

It works on my machine over Socket Mode. What changes in production?

Socket Mode carries everything down one connection, so neither interactivity URL field is consulted and interactivity works whether or not the switch has ever been touched. Production has the switch off and events over HTTPS, which is why buttons that worked all through development fail on the first day. The script checks Socket Mode first for exactly this reason, and reports a socket app as routed rather than as broken.

Our menus open completely empty, but our buttons work. Same problem?

The same screen, a different field. An external select does not carry its choices in the message: Slack fetches them at the moment the menu opens, from message_menu_options_url, which is configured separately from the interactivity request URL. Interactivity can be enabled and working and every external menu still open empty because that third field was never filled in. The script raises it only when the app has actually posted an external select.

Could the clicks be arriving and the handler dropping them silently?

Yes, and the script is built to tell you that rather than guess. If the manifest shows a configured route and the workspace still shows no follow-up after any interactive message, the verdict is route-set-still-dead, which points at the endpoint and the handler rather than at the configuration screen. The behavioural half is also honest about its blind spot: a click that opens a modal and does nothing else leaves no trace in the channel, so with a small sample it reports no-evidence instead of a conclusion.

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.