You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Upstream added on_message_updated / on_message_deleted callbacks with Slack as the reference emitter. The Python Slack adapter drops message_deleted and uses message_changed only to cache link unfurls, so bots cannot mirror edits or deletes. This ports the Slack half of #788 (plus #846's async previous_message parse) on top of #196's core handlers, with one thread-id helper so a message, its edit and its delete resolve to the same thread.
Upstream changes
4ac04551 feat(chat): add message update and delete lifecycle callbacks (#788) — chat@4.37.0. Slack half: removes message_deleted from the ignored subtypes and adds handleMessageDeleted; turns handleMessageChanged into a dispatcher that keeps the unfurl cache, ignores inner tombstone, ignores hidden updates unless edited.ts or text differs from previous_message, ignores "no content change" updates (locale detection), and forwards previous_message; adds parseSlackTimestamp and threadIdForMessageEvent. Core skips the bot's own edits (post+edit streaming calls chat.update per delta).
864d9222 fix(slack): keep alert attachment content on normalized messages (#846) — chat@4.39.0. Only the handleMessageChanged hunk applies: previousMessage becomes a lazy async factory parsed with parseSlackMessage (so mentions render identically on both sides), falling back to parseSlackMessageSync with a warning if the lookup throws. The attachment-content part belongs to [4.41/SL5] Slack inbound: pasted tables and alert attachments in message content #210.
Current Python behavior
src/chat_sdk/adapters/slack/adapter.py:185-206_IGNORED_SUBTYPES contains "message_deleted" (it also lists top-level "tombstone"; upstream's new check is for an innermessage.subtype == "tombstone" on message_changed), so deletes are dropped.
adapter.py:2611-2634_handle_message_event routes message_changed to _handle_message_changed and computes the thread id inline at :2632-2634 (DM: thread_ts or "", otherwise thread_ts or ts).
adapter.py:3159-3224_handle_message_changed only caches unfurls (slack:unfurls:{ts}) and returns early ("Ignoring message_changed without unfurl data", :3177). No handler is ever dispatched.
There is no _parse_slack_timestamp helper, and _parse_slack_message inlines the timestamp parsing at :3345-3356.
Existing tests that encode the old behavior: tests/test_slack_extended.py:730-745 (TestIgnoredSubtypes parametrizes message_changed/message_deleted), and tests/test_slack_webhook.py:822test_message_changed_does_not_route_to_process_message and :835test_ignores_message_deleted.
Scope
Add _thread_id_for_message_event(event) -> str in adapter.py (DM → thread_ts or "", else thread_ts or ts or ""), and use it in _handle_message_event, _handle_message_changed and _handle_message_deleted. Leave a clearly marked hook for the agent_view branch that [4.41/SL9] Slack agent_view (Agent messaging experience) + declarative agent config #214 fills in.
Add _parse_slack_timestamp(ts: str | None) -> datetime | None (UTC; None on missing or non-numeric input).
Remove message_deleted from _IGNORED_SUBTYPES. In _handle_message_event, route it to a new _handle_message_deleted before the ignored-subtype check.
Rewrite _handle_message_changed(event, options):
build a normalized inner message that inherits channel, channel_type, team and team_id from the outer event, with type defaulting to "message";
return for hidden non-edits and for no-content-change updates;
otherwise call core process_message_updated with message= an async factory, previous_message= (only when Slack sent one) an async factory over the snapshot (inheriting channel/channel_type from normalized, type defaulting to "message") that tries _parse_slack_message and falls back to _parse_slack_message_sync, and options.
_handle_message_deleted: deleted_ts = first non-None of event.deleted_ts, event.message.ts, event.previous_message.ts (return if falsy), normalize previous_message, derive the thread id from previous.thread_ts / previous.ts, and call process_message_deleted with channel_id, deleted_at (_parse_slack_timestamp(event_ts ?? ts)), message_id, previous_message (sync parse), raw and thread_id.
Extend the SlackEvent TypedDict (src/chat_sdk/adapters/slack/types.py:224) with deleted_ts, event_ts, hidden, message and previous_message.
Update the three existing tests above to the new contract. Deletes and edits must still never reach process_message. tests/test_slack_webhook.py:1218test_message_changed_with_no_unfurls_does_not_write_state must still pass.
Other adapters: at chat@4.41.1 only Slack emits these events, so there is nothing else to port.
Porting notes
Upstream uses ?? for the deleted_ts chain and the channel/team inheritance: use is not None, not or. The thread-id helper deliberately uses || (empty thread_ts falls through to ts), so use or there.
is_hidden_message_edit compares inner.get("edited", {}).get("ts") != prev.get("edited", {}).get("ts") or the texts differ. Guard edited being a non-dict. event.get("hidden") is True, not truthiness.
The previous_message factory is an async def closure; catch Exception (so CancelledError propagates) and warn with the thread id and error.
deleted_at is a tz-aware UTC datetime (datetime.fromtimestamp(v, tz=timezone.utc)); raw is the untouched Slack payload.
Do not create tasks here; core's process_* owns task lifecycle and wait_until.
Self-edit cost (decision). Core drops the bot's own edits only after resolving the message factory, i.e. after a full _parse_slack_message (user lookup, participant write, unfurl polling up to _UNFURL_WAIT_MS). Post+edit and native streaming emit a message_changed per update. Recommended default: in _handle_message_changed, return early when self._is_message_from_self(normalized) (after unfurl caching). Handler-visible behavior is identical; record it in docs/UPSTREAM_SYNC.md as a performance divergence.
Tests
Upstream packages/adapter-slack/src/index.test.ts is not fidelity-mapped. Port these from the message subtype handling describe into tests/test_slack_webhook.py (or a new tests/test_slack_lifecycle.py):
"dispatches message_changed subtypes as message updates"
"forwards the pre-edit message so handlers can diff the change" (4.41.1 version: previous_message resolves through the async path)
"ignores a message_changed where nothing actually changed"
"leaves previousMessage undefined when Slack omits it"
"dispatches hidden message_changed edits as message updates"
"ignores hidden message_changed thread metadata updates after deletes"
"dispatches message_deleted subtypes as message deletes"
The existing "link unfurl enrichment" test "should ignore hidden message_changed without unfurl attachments" must still pass. Check whether the Python suite already has an equivalent before adding it.
Python-specific:
when the async user lookup raises, the previous-message factory falls back to the sync parse and logs a warning (AsyncMock(side_effect=...));
_parse_slack_timestamp returns None for None, "" and "abc", and a UTC datetime for "1700000000.123";
the unfurl cache is still written when an unfurl message_changed also carries an edit;
a message_changed authored by the bot itself makes no users_info call and no process_message_updated call (if the self-edit short-circuit is adopted).
Mock core with AsyncMock for process_message_updated / process_message_deleted and assert the call kwargs, not just .called.
Acceptance criteria
Full validation command from CLAUDE.md passes.
docs/UPSTREAM_SYNC.md updated for any divergence, for example a sync-only previous_message if C4 took that route.
CHANGELOG entry under "Unreleased (4.41 wave)".
Consumer-visible changes called out: Slack now invokes on_message_updated / on_message_deleted, and deletes and edits are still never routed as new messages. With no handler registered, behavior is unchanged apart from extra debug logs.
Dependencies
Blocked by #196. Blocks nothing directly; #214 extends _thread_id_for_message_event with the agent_view rule.
Metadata
Effort: M (about 400 LOC including tests)
Consumer impact: low. Opt-in via handler registration. The bot's own post+edit streaming edits are filtered in core, so streaming users see no extra handler calls.
Summary
Upstream added
on_message_updated/on_message_deletedcallbacks with Slack as the reference emitter. The Python Slack adapter dropsmessage_deletedand usesmessage_changedonly to cache link unfurls, so bots cannot mirror edits or deletes. This ports the Slack half of #788 (plus #846's asyncprevious_messageparse) on top of #196's core handlers, with one thread-id helper so a message, its edit and its delete resolve to the same thread.Upstream changes
4ac04551feat(chat): add message update and delete lifecycle callbacks (#788) — chat@4.37.0. Slack half: removesmessage_deletedfrom the ignored subtypes and addshandleMessageDeleted; turnshandleMessageChangedinto a dispatcher that keeps the unfurl cache, ignores innertombstone, ignores hidden updates unlessedited.tsortextdiffers fromprevious_message, ignores "no content change" updates (locale detection), and forwardsprevious_message; addsparseSlackTimestampandthreadIdForMessageEvent. Core skips the bot's own edits (post+edit streaming callschat.updateper delta).864d9222fix(slack): keep alert attachment content on normalized messages (#846) — chat@4.39.0. Only thehandleMessageChangedhunk applies:previousMessagebecomes a lazy async factory parsed withparseSlackMessage(so mentions render identically on both sides), falling back toparseSlackMessageSyncwith a warning if the lookup throws. The attachment-content part belongs to [4.41/SL5] Slack inbound: pasted tables and alert attachments in message content #210.Current Python behavior
src/chat_sdk/adapters/slack/adapter.py:185-206_IGNORED_SUBTYPEScontains"message_deleted"(it also lists top-level"tombstone"; upstream's new check is for an innermessage.subtype == "tombstone"onmessage_changed), so deletes are dropped.adapter.py:2611-2634_handle_message_eventroutesmessage_changedto_handle_message_changedand computes the thread id inline at:2632-2634(DM:thread_ts or "", otherwisethread_ts or ts).adapter.py:3159-3224_handle_message_changedonly caches unfurls (slack:unfurls:{ts}) and returns early ("Ignoring message_changed without unfurl data",:3177). No handler is ever dispatched.grep -rn 'process_message_updated\|process_message_deleted\|on_message_updated' src/chat_sdkreturns nothing; [4.41/C4] Core lifecycle events: message updated/deleted, installed/uninstalled, app context changed #196 adds them._parse_slack_timestamphelper, and_parse_slack_messageinlines the timestamp parsing at:3345-3356.tests/test_slack_extended.py:730-745(TestIgnoredSubtypesparametrizesmessage_changed/message_deleted), andtests/test_slack_webhook.py:822test_message_changed_does_not_route_to_process_messageand:835test_ignores_message_deleted.Scope
_thread_id_for_message_event(event) -> strinadapter.py(DM →thread_ts or "", elsethread_ts or ts or ""), and use it in_handle_message_event,_handle_message_changedand_handle_message_deleted. Leave a clearly marked hook for theagent_viewbranch that [4.41/SL9] Slack agent_view (Agent messaging experience) + declarative agent config #214 fills in._parse_slack_timestamp(ts: str | None) -> datetime | None(UTC;Noneon missing or non-numeric input).message_deletedfrom_IGNORED_SUBTYPES. In_handle_message_event, route it to a new_handle_message_deletedbefore the ignored-subtype check._handle_message_changed(event, options):normalizedinner message that inheritschannel,channel_type,teamandteam_idfrom the outer event, withtypedefaulting to"message";tombstone;is_hidden_message_edit;process_message_updatedwithmessage=an async factory,previous_message=(only when Slack sent one) an async factory over the snapshot (inheritingchannel/channel_typefromnormalized,typedefaulting to"message") that tries_parse_slack_messageand falls back to_parse_slack_message_sync, andoptions._handle_message_deleted:deleted_ts= first non-Noneofevent.deleted_ts,event.message.ts,event.previous_message.ts(return if falsy), normalizeprevious_message, derive the thread id fromprevious.thread_ts/previous.ts, and callprocess_message_deletedwithchannel_id,deleted_at(_parse_slack_timestamp(event_ts ?? ts)),message_id,previous_message(sync parse),rawandthread_id.SlackEventTypedDict (src/chat_sdk/adapters/slack/types.py:224) withdeleted_ts,event_ts,hidden,messageandprevious_message.process_message.tests/test_slack_webhook.py:1218test_message_changed_with_no_unfurls_does_not_write_statemust still pass.Out of scope
on_message_updated/on_message_deleted/process_*, the bot-own-edit filter and the conversation scoping for these handlers, all in [4.41/C4] Core lifecycle events: message updated/deleted, installed/uninstalled, app context changed #196.agent_viewthread rule and the openDM bridge, which belong to [4.41/SL9] Slack agent_view (Agent messaging experience) + declarative agent config #214. Add only the helper hook here.Porting notes
??for thedeleted_tschain and the channel/team inheritance: useis not None, notor. The thread-id helper deliberately uses||(emptythread_tsfalls through tots), so useorthere.is_hidden_message_editcomparesinner.get("edited", {}).get("ts") != prev.get("edited", {}).get("ts")or the texts differ. Guardeditedbeing a non-dict.event.get("hidden") is True, not truthiness.previous_messagefactory is anasync defclosure; catchException(soCancelledErrorpropagates) and warn with the thread id and error.Message | Callable[[], Awaitable[Message]]forprevious_messageis [4.41/C4] Core lifecycle events: message updated/deleted, installed/uninstalled, app context changed #196's decision; follow what C4 landed. If C4 only accepts aMessage, parse synchronously and record the difference indocs/UPSTREAM_SYNC.md.deleted_atis a tz-aware UTCdatetime(datetime.fromtimestamp(v, tz=timezone.utc));rawis the untouched Slack payload.process_*owns task lifecycle andwait_until.messagefactory, i.e. after a full_parse_slack_message(user lookup, participant write, unfurl polling up to_UNFURL_WAIT_MS). Post+edit and native streaming emit amessage_changedper update. Recommended default: in_handle_message_changed, return early whenself._is_message_from_self(normalized)(after unfurl caching). Handler-visible behavior is identical; record it indocs/UPSTREAM_SYNC.mdas a performance divergence.Tests
Upstream
packages/adapter-slack/src/index.test.tsis not fidelity-mapped. Port these from themessage subtype handlingdescribe intotests/test_slack_webhook.py(or a newtests/test_slack_lifecycle.py):previous_messageresolves through the async path)it.each"routes the message, its edit, and its delete to one thread id in %s". Port only the "a flat DM" case (slack:D_DM:) here; [4.41/SL9] Slack agent_view (Agent messaging experience) + declarative agent config #214 adds the "a threaded agent_view DM" case.Python-specific:
AsyncMock(side_effect=...));_parse_slack_timestampreturnsNoneforNone,""and"abc", and a UTC datetime for"1700000000.123";message_changedalso carries an edit;message_changedauthored by the bot itself makes nousers_infocall and noprocess_message_updatedcall (if the self-edit short-circuit is adopted).Mock core with
AsyncMockforprocess_message_updated/process_message_deletedand assert the call kwargs, not just.called.Acceptance criteria
docs/UPSTREAM_SYNC.mdupdated for any divergence, for example a sync-onlyprevious_messageif C4 took that route.on_message_updated/on_message_deleted, and deletes and edits are still never routed as new messages. With no handler registered, behavior is unchanged apart from extra debug logs.Dependencies
Blocked by #196. Blocks nothing directly; #214 extends
_thread_id_for_message_eventwith the agent_view rule.Metadata
sync/4.41-sl6Part of #184.