Skip to content

[4.41/SL6] Slack: dispatch message_changed / message_deleted to lifecycle handlers #211

Description

@patrick-chinchill

Summary

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 inner message.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.
  • grep -rn 'process_message_updated\|process_message_deleted\|on_message_updated' src/chat_sdk returns nothing; [4.41/C4] Core lifecycle events: message updated/deleted, installed/uninstalled, app context changed #196 adds them.
  • 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:822 test_message_changed_does_not_route_to_process_message and :835 test_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";
    • ignore tombstone;
    • keep the unfurl caching (including any cache-key helper [4.41/SL0] Slack: installation-scoped caches, drop unresolved installs, strict response_url, bounded regexes #205 introduced) but drop its early returns: caching is a side step and processing continues, as upstream does;
    • compute is_hidden_message_edit;
    • 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:1218 test_message_changed_with_no_unfurls_does_not_write_state must still pass.

Out of scope

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.
  • Whether core accepts Message | Callable[[], Awaitable[Message]] for previous_message is [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 a Message, parse synchronously and record the difference in docs/UPSTREAM_SYNC.md.
  • 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"
  • "ignores message_changed tombstone subtypes"
  • 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.
  • 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.
  • Suggested branch: sync/4.41-sl6

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions