Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ The seed only ever runs against a freshly created, empty database. With demo cre
| `bookmark_reasons_add` | A reason of your own, on top of the ones you started with |
| `bookmark_reasons_edit` | A reason reworded, on every bookmark already carrying it |
| `bookmark_reasons_retire` | A reason taken out of circulation, without disturbing the bookmarks that carry it |
| `messages_fetch` | One message read live from Discord — the text, embeds, reactions, freshly signed attachment links and reply header it has right now for when the stored copy has gone stale |
| `messages_fetch` | One message read live from Discord — named by its stored id or by a link you paste — the text, embeds, reactions, freshly signed attachment links and reply header it has right now, for when the stored copy has gone stale |
| `messages_send` | A message posted to a channel as your bot, optionally as a reply to a message in that same channel, or as a guarded retry of an earlier send |
| `messages_send_status` | Where a send ended up — delivered, skipped, failed, still on its way, or stalled when nothing was ever recorded — whether it can be retried, and every attempt already made at it |
| `threads_create` | A public thread created in a channel, or anchored on a message the bot has ingested, with a `channelId` of its own to post into and the link that opens it in Discord |
Expand All @@ -129,7 +129,7 @@ The seed only ever runs against a freshly created, empty database. With demo cre
- **A reply says what it answers, whether or not it pinged anyone.** Discord stamps a reply with the message it answers, and that stamp survives the sender switching the ping off. Every message `messages_catch_up`, `mentions_list` and `bookmarks_list` return carries `repliedTo` — the answered message's `discordMessageId`, a link that opens it in Discord, and its `messageId` when the store holds it too, so your assistant can read the question straight back. Like embeds, this is captured from the moment you start ingesting: a message the bot recorded before then carries `repliedTo` null, and `messages_fetch` reads the reply header live for those.
- **Answers to your bot are mentions of you.** Your bot posts on your behalf through `messages_send`, and people reply to it — sometimes naming it, more often as a reply Discord stamps as a ping. `mentions_list` counts a ping of your bot exactly as it counts a ping of you, and `activity_since` counts it by the same rule. You never configure the bot's id: the ingest daemon reads it off Discord the moment it connects, so this works from the first run of `pnpm run ingest`, and a store no daemon has ever connected simply keeps answering with pings of you alone. Nobody pings themselves, so what your bot posted naming your bot stays out, and so does a note you wrote naming yourself — while a message your bot posted naming you still comes back.
- **A standing watch waits, instead of asking over and over.** `activity_since` answers on the spot, which is all a cheap poll needs. Give it `waitSeconds` — a whole number from 1 to 55 — and it holds the call open instead: it comes back the moment the store records anything after your cursor, and otherwise at the deadline with the same zeros. A watch loop is then one waiting call after another, so your assistant notices a message a second or two after it lands rather than spending a turn on every check, and reads the stream with `messages_catch_up`, `mentions_list` or `bookmarks_list` once a count comes back positive. The 55-second cap keeps every wait inside the one-minute limit MCP clients usually put on a call.
- **`messages_fetch` is the escape hatch, not the reading tool.** Catching up, listing mentions and listing bookmarks answer instantly out of the local store and never touch the network — that is where reading belongs. Reach for `messages_fetch` when you need what only Discord has right now: a fresh attachment link, the embeds of a message ingested before they were captured, or the reactions standing on a message — which emoji, how many of each, and whether you are among the people who reacted. It never tells you *who* the others are: reactor identities are read to answer that one question and never leave your machine. If Discord refuses to list them, the fetch still comes back with the message and simply says nothing about reactions, rather than claiming there are none.
- **`messages_fetch` is the escape hatch, not the reading tool.** Catching up, listing mentions and listing bookmarks answer instantly out of the local store and never touch the network — that is where reading belongs. Reach for `messages_fetch` when you need what only Discord has right now: a fresh attachment link, the embeds of a message ingested before they were captured, or the reactions standing on a message — which emoji, how many of each, and whether you are among the people who reacted. Paste it a message link and it reads that one message in a single call — *"what does this say?"* with the link Discord's Copy Message Link gave you, and no hunting through a channel first — as long as the bot already ingested the message. It never tells you *who* the others are: reactor identities are read to answer that one question and never leave your machine. If Discord refuses to list them, the fetch still comes back with the message and simply says nothing about reactions, rather than claiming there are none.
- **Pinging somebody takes their id, not their name.** Discord only pings when a message carries `<@id>`, so a reply that reads *"Maya — can you take this?"* reaches nobody. `members_list` turns part of a name into that id, and turns an id you met inside somebody else's message back into a person. The bot learns a person the first time it sees them post, which is also why nobody who has only ever lurked is in there. Only the bot you post through is marked, with `isYourBot` — Discord tells the ingest daemon which account it connected as, and nothing in the store says whether anyone else is a bot, so nothing here claims to know.
- **One deployment, one server, one owner.** The bot only records the server you configured, and only answers to you. Teammates clone the repo and create their own app — five minutes each, no shared infrastructure.
- **This is a bot, not your account.** Automating a user account ("self-botting") violates Discord's Terms of Service and risks a ban; Discord Manager only ever acts through a bot you created, posting as itself.
Expand Down
4 changes: 1 addition & 3 deletions app/business/bookmarks.common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { messageLinkMessage } from '~/business/messages.common'

const bookmarkListLimit = 100

Expand All @@ -20,9 +21,6 @@ const bookmarkLimitMessage = `Ask for a whole number of bookmarks, from 1 to ${b
const includeSnoozedMessage =
'Pass true to include snoozed bookmarks; pass false or leave it out to keep them hidden'

const messageLinkMessage =
'Paste the link Discord copies with Copy Message Link, such as https://discord.com/channels/<server>/<channel>/<message>'

const reasonDescriptionMessage =
'Describe when this reason applies, in a sentence — it is what an assistant reads to sort a bookmark'

Expand Down
39 changes: 6 additions & 33 deletions app/business/bookmarks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,13 @@ import {
import {
messageAttachmentsSchema,
messageEmbedsSchema,
messageLinkTarget,
messageReactionsSchema,
storedRepliedTo,
} from '~/business/messages.common'
import { db } from '~/db/db.server'
import { newId } from '~/framework/db.server'

const discordMessageLinkPattern =
/^https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/([^/]+)\/([^/]+)\/([^/]+)$/
const discordSnowflakePattern = /^\d{17,20}$/

const bookmarksContextSchema = ownerContextSchema.extend({
canManageBookmarks: z.literal(true),
})
Expand Down Expand Up @@ -538,37 +535,13 @@ const addBookmarkByLink = applySchema(
addBookmarkByLinkSchema,
bookmarksContextSchema
)(async ({ messageLink, reasonId }, context) => {
const link = messageLink.match(discordMessageLinkPattern)

if (!link) {
throw new InputError(
'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/<server>/<channel>/<message>.',
['messageLink']
)
}

const [, linkGuildId, linkChannelId, linkMessageId] = link

if (
![linkGuildId, linkChannelId, linkMessageId].every((id) =>
discordSnowflakePattern.test(id)
)
) {
throw new InputError(
'That message link carries something other than Discord ids. Copy it again from Discord without editing the numbers.',
['messageLink']
)
}

if (linkGuildId !== context.owner.guildId) {
throw new InputError(
'That link points at a different Discord server than this deployment manages. Pick a message from the server this deployment manages.',
['messageLink']
)
}
const linked = messageLinkTarget({
guildId: context.owner.guildId,
messageLink,
})

const message = await messagesInGuild(context.owner.guildId)
.where('messages.discordMessageId', '=', linkMessageId)
.where('messages.discordMessageId', '=', linked.discordMessageId)
.executeTakeFirst()

if (!message) {
Expand Down
63 changes: 62 additions & 1 deletion app/business/messages.common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
import { InputError } from 'composable-functions'
import { z } from 'zod'

const discordMessageLinkPattern =
/^https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/([^/]+)\/([^/]+)\/([^/]+)$/
const discordSnowflakePattern = /^\d{17,20}$/

function messageLinkTarget({
guildId,
messageLink,
}: {
guildId: string
messageLink: string
}) {
const link = messageLink.match(discordMessageLinkPattern)

if (!link) {
throw new InputError(
'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/<server>/<channel>/<message>.',
['messageLink']
)
}

const [, discordGuildId, discordChannelId, discordMessageId] = link

if (
![discordGuildId, discordChannelId, discordMessageId].every((id) =>
discordSnowflakePattern.test(id)
)
) {
throw new InputError(
'That message link carries something other than Discord ids. Copy it again from Discord without editing the numbers.',
['messageLink']
)
}

if (discordGuildId !== guildId) {
throw new InputError(
'That link points at a different Discord server than this deployment manages. Pick a message from the server this deployment manages.',
['messageLink']
)
}

return { discordChannelId, discordGuildId, discordMessageId }
}

const observedEmbedSchema = z.object({
authorName: z.string().optional(),
description: z.string().optional(),
Expand Down Expand Up @@ -138,12 +182,26 @@ function messageFetchGuidance(outcome: MessageFetchOutcome) {
const messageIdMessage =
'Pass a `messageId` from messages_catch_up, mentions_list or bookmarks_list, not the Discord message snowflake'

const messageLinkMessage =
'Paste the link Discord copies with Copy Message Link, such as https://discord.com/channels/<server>/<channel>/<message>'

const oneLocatorMessage =
'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'

const fetchMessageSchema = z.object({
messageId: z
.string({ error: messageIdMessage })
.min(1, messageIdMessage)
.optional()
.describe(
'The `messageId` from messages_catch_up, mentions_list or bookmarks_list — not the Discord message snowflake. Leave it out when you pass `messageLink`.'
),
messageLink: z
.string({ error: messageLinkMessage })
.min(1, messageLinkMessage)
.optional()
.describe(
'The `messageId` from messages_catch_up, mentions_list or bookmarks_list — not the Discord message snowflake.'
'A Discord message link, from Copy Message Link in Discord: https://discord.com/channels/<server>/<channel>/<message>. Links from canary.discord.com, ptb.discord.com and discordapp.com work too. Pass a link the owner gave you straight through — no lookup first — as long as the bot has already ingested the message it names. Leave it out when you pass `messageId`.'
),
})

Expand Down Expand Up @@ -269,11 +327,14 @@ export {
messageFetchGuidance,
messageFetchRetrievalCopy,
messageFetchSkipCopy,
messageLinkMessage,
messageLinkTarget,
messageReactionsSchema,
observedAttachmentSchema,
observedEmbedSchema,
observedEmojiSchema,
observedReplyReferenceSchema,
oneLocatorMessage,
renderEmbed,
renderEmoji,
repliedTo,
Expand Down
Loading