5538bd1f93
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. That comparison had several ways to fail on the *same* sentence, each of which showed the author their own words a second time: 1. HTML entities — the flat copy escapes `&`/`<`/`>` while `blocks[].link.url` stays raw, so any link with query parameters (every "Copy link" on a thread) mismatched. 2. Permalink unfurls — the live inbound path skips `is_msg_unfurl` attachments, thread/parent hydration did not, so the linked message's body was appended again. 3. The Block Kit dump — it serialized the authored `rich_text` alongside the UI blocks it exists for, and its allowlist drops `url`, so the sentence reappeared with every link removed. 4. Unknown inline elements — the renderer knew eight types and silently dropped the rest. A pasted message permalink arrives as `message_mention`, so the link vanished from the render and the sides stopped comparing equal. 5. `message_mention` without a url — `url` is optional on that element while `channel_id` and `message_ts` are not, so the element rendered as nothing and the sentence came back with a blank in the link's place. 6. `date` elements — `fallback` and `url` are both optional, and the flat `<!date^…>` form was never read down to what the rich text renders. 7. Labelled mentions — Slack may attach a label (`<@U…|name>`, `<#C…|general>`, `<!subteam^S…|@marketing>`, `<!here|@here>`) in the flat text while the blocks carry the bare id. The bot's own mention is one of these, and stripping only its bare form left it in the flat copy. 8. Autolink schemes — only `https` and `mailto` were matched, so a `tel:` link kept its angle brackets and mismatched too. Unknown inline types are now read by their `url`/`text`/`fallback` so a type Slack adds later still renders, and `team`, `color` and a fallback-less `date` render into the flat form Slack sends. 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 one reaches the renderer's `str.join` and raises there, which costs the whole message. `channel_id` and `message_ts` are the permalink's own components, so a url-less `message_mention` renders the permalink's tail; the workspace host and the thread query cannot be rebuilt from the element, so a permalink on either side is reduced to that same tail. Canonicalization is used for matching only -- the authored text still reaches the agent verbatim, so a mistake here can cost an unrendered element, never an altered or missing message. 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 was tried and is worse: an app message whose body lives only in the blocks disappears, and a forwarded quote is dropped. Genuinely additional content -- quotes, lists, code blocks, attachments, interactive bot blocks -- is unaffected throughout. Tests cover both merge sites (live inbound and thread hydration) and the negative cases.