Diagnostic Slack
message_not_found: the ts no longer resolves to a message
The bot posts a status message and then edits it every thirty seconds as the deploy progresses. It has done this for months. This afternoon the first edit comes back {"ok": false, "error": "message_not_found"} and every edit after it does the same, so the message in the channel is frozen at starting forever.
Nothing about the token changed, the bot is still in the channel, and the message is visibly right there on screen. chat.update identifies a message by the pair (channel, ts), both have to match exactly, and one of the two no longer does.
message_not_found from chat.update or chat.delete means the pair you handed Slack does not name a message. There are only four ways that happens, and they want four different repairs: the message was deleted, the channel is not the channel the message is in, the message came from an incoming webhook so there was never a usable ts to store, or the ts itself has been damaged in storage.
That last one is the one worth owning. A Slack ts is a string. It looks like a number, so at some point it passes through a JSON parser, a database column, a message queue or a JavaScript Number and comes out as a float. 1755000000.000200 becomes 1755000000.0002, the trailing zeros are gone, and that value will never match anything again. The message is still there. The key is not.
All four are decidable with a read. Ask conversations.history for the single-message window latest=<ts>&oldest=<ts>&inclusive=true: one message back means the pair is fine, none back means it is not. Then widen the window by a second and look at the neighbours, because the neighbour whose ts re-pads to yours is the proof that this was a float and not a deletion.
The problem in plain words
What makes this expensive is that all four causes produce the same five words, and the first thing everyone does is the wrong thing: they open Slack, see the message sitting in the channel, and conclude that Slack is lying. So the investigation starts from the assumption that the API is broken, which is the one hypothesis that is never true.
The float story is worth walking through slowly, because it is the one that survives code review. The bot posts, gets back {"ok": true, "ts": "1755000000.000200"}, and stores it. If the storage layer is a JSON column, a Redis value round-tripped through JSON.parse, a Kafka payload, or an ORM field typed as numeric, the string is now a double. Doubles have no memory of trailing zeros. It comes back as 1755000000.0002, which formats as "1755000000.0002", which is not the key of any message in any workspace. Everything about the code looks right. The value looks right. It is off by four characters nobody can see.
The wrong-channel case is quieter and more embarrassing. A ts is only unique within a channel, so a ts captured in #deploys and used against #deploys-staging resolves to nothing, and there is no error that says so. Apps that thread replies across channels, or that store a ts without the channel beside it and reconstruct the channel from configuration later, hit this the first time somebody renames or splits a channel.
The webhook case is a design dead end rather than a bug. An incoming webhook returns the literal body ok and no JSON, so there is no ts at all. Code that posts through a webhook and later wants to edit is not one field away from working; it is on the wrong API. There is nothing to store and nothing to update.
And the ordinary case, someone deleted the message, is real and is usually the least likely of the four. It deserves a fallback in the send path rather than an investigation, which is why the script separates it from the other three instead of grouping everything under gone.
Why it happens
The pair is the identity, so half of it going wrong is invisible. Slack never tells you which half. A wrong channel and a wrong ts produce byte-identical errors, and the only way to tell them apart is to read the channel and see what is actually in it.
A ts must be a string at every layer, not just in your code. The variable in the function that calls chat.update is almost always fine. The damage happens in the layer nobody thinks of as a layer: the JSON column, the queue payload, the cache, the test fixture. The script checks the shape of stored values because that is where the mistake lives.
The re-padded neighbour is the proof. Reporting this ts does not resolve is not enough to change anyone's mind. Reporting this ts does not resolve, and the message at 1755000000.000200 two rows away is the one you meant ends the argument in one line, which is why the script widens the window after a miss instead of stopping at the first read.
Re-padding is a repair for exactly one failure mode and the script refuses to over-claim it. Adding trailing zeros back is safe when trailing zeros are what was lost. If the fraction is longer than six digits, or the seconds part is the wrong length, something else happened, and the script says it cannot decide rather than inventing a value that would silently edit the wrong message.
Deleted is a runtime condition, not an incident. People delete messages. An app that edits a long-lived message needs a fallback that posts a fresh one and re-captures the ts, and the script separates deleted from the three genuine bugs so that this one row does not get fixed by adding a retry to all four.
The authorship question is a different note. If the message resolves and Slack still refuses the edit, the error is cant_update_message, not message_not_found, and it is about which identity wrote it. This script deliberately stops at existence.
The fix, as a flow
The red box is the second one, and every box after it is a consequence rather than a cause. Nothing failed at the moment anything was written: a value went into a column typed as a number and came out four invisible characters shorter, and the first thing anybody sees is an edit refusing an hour later. The fix branch sorts four causes that share one error string, and the two at the bottom are the two that most often get merged into one row and should not be.
How to fix it
Check the shape of every stored ts before you call anything
Run with --pairs state.json --shape-only and no token. ts_fault reads each stored value and reports not-a-string for a JSON number, unpadded for a fraction shorter than six digits, and no-fraction for an integer. Most of the time this ends the investigation before a single API call is made.
Ask for the one-message window
window_params builds the conversations.history query that asks about exactly one message: latest and oldest both set to the ts, inclusive=true, limit=1. One message back with a matching ts means chat.update would have worked and your problem is elsewhere.
Widen the window when the exact read comes back empty
The second read asks for the second either side. resolution then sorts the answer: an exact hit is found, a neighbour whose ts re-pads to yours is precision-loss and names the real value, an empty window is absent, and a window full of other people's messages is neighbours-only, which is what a ts from a different channel looks like.
Read absent and neighbours-only as different findings
absent means nothing at all exists around that moment in this channel, which is consistent with a deletion. neighbours-only means the channel was busy at that instant and none of it was yours, which is consistent with the ts having been captured somewhere else. Collapsing the two into gone loses the only distinction that changes what you do.
Take the repair the script prints rather than the one it implies
For precision-loss the printed repair is a storage change, not a data fix: make the column text, stop parsing the value, and re-capture. Patching the one broken row leaves the parser that broke it in place, and the next message posted with a trailing zero breaks the same way.
Check whether the message could ever have had a ts
If the code path that posted it was an incoming webhook, stop here. A webhook answers with the three characters ok and no timestamp, so there is no valid pair to store and no update path to fix. Moving that path to chat.postMessage is the change, and no amount of storage hygiene substitutes for it.
How to check it worked
Run it against the state your app stores. The finding you want is a named ts and a named cause, not a count.
python3 slack_update_message_not_found.py --pairs deploy-state.json
# pairs 4 pair(s) read from deploy-state.json
# shape unpadded C024BE91L ts=1755000000.0002 the fraction has 4 digits and
# Slack always sends 6; trailing zeros are what a float drops
# resolve precision-loss C024BE91L ts=1755000000.0002 the message is at
# 1755000000.000200, two rows from where you looked
# shape no-fraction C07J4K2QT ts=1755000398 an integer, so the fraction is gone
# resolve absent C07J4K2QT ts=1755000398 nothing exists around that second
# resolve found C024BE91L ts=1755000411.418500
# verdict 3 of 4 pair(s) would fail chat.update
# repair: store ts as text at every layer, not only in the calling function
# repair: store the channel id beside it; a ts is unique per channel, not per workspace
The full code
Four pure functions and two reads. ts_fault is the whole shape argument in one table and needs no network. repad is the repair, and it is written to refuse rather than to guess, because a re-padded value that is wrong would edit somebody else's message. same_ts exists so the comparison is done in one place rather than three. resolution turns a history window into the four causes, and the two that look alike are the two it keeps apart.
"""Find the (channel, ts) pairs your app can no longer update.
Read only. Nothing here posts, edits, deletes or schedules a message: the
question is whether the message chat.update would aim at still resolves, and
conversations.history answers that with a read. It also checks the shape of the
ts values you have stored, because the most common reason a ts stops matching
is not that the message went away, it is that a JSON parser somewhere turned
the string into a float and the trailing zeros went with it.
"""
import argparse
import json
import logging
import os
import re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_update_message_not_found")
API = "https://slack.com/api/"
# Slack sends ten digits, a dot, and exactly six digits. Anything else has been
# through something that reformatted it.
TS_SHAPE = re.compile(r"^(\d{10,})\.(\d+)$")
FRACTION_DIGITS = 6
def ts_fault(value):
"""Is this stored ts still the exact string Slack handed you? Pure.
Returns (verdict, detail). usable is the only verdict that means the value
can be sent as it stands. Everything else is a value that will produce
message_not_found no matter what the channel is or whether the message is
still there.
"""
if value is None or (isinstance(value, str) and not value.strip()):
return ("missing", "no ts stored. An incoming webhook answers with the literal "
"text ok and no timestamp, so a webhook post has no ts to "
"keep and no update path at all")
if isinstance(value, bool):
return ("not-a-string", "a boolean, which is what a truthiness check stored "
"instead of the value")
if isinstance(value, float):
return ("not-a-string", "a float. Slack sends a ts as a string, and a double "
"cannot hold %r without dropping trailing zeros; "
"the value in your store is already damaged"
% value)
if isinstance(value, int):
return ("not-a-string", "an integer, so the fractional part is gone entirely. "
"A ts is a string and the six digits after the dot are "
"part of the key")
text = str(value).strip()
if re.search(r"\d[eE][+-]?\d", text):
return ("scientific", "%s is in exponent notation, which is what a float "
"formatter does to a value it decided was large. Nothing "
"will match this" % text)
m = TS_SHAPE.match(text)
if not m:
return ("not-a-timestamp", "%s is not shaped like a Slack ts. Slack sends ten "
"digits, a dot and six digits" % text[:40])
fraction = m.group(2)
if len(fraction) < FRACTION_DIGITS:
return ("unpadded", "the fraction has %d digit(s) and Slack always sends %d. "
"Trailing zeros are exactly what a float round trip drops, "
"so this value was a number at some point"
% (len(fraction), FRACTION_DIGITS))
if len(fraction) > FRACTION_DIGITS:
return ("over-precise", "the fraction has %d digits and Slack sends %d. "
"Something appended precision that was never there"
% (len(fraction), FRACTION_DIGITS))
return ("usable", "%s is the shape Slack sends" % text)
def repad(value):
"""Normalise a ts back to the string form, or return "" rather than guess. Pure.
Re-padding is a repair for exactly one failure: a float dropped trailing
zeros. That is recoverable because zeros carry no information. A fraction
that is longer than six digits and does not end in zeros was damaged some
other way, and inventing a value there would aim chat.update at a message
that is not the one you meant, so this refuses.
"""
if value is None or isinstance(value, bool):
return ""
if isinstance(value, float):
text = "%.6f" % value
elif isinstance(value, int):
text = "%d.%s" % (value, "0" * FRACTION_DIGITS)
else:
text = str(value).strip()
m = TS_SHAPE.match(text)
if not m:
return ""
seconds, fraction = m.group(1), m.group(2)
if len(fraction) < FRACTION_DIGITS:
return "%s.%s" % (seconds, fraction.ljust(FRACTION_DIGITS, "0"))
if len(fraction) > FRACTION_DIGITS:
head, tail = fraction[:FRACTION_DIGITS], fraction[FRACTION_DIGITS:]
return "%s.%s" % (seconds, head) if set(tail) == {"0"} else ""
return "%s.%s" % (seconds, fraction)
def same_ts(a, b):
"""Do these two values name the same message? Pure.
Kept in one place because the comparison is the whole bug: a == b on the
raw values is what every caller writes and what silently answers no.
"""
left, right = repad(a), repad(b)
return bool(left) and left == right
def window_params(channel, ts, spread=0.0, limit=1):
"""The conversations.history query that asks about one message. Pure.
With spread=0 this is the exact probe: latest and oldest both the ts, and
inclusive, so a single message comes back or nothing does. With a spread it
widens to the neighbours, which is the read that tells a deletion apart
from a ts that lost its trailing zeros.
"""
anchor = repad(ts) or str(ts)
if not spread:
return {"channel": str(channel), "latest": anchor, "oldest": anchor,
"inclusive": "true", "limit": "1"}
try:
centre = float(anchor)
except (TypeError, ValueError):
centre = 0.0
return {"channel": str(channel),
"latest": "%.6f" % (centre + spread),
"oldest": "%.6f" % (max(0.0, centre - spread)),
"inclusive": "true", "limit": str(limit)}
def resolution(ts, window):
"""What did the history window say about this ts? Pure.
Returns (verdict, detail, matched). The two verdicts worth separating are
absent and neighbours-only: an empty window is consistent with the message
having been deleted, while a window full of other messages means the
channel was busy at that instant and none of it was yours, which is what a
ts captured in a different channel looks like from here.
"""
stored = str(ts)
rows = [str((m or {}).get("ts") or "") for m in window or []]
if stored in rows:
return ("found", "%s resolves; chat.update would have worked on this pair"
% stored, stored)
for candidate in rows:
if same_ts(stored, candidate):
return ("precision-loss",
"the message is at %s and you stored %s. Those are the same six "
"digits with the trailing zeros dropped, which is a float round "
"trip and not a deletion" % (candidate, stored), candidate)
if not rows:
return ("absent", "nothing at all exists around that second in this channel. "
"That is consistent with the message having been deleted",
"")
return ("neighbours-only",
"%d other message(s) sit around that second and none of them is yours. A ts "
"is unique within one channel, so a ts captured somewhere else looks exactly "
"like this" % len(rows), "")
def load_pairs(path):
"""Read the (channel, ts) pairs the app stores. Deliberately forgiving."""
raw = json.loads(open(path, encoding="utf-8").read())
if isinstance(raw, dict):
raw = raw.get("pairs") or raw.get("messages") or []
out = []
for row in raw or []:
if isinstance(row, dict):
out.append((row.get("channel") or row.get("channel_id") or "",
row.get("ts") if "ts" in row else row.get("message_ts")))
elif isinstance(row, (list, tuple)) and len(row) == 2:
out.append((row[0], row[1]))
return out
def report_shape(channel, ts, verdict, detail):
bad = verdict != "usable"
(log.warning if bad else log.info)("shape %-14s %s ts=%s %s", verdict,
channel or "?", ts, detail)
return 1 if bad else 0
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--pairs", default="",
help="a JSON file of {channel, ts} records your app stores")
ap.add_argument("--channel", default="", help="one channel, used with --ts")
ap.add_argument("--ts", action="append", default=[],
help="a stored ts to check in --channel; repeatable")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
ap.add_argument("--shape-only", action="store_true",
help="check the stored values and make no API call at all")
ap.add_argument("--spread", type=float, default=1.0,
help="seconds either side to search after an exact miss")
ap.add_argument("--limit", type=int, default=20,
help="neighbours to read in the widened window")
args = ap.parse_args()
pairs = load_pairs(args.pairs) if args.pairs else [
(args.channel, t) for t in args.ts]
if not pairs:
log.error("pass --pairs FILE, or --channel with one or more --ts")
return 2
if args.pairs:
log.info("pairs %d pair(s) read from %s", len(pairs), args.pairs)
broken = 0
unusable = set()
for channel, ts in pairs:
verdict, detail = ts_fault(ts)
if report_shape(channel, ts, verdict, detail):
broken += 1
if verdict in ("missing", "not-a-timestamp", "scientific"):
unusable.add((str(channel), str(ts)))
if args.shape_only:
if broken:
log.warning(" repair: store ts as text at every layer, not only in the "
"function that calls chat.update")
return 1
log.info("verdict clean every stored ts is the shape Slack sends")
return 0
token = os.environ.get(args.token_env)
if not token:
log.error("set %s, or pass --shape-only to check the stored values with no token",
args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
who = s.get(API + "auth.test", timeout=30).json()
if who.get("ok") is not True:
log.error("auth.test unavailable %s", who.get("error"))
return 2
log.info("identity %s in %s", who.get("user_id"), who.get("team"))
failures = 0
for channel, ts in pairs:
if not channel or (str(channel), str(ts)) in unusable:
continue
exact = s.get(API + "conversations.history", timeout=30,
params=window_params(channel, ts)).json()
if exact.get("ok") is not True:
log.warning("history unavailable %s: %s", channel, exact.get("error"))
continue
window = exact.get("messages") or []
verdict, detail, _ = resolution(ts, window)
if verdict != "found":
wide = s.get(API + "conversations.history", timeout=30,
params=window_params(channel, ts, args.spread,
args.limit)).json()
if wide.get("ok") is True:
verdict, detail, _ = resolution(ts, wide.get("messages") or [])
if verdict == "found":
log.info("resolve %-14s %s ts=%s", verdict, channel, ts)
continue
failures += 1
log.warning("resolve %-14s %s ts=%s %s", verdict, channel, ts, detail)
if failures or broken:
log.warning("verdict %d pair(s) would fail chat.update", failures or broken)
log.warning(" repair: store ts as text at every layer, not only in the "
"function that calls chat.update")
log.warning(" repair: store the channel id beside it; a ts is unique per "
"channel, not per workspace")
log.warning(" repair: tolerate a genuine deletion by posting a fresh message "
"and re-capturing the ts it returns")
return 1
log.info("verdict clean every pair still resolves to a message")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find the (channel, ts) pairs your app can no longer update.
*
* Read only. Nothing here posts, edits, deletes or schedules a message: the
* question is whether the message chat.update would aim at still resolves, and
* conversations.history answers that with a read. It also checks the shape of
* the stored ts values, which matters more in this runtime than in any other:
* JSON.parse turns "1755000000.000200" into the number 1755000000.0002 and
* there is no warning anywhere.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Slack sends ten digits, a dot, and exactly six digits. Anything else has
// been through something that reformatted it.
const TS_SHAPE = /^(\d{10,})\.(\d+)$/;
const FRACTION_DIGITS = 6;
/**
* Is this stored ts still the exact string Slack handed you? Pure.
* Returns [verdict, detail]; usable is the only one that can be sent as it is.
*/
export function tsFault(value) {
if (value === null || value === undefined
|| (typeof value === 'string' && !value.trim())) {
return ['missing', 'no ts stored. An incoming webhook answers with the literal '
+ 'text ok and no timestamp, so a webhook post has no ts to keep and no '
+ 'update path at all'];
}
if (typeof value === 'boolean') {
return ['not-a-string', 'a boolean, which is what a truthiness check stored '
+ 'instead of the value'];
}
if (typeof value === 'number') {
return ['not-a-string', `a JSON number. JSON.parse reads "1755000000.000200" as `
+ `${value} and the trailing zeros are gone; the value in your store is `
+ 'already damaged'];
}
const text = String(value).trim();
if (/\d[eE][+-]?\d/.test(text)) {
return ['scientific', `${text} is in exponent notation, which is what a number `
+ 'formatter does to a value it decided was large. Nothing will match this'];
}
const m = TS_SHAPE.exec(text);
if (!m) {
return ['not-a-timestamp', `${text.slice(0, 40)} is not shaped like a Slack ts. `
+ 'Slack sends ten digits, a dot and six digits'];
}
const fraction = m[2];
if (fraction.length < FRACTION_DIGITS) {
return ['unpadded', `the fraction has ${fraction.length} digit(s) and Slack always `
+ `sends ${FRACTION_DIGITS}. Trailing zeros are exactly what a float round trip `
+ 'drops, so this value was a number at some point'];
}
if (fraction.length > FRACTION_DIGITS) {
return ['over-precise', `the fraction has ${fraction.length} digits and Slack sends `
+ `${FRACTION_DIGITS}. Something appended precision that was never there`];
}
return ['usable', `${text} is the shape Slack sends`];
}
/**
* Normalise a ts back to the string form, or return '' rather than guess. Pure.
*
* Re-padding repairs exactly one failure: a float dropped trailing zeros, and
* zeros carry no information. Anything else would aim chat.update at a message
* that is not the one you meant, so this refuses instead.
*/
export function repad(value) {
if (value === null || value === undefined || typeof value === 'boolean') return '';
let text;
if (typeof value === 'number') {
if (!Number.isFinite(value)) return '';
text = value.toFixed(FRACTION_DIGITS);
} else {
text = String(value).trim();
}
const m = TS_SHAPE.exec(text);
if (!m) return '';
const [, seconds, fraction] = m;
if (fraction.length < FRACTION_DIGITS) {
return `${seconds}.${fraction.padEnd(FRACTION_DIGITS, '0')}`;
}
if (fraction.length > FRACTION_DIGITS) {
const tail = fraction.slice(FRACTION_DIGITS);
return /^0+$/.test(tail) ? `${seconds}.${fraction.slice(0, FRACTION_DIGITS)}` : '';
}
return `${seconds}.${fraction}`;
}
/** Do these two values name the same message? Pure. */
export function sameTs(a, b) {
const left = repad(a);
const right = repad(b);
return Boolean(left) && left === right;
}
/**
* The conversations.history query that asks about one message. Pure.
* With spread 0 it is the exact probe; with a spread it reaches the neighbours.
*/
export function windowParams(channel, ts, spread = 0, limit = 1) {
const anchor = repad(ts) || String(ts);
if (!spread) {
return { channel: String(channel), latest: anchor, oldest: anchor,
inclusive: 'true', limit: '1' };
}
const centre = Number.isFinite(Number(anchor)) ? Number(anchor) : 0;
return {
channel: String(channel),
latest: (centre + spread).toFixed(6),
oldest: Math.max(0, centre - spread).toFixed(6),
inclusive: 'true',
limit: String(limit),
};
}
/**
* What did the history window say about this ts? Pure.
* Returns [verdict, detail, matched].
*/
export function resolution(ts, window) {
const stored = String(ts);
const rows = (window ?? []).map((m) => String((m ?? {}).ts ?? ''));
if (rows.includes(stored)) {
return ['found', `${stored} resolves; chat.update would have worked on this pair`,
stored];
}
for (const candidate of rows) {
if (sameTs(stored, candidate)) {
return ['precision-loss', `the message is at ${candidate} and you stored `
+ `${stored}. Those are the same six digits with the trailing zeros dropped, `
+ 'which is a float round trip and not a deletion', candidate];
}
}
if (!rows.length) {
return ['absent', 'nothing at all exists around that second in this channel. That '
+ 'is consistent with the message having been deleted', ''];
}
return ['neighbours-only', `${rows.length} other message(s) sit around that second `
+ 'and none of them is yours. A ts is unique within one channel, so a ts captured '
+ 'somewhere else looks exactly like this', ''];
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
function argAll(args, name) {
const out = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
}
return out;
}
function loadPairs(raw) {
let rows = raw;
if (rows && !Array.isArray(rows) && typeof rows === 'object') {
rows = rows.pairs ?? rows.messages ?? [];
}
const out = [];
for (const row of rows ?? []) {
if (Array.isArray(row) && row.length === 2) out.push([row[0], row[1]]);
else if (row && typeof row === 'object') {
out.push([row.channel ?? row.channel_id ?? '',
'ts' in row ? row.ts : row.message_ts]);
}
}
return out;
}
async function main() {
const args = process.argv.slice(2);
const pairsFile = arg(args, '--pairs', '');
const channel = arg(args, '--channel', '');
const shapeOnly = args.includes('--shape-only');
const spread = Number(arg(args, '--spread', '1'));
const limit = arg(args, '--limit', '20');
const pairs = pairsFile
? loadPairs(JSON.parse(await readFile(pairsFile, 'utf8')))
: argAll(args, '--ts').map((t) => [channel, t]);
if (!pairs.length) {
console.error('pass --pairs FILE, or --channel with one or more --ts');
process.exitCode = 2;
return;
}
if (pairsFile) console.log(`pairs ${pairs.length} pair(s) read from ${pairsFile}`);
let broken = 0;
const unusable = new Set();
for (const [ch, ts] of pairs) {
const [verdict, detail] = tsFault(ts);
const line = `shape ${verdict.padEnd(14)} ${ch || '?'} ts=${ts} ${detail}`;
if (verdict === 'usable') console.log(line);
else {
console.warn(line);
broken += 1;
if (['missing', 'not-a-timestamp', 'scientific'].includes(verdict)) {
unusable.add(`${ch}\u0000${ts}`);
}
}
}
if (shapeOnly) {
if (broken) {
console.warn(' repair: store ts as text at every layer, not only in the function '
+ 'that calls chat.update');
process.exitCode = 1;
} else {
console.log('verdict clean every stored ts is the shape Slack sends');
}
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv}, or pass --shape-only to check the stored values `
+ 'with no token');
process.exitCode = 2;
return;
}
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;
}
console.log(`identity ${who.user_id} in ${who.team}`);
const read = async (params) => {
const qs = new URLSearchParams(params).toString();
return (await fetch(`${API}conversations.history?${qs}`, { headers })).json();
};
let failures = 0;
for (const [ch, ts] of pairs) {
if (!ch || unusable.has(`${ch}\u0000${ts}`)) continue;
const exact = await read(windowParams(ch, ts));
if (exact.ok !== true) {
console.warn(`history unavailable ${ch}: ${exact.error}`);
continue;
}
let [verdict, detail] = resolution(ts, exact.messages ?? []);
if (verdict !== 'found') {
const wide = await read(windowParams(ch, ts, spread, limit));
if (wide.ok === true) [verdict, detail] = resolution(ts, wide.messages ?? []);
}
if (verdict === 'found') {
console.log(`resolve ${verdict.padEnd(14)} ${ch} ts=${ts}`);
continue;
}
failures += 1;
console.warn(`resolve ${verdict.padEnd(14)} ${ch} ts=${ts} ${detail}`);
}
if (failures || broken) {
console.warn(`verdict ${failures || broken} pair(s) would fail chat.update`);
console.warn(' repair: store ts as text at every layer, not only in the function '
+ 'that calls chat.update');
console.warn(' repair: store the channel id beside it; a ts is unique per channel, '
+ 'not per workspace');
console.warn(' repair: tolerate a genuine deletion by posting a fresh message and '
+ 're-capturing the ts it returns');
process.exitCode = 1;
} else {
console.log('verdict clean every pair still resolves to a message');
}
}
// 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 assertions that earn their place are about refusing to guess. repad must recover a value that only lost zeros and must return nothing at all when the damage could have been something else, because a confident wrong answer here edits a stranger's message. resolution must call an empty window absent and a busy one neighbours-only, since those two are the deletion and the wrong-channel case and they are the only pair in this script that a reader could reasonably conflate. And the float tests use the literal values a real round trip produces rather than made-up ones.
from slack_update_message_not_found import (repad, resolution, same_ts, ts_fault,
window_params)
def msg(ts):
return {"ts": ts, "text": "x"}
def test_the_shape_slack_sends_is_the_only_usable_verdict():
verdict, detail = ts_fault("1755000000.000200")
assert verdict == "usable"
assert "1755000000.000200" in detail
def test_a_float_is_named_as_damaged_rather_than_as_a_type_complaint():
verdict, detail = ts_fault(1755000000.0002)
assert verdict == "not-a-string"
assert "trailing zeros" in detail
def test_an_integer_ts_has_lost_the_whole_fraction():
verdict, detail = ts_fault(1755000398)
assert verdict == "not-a-string"
assert "fractional part is gone" in detail
def test_a_string_that_lost_its_trailing_zeros_is_unpadded():
verdict, detail = ts_fault("1755000000.0002")
assert verdict == "unpadded"
assert "4 digit(s)" in detail
def test_a_fraction_longer_than_six_is_over_precise():
assert ts_fault("1755000000.00020012")[0] == "over-precise"
def test_an_integer_string_with_no_dot_is_not_a_timestamp():
assert ts_fault("1755000398")[0] == "not-a-timestamp"
assert ts_fault("hello")[0] == "not-a-timestamp"
def test_exponent_notation_is_its_own_verdict():
assert ts_fault("1.7550000000002e9")[0] == "scientific"
def test_a_missing_ts_names_the_webhook_dead_end():
for value in (None, "", " "):
verdict, detail = ts_fault(value)
assert verdict == "missing"
assert "webhook" in detail
def test_a_boolean_is_not_quietly_stringified():
assert ts_fault(True)[0] == "not-a-string"
def test_repad_restores_only_the_zeros_a_float_drops():
assert repad("1755000000.0002") == "1755000000.000200"
assert repad(1755000000.0002) == "1755000000.000200"
assert repad(1755000398) == "1755000398.000000"
assert repad("1755000000.000200") == "1755000000.000200"
def test_repad_refuses_when_the_damage_could_have_been_anything_else():
assert repad("1755000000.00020012") == ""
assert repad("not a ts") == ""
assert repad(None) == ""
assert repad(True) == ""
def test_repad_trims_a_tail_that_is_only_zeros():
assert repad("1755000000.0002000") == "1755000000.000200"
def test_same_ts_sees_through_the_round_trip_that_equality_misses():
assert "1755000000.0002" != "1755000000.000200"
assert same_ts("1755000000.0002", "1755000000.000200") is True
assert same_ts(1755000000.0002, "1755000000.000200") is True
assert same_ts("1755000000.000200", "1755000000.000201") is False
assert same_ts("", "1755000000.000200") is False
def test_the_exact_probe_asks_for_one_message_at_that_instant():
params = window_params("C1", "1755000000.0002")
assert params["latest"] == params["oldest"] == "1755000000.000200"
assert params["inclusive"] == "true"
assert params["limit"] == "1"
def test_the_widened_probe_reaches_the_neighbours():
params = window_params("C1", "1755000000.000200", spread=1.0, limit=20)
assert params["oldest"] == "1754999999.000200"
assert params["latest"] == "1755000001.000200"
assert params["limit"] == "20"
def test_an_exact_hit_is_found():
verdict, detail, matched = resolution("1755000000.000200",
[msg("1755000000.000200")])
assert verdict == "found"
assert matched == "1755000000.000200"
def test_a_neighbour_that_repads_to_yours_is_the_proof_it_was_a_float():
verdict, detail, matched = resolution(
"1755000000.0002",
[msg("1755000000.111111"), msg("1755000000.000200")])
assert verdict == "precision-loss"
assert matched == "1755000000.000200"
assert "not a deletion" in detail
def test_an_empty_window_is_absent_and_reads_as_a_deletion():
verdict, detail, _ = resolution("1755000000.000200", [])
assert verdict == "absent"
assert "deleted" in detail
def test_a_busy_window_with_nothing_of_yours_is_the_wrong_channel_shape():
verdict, detail, _ = resolution("1755000000.000200",
[msg("1755000000.111111"), msg("1755000000.222222")])
assert verdict == "neighbours-only"
assert "unique within one channel" in detail
def test_absent_and_neighbours_only_are_never_collapsed():
assert resolution("1755000000.000200", [])[0] \
!= resolution("1755000000.000200", [msg("1755000000.999999")])[0]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
repad, resolution, sameTs, tsFault, windowParams,
} from './slack-update-message-not-found.mjs';
const msg = (ts) => ({ ts, text: 'x' });
test('the shape Slack sends is the only usable verdict', () => {
const [verdict, detail] = tsFault('1755000000.000200');
assert.equal(verdict, 'usable');
assert.match(detail, /1755000000\.000200/);
});
test('JSON.parse really does destroy the value this note is about', () => {
assert.equal(JSON.parse('{"ts": 1755000000.000200}').ts, 1755000000.0002);
assert.notEqual(String(JSON.parse('{"ts": 1755000000.000200}').ts),
'1755000000.000200');
});
test('a JSON number is named as damaged rather than as a type complaint', () => {
const [verdict, detail] = tsFault(1755000000.0002);
assert.equal(verdict, 'not-a-string');
assert.match(detail, /trailing zeros are gone/);
});
test('a string that lost its trailing zeros is unpadded', () => {
const [verdict, detail] = tsFault('1755000000.0002');
assert.equal(verdict, 'unpadded');
assert.match(detail, /4 digit\(s\)/);
});
test('a fraction longer than six is over precise', () => {
assert.equal(tsFault('1755000000.00020012')[0], 'over-precise');
});
test('an integer string with no dot is not a timestamp', () => {
assert.equal(tsFault('1755000398')[0], 'not-a-timestamp');
assert.equal(tsFault('hello')[0], 'not-a-timestamp');
});
test('exponent notation is its own verdict', () => {
assert.equal(tsFault('1.7550000000002e9')[0], 'scientific');
});
test('a missing ts names the webhook dead end', () => {
for (const value of [null, undefined, '', ' ']) {
const [verdict, detail] = tsFault(value);
assert.equal(verdict, 'missing');
assert.match(detail, /webhook/);
}
});
test('a boolean is not quietly stringified', () => {
assert.equal(tsFault(true)[0], 'not-a-string');
});
test('repad restores only the zeros a float drops', () => {
assert.equal(repad('1755000000.0002'), '1755000000.000200');
assert.equal(repad(1755000000.0002), '1755000000.000200');
assert.equal(repad(1755000398), '1755000398.000000');
assert.equal(repad('1755000000.000200'), '1755000000.000200');
});
test('repad refuses when the damage could have been anything else', () => {
assert.equal(repad('1755000000.00020012'), '');
assert.equal(repad('not a ts'), '');
assert.equal(repad(null), '');
assert.equal(repad(true), '');
assert.equal(repad(Number.NaN), '');
});
test('repad trims a tail that is only zeros', () => {
assert.equal(repad('1755000000.0002000'), '1755000000.000200');
});
test('sameTs sees through the round trip that equality misses', () => {
assert.notEqual('1755000000.0002', '1755000000.000200');
assert.equal(sameTs('1755000000.0002', '1755000000.000200'), true);
assert.equal(sameTs(1755000000.0002, '1755000000.000200'), true);
assert.equal(sameTs('1755000000.000200', '1755000000.000201'), false);
assert.equal(sameTs('', '1755000000.000200'), false);
});
test('the exact probe asks for one message at that instant', () => {
const params = windowParams('C1', '1755000000.0002');
assert.equal(params.latest, '1755000000.000200');
assert.equal(params.oldest, '1755000000.000200');
assert.equal(params.inclusive, 'true');
assert.equal(params.limit, '1');
});
test('the widened probe reaches the neighbours', () => {
const params = windowParams('C1', '1755000000.000200', 1, 20);
assert.equal(params.oldest, '1754999999.000200');
assert.equal(params.latest, '1755000001.000200');
assert.equal(params.limit, '20');
});
test('an exact hit is found', () => {
const [verdict, , matched] = resolution('1755000000.000200',
[msg('1755000000.000200')]);
assert.equal(verdict, 'found');
assert.equal(matched, '1755000000.000200');
});
test('a neighbour that repads to yours is the proof it was a float', () => {
const [verdict, detail, matched] = resolution('1755000000.0002',
[msg('1755000000.111111'), msg('1755000000.000200')]);
assert.equal(verdict, 'precision-loss');
assert.equal(matched, '1755000000.000200');
assert.match(detail, /not a deletion/);
});
test('an empty window is absent and reads as a deletion', () => {
const [verdict, detail] = resolution('1755000000.000200', []);
assert.equal(verdict, 'absent');
assert.match(detail, /deleted/);
});
test('a busy window with nothing of yours is the wrong channel shape', () => {
const [verdict, detail] = resolution('1755000000.000200',
[msg('1755000000.111111'), msg('1755000000.222222')]);
assert.equal(verdict, 'neighbours-only');
assert.match(detail, /unique within one channel/);
});
test('absent and neighbours-only are never collapsed', () => {
assert.notEqual(resolution('1755000000.000200', [])[0],
resolution('1755000000.000200', [msg('1755000000.999999')])[0]);
});
FAQ
The message is right there in the channel. How can it not be found?
Because Slack is not looking for the message, it is looking for the pair. chat.update takes a channel and a ts and matches both exactly. If either one is wrong the message you can see is not the message you asked about, and message_not_found is the honest answer. The most common wrong half is the ts, and the most common reason it is wrong is that it stopped being a string somewhere in your storage.
Why does the ts have to be a string when it looks exactly like a number?
Because it is a key, not a quantity. Nothing ever adds one ts to another. The moment it becomes a double it loses trailing zeros, so 1755000000.000200 comes back as 1755000000.0002, and a value that differs from the key by four invisible characters matches nothing. Slack sends it as a string for this reason, and every layer of your stack has to keep it that way, not just the variable in the calling function.
Can I just re-pad the ts back to six digits and retry?
For the specific case where trailing zeros were dropped, yes, and this script does exactly that to find the message and prove the cause. As a fix, no. The re-pad works because zeros carry no information, and the moment the damage is anything else the repaired value points at a different message, which is a far worse outcome than an error. Fix the storage and re-capture the ts.
We post through an incoming webhook and cannot update anything. Is this the same problem?
It is a different problem with the same error. A webhook responds with the plain text ok and no JSON, so there was never a ts to store. There is no field to fix and no scope to add; the message is unaddressable by design. If you need to edit it later, that path has to move to chat.postMessage with a bot token.
The message resolves fine and Slack still will not update it. Now what?
Then the error will not be message_not_found. If the pair resolves and the edit is refused, the answer is cant_update_message, which is about which identity authored the message rather than whether it exists. That is a separate check and a separate note, and this script deliberately stops at existence so the two do not get confused.
Related field notes
- when the message resolves and is still not yours
- the other half of the pair, wrong in a different way
- why the refusal arrived looking like a success
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- chat.update method reference - Slack Docs
- conversations.history method reference - Slack Docs
- Retrieving messages - Slack Docs
- chat.delete method reference - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.