Skip to content

fix(slack): render pasted message permalinks instead of duplicating the message - #26

Merged
nikitaBarkov merged 1 commit into
mainfrom
nikita.barkov/slack-message-mention-duplication
Aug 11, 2026
Merged

fix(slack): render pasted message permalinks instead of duplicating the message#26
nikitaBarkov merged 1 commit into
mainfrom
nikita.barkov/slack-message-mention-duplication

Conversation

@nikitaBarkov

@nikitaBarkov nikitaBarkov commented Aug 10, 2026

Copy link
Copy Markdown

What does this PR do?

Fixes a pasted Slack message permalink being delivered to the agent twice — once with the link, once as the same sentence with a blank where the link had been.

Slack sends an authored message twice: flat in event.text and structurally in event.blocks. The blocks are rendered so quoted and forwarded content is not lost, and whatever the render carries beyond the flat text is appended to the message. A pasted permalink arrives as a message_mention element, which _render_inline_elements() did not know — its list of eight inline types had no else branch, so the element was dropped silently. The link vanished from the render, the two sides stopped comparing equal, and the sentence was appended a second time.

Confirmed on a live gateway log: one inbound message line per message (so this is a rendering bug, not a repeated delivery), the transcript carrying the sentence twice with a double space where the URL had been, and the same messages arriving once after the fix.

Related Issue

None — reported directly.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • plugins/platforms/slack/adapter.py: inline-element rendering moved into _render_slack_inline_element(). message_mention is read like link (a url plus an optional label), and any unknown type by its url/text/fallback, so a type Slack adds later still renders instead of vanishing.
  • plugins/platforms/slack/adapter.py: team, color and a date without a fallback render into the flat form Slack sends rather than into nothing.
  • plugins/platforms/slack/adapter.py: url is optional on message_mention while channel_id and message_ts are not, and those two are the permalink's own components, so an element without a url renders the permalink's tail. Neither the workspace host nor the thread query can be rebuilt from the element, so _normalize_slack_text_for_dedupe() reduces a permalink on either side to that same tail. That reduction stops at the query rather than running to the next space: a labelled link is canonicalized to label (url), and swallowing the closing parenthesis left the two sides unequal again whenever only one of them carried ?thread_ts=….
  • plugins/platforms/slack/adapter.py: _normalize_slack_text_for_dedupe() reads down the optional label Slack may attach to a mention (<@U…|name>, <#C…|general>, <!subteam^S…|@marketing>, <!here|@here>), strips the bot's own mention after that label so every form of it is dropped, and matches any autolink scheme instead of https/mailto only.
  • plugins/platforms/slack/adapter.py: every field is read as a string or not at all, through one helper. Block Kit carries text as an object in many places, so an element -- an unknown one above all -- may hold one where a string belongs, and it would raise in _render_inline_elements()'s str.join and cost the whole message. Four such payloads raise on main and none do here.
  • tests/gateway/test_slack.py: 35 tests added to TestSlackAuthoredTextDeduplication.

Known gap, deliberately left open: an element carrying neither a url nor a label still renders as nothing, and a message containing one is still appended twice. Suppressing such a render is worse — an app message whose body lives only in the blocks disappears, and a forwarded quote is dropped. An unrendered element is the lesser cost, and a duplicate is what main already does.

How to Test

  1. In Slack, paste a permalink to a message (.../archives/C…/p…) and send it to the bot.
  2. The agent receives one line, with the link intact.
  3. Repeat with the same permalink taken from "Copy link" (it carries ?thread_ts=…&cid=…), the same one given a label of your own, a link carrying a label, a channel / user group / @here mention, and a tel: link — each arrives once.
  4. scripts/run_tests.sh tests/gateway/test_slack.py245 passed.
  5. scripts/run_tests.sh tests/gateway/4957 passed (6 pre-existing environment failures: an optional XML dependency for wecom, Linux-only abstract sockets on macOS).

Checklist

Code

  • I've read the Contributing Guide
  • My commit message follows Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate — nothing open upstream touches these paths
  • This PR touches only the two files related to this fix, in one commit
  • I've run all affected tests and they pass
  • I've added regression tests for the bug and its edge cases — reverting the adapter to main fails 28 of the 47 tests in TestSlackAuthoredTextDeduplication
  • Every change lives in the comparison between the flat text field and the block render: _normalize_slack_text_for_dedupe() is called only from that comparison, never from the text the agent receives, so a canonicalization mistake can cost an unrendered element, never an altered or missing message
  • I've verified the behavior in a live Slack workspace

Documentation & Housekeeping

  • Documentation updates — N/A, no user-facing behavior or configuration changed
  • cli-config.yaml.example — N/A, no config changes
  • CONTRIBUTING.md / AGENTS.md — N/A, no architecture or workflow changes
  • Cross-platform impact considered — pure Python Slack adapter behavior
  • Tool descriptions/schemas — N/A, no tool changes

Notes

Out of scope: Slack also re-delivers a message as message_changed when it attaches an unfurl preview, and _processed_message_ts[ts] is written only at the end of _handle_slack_message, after every long await. That is a separate class — repeated delivery rather than a duplicated render — it did not occur in the logs for this report, and a naive fix breaks "an @mention added by an edit still wakes the bot". Upstream PR NousResearch#73450 already rewrites that branch.

@nikitaBarkov
nikitaBarkov force-pushed the nikita.barkov/slack-message-mention-duplication branch 9 times, most recently from 07ee0e1 to 43161d5 Compare August 11, 2026 08:36
Slack sends an authored message twice: flat in `event.text` and structurally
in `event.blocks`. The blocks are rendered so quoted and forwarded content is
not lost, and whatever the render carries beyond the flat text is appended to
the message. A pasted message permalink arrives as a `message_mention`
element, which the renderer did not know: the link vanished from the render,
the two sides stopped comparing equal, and the author's own sentence was
appended a second time with a blank where the link had been. Confirmed on a
live gateway log, and fixed by the same run.

The renderer now reads `message_mention` like `link`, and any unknown inline
type by its `url`/`text`/`fallback`, so a type Slack adds later still renders
instead of vanishing. `team`, `color` and a `date` without a `fallback` render
into the flat form Slack sends. The comparison also reads down the optional
label Slack may attach to a mention (`<@U…|name>`, `<#C…|general>`,
`<!subteam^S…|@marketing>`, `<!here|@here>`), strips the bot's own mention
after that label so every form of it is dropped, and accepts any autolink
scheme rather than `https`/`mailto` only.

Every field is read as a string or not at all. Block Kit carries text as an
object in many places, and a non-string field reaches the renderer's
`str.join` and raises there, which costs the whole message: four such
payloads raise before this change and none after it.

`url` is optional on `message_mention` while `channel_id` and `message_ts`
are not, and those two are the permalink's own components, so an element
without a url renders the permalink's tail. Neither the workspace host nor
the thread query can be rebuilt from the element, so the comparison reduces a
permalink on either side to that same tail.

An element carrying neither a url nor a label still renders as nothing, and a
message containing one is still appended twice. Suppressing such a render is
worse: an app message whose body lives only in the blocks disappears, and a
forwarded quote is dropped. An unrendered element is the lesser cost.

Tests cover the pasted permalink through both merge sites, the same element
without a url against both flat forms of the link, unknown types with a url
and with a label, all four labelled mention forms, the bot's own mention in
its three flat forms, every `date` form, a non-http scheme, and the negative
cases: quotes, alert attachments and app message bodies must still be
delivered.

Co-authored-by: Junie <junie@jetbrains.com>
@nikitaBarkov
nikitaBarkov force-pushed the nikita.barkov/slack-message-mention-duplication branch from 43161d5 to 5ab27d9 Compare August 11, 2026 09:01
@nikitaBarkov
nikitaBarkov merged commit 56aea81 into main Aug 11, 2026
35 of 40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant