Skip to content

Read a message straight from its Discord link - #90

Draft
lucasrcezimbra wants to merge 1 commit into
seasonedcc:mainfrom
lucasrcezimbra:issue-88-fetch-by-message-link
Draft

Read a message straight from its Discord link#90
lucasrcezimbra wants to merge 1 commit into
seasonedcc:mainfrom
lucasrcezimbra:issue-88-fetch-by-message-link

Conversation

@lucasrcezimbra

@lucasrcezimbra lucasrcezimbra commented Aug 18, 2026

Copy link
Copy Markdown

Closes #88

The problem

Somebody pastes a Discord message link into a chat and asks "what does this say?". Until now the assistant could not answer in one call. messages_fetch only took the internal messageId that messages_catch_up, mentions_list or bookmarks_list hand out, so from a bare link the assistant had to list the channels to turn the link's channel snowflake into a stored channelId, derive a timestamp from the message snowflake, catch up from that timestamp, scan the results for the matching discordMessageId — 200 messages at a time, in a busy channel — and only then fetch. Indirect, token-heavy, and easy to get wrong.

What changed

messages_fetch now accepts either locator, exactly one of the two:

{ "messageId": "01a0…-…" }
{ "messageLink": "https://discord.com/channels/<server>/<channel>/<message>" }

A link resolves to the ingested message it names — messages.discord_message_id is unique across the store, so the message snowflake alone is the key — and then follows the fetch that already existed, unchanged: the same request, retrieval, skip and failure rows, the same statuses, the same copy. No new Discord operation, no new table, no new reason.

What the owner reads when a link cannot be used, each naming the fix:

  • not a Discord message link → "That is not a Discord message link. Right-click the message in Discord, choose Copy Message Link, and pass that — it looks like https://discord.com/channels/&lt;server&gt;/&lt;channel&gt;/&lt;message>."
  • the right shape carrying something other than snowflakes → "That message link carries something other than Discord ids. Copy it again from Discord without editing the numbers."
  • a link into another server → "That link points at a different Discord server than this deployment manages. Pick a message from the server this deployment manages."
  • a link nobody ingested (new wording, fetch's own) → "That message has not been ingested, so it cannot be read live. Let the bot catch up on that channel, then fetch the link again."
  • both locators, or neither → "Pass either messageId from messages_catch_up, mentions_list or bookmarks_list, or messageLink copied from Discord with Copy Message Link — one of the two, never both"

The first three are exactly what bookmarks_add has always answered, because they now come from the same source. bookmarks_add itself is unchanged, including its own not-ingested wording.

One source for the link's gates, and why that diverges from the usual advice

The link's three gates — host and shape, the three snowflakes, the configured server — were inlined in addBookmarkByLink. They now live in messageLinkTarget in app/business/messages.common.ts, and both bookmarks_add and messages_fetch call it.

The business-folder skill prefers a private copy per file for a utility with two consumers, and extraction only at three or more. That preference is about helpers, and what is shared here is not a helper: it is three sentences the owner reads. A forked copy of a refusal is a product defect no type checker can see — two wordings for one situation, drifting the first time either is improved, and the owner has no way to tell which tool taught them the wrong phrasing. The same reasoning moved the messageLink field's Zod message into messages.common.ts, which bookmarks.common.ts now imports.

It lives in .common.ts, not in a .server.ts, on purpose: the parity test reads every exported function in app/business/*.server.ts as an owner-facing capability, so exporting a parser from there would demand a parity-exemptions.ts entry that is false by that test's own standard. messages.common.ts is also the right domain — identifying a message by its link is a fact about messages, which is why bookmarks.server.ts already imported from it.

fetchMessageSchema stays a plain z.object with both fields optional, and the exactly-one rule is enforced in fetchMessage, where the locators are resolved and the error can speak in fetch's own vocabulary. createThreadSchema puts its sibling rule (channelId xor messageId) in a .refine() instead; this shape is the more robust of the two, because app/mcp/field-messages.test.ts walks a tool's fields only when its schema is a ZodObject — true through a refinement in Zod 4 today, but true by construction here.

How it was verified

  • Unit — eight new cases on fetchMessage: a link answers byte for byte what the stored id answers (both fetches deep-compared), a canary-host link works, each of the three shared gates refuses, a link nobody ingested refuses, and both-locators and neither-locator refuse without touching Discord or writing a telemetry row. One more at the MCP layer proves messages_fetch is callable with a link through callTool. bookmarks_add's existing suite stayed green against the shared parser.
  • Mutation proofs — neutering the configured-server gate turns the wrong-server test red in both the messages and the bookmarks suites, which is what proves the gate is genuinely shared; resolving the link by the wrong column turns the link tests and the new end-to-end spec red; dropping the both-locators guard turns that test red.
  • End to end — a new spec drives the real MCP server over stdio, calls messages_fetch with nothing but a link built by the seed, and reads the message back; it then passes both locators and asserts the refusal and that Discord was never asked a second time.
  • A real MCP client session against a fresh store built by pnpm run db:migrate and pnpm run db:seed:dev: a link assembled from the seeded snowflakes resolved to the right stored message and came back failed with "Discord refused to hand this message over, so nothing was read." — the demo token is not a real bot token, which is exactly the mapped copy for that — while the malformed, wrong-server, never-ingested, both-locator and neither-locator calls each came back with the sentence quoted above.
  • pnpm run lint, pnpm run tsc, pnpm run test:unit (597 passing), pnpm run test:e2e (59 specs, 20 of 20 tools reached), pnpm run test:seed-coverage (17 demonstrated, 3 unseedable) all green. messages_fetch stays declared unseedable: it still reads live from Discord, whichever locator names the message.

Docs updated in the same change: the README tool table and its escape-hatch note, and the architecture document's tool list plus a new paragraph in "The live escape hatch" recording why an un-ingested link is refused rather than fetched blind.

messages_fetch now takes either the stored `messageId` a reader handed out
or a `messageLink` copied out of Discord, exactly one of the two. A pasted
link resolves to the ingested message it names and then follows the same
fetch it always did, so telemetry, statuses and copy are unchanged.

The three gates a link passes — the host and shape check, the snowflake
check, and the configured-server check — and their exact wording now live
in one place, messageLinkTarget in messages.common.ts, which bookmarks_add
consumes too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Read a message directly from its Discord link

1 participant