Skip to content

Diagnostic Slack

cant_update_message: that message is not yours to edit

The message exists. You just read it back from conversations.history, the ts is exactly right, the channel is right, and chat.update answers {"ok": false, "error": "cant_update_message"}. This is not message_not_found. Slack found it. Slack will not let you touch it.

Editing and deleting are the two Slack methods where the token has to be the author and not merely authorised. A scope does not help, a reinstall does not help, and admin rights on the workspace mostly do not help either. The message belongs to whoever wrote it, and the only question worth asking is who that was.

Read-only token Python and Node.js Tests included
Man loading boxes from a truck onto a dolly.
Photo by chansu shin on Unsplash
The short answer

A message can be modified only by the identity that authored it. A bot token can change messages carrying that bot's bot_id. A user token can change messages carrying that user's user_id. They do not overlap in either direction: a bot token cannot edit a message posted with a user token, and a user token cannot edit a message posted by your own bot, even when that user is the person who installed the app.

Everything else follows from that. A message from another app carries a different bot_id and is unreachable. A message from an incoming webhook or a legacy custom integration carries a bot_id with no app_id beside it and is unreachable by any token at all. A human's message can never be edited by anyone but that human, and deleting one needs a user token belonging to an admin with chat:write, subject to workspace policy on top.

The check is a comparison and it is cheap: read auth.test once for your bot_id, your user_id and which class of token you are actually holding, then read the messages you intend to change and compare the authorship fields. The interesting output is not a boolean but a reason, because a different app wrote this and you are holding the wrong kind of token want completely different fixes.

The problem in plain words

The confusing shape of this bug is that the code is usually right and the credential is usually wrong. A team writes an approval flow: a bot posts a message with a button, the button is clicked, and the handler edits the message to say approved by Dana. It works in development. In production the same handler runs under a user token, because that is the token the OAuth flow happened to store, and every edit comes back cant_update_message. Nothing in the payload changed. The author did.

The reverse direction catches teams who added a user-token feature later. A message posted with chat.postMessage on a user token appears in the channel as that person, with their name and their avatar. It is their message. The bot that runs the rest of the app is a different identity with a different bot_id, and it cannot edit that message any more than it could edit yours. Apps that post as the user for one feature and clean up with the bot for another discover this at the cleanup step.

Then there is the identity that looks like yours and is not. Posting with username and icon_emoji overrides changes what a message looks like and not who wrote it: those messages are still your bot's and still editable, which surprises people in the pleasant direction. An incoming webhook is the opposite surprise. It posts under an identity that renders like an app, carries a bot_id, and has no app_id beside it, and there is no token in the world that can edit it. It also returns the literal text ok rather than a ts, so the update path was never viable to begin with.

Deletion is stricter still and worth separating. chat.delete on your own bot's message is fine. chat.delete on a human's message requires a user token from someone with the right to do it, and workspace settings can remove that right entirely. A support tool that deletes messages containing an access key on a bot token will fail on every message it was built to clean up, because the messages it is cleaning up are all written by people.

All of this is visible before you call anything. The authorship fields are right there in conversations.history, and auth.test tells you which identity you hold. The only reason this is ever discovered at runtime is that nobody compares the two.

Bot postswith a button onitClick arriveshandler wakes upReads the tsit resolves fineEdits on a usertokena different authorcant_update_messagereinstalled twiceby now
Adding a scope is the reflex and it changes nothing here. Editing is the one place where the token has to be the author, and an author is not something a scope grants.

Why it happens

Authorship is not a permission, so no scope fixes it. This is the part that costs the most time. The instinct on any Slack error is to add a scope and reinstall, and here that changes nothing at all: chat:write lets you write as yourself, and there is no scope that lets you write as somebody else. The script says which identity wrote the message so the conversation skips the reinstall entirely.

auth.test tells you which class of token you are holding, and you should not assume you know. A bot token's auth.test comes back with a bot_id; a user token's does not. Apps that store both and pick one by variable name get this wrong regularly, and the symptom is a method that works for months and then does not after a config change nobody connected to it.

The two directions of the mismatch are one verdict, because the fix is the same shape. Bot token against a user's message and user token against the bot's message are both wrong token class, and the repair in both is to make the identity that wrote it be the identity that changes it. Splitting them into two findings implies two different fixes and there is only one.

A webhook message has no update path at all, and that is a design finding rather than a bug. The script names it separately from another app wrote this, because another app can sometimes be solved by asking that team, and a webhook cannot be solved by anyone. Moving the send to chat.postMessage is the whole answer.

The interaction path is the exception that rescues most real cases. A button click hands you a response_url, and posting to it with replace_original replaces the original message regardless of which token posted it. That window is thirty minutes and five uses, which is why the script gives you a function that answers whether the window is still open rather than leaving it as a footnote.

Deleting somebody else's message is a policy question, not an API question. The method exists, the credential is a user token belonging to an admin, and the workspace can still say no. The script reports human, and this needs an admin user token rather than pretending a code change will do it.

The fix, as a flow

This chain goes red at the last arrow, which is the opposite of the note before it. The message exists, the pair resolves, the channel is right and the scope is granted, so there is nothing to colour until two identities are put beside each other. The fix branch is a list of authors rather than a list of faults, and only one row on it is a thing your own app can change.

auth.test, and the authorsbot_id, app_id, userYour bot, wrong token classthe one row you can fix todayA different app entirelyno scope reaches itbot_id with no app_ida webhook, so nobody canA person wrote itremoval wants an admin user tokenYours, overrides and alla username change is decoration
auth.test names the identity you are holding, and the message names the one that wrote it. The comparison is cheap, and the answer is a sentence about who rather than a boolean about whether.

How to fix it

Find out which token you are actually holding

auth.test first, every time. token_class reads the response and returns bot or user: a bot token's answer carries a bot_id and a user token's does not. This one line settles half the cases before any message is examined.

Read the authorship fields rather than the rendered name

authorship looks at bot_id, app_id and user together. The displayed name and avatar are decoration and can be overridden per message; the three id fields are the identity, and they are what Slack compares when you try to change something.

Compare, and read the verdict as a reason rather than a boolean

mutability returns one of yours, wrong-token-class, other-app, webhook-or-legacy, human, another-user or unknown-author. Only the first is editable, and the other six each want a different next move.

Take wrong-token-class seriously before you take anything else seriously

It is the only verdict where your own code posted the message and your own code cannot change it. It means the two halves of your app are running as two identities, which is usually an accident of which token got read from the environment, and it is the cheapest of the six to fix.

Check whether the interaction window is still open

If the edit is in response to a button, response_url_plan answers whether replace_original is still available: thirty minutes from the interaction, five uses. That path ignores authorship entirely, which makes it the right answer for most approval and acknowledgement flows and the wrong answer for a status message you update for an hour.

Stop at human rather than looking for a clever way through

There is no bot-token route to deleting a person's message, and no scope that adds one. The script prints the requirement, which is an admin's user token with chat:write and a workspace that permits it, and leaves the decision with you rather than suggesting a workaround that does not exist.

How to check it worked

Point it at the channel your app edits in. The line you want is a named identity, not a count of failures.

python3 slack_cant_update_message.py --channel C024BE91L
# identity   user           U04HZ9K1M holds a user token; auth.test returned no bot_id
# authors    47 message(s) read in C024BE91L
# mutable    yours          12  posted by U04HZ9K1M with this same user token
# blocked    wrong-token-class 21  authored by B01J9Q7XA, which is your own bot. A user
#                               token cannot edit a message a bot wrote, and no scope
#                               changes that
# blocked    human          9   written by people; chat.update can never touch these and
#                               chat.delete needs an admin user token with chat:write
# blocked    webhook-or-legacy 5  a bot_id with no app_id beside it, so no token can edit
#                               it and no ts was ever returned to store
#   repair: use the identity that posted the message to change it, or replace it through
#           the interaction response_url within thirty minutes and five uses

The full code

Four pure functions and one read. token_class is the line nobody writes and everybody needs. authorship deliberately ignores username and icons, because those are the fields that make a message look like it belongs to somebody it does not. mutability is the comparison, and it returns a reason rather than a boolean so that the two verdicts you can fix are never buried among the four you cannot. response_url_plan is the escape hatch, with its clock.

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_cant_update_message.py
"""Say which messages your token is allowed to change, and why not.

Read only. Nothing here edits or removes a message: the whole point is that
editing and removing are the two Slack methods where the token has to be the
author, and that is decidable in advance from conversations.history plus one
auth.test. The script compares the identities and prints the repair.
"""
import argparse
import logging
import os
import sys

import requests

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

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

# Slack's documented window on an interaction response_url: half an hour, and
# five uses. Both are ceilings, and hitting either one closes the only path
# that ignores authorship.
RESPONSE_URL_SECONDS = 1800
RESPONSE_URL_USES = 5


def token_class(auth):
    """Which kind of token is this, really? Pure.

    auth is the auth.test body. A bot token's answer carries a bot_id and a
    user token's does not, which is the only reliable discriminator; the
    prefix on the token string is not, because apps store both and read the
    wrong one by variable name.
    """
    if not isinstance(auth, dict) or auth.get("ok") is not True:
        return ("unknown", "auth.test did not answer ok, so the identity behind this "
                           "token is unknown and nothing below can be decided")
    if auth.get("bot_id"):
        return ("bot", "%s holds a bot token; auth.test returned bot_id %s"
                % (auth.get("user_id") or "?", auth.get("bot_id")))
    if auth.get("user_id"):
        return ("user", "%s holds a user token; auth.test returned no bot_id"
                % auth.get("user_id"))
    return ("unknown", "auth.test answered ok with no user_id, which should not happen")


def authorship(message):
    """Who wrote this message? Pure.

    Returns (kind, ident). Deliberately ignores username, icons and any display
    name: those are per-message decoration and can be set to anything, while
    bot_id, app_id and user are the identity Slack compares against your token.
    """
    if not isinstance(message, dict):
        return ("unknown", "")
    bot_id = str(message.get("bot_id") or "")
    app_id = str(message.get("app_id") or "")
    user = str(message.get("user") or "")
    if bot_id and app_id:
        return ("app", bot_id)
    if bot_id:
        return ("bot-no-app", bot_id)
    if user:
        return ("human", user)
    return ("unknown", "")


def mutability(message, identity):
    """Can this token change this message, and if not, why not? Pure.

    identity is {"bot_id", "user_id", "token"} where token is bot or user.
    Returns (verdict, detail). Only yours is changeable. The other verdicts are
    kept apart because they want different repairs: wrong-token-class is a
    credential your app already has, other-app is somebody else's problem, and
    human is a policy question with no code answer at all.
    """
    ident = identity or {}
    holder = str(ident.get("token") or "unknown")
    our_bot = str(ident.get("bot_id") or "")
    our_user = str(ident.get("user_id") or "")
    kind, author = authorship(message)

    if kind == "unknown":
        return ("unknown-author", "no bot_id, app_id or user on this message, so there "
                                  "is no identity to compare and no safe assumption")

    if kind in ("app", "bot-no-app"):
        if our_bot and author == our_bot:
            if holder == "bot":
                return ("yours", "authored by %s, which is this token's own bot. A "
                                 "username or icon_emoji override changes how it looks "
                                 "and not who wrote it, so those are editable too"
                        % author)
            return ("wrong-token-class",
                    "authored by %s, which is your own bot. A user token cannot edit a "
                    "message a bot wrote, even when that user installed the app, and no "
                    "scope changes that" % author)
        if kind == "bot-no-app":
            return ("webhook-or-legacy",
                    "a bot_id (%s) with no app_id beside it, which is what an incoming "
                    "webhook or a legacy custom integration produces. No token can edit "
                    "it, and a webhook answers with the text ok rather than a ts, so "
                    "there was never a pair to store" % author)
        return ("other-app", "authored by %s, a different app. Authorship is not a "
                             "permission, so there is no scope and no reinstall that "
                             "makes this editable by you" % author)

    if holder == "user" and our_user and author == our_user:
        return ("yours", "posted by %s with this same user token" % author)
    if holder == "user":
        return ("another-user", "written by %s, a different person. chat.update can "
                                "never touch it, and chat.delete needs an admin user "
                                "token with chat:write and a workspace that allows it"
                % author)
    return ("human", "written by %s, a person. A bot token can never edit this, and "
                     "deleting it needs an admin user token with chat:write rather than "
                     "any bot scope" % author)


def response_url_plan(age_seconds, uses=0, max_age=RESPONSE_URL_SECONDS,
                      max_uses=RESPONSE_URL_USES):
    """Is the interaction escape hatch still open? Pure.

    A response_url from a button click replaces the original message regardless
    of which token posted it, which is the one path that ignores authorship
    entirely. It is also the one path with a clock on it, and code that treats
    it as permanent fails silently the first time somebody clicks a button on
    yesterday's message.
    """
    if age_seconds is None:
        return ("no-interaction", "this edit is not in response to an interaction, so "
                                  "there is no response_url and authorship is the only "
                                  "rule that applies")
    try:
        age = float(age_seconds)
    except (TypeError, ValueError):
        return ("no-interaction", "no usable interaction age, so treat authorship as "
                                  "the only rule")
    used = int(uses or 0)
    if used >= max_uses:
        return ("exhausted", "%d of the %d permitted uses are gone; the url is spent "
                             "even though the clock has not run out" % (used, max_uses))
    if age > max_age:
        return ("expired", "%.0f seconds since the interaction and the window is %d. "
                           "Replacing the original is no longer possible and authorship "
                           "is back to being the only rule" % (age, max_age))
    return ("usable", "%.0f seconds in, %d of %d uses spent: replace_original still "
                      "works here and ignores which token posted the message"
            % (age, used, max_uses))


def summarise(messages, identity):
    """Group a channel's messages by what this token could do to them."""
    counts = {}
    for m in messages or []:
        verdict, detail = mutability(m, identity)
        row = counts.setdefault(verdict, {"count": 0, "detail": detail})
        row["count"] += 1
    return counts


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--channel", action="append", default=[],
                    help="a channel your app edits messages in; repeatable")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the token to test")
    ap.add_argument("--limit", type=int, default=200, help="messages read per channel")
    ap.add_argument("--interaction-age", type=float, default=None,
                    help="seconds since the button click, to test the response_url path")
    ap.add_argument("--interaction-uses", type=int, default=0,
                    help="how many times that response_url has already been used")
    args = ap.parse_args()

    if not args.channel:
        log.error("pass at least one --channel")
        return 2
    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s to the token whose edits are failing", args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    auth = s.get(API + "auth.test", timeout=30).json()
    holder, detail = token_class(auth)
    if holder == "unknown":
        log.error("identity   unknown        %s", detail)
        return 2
    log.info("identity   %-14s %s", holder, detail)
    identity = {"bot_id": auth.get("bot_id") or "", "user_id": auth.get("user_id") or "",
                "token": holder}

    if args.interaction_age is not None:
        verdict, why = response_url_plan(args.interaction_age, args.interaction_uses)
        (log.info if verdict == "usable" else log.warning)(
            "response   %-14s %s", verdict, why)

    blocked = 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
        messages = body.get("messages") or []
        log.info("authors    %d message(s) read in %s", len(messages), channel)
        for verdict, row in sorted(summarise(messages, identity).items()):
            if verdict == "yours":
                log.info("mutable    %-18s %d  %s", verdict, row["count"], row["detail"])
            else:
                blocked += row["count"]
                log.warning("blocked    %-18s %d  %s", verdict, row["count"],
                            row["detail"])

    if blocked:
        log.warning("  repair: use the identity that posted the message to change it; "
                    "authorship is not a permission and no scope grants it")
        log.warning("  repair: for an edit in response to a button, replace the original "
                    "through the interaction response_url, within thirty minutes and "
                    "five uses")
        log.warning("  repair: to remove somebody else's message you need an admin user "
                    "token with chat:write, and the workspace can still refuse")
        return 1
    log.info("verdict    clean          every message read is one this token authored")
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-cant-update-message.mjs
/**
 * Say which messages your token is allowed to change, and why not.
 *
 * Read only. Nothing here edits or removes a message: the whole point is that
 * editing and removing are the two Slack methods where the token has to be the
 * author, and that is decidable in advance from conversations.history plus one
 * auth.test. The script compares the identities and prints the repair.
 */

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

// Slack's documented window on an interaction response_url: half an hour, and
// five uses. Hitting either closes the only path that ignores authorship.
const RESPONSE_URL_SECONDS = 1800;
const RESPONSE_URL_USES = 5;

/**
 * Which kind of token is this, really? Pure.
 * A bot token's auth.test carries a bot_id and a user token's does not.
 */
export function tokenClass(auth) {
  if (!auth || typeof auth !== 'object' || auth.ok !== true) {
    return ['unknown', 'auth.test did not answer ok, so the identity behind this token '
      + 'is unknown and nothing below can be decided'];
  }
  if (auth.bot_id) {
    return ['bot', `${auth.user_id ?? '?'} holds a bot token; auth.test returned bot_id `
      + `${auth.bot_id}`];
  }
  if (auth.user_id) {
    return ['user', `${auth.user_id} holds a user token; auth.test returned no bot_id`];
  }
  return ['unknown', 'auth.test answered ok with no user_id, which should not happen'];
}

/**
 * Who wrote this message? Pure. Returns [kind, ident].
 * Ignores username and icons on purpose: those are per-message decoration.
 */
export function authorship(message) {
  if (!message || typeof message !== 'object') return ['unknown', ''];
  const botId = String(message.bot_id ?? '');
  const appId = String(message.app_id ?? '');
  const user = String(message.user ?? '');
  if (botId && appId) return ['app', botId];
  if (botId) return ['bot-no-app', botId];
  if (user) return ['human', user];
  return ['unknown', ''];
}

/**
 * Can this token change this message, and if not, why not? Pure.
 * identity is { bot_id, user_id, token } where token is bot or user.
 */
export function mutability(message, identity) {
  const ident = identity ?? {};
  const holder = String(ident.token ?? 'unknown');
  const ourBot = String(ident.bot_id ?? '');
  const ourUser = String(ident.user_id ?? '');
  const [kind, author] = authorship(message);

  if (kind === 'unknown') {
    return ['unknown-author', 'no bot_id, app_id or user on this message, so there is '
      + 'no identity to compare and no safe assumption'];
  }

  if (kind === 'app' || kind === 'bot-no-app') {
    if (ourBot && author === ourBot) {
      if (holder === 'bot') {
        return ['yours', `authored by ${author}, which is this token's own bot. A `
          + 'username or icon_emoji override changes how it looks and not who wrote '
          + 'it, so those are editable too'];
      }
      return ['wrong-token-class', `authored by ${author}, which is your own bot. A `
        + 'user token cannot edit a message a bot wrote, even when that user installed '
        + 'the app, and no scope changes that'];
    }
    if (kind === 'bot-no-app') {
      return ['webhook-or-legacy', `a bot_id (${author}) with no app_id beside it, `
        + 'which is what an incoming webhook or a legacy custom integration produces. '
        + 'No token can edit it, and a webhook answers with the text ok rather than a '
        + 'ts, so there was never a pair to store'];
    }
    return ['other-app', `authored by ${author}, a different app. Authorship is not a `
      + 'permission, so there is no scope and no reinstall that makes this editable '
      + 'by you'];
  }

  if (holder === 'user' && ourUser && author === ourUser) {
    return ['yours', `posted by ${author} with this same user token`];
  }
  if (holder === 'user') {
    return ['another-user', `written by ${author}, a different person. chat.update can `
      + 'never touch it, and chat.delete needs an admin user token with chat:write and '
      + 'a workspace that allows it'];
  }
  return ['human', `written by ${author}, a person. A bot token can never edit this, `
    + 'and deleting it needs an admin user token with chat:write rather than any bot '
    + 'scope'];
}

/**
 * Is the interaction escape hatch still open? Pure.
 * A response_url replaces the original regardless of which token posted it,
 * and it is the one path with a clock on it.
 */
export function responseUrlPlan(ageSeconds, uses = 0, maxAge = RESPONSE_URL_SECONDS,
  maxUses = RESPONSE_URL_USES) {
  if (ageSeconds === null || ageSeconds === undefined
      || !Number.isFinite(Number(ageSeconds))) {
    return ['no-interaction', 'this edit is not in response to an interaction, so there '
      + 'is no response_url and authorship is the only rule that applies'];
  }
  const age = Number(ageSeconds);
  const used = Number(uses ?? 0) || 0;
  if (used >= maxUses) {
    return ['exhausted', `${used} of the ${maxUses} permitted uses are gone; the url is `
      + 'spent even though the clock has not run out'];
  }
  if (age > maxAge) {
    return ['expired', `${age.toFixed(0)} seconds since the interaction and the window `
      + `is ${maxAge}. Replacing the original is no longer possible and authorship is `
      + 'back to being the only rule'];
  }
  return ['usable', `${age.toFixed(0)} seconds in, ${used} of ${maxUses} uses spent: `
    + 'replace_original still works here and ignores which token posted the message'];
}

/** Group a channel's messages by what this token could do to them. */
export function summarise(messages, identity) {
  const counts = new Map();
  for (const m of messages ?? []) {
    const [verdict, detail] = mutability(m, identity);
    if (!counts.has(verdict)) counts.set(verdict, { count: 0, detail });
    counts.get(verdict).count += 1;
  }
  return counts;
}

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 channels = argAll(args, '--channel');
  if (!channels.length) {
    console.error('pass at least one --channel');
    process.exitCode = 2;
    return;
  }
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} to the token whose edits are failing`);
    process.exitCode = 2;
    return;
  }
  const limit = arg(args, '--limit', '200');
  const headers = { Authorization: `Bearer ${token}` };

  const auth = await (await fetch(`${API}auth.test`, { headers })).json();
  const [holder, detail] = tokenClass(auth);
  if (holder === 'unknown') {
    console.error(`identity   unknown        ${detail}`);
    process.exitCode = 2;
    return;
  }
  console.log(`identity   ${holder.padEnd(14)} ${detail}`);
  const identity = { bot_id: auth.bot_id ?? '', user_id: auth.user_id ?? '',
    token: holder };

  const age = arg(args, '--interaction-age', null);
  if (age !== null) {
    const [verdict, why] = responseUrlPlan(Number(age),
      Number(arg(args, '--interaction-uses', '0')));
    const line = `response   ${verdict.padEnd(14)} ${why}`;
    if (verdict === 'usable') console.log(line); else console.warn(line);
  }

  let blocked = 0;
  for (const channel of channels) {
    const qs = new URLSearchParams({ channel, limit: String(limit) }).toString();
    const body = await (await fetch(`${API}conversations.history?${qs}`, { headers }))
      .json();
    if (body.ok !== true) {
      console.warn(`history    unavailable    ${channel}: ${body.error}`);
      continue;
    }
    const messages = body.messages ?? [];
    console.log(`authors    ${messages.length} message(s) read in ${channel}`);
    for (const [verdict, row] of [...summarise(messages, identity)].sort()) {
      if (verdict === 'yours') {
        console.log(`mutable    ${verdict.padEnd(18)} ${row.count}  ${row.detail}`);
      } else {
        blocked += row.count;
        console.warn(`blocked    ${verdict.padEnd(18)} ${row.count}  ${row.detail}`);
      }
    }
  }

  if (blocked) {
    console.warn('  repair: use the identity that posted the message to change it; '
      + 'authorship is not a permission and no scope grants it');
    console.warn('  repair: for an edit in response to a button, replace the original '
      + 'through the interaction response_url, within thirty minutes and five uses');
    console.warn('  repair: to remove somebody else\'s message you need an admin user '
      + 'token with chat:write, and the workspace can still refuse');
    process.exitCode = 1;
  } else {
    console.log('verdict    clean          every message read is one this token authored');
  }
}

// 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 tests are mostly about the two directions of the same mistake, because that symmetry is the note. A bot token facing a message a user wrote and a user token facing a message the bot wrote must both come back wrong-token-class, since the repair is the same in both. A username override must not move a message out of yours, because the display name is the field most likely to make somebody believe the wrong thing. And response_url_plan must go exhausted on the fifth use even at one second old, since the two limits are independent and code that only watches the clock is wrong about half the time.

test_slack_cant_update_message.py
from slack_cant_update_message import (authorship, mutability, response_url_plan,
                                       summarise, token_class)

BOT = {"bot_id": "B01J9Q7XA", "user_id": "U0BOTUSER", "token": "bot"}
USER = {"bot_id": "", "user_id": "U04HZ9K1M", "token": "user"}


def test_a_bot_token_is_recognised_by_its_bot_id():
    verdict, detail = token_class({"ok": True, "user_id": "U0BOTUSER",
                                   "bot_id": "B01J9Q7XA"})
    assert verdict == "bot"
    assert "B01J9Q7XA" in detail


def test_a_user_token_is_the_absence_of_a_bot_id():
    verdict, detail = token_class({"ok": True, "user_id": "U04HZ9K1M"})
    assert verdict == "user"
    assert "no bot_id" in detail


def test_a_failed_auth_test_is_never_guessed_at():
    assert token_class({"ok": False, "error": "invalid_auth"})[0] == "unknown"
    assert token_class(None)[0] == "unknown"


def test_authorship_reads_ids_and_not_the_displayed_name():
    assert authorship({"bot_id": "B1", "app_id": "A1", "username": "Totally Dana"}) \
        == ("app", "B1")
    assert authorship({"bot_id": "B1"}) == ("bot-no-app", "B1")
    assert authorship({"user": "U9"}) == ("human", "U9")
    assert authorship({}) == ("unknown", "")
    assert authorship("not a message") == ("unknown", "")


def test_a_bot_token_can_change_its_own_bot_message():
    verdict, detail = mutability({"bot_id": "B01J9Q7XA", "app_id": "A1"}, BOT)
    assert verdict == "yours"
    assert "own bot" in detail


def test_a_username_override_does_not_change_who_wrote_it():
    message = {"bot_id": "B01J9Q7XA", "app_id": "A1", "subtype": "bot_message",
               "username": "Deploy Bot", "icons": {"emoji": ":rocket:"}}
    verdict, detail = mutability(message, BOT)
    assert verdict == "yours"
    assert "icon_emoji" in detail


def test_a_user_token_cannot_edit_your_own_bots_message():
    verdict, detail = mutability({"bot_id": "B01J9Q7XA", "app_id": "A1"},
                                 dict(USER, bot_id="B01J9Q7XA"))
    assert verdict == "wrong-token-class"
    assert "installed the app" in detail


def test_a_bot_token_cannot_edit_a_message_a_person_wrote():
    verdict, detail = mutability({"user": "U04HZ9K1M"}, BOT)
    assert verdict == "human"
    assert "admin user token" in detail


def test_a_user_token_owns_only_that_users_messages():
    assert mutability({"user": "U04HZ9K1M"}, USER)[0] == "yours"
    verdict, detail = mutability({"user": "U7SOMEONE"}, USER)
    assert verdict == "another-user"
    assert "chat.delete needs an admin" in detail


def test_another_apps_message_is_named_as_a_different_app():
    verdict, detail = mutability({"bot_id": "B99OTHER", "app_id": "A99"}, BOT)
    assert verdict == "other-app"
    assert "not a permission" in detail


def test_a_bot_id_with_no_app_id_is_the_webhook_dead_end():
    verdict, detail = mutability({"bot_id": "B77HOOK", "subtype": "bot_message"}, BOT)
    assert verdict == "webhook-or-legacy"
    assert "never a pair to store" in detail


def test_a_message_with_no_author_fields_is_not_assumed_editable():
    assert mutability({"text": "hi"}, BOT)[0] == "unknown-author"


def test_the_two_directions_of_the_mismatch_share_one_verdict():
    as_user = mutability({"bot_id": "B01J9Q7XA", "app_id": "A1"},
                         dict(USER, bot_id="B01J9Q7XA"))[0]
    assert as_user == "wrong-token-class"


def test_the_response_url_window_is_open_inside_thirty_minutes():
    verdict, detail = response_url_plan(120, 1)
    assert verdict == "usable"
    assert "replace_original" in detail


def test_the_response_url_expires_on_the_clock():
    verdict, detail = response_url_plan(1801, 0)
    assert verdict == "expired"
    assert "1800" in detail


def test_the_response_url_expires_on_uses_independently_of_the_clock():
    verdict, detail = response_url_plan(1, 5)
    assert verdict == "exhausted"
    assert "clock has not run out" in detail


def test_an_edit_with_no_interaction_behind_it_says_so():
    assert response_url_plan(None)[0] == "no-interaction"
    assert response_url_plan("soon")[0] == "no-interaction"


def test_summarise_counts_by_reason_rather_than_by_pass_or_fail():
    messages = [{"bot_id": "B01J9Q7XA", "app_id": "A1"},
                {"bot_id": "B01J9Q7XA", "app_id": "A1"},
                {"user": "U04HZ9K1M"},
                {"bot_id": "B99OTHER", "app_id": "A99"}]
    counts = summarise(messages, BOT)
    assert counts["yours"]["count"] == 2
    assert counts["human"]["count"] == 1
    assert counts["other-app"]["count"] == 1


def test_an_empty_channel_produces_no_findings():
    assert summarise([], BOT) == {}
slack-cant-update-message.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  authorship, mutability, responseUrlPlan, summarise, tokenClass,
} from './slack-cant-update-message.mjs';

const BOT = { bot_id: 'B01J9Q7XA', user_id: 'U0BOTUSER', token: 'bot' };
const USER = { bot_id: '', user_id: 'U04HZ9K1M', token: 'user' };

test('a bot token is recognised by its bot_id', () => {
  const [verdict, detail] = tokenClass({ ok: true, user_id: 'U0BOTUSER',
    bot_id: 'B01J9Q7XA' });
  assert.equal(verdict, 'bot');
  assert.match(detail, /B01J9Q7XA/);
});

test('a user token is the absence of a bot_id', () => {
  const [verdict, detail] = tokenClass({ ok: true, user_id: 'U04HZ9K1M' });
  assert.equal(verdict, 'user');
  assert.match(detail, /no bot_id/);
});

test('a failed auth.test is never guessed at', () => {
  assert.equal(tokenClass({ ok: false, error: 'invalid_auth' })[0], 'unknown');
  assert.equal(tokenClass(null)[0], 'unknown');
});

test('authorship reads ids and not the displayed name', () => {
  assert.deepEqual(authorship({ bot_id: 'B1', app_id: 'A1', username: 'Totally Dana' }),
    ['app', 'B1']);
  assert.deepEqual(authorship({ bot_id: 'B1' }), ['bot-no-app', 'B1']);
  assert.deepEqual(authorship({ user: 'U9' }), ['human', 'U9']);
  assert.deepEqual(authorship({}), ['unknown', '']);
  assert.deepEqual(authorship('not a message'), ['unknown', '']);
});

test('a bot token can change its own bot message', () => {
  const [verdict, detail] = mutability({ bot_id: 'B01J9Q7XA', app_id: 'A1' }, BOT);
  assert.equal(verdict, 'yours');
  assert.match(detail, /own bot/);
});

test('a username override does not change who wrote it', () => {
  const message = { bot_id: 'B01J9Q7XA', app_id: 'A1', subtype: 'bot_message',
    username: 'Deploy Bot', icons: { emoji: ':rocket:' } };
  const [verdict, detail] = mutability(message, BOT);
  assert.equal(verdict, 'yours');
  assert.match(detail, /icon_emoji/);
});

test('a user token cannot edit your own bot message', () => {
  const [verdict, detail] = mutability({ bot_id: 'B01J9Q7XA', app_id: 'A1' },
    { ...USER, bot_id: 'B01J9Q7XA' });
  assert.equal(verdict, 'wrong-token-class');
  assert.match(detail, /installed the app/);
});

test('a bot token cannot edit a message a person wrote', () => {
  const [verdict, detail] = mutability({ user: 'U04HZ9K1M' }, BOT);
  assert.equal(verdict, 'human');
  assert.match(detail, /admin user token/);
});

test('a user token owns only that user messages', () => {
  assert.equal(mutability({ user: 'U04HZ9K1M' }, USER)[0], 'yours');
  const [verdict, detail] = mutability({ user: 'U7SOMEONE' }, USER);
  assert.equal(verdict, 'another-user');
  assert.match(detail, /chat\.delete needs an admin/);
});

test('another app message is named as a different app', () => {
  const [verdict, detail] = mutability({ bot_id: 'B99OTHER', app_id: 'A99' }, BOT);
  assert.equal(verdict, 'other-app');
  assert.match(detail, /not a permission/);
});

test('a bot_id with no app_id is the webhook dead end', () => {
  const [verdict, detail] = mutability({ bot_id: 'B77HOOK', subtype: 'bot_message' },
    BOT);
  assert.equal(verdict, 'webhook-or-legacy');
  assert.match(detail, /never a pair to store/);
});

test('a message with no author fields is not assumed editable', () => {
  assert.equal(mutability({ text: 'hi' }, BOT)[0], 'unknown-author');
});

test('the response_url window is open inside thirty minutes', () => {
  const [verdict, detail] = responseUrlPlan(120, 1);
  assert.equal(verdict, 'usable');
  assert.match(detail, /replace_original/);
});

test('the response_url expires on the clock', () => {
  const [verdict, detail] = responseUrlPlan(1801, 0);
  assert.equal(verdict, 'expired');
  assert.match(detail, /1800/);
});

test('the response_url expires on uses independently of the clock', () => {
  const [verdict, detail] = responseUrlPlan(1, 5);
  assert.equal(verdict, 'exhausted');
  assert.match(detail, /clock has not run out/);
});

test('an edit with no interaction behind it says so', () => {
  assert.equal(responseUrlPlan(null)[0], 'no-interaction');
  assert.equal(responseUrlPlan(undefined)[0], 'no-interaction');
  assert.equal(responseUrlPlan('soon')[0], 'no-interaction');
});

test('summarise counts by reason rather than by pass or fail', () => {
  const messages = [{ bot_id: 'B01J9Q7XA', app_id: 'A1' },
    { bot_id: 'B01J9Q7XA', app_id: 'A1' },
    { user: 'U04HZ9K1M' },
    { bot_id: 'B99OTHER', app_id: 'A99' }];
  const counts = summarise(messages, BOT);
  assert.equal(counts.get('yours').count, 2);
  assert.equal(counts.get('human').count, 1);
  assert.equal(counts.get('other-app').count, 1);
});

test('an empty channel produces no findings', () => {
  assert.equal(summarise([], BOT).size, 0);
});

FAQ

Which scope do I need so my bot can edit this message?

There is not one. chat:write lets a token write as itself, and Slack has no scope that lets one identity edit another identity's message. This is the single most useful thing to know about this error, because the reflex on any Slack failure is to add a scope and reinstall, and here that costs an hour and changes nothing. The fix is always about which identity is making the call.

We post as the user for one feature and as the bot for the rest. Why does cleanup fail?

Because those are two authors. A message posted on a user token belongs to that person and renders with their name; the bot is a separate identity with its own bot_id and cannot edit it. This is the wrong-token-class verdict in both directions, and the usual fix is to make the identity that posts a given message also be the identity that later changes it.

Does posting with username or icon_emoji change who owns the message?

No, and that is the good news in this note. Those overrides change the rendering only. The message still carries your bot's bot_id, so your bot token can still edit and delete it. If a message looks like it was posted by something else and your token can change it anyway, this is why.

How do I delete a message a person posted, for example one containing a secret?

With a user token belonging to someone allowed to delete other people's messages, holding chat:write, and only if the workspace's settings permit it. There is no bot-token path. If you are building a secret-scrubbing tool, plan on a user token from an admin from the start, because every message such a tool is meant to remove will be written by a human.

The message came from an incoming webhook. Can I edit it with the right token?

No token can. A webhook message has no app-level identity that chat.update will accept, and the webhook itself answered your post with the plain text ok rather than a ts, so there was never a valid pair to store. If you need to edit later, the send has to move to chat.postMessage with a bot token; there is no smaller change that works.

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.