Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions backlot/routers/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,19 @@ def _uid(email: str) -> str:
return SERVICE_USER_ID if email == SERVICE_EMAIL else synth.slack_user_id(email)


def _parent_uid(root) -> str:
"""The `parent_user_id` a reply in ``root``'s thread carries: the `user` the root is served
with, which is ``USLACKBOT`` once the root is deleted with a reply still under it.

Measured against slack.com on 2026-10-30 and 2026-10-31, over a user and a bot token: the
`chat.postMessage` answer to a reply carries the root's poster whether `thread_ts` names the
root or a reply under it, and `conversations.replies` lists each reply with the same id. With
the root deleted, a reply posted to the placeholder or to a reply under it, and each reply
`conversations.replies` listed, carried ``USLACKBOT`` instead.
"""
return SLACKBOT_ID if root["subtype"] == "tombstone" else _uid(root["author_email"])


def _handle(email: str) -> str:
"""A corpus email as Slack's handle — the `name` a user object carries and the `user`
`auth.test` reports for the same person."""
Expand Down Expand Up @@ -1643,7 +1656,7 @@ async def conversations_replies(request: Request):
latest = store.slack_latest_reply_ts(conn, name, root["ts"], ids) if rc else None
ru = store.slack_reply_authors(conn, name, root["ts"], ids)
ruids = [_uid(e) for e in ru[:5]]
parent_uid = _uid(root["author_email"])
parent_uid = _parent_uid(root)
messages = [
_message(
x,
Expand Down Expand Up @@ -2185,6 +2198,7 @@ async def chat_post_message(request: Request):
parent = (
store.document_by_key(conn, "slack", (name, wanted), ids) if wanted is not None else None
)
parent_uid = None
if parent is not None:
# Slack threads a reply under the ROOT, not under whichever message was named: replying to
# a reply puts it in the same thread. The corpus stores that as the root's own ts, which is
Expand All @@ -2197,6 +2211,9 @@ async def chat_post_message(request: Request):
# `conversations.replies` answers the root alone and the message reads back with no
# thread at all — a thread this API made and cannot show.
store.patch_document(conn, "slack", (name, parent["ts"]), "thread_ts", thread_ts)
root = store.document_by_key(conn, "slack", (name, thread_ts), ids)
if root is not None:
parent_uid = _parent_uid(root)
now = int(time.time())
ts = store.slack_next_ts(conn, name, now)
store.insert_document(
Expand All @@ -2223,7 +2240,9 @@ async def chat_post_message(request: Request):
"ok": True,
"channel": synth.slack_channel_id(name),
"ts": ts,
"message": _with_root(conn, name, ids, caller, row, _message(row)),
"message": _with_root(
conn, name, ids, caller, row, _message(row, parent_user_id=parent_uid)
),
}


Expand Down
44 changes: 43 additions & 1 deletion tests/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5830,7 +5830,7 @@ def test_editing_a_corpus_message_leaves_one_search_hit(wclient, tokens, ro_conn
assert stale["total"] == len(stale["matches"])


def test_posting_with_thread_ts_replies_in_that_thread(wclient, tokens, ro_conn):
def test_posting_with_thread_ts_replies_in_that_thread(wclient, tokens, admin_h, ro_conn):
# Ignoring `thread_ts` gave an agent told to reply in a thread a top-level message and a
# `200 ok` — a wrong answer with no error in it.
root_ts = ro_conn.execute(
Expand Down Expand Up @@ -5875,6 +5875,45 @@ def test_posting_with_thread_ts_replies_in_that_thread(wclient, tokens, ro_conn)
assert [m["ts"] for m in thread] == [alone, answered]
assert thread[0]["thread_ts"] == alone and thread[0]["reply_count"] == 1

# `_parent_uid`'s rule, in the answer to the post and on each reply `conversations.replies`
# lists. The root's poster and the replier differ in each row.
def post(headers, text, **extra):
data = {"channel": cid, "text": text, **extra}
return wclient.post("/slack/api/chat.postMessage", headers=headers, data=data).json()

by = {"admin": admin_h, "ava": h}
ava = synth.slack_user_id("ava@acme.com")
rows = [
# root's poster, replier, what `thread_ts` names, `reply_broadcast`, root deleted first
(("admin", "ava", "root", None, False), "USERVICE0"),
(("ava", "admin", "root", None, False), ava),
(("admin", "ava", "reply", None, False), "USERVICE0"),
(("ava", "admin", "reply", None, False), ava),
(("admin", "ava", "root", "", False), "USERVICE0"),
(("admin", "ava", "root", "false", False), "USERVICE0"),
(("admin", "ava", "root", "FALSE", False), "USERVICE0"),
(("admin", "ava", "root", "0", False), "USERVICE0"),
(("admin", "ava", "root", None, True), slack.SLACKBOT_ID),
(("ava", "admin", "reply", None, True), slack.SLACKBOT_ID),
]
got = []
for (root_by, reply_by, names, broadcast, deleted), _ in rows:
root = post(by[root_by], "root")["ts"]
target = root
if names == "reply" or deleted:
first = post(by[reply_by], "first reply", thread_ts=root)["ts"]
target = first if names == "reply" else root
if deleted:
gone = {"channel": cid, "ts": root}
wclient.post("/slack/api/chat.delete", headers=by[root_by], data=gone)
flags = {} if broadcast is None else {"reply_broadcast": broadcast}
echo = post(by[reply_by], "reply", thread_ts=target, **flags)["message"]
listed = wclient.post(
"/slack/api/conversations.replies", headers=h, data={"channel": cid, "ts": root}
).json()["messages"]
got.append((echo.get("parent_user_id"), {m.get("parent_user_id") for m in listed[1:]}))
assert got == [(uid, {uid}) for _, uid in rows]


def test_replying_to_a_reply_lands_in_the_same_thread(wclient, tokens, ro_conn):
# Slack threads under the ROOT, not under whichever message was named.
Expand All @@ -5894,6 +5933,8 @@ def test_replying_to_a_reply_lands_in_the_same_thread(wclient, tokens, ro_conn):
"/slack/api/conversations.replies", headers=h, data={"channel": cid, "ts": root_ts}
).json()["messages"]
assert posted["ts"] in [m["ts"] for m in thread]
root_user = next(m["user"] for m in thread if m["ts"] == root_ts)
assert posted["message"]["parent_user_id"] == root_user


def _broadcast_thread(wclient, h, cid):
Expand Down Expand Up @@ -6006,6 +6047,7 @@ def test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_ref
).json()
assert j["ok"] is True, value
assert "thread_ts" not in j["message"] and "root" not in j["message"], value
assert "parent_user_id" not in j["message"], value
assert (j["message"].get("subtype"), "team" in j["message"]) == (
subtype,
subtype is None,
Expand Down
Loading