From c6453293e8a80035bfb8e1e549d16eaf2a96429c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=9C=E4=BA=91?= Date: Fri, 30 Oct 2026 09:34:01 +0800 Subject: [PATCH 1/2] slack: a reply posted with reply_broadcast is listed in conversations.history as a thread_broadcast carrying its root chat.postMessage with thread_ts and a true reply_broadcast stores the reply as subtype thread_broadcast. conversations.history lists it beside top-level messages, and the echo, history and conversations.replies carry its root, with no team or parent_user_id. Measured against slack.com on 2026-10-30. Closes #1150. --- backlot/fidelity/baseline/slack.json | 6 +- backlot/routers/slack.py | 58 +++++++++++++++++-- backlot/store.py | 9 ++- tests/test_slack.py | 83 +++++++++++++++++++++++++++- 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/backlot/fidelity/baseline/slack.json b/backlot/fidelity/baseline/slack.json index 33d2769fe..6857f3651 100644 --- a/backlot/fidelity/baseline/slack.json +++ b/backlot/fidelity/baseline/slack.json @@ -3,7 +3,7 @@ "endpoints": [ "https://docs.slack.dev/reference/methods.md" ], - "measured": "2026-10-29", + "measured": "2026-10-30", "operations": [ { "operation": "admin.analytics.getFile", @@ -1522,10 +1522,6 @@ "kind": "missing_param", "severity": "gap" }, - "reply_broadcast": { - "kind": "missing_param", - "severity": "gap" - }, "token": { "kind": "missing_param", "severity": "gap" diff --git a/backlot/routers/slack.py b/backlot/routers/slack.py index 9e1a093d6..f55be45a6 100644 --- a/backlot/routers/slack.py +++ b/backlot/routers/slack.py @@ -178,7 +178,12 @@ class SlackFileSearch(_SlackOk): # The write surface. Each lists what Backlot actually READS, the way every other `_P_` here does: # a param the vendor documents but Backlot ignores stays an acknowledged gap in the fidelity # baseline rather than a declaration that promises behaviour it does not have. -_P_POST_MESSAGE = [qp("channel", required=True), qp("text", required=True), qp("thread_ts")] +_P_POST_MESSAGE = [ + qp("channel", required=True), + qp("text", required=True), + qp("thread_ts"), + qp("reply_broadcast", "boolean"), +] # `text` is NOT required here, where `chat.postMessage` requires it (`chat_post_ephemeral`). _P_POST_EPHEMERAL = [qp("channel", required=True), qp("user", required=True), qp("text")] _P_UPDATE = [qp("channel", required=True), qp("ts", required=True), qp("text", required=True)] @@ -1559,6 +1564,7 @@ def _in_window(r) -> bool: subscribed=_subscribed(caller, r["author_email"], ru), ) ) + _with_root(conn, name, ids, caller, r, messages[-1]) cursor = next_cursor(offset, len(rows), total) body = { "ok": True, @@ -1652,6 +1658,8 @@ async def conversations_replies(request: Request): ) for x in rows ] + for x, m in zip(rows, messages, strict=True): + _with_root(conn, name, ids, caller, x, m) return {"ok": True, "messages": messages, "has_more": False} @@ -2150,7 +2158,7 @@ async def chat_post_message(request: Request): `blocks` the message reads back with are composed from its `text` (`synth.slack_blocks`), as a corpus message's are, rather than being the ones the caller sent. Arguments the vendor documents and Backlot ignores outright: `as_user`, `username`, `icon_emoji`, `icon_url`, - `reply_broadcast`, `unfurl_links`, `unfurl_media`, `parse`, `link_names`, `mrkdwn`. + `unfurl_links`, `unfurl_media`, `parse`, `link_names`, `mrkdwn`. `backlot diff` cannot see them -- it compares query parameters and Slack's spec declares these as form fields (see `backlot/fidelity/operations.py`) -- so this list is the record until that gap is closed. @@ -2203,7 +2211,11 @@ async def chat_post_message(request: Request): "created_ts": now, "thread_ts": thread_ts, "thread_seq": thread_seq, - "subtype": None, + # A reply also sent to the channel. Measured against slack.com on 2026-10-30: it is + # listed in `conversations.history` and carries this subtype and a `root`. + "subtype": "thread_broadcast" + if thread_ts and _flag(request, "reply_broadcast") + else None, }, ) row = store.document_by_key(conn, "slack", (name, ts)) @@ -2211,7 +2223,7 @@ async def chat_post_message(request: Request): "ok": True, "channel": synth.slack_channel_id(name), "ts": ts, - "message": _message(row), + "message": _with_root(conn, name, ids, caller, row, _message(row)), } @@ -3005,6 +3017,35 @@ def _member_count(request: Request, conn, name: str) -> int: return store.count_slack_channel_members(conn, name) +def _flag(request: Request, key: str) -> bool: + """A boolean argument: absent, empty, `0` and `false` (any case) are off.""" + return (_param(request, key) or "").lower() not in ("", "0", "false") + + +def _with_root(conn, name: str, ids, caller: Caller, row, m: dict) -> dict: + """Add `root` to a `thread_broadcast` message: its thread's root, as history lists it. + + Measured against slack.com on 2026-10-30: the echo, `conversations.history` and + `conversations.replies` all carry it, with the root's `reply_count` and `latest_reply`. + """ + if row["subtype"] != "thread_broadcast": + return m + root = store.document_by_key(conn, "slack", (name, row["thread_ts"]), ids) + if root is None: + return m + rc = store.slack_reply_count(conn, name, root["ts"], ids) + ru = store.slack_reply_authors(conn, name, root["ts"], ids) if rc else [] + m["root"] = _message( + root, + reply_count=rc, + latest_reply=store.slack_latest_reply_ts(conn, name, root["ts"], ids) if rc else None, + reply_users=[_uid(e) for e in ru[:5]], + reply_users_count=len(ru), + subscribed=_subscribed(caller, root["author_email"], ru), + ) + return m + + def _subscribed(caller: Caller, root_author: str | None, reply_authors) -> bool: """Whether the CALLER follows this thread — Slack subscribes you to one you started or replied in. An admin/service token is not a person and follows nothing, the same reasoning that makes @@ -3047,7 +3088,7 @@ def _message( # app is what posted it either way. It is not a subtype that says so: a bot-token post # has none. A corpus message stands for one a Slack client posted and carries none. m["bot_id"] = BOT_ID - if not row["subtype"]: + if row["subtype"] in (None, "thread_broadcast"): # `client_msg_id` is minted by the CLIENT that posted, and `blocks` is what that client # composed, so neither belongs on a message Slack itself generated — measured: a # channel_join carries only type/user/text/ts/subtype. `team` is left on both: it is @@ -3089,6 +3130,11 @@ def _message( m["parent_user_id"] = SLACKBOT_ID m.pop("team", None) m.pop("bot_id", None) + broadcast = row["subtype"] == "thread_broadcast" + if broadcast: + # Measured against slack.com on 2026-10-30: a reply posted with `reply_broadcast` carries + # no `team` and no `parent_user_id`, in the echo, in history and in replies. + m.pop("team", None) if row["thread_ts"]: # part of a thread m["thread_ts"] = row["thread_ts"] if row["thread_seq"] == 0 and reply_count > 0: # thread root @@ -3109,7 +3155,7 @@ def _message( "is_locked": False, } ) - elif row["thread_seq"] > 0 and parent_user_id: # a reply + elif row["thread_seq"] > 0 and parent_user_id and not broadcast: # a reply m["parent_user_id"] = parent_user_id return m diff --git a/backlot/store.py b/backlot/store.py index afa09aa20..fb1aef3e3 100644 --- a/backlot/store.py +++ b/backlot/store.py @@ -3855,12 +3855,13 @@ def list_slack_top_level( *, newest_first=False, ) -> list[sqlite3.Row]: - """Top-level (thread-root/standalone) messages in a channel, oldest first unless + """Top-level (thread-root/standalone) messages in a channel, and the replies also sent to it + (`thread_broadcast`, which real Slack lists in history), oldest first unless ``newest_first``. ``ts_lo``/``ts_hi`` bound ``created_ts`` for a time-windowed conversations.history, so a day window is an indexed range rather than the whole channel filtered in Python. Widen the bounds by ±1s — the public ts carries a sub-second fraction — and re-check the exact float window in the caller.""" - sql = f"SELECT * FROM {merged_source(conn, 'slack')} WHERE channel = ? AND thread_seq = 0" + sql = f"SELECT * FROM {merged_source(conn, 'slack')} WHERE channel = ? AND (thread_seq = 0 OR subtype = 'thread_broadcast')" params: list = [channel] if ts_lo is not None or ts_hi is not None: lo = ts_lo if ts_lo is not None else -(1 << 62) @@ -3874,9 +3875,7 @@ def list_slack_top_level( def count_slack_top_level(conn, channel, visible_ids=None) -> int: - sql = ( - f"SELECT COUNT(*) FROM {merged_source(conn, 'slack')} WHERE channel = ? AND thread_seq = 0" - ) + sql = f"SELECT COUNT(*) FROM {merged_source(conn, 'slack')} WHERE channel = ? AND (thread_seq = 0 OR subtype = 'thread_broadcast')" params: list = [channel] clause, cparams = _acl_clause("slack", visible_ids=visible_ids) sql += clause diff --git a/tests/test_slack.py b/tests/test_slack.py index f5b95ae01..5b488b1a1 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -4741,7 +4741,7 @@ def test_a_root_whose_last_reply_goes_is_a_standalone_message_again(wclient, tok #: `backlot mcp --source slack` turns into tool arguments, so an agent can only send what is #: here — and a mutation run showed every one of them could be dropped with the suite green. _DECLARED = { - "chat.postMessage": {"channel", "text", "thread_ts"}, + "chat.postMessage": {"channel", "text", "thread_ts", "reply_broadcast"}, "chat.postEphemeral": {"channel", "user", "text"}, "chat.update": {"channel", "ts", "text"}, "chat.delete": {"channel", "ts"}, @@ -5896,6 +5896,87 @@ def test_replying_to_a_reply_lands_in_the_same_thread(wclient, tokens, ro_conn): assert posted["ts"] in [m["ts"] for m in thread] +def _broadcast_thread(wclient, h, cid): + """A parent, a plain reply, a reply with `reply_broadcast`, then a later top-level post.""" + + def post(text, **extra): + data = {"channel": cid, "text": text, **extra} + return wclient.post("/slack/api/chat.postMessage", headers=h, data=data).json() + + parent = post("bc-parent")["ts"] + reply = post("bc-reply", thread_ts=parent)["ts"] + echo = post("bc-broadcast", thread_ts=parent, reply_broadcast="true") + after = post("bc-after")["ts"] + names = {parent: "parent", reply: "reply", echo["ts"]: "broadcast", after: "after"} + return parent, echo, names + + +def _history(wclient, h, cid, **extra): + data = {"channel": cid, "oldest": extra.pop("oldest"), "inclusive": "true", **extra} + return wclient.post("/slack/api/conversations.history", headers=h, data=data).json() + + +# The order is real's: `_with_root` records the measurement. +@pytest.mark.parametrize( + ("limit", "pages"), + [ + (10, [["after", "broadcast", "parent"]]), + (1, [["parent"], ["broadcast"], ["after"]]), + ], +) +def test_a_broadcast_reply_is_listed_in_history(wclient, tokens, limit, pages): + h = _uh(tokens, "ava@acme.com") + cid = synth.slack_channel_id("incidents") + parent, _, names = _broadcast_thread(wclient, h, cid) + got, cursor = [], None + while True: + extra = {"limit": limit, **({"cursor": cursor} if cursor else {})} + page = _history(wclient, h, cid, oldest=parent, **extra) + got.append([names[m["ts"]] for m in page["messages"]]) + cursor = page.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + assert got == pages + + +def test_a_broadcast_reply_carries_its_subtype_and_root(wclient, tokens): + h = _uh(tokens, "ava@acme.com") + cid = synth.slack_channel_id("incidents") + parent, echo, names = _broadcast_thread(wclient, h, cid) + hist = _history(wclient, h, cid, oldest=parent)["messages"] + replies = wclient.post( + "/slack/api/conversations.replies", headers=h, data={"channel": cid, "ts": parent} + ).json()["messages"] + by_name = { + "chat.postMessage": echo["message"], + "conversations.history": next(m for m in hist if names[m["ts"]] == "broadcast"), + "conversations.replies": next(m for m in replies if names[m["ts"]] == "broadcast"), + } + for method, m in by_name.items(): + assert m["subtype"] == "thread_broadcast", method + assert m["thread_ts"] == parent, method + assert "team" not in m and "parent_user_id" not in m, method + root = m["root"] + assert (root["ts"], root["text"], root["reply_count"]) == (parent, "bc-parent", 2), method + assert root["latest_reply"] == echo["ts"], method + plain = next(m for m in replies if names[m["ts"]] == "reply") + assert "subtype" not in plain and "root" not in plain + + +def test_deleting_a_broadcast_reply_takes_it_out_of_history(wclient, tokens): + # Real leaves no placeholder and the root counts one reply fewer (measured with `_with_root`'s). + h = _uh(tokens, "ava@acme.com") + cid = synth.slack_channel_id("incidents") + parent, echo, names = _broadcast_thread(wclient, h, cid) + gone = wclient.post( + "/slack/api/chat.delete", headers=h, data={"channel": cid, "ts": echo["ts"]} + ).json() + assert gone["ok"] is True + hist = _history(wclient, h, cid, oldest=parent)["messages"] + assert [names[m["ts"]] for m in hist] == ["after", "parent"] + assert hist[1]["reply_count"] == 1 + + def test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_refused( wclient, tokens ): From 101880cede2dbbfce62f3923812995afb17b9847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=9C=E4=BA=91?= Date: Fri, 30 Oct 2026 20:36:20 +0800 Subject: [PATCH 2/2] slack: reply_broadcast counts beside any thread_ts sent, and root is on chat.update, reactions.get and reactions.list --- backlot/routers/slack.py | 72 +++++++++++++++++++++++----------------- backlot/store.py | 6 ++-- tests/test_slack.py | 58 ++++++++++++++++++++++---------- 3 files changed, 87 insertions(+), 49 deletions(-) diff --git a/backlot/routers/slack.py b/backlot/routers/slack.py index f55be45a6..dee14007b 100644 --- a/backlot/routers/slack.py +++ b/backlot/routers/slack.py @@ -1499,7 +1499,7 @@ async def conversations_history(request: Request): # Measured against slack.com/api on 2026-10-18: only absent, empty, "0" and # case-insensitive "false" exclude the bound. Other strings, including "00", "no" # and whitespace-padded "false", include it. Repeated query values use the last one. - inclusive = (_param(request, "inclusive") or "").lower() not in ("", "0", "false") + inclusive = _flag(request, "inclusive") def _ts_to_us(ts_str: str) -> int: """Convert a Slack ts string "SSSSSSSSSS.MMMMMM" to total microseconds.""" @@ -2158,10 +2158,9 @@ async def chat_post_message(request: Request): `blocks` the message reads back with are composed from its `text` (`synth.slack_blocks`), as a corpus message's are, rather than being the ones the caller sent. Arguments the vendor documents and Backlot ignores outright: `as_user`, `username`, `icon_emoji`, `icon_url`, - `unfurl_links`, `unfurl_media`, `parse`, `link_names`, `mrkdwn`. - `backlot diff` cannot see them -- it compares query parameters and Slack's spec declares - these as form fields (see `backlot/fidelity/operations.py`) -- so this list is the record - until that gap is closed. + `unfurl_links`, `unfurl_media`, `parse`, `link_names`, `mrkdwn`. `backlot diff` cannot see + them -- it compares query parameters and Slack's spec declares these as form fields (see + `backlot/fidelity/operations.py`) -- so this list is the record until that gap is closed. """ conn = auth.conn(request) caller, err = _caller_or_error(request) @@ -2211,10 +2210,11 @@ async def chat_post_message(request: Request): "created_ts": now, "thread_ts": thread_ts, "thread_seq": thread_seq, - # A reply also sent to the channel. Measured against slack.com on 2026-10-30: it is - # listed in `conversations.history` and carries this subtype and a `root`. + # The flag counts beside any `thread_ts` sent: measured against slack.com on 2026-10-30, + # `0`, an empty one and one naming no message each posted the message top-level with + # this subtype all the same, and a post with no `thread_ts` had no subtype. "subtype": "thread_broadcast" - if thread_ts and _flag(request, "reply_broadcast") + if _param(request, "thread_ts") is not None and _flag(request, "reply_broadcast") else None, }, ) @@ -2361,26 +2361,30 @@ async def chat_update(request: Request): author = _writer(caller) store.patch_document(conn, "slack", (name, ts), "content", text) # Real Slack stamps an edited message with `edited: {user, ts}`, which is what a client renders - # "(edited)" from. `_message` already serves the column, so filling it is the whole of it. + # "(edited)" from. `_message` already serves the column, so filling it is the whole of it. A + # `thread_broadcast` gets none: measured against slack.com on 2026-10-30, an edited one + # carried no `edited` in the echo, in history or in replies, over a user and a bot token. # # The ADDRESS is stored, not the id: `_edited` renders one from the other when it serves the # message, so an id here would be rendered a second time — the same representation a corpus # states and `reactions.users` keeps. - store.patch_document( - conn, - "slack", - (name, ts), - "edited", - json.dumps( - {"user": author, "ts": synth.slack_fmt_ts(int(time.time()), f"edit:{name}:{ts}")} - ), - ) + if row["subtype"] != "thread_broadcast": + store.patch_document( + conn, + "slack", + (name, ts), + "edited", + json.dumps( + {"user": author, "ts": synth.slack_fmt_ts(int(time.time()), f"edit:{name}:{ts}")} + ), + ) updated = store.document_by_key(conn, "slack", (name, ts)) # The message an edit echoes is not the message a read answers: measured against slack.com on # 2026-10-21, `chat.update` carried neither `ts` nor `reactions` for a threaded message with a # reaction, where `conversations.replies` answered the same message carrying both. `ts` is on # the answer beside it either way. echoed = {k: v for k, v in _message(updated).items() if k not in ("ts", "reactions")} + _with_root(conn, name, auth.visible_ids(request, caller), caller, updated, echoed) return { "ok": True, "channel": synth.slack_channel_id(name), @@ -2761,8 +2765,8 @@ async def reactions_get(request: Request): Errors quoted from the spec's ``default`` response: `bad_timestamp`, `file_not_found`, `file_comment_not_found`, `message_not_found`, `no_item_specified`. - The message is built by `_message`, the same builder conversations.history uses, so a client - that reads a reaction here and the message there cannot get two shapes for one message. + The message is built by `_message` and `_with_root`, as conversations.history builds it, so a + client that reads a reaction here and the message there cannot get two shapes for one message. """ conn = auth.conn(request) caller, err = _caller_or_error(request) @@ -2773,6 +2777,7 @@ async def reactions_get(request: Request): ) if err is not None: return err + message = _with_root(conn, name, auth.visible_ids(request, caller), caller, row, _message(row)) return { "ok": True, "type": "message", @@ -2781,7 +2786,7 @@ async def reactions_get(request: Request): # that answers one: measured against slack.com on 2026-10-21, `conversations.history` and # `conversations.replies` carry none. So it goes on at these two call sites rather than # in `_message`, which all of them share. - "message": _message(row) | {"permalink": _permalink(name, row)}, + "message": message | {"permalink": _permalink(name, row)}, } @@ -2857,7 +2862,8 @@ async def reactions_list(request: Request): { "type": "message", "channel": synth.slack_channel_id(channel), - "message": _message(row) | {"permalink": _permalink(channel, row)}, + "message": _with_root(conn, channel, ids, caller, row, _message(row)) + | {"permalink": _permalink(channel, row)}, } ) page = items[offset : offset + limit] @@ -3018,15 +3024,20 @@ def _member_count(request: Request, conn, name: str) -> int: def _flag(request: Request, key: str) -> bool: - """A boolean argument: absent, empty, `0` and `false` (any case) are off.""" + """A boolean argument as `conversations_history` measured `inclusive`: absent, empty, `0` and + `false` in any case are off, and every other value is on. `reply_broadcast` read the same as a + form field on slack.com on 2026-10-30, `00`, `no`, `null` and ` false` among the values on.""" return (_param(request, key) or "").lower() not in ("", "0", "false") def _with_root(conn, name: str, ids, caller: Caller, row, m: dict) -> dict: - """Add `root` to a `thread_broadcast` message: its thread's root, as history lists it. + """Add `root` to a `thread_broadcast` in a thread: its thread's root, as history lists it. - Measured against slack.com on 2026-10-30: the echo, `conversations.history` and - `conversations.replies` all carry it, with the root's `reply_count` and `latest_reply`. + Measured against slack.com on 2026-10-30: the `chat.postMessage` and `chat.update` echoes, + `conversations.history`, `conversations.replies`, `reactions.get` and `reactions.list` all + carry it, with the root's `reply_count`, `reply_users` and `latest_reply`, and a broadcast + posted top-level (`chat_post_message`) carries none. The broadcast is one of the root's + replies, so `reply_count` is at least 1 here. """ if row["subtype"] != "thread_broadcast": return m @@ -3034,11 +3045,11 @@ def _with_root(conn, name: str, ids, caller: Caller, row, m: dict) -> dict: if root is None: return m rc = store.slack_reply_count(conn, name, root["ts"], ids) - ru = store.slack_reply_authors(conn, name, root["ts"], ids) if rc else [] + ru = store.slack_reply_authors(conn, name, root["ts"], ids) m["root"] = _message( root, reply_count=rc, - latest_reply=store.slack_latest_reply_ts(conn, name, root["ts"], ids) if rc else None, + latest_reply=store.slack_latest_reply_ts(conn, name, root["ts"], ids), reply_users=[_uid(e) for e in ru[:5]], reply_users_count=len(ru), subscribed=_subscribed(caller, root["author_email"], ru), @@ -3132,8 +3143,9 @@ def _message( m.pop("bot_id", None) broadcast = row["subtype"] == "thread_broadcast" if broadcast: - # Measured against slack.com on 2026-10-30: a reply posted with `reply_broadcast` carries - # no `team` and no `parent_user_id`, in the echo, in history and in replies. + # Measured against slack.com on 2026-10-30: a `thread_broadcast` carries no `team`, one + # posted top-level included, and a reply among them no `parent_user_id`, in each method + # `_with_root` names. m.pop("team", None) if row["thread_ts"]: # part of a thread m["thread_ts"] = row["thread_ts"] diff --git a/backlot/store.py b/backlot/store.py index fb1aef3e3..d9cf1e105 100644 --- a/backlot/store.py +++ b/backlot/store.py @@ -3856,8 +3856,10 @@ def list_slack_top_level( newest_first=False, ) -> list[sqlite3.Row]: """Top-level (thread-root/standalone) messages in a channel, and the replies also sent to it - (`thread_broadcast`, which real Slack lists in history), oldest first unless - ``newest_first``. ``ts_lo``/``ts_hi`` bound ``created_ts`` for a time-windowed + (`thread_broadcast`), each at its own ts, oldest first unless ``newest_first``: measured + against slack.com on 2026-10-30, history listed a broadcast between its root and a later + post, on `limit=1` pages too, and a deleted one not at all, with no placeholder and its root + one reply fewer. ``ts_lo``/``ts_hi`` bound ``created_ts`` for a time-windowed conversations.history, so a day window is an indexed range rather than the whole channel filtered in Python. Widen the bounds by ±1s — the public ts carries a sub-second fraction — and re-check the exact float window in the caller.""" diff --git a/tests/test_slack.py b/tests/test_slack.py index 5b488b1a1..e551d8b3a 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -5911,12 +5911,12 @@ def post(text, **extra): return parent, echo, names -def _history(wclient, h, cid, **extra): - data = {"channel": cid, "oldest": extra.pop("oldest"), "inclusive": "true", **extra} +def _history(wclient, h, cid, oldest, **extra): + data = {"channel": cid, "oldest": oldest, "inclusive": "true", **extra} return wclient.post("/slack/api/conversations.history", headers=h, data=data).json() -# The order is real's: `_with_root` records the measurement. +# The order is real's (`store.list_slack_top_level`). @pytest.mark.parametrize( ("limit", "pages"), [ @@ -5943,28 +5943,39 @@ def test_a_broadcast_reply_carries_its_subtype_and_root(wclient, tokens): h = _uh(tokens, "ava@acme.com") cid = synth.slack_channel_id("incidents") parent, echo, names = _broadcast_thread(wclient, h, cid) + + def call(method, **data): + return wclient.post(f"/slack/api/{method}", headers=h, data=data).json() + + assert call("reactions.add", channel=cid, timestamp=echo["ts"], name="eyes")["ok"] is True + edited = call("chat.update", channel=cid, ts=echo["ts"], text="bc-broadcast, edited") hist = _history(wclient, h, cid, oldest=parent)["messages"] - replies = wclient.post( - "/slack/api/conversations.replies", headers=h, data={"channel": cid, "ts": parent} - ).json()["messages"] + replies = call("conversations.replies", channel=cid, ts=parent)["messages"] + listed = call("reactions.list")["items"] by_name = { "chat.postMessage": echo["message"], + "chat.update": edited["message"], "conversations.history": next(m for m in hist if names[m["ts"]] == "broadcast"), "conversations.replies": next(m for m in replies if names[m["ts"]] == "broadcast"), + "reactions.get": call("reactions.get", channel=cid, timestamp=echo["ts"])["message"], + "reactions.list": next(i["message"] for i in listed if i["message"]["ts"] == echo["ts"]), } + ava = synth.slack_user_id("ava@acme.com") for method, m in by_name.items(): assert m["subtype"] == "thread_broadcast", method assert m["thread_ts"] == parent, method - assert "team" not in m and "parent_user_id" not in m, method + assert "blocks" in m, method + assert "team" not in m and "parent_user_id" not in m and "edited" not in m, method root = m["root"] assert (root["ts"], root["text"], root["reply_count"]) == (parent, "bc-parent", 2), method + assert (root["reply_users"], root["reply_users_count"]) == ([ava], 1), method assert root["latest_reply"] == echo["ts"], method plain = next(m for m in replies if names[m["ts"]] == "reply") assert "subtype" not in plain and "root" not in plain def test_deleting_a_broadcast_reply_takes_it_out_of_history(wclient, tokens): - # Real leaves no placeholder and the root counts one reply fewer (measured with `_with_root`'s). + # Real leaves no placeholder and the root counts one reply fewer (`store.list_slack_top_level`). h = _uh(tokens, "ava@acme.com") cid = synth.slack_channel_id("incidents") parent, echo, names = _broadcast_thread(wclient, h, cid) @@ -5981,17 +5992,30 @@ def test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_ref wclient, tokens ): # `chat_post_message` records the measurement: a well-formed `thread_ts` that names no message - # posts top-level, an empty one does the same, and one real cannot read is refused. + # posts top-level, an empty one or `0` does the same, `reply_broadcast` makes each of those a + # `thread_broadcast` and a post with no `thread_ts` a plain message, and one real cannot read is + # refused. h = _uh(tokens, "ava@acme.com") cid = synth.slack_channel_id("incidents") - for value in ("1700000000.000001", ""): - j = wclient.post( - "/slack/api/chat.postMessage", - headers=h, - data={"channel": cid, "text": "orphan", "thread_ts": value}, - ).json() - assert j["ok"] is True, value - assert "thread_ts" not in j["message"], value + for value in ("1700000000.000001", "", "0"): + for extra, subtype in (({}, None), ({"reply_broadcast": "true"}, "thread_broadcast")): + j = wclient.post( + "/slack/api/chat.postMessage", + headers=h, + data={"channel": cid, "text": "orphan", "thread_ts": value, **extra}, + ).json() + assert j["ok"] is True, value + assert "thread_ts" not in j["message"] and "root" not in j["message"], value + assert (j["message"].get("subtype"), "team" in j["message"]) == ( + subtype, + subtype is None, + ), value + alone = wclient.post( + "/slack/api/chat.postMessage", + headers=h, + data={"channel": cid, "text": "orphan", "reply_broadcast": "true"}, + ).json() + assert "subtype" not in alone["message"] # Refused outside the window `_thread_ts_error` records, and the message carries the value # as real reads it, in microseconds. import time as _time