@mulmobridge/discord 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,67 @@ Files posted with a message are downloaded from Discord's CDN and forwarded to M
12
12
  - Images and PDFs reach Claude directly; text, DOCX, XLSX and PPTX are converted server-side. Other types are skipped with a log line.
13
13
  - Attachments arrive over the **Message Content Intent**, the same privileged intent the bot already needs to read text.
14
14
 
15
+ ## Threads
16
+
17
+ A Discord thread is a **channel of its own** — it has its own id, separate from the channel it hangs off. That single fact drives both settings below.
18
+
19
+ > **Check the bot's permissions first.** Posting inside a thread needs `Send Messages in Threads`, which Discord treats as separate from `Send Messages`. Without it the bot receives thread messages and silently fails to reply. See [Setup](#3-invite-the-bot-to-your-server).
20
+
21
+ ### The allowlist judges a thread by its parent channel
22
+
23
+ `DISCORD_ALLOWED_CHANNELS` used to compare a thread against its own id, and a thread's id is minted fresh every time someone opens one. So the allowlist and threads could not be used together: you had to paste each new thread's id into `.env` and restart, or leave the list empty and let the bot answer in every channel it can see.
24
+
25
+ Now a message in a thread is admitted when the **parent channel** is listed. Allow `#ai-help` once and every thread under it works, with the permitted area still closed at that channel. Listing a thread's own id also still works, so an existing `.env` keeps behaving as it did.
26
+
27
+ ### `DISCORD_SESSION_GRANULARITY` — one session per thread, or per channel
28
+
29
+ > **What's a "session"?** In MulmoClaude a *session* is one continuous conversation — it remembers what you said earlier and builds on it. This setting decides how many sessions one Discord channel maps to.
30
+
31
+ #### 🧵 `thread` (default) — a thread is its own conversation
32
+
33
+ ```text
34
+ #ai-help
35
+ ├── Alice: "Summarize yesterday's standup" → session: #ai-help
36
+ │
37
+ ├── 🧵 Deploy question
38
+ │ ├── Bob: "What's the rollback procedure?" ┐
39
+ │ └── Bob: "And who approves it?" ┘ → its own session
40
+ │
41
+ └── 🧵 Translation task
42
+ ├── Alice: "Translate the release notes" ┐
43
+ └── Alice: "Now make it shorter" ┘ → its own session
44
+ ```
45
+
46
+ Open a thread for each topic and the AI keeps them apart — Bob's deploy question never mixes into Alice's translation. Because a thread carries its own id, this is what the bridge has always done for threads that reached it; the default keeps that.
47
+
48
+ #### 🗂 `channel` — every thread joins its parent channel's conversation
49
+
50
+ ```text
51
+ #ai-help
52
+ ├── Alice: "Summarize yesterday's standup" ┐
53
+ ├── 🧵 Deploy question │
54
+ │ └── Bob: "What's the rollback procedure?" ├ one session, keyed by #ai-help
55
+ └── 🧵 Translation task │
56
+ └── Alice: "Translate the release notes" ┘
57
+ ```
58
+
59
+ Use this when threads are just a tidier way to lay out **one** running conversation and you want the AI to carry context across all of them. Watch out: a long-lived channel session accumulates context, so answers get slower and pull in stale details.
60
+
61
+ | | `thread` *(default)* | `channel` |
62
+ |---|---|---|
63
+ | Message in a channel | channel session | channel session |
64
+ | Message in a thread | **thread session** | **parent channel session** |
65
+ | Post in a **forum** channel | thread session | thread session |
66
+ | DM | one session per DM | one session per DM |
67
+
68
+ Notes:
69
+
70
+ - **A forum post never folds**, in either mode. Discord does not allow posting into a forum channel itself — you open a post — so a session keyed by the forum id would have nowhere to deliver a server-initiated message. A forum post is its own conversation anyway; there is no channel-level talk to fold it into. The forum id still works in `DISCORD_ALLOWED_CHANNELS`.
71
+ - **A thread never folds onto a parent your allowlist does not cover.** If you allow a thread by its own id — the older workaround — its parent channel is not thereby allowed, so `channel` mode keeps that thread's own session rather than aiming replies at a channel you chose to leave out. Both rules are the same invariant: a session is only ever keyed to a channel the bot may actually talk in.
72
+ - The value is case-insensitive. Anything other than `thread` or `channel` makes the bridge exit at startup rather than guess — including Slack's `auto`, which has no separate meaning here.
73
+ - The default differs from [`@mulmobridge/slack`](https://www.npmjs.com/package/@mulmobridge/slack), where `SLACK_SESSION_GRANULARITY` defaults to `channel`. A Slack thread is a facet of a channel; a Discord thread is a channel. Set both explicitly if you run the two bridges and want them to match.
74
+ - Switching modes does not delete anything. It only changes how *new* messages map to sessions — old conversations stay in the MulmoClaude UI, and the AI does not port their context into the new ones.
75
+
15
76
  ## Setup
16
77
 
17
78
  ### 1. Create a Discord Application
@@ -29,7 +90,9 @@ Files posted with a message are downloaded from Discord's CDN and forwarded to M
29
90
 
30
91
  **OAuth2** → **URL Generator**:
31
92
  - Scopes: `bot`
32
- - Permissions: `Send Messages`, `Read Message History`
93
+ - Permissions: `Send Messages`, `Send Messages in Threads`, `Read Message History`
94
+
95
+ `Send Messages in Threads` is a **separate** Discord permission from `Send Messages` — a bot granted only the latter receives messages posted in a thread and then fails to reply to them. Grant both, or the bot will look silent inside every thread. Already-invited bots do not pick up a new permission from a fresh invite URL alone; add it to the bot's role, or to the channel's permission overwrites.
33
96
 
34
97
  Copy the generated URL and open it in your browser to invite the bot.
35
98
 
@@ -52,7 +115,8 @@ npx @mulmobridge/discord
52
115
  | Variable | Required | Description |
53
116
  |---|---|---|
54
117
  | `DISCORD_BOT_TOKEN` | Yes | Bot token from Developer Portal |
55
- | `DISCORD_ALLOWED_CHANNELS` | No | CSV of channel IDs to restrict (empty = all) |
118
+ | `DISCORD_ALLOWED_CHANNELS` | No | CSV of channel IDs to restrict (empty = all). A thread is admitted by its **parent** channel, or by its own id — see [Threads](#threads) |
119
+ | `DISCORD_SESSION_GRANULARITY` | No | `thread` *(default)* \| `channel`. Whether a thread is its own session or joins its parent channel's. `channel` folds only onto a parent that is postable and allow-listed. See [Threads](#threads) |
56
120
  | `MULMOCLAUDE_API_URL` | No | Default: auto (`.server-port`; waits if nothing is published) |
57
121
  | `MULMOCLAUDE_AUTH_TOKEN` | No | Bearer token (auto-read from workspace) |
58
122
  | `DISCORD_BRIDGE_DEFAULT_ROLE` | No | Role id to seed new bridge sessions with (e.g. `coder`, `general`). Applied ONLY when a discord session first appears — once the user switches role via `/role <id>` the session's own role wins. Unknown role ids silently fall back to the server's default with a warn log. |
package/dist/index.js CHANGED
@@ -5,7 +5,11 @@
5
5
  // DISCORD_BOT_TOKEN — Bot token from Discord Developer Portal
6
6
  //
7
7
  // Optional:
8
- // DISCORD_ALLOWED_CHANNELS — CSV of channel IDs (empty = allow all)
8
+ // DISCORD_ALLOWED_CHANNELS — CSV of channel IDs (empty = allow all).
9
+ // A thread is admitted by its PARENT channel.
10
+ // DISCORD_SESSION_GRANULARITY — "thread" (default) | "channel"
11
+ // Whether a thread is its own session or joins
12
+ // its parent channel's. See README.md.
9
13
  // MULMOCLAUDE_API_URL — default: the port in <workspace>/.server-port.
10
14
  // With none published the client waits (#3078)
11
15
  // MULMOCLAUDE_AUTH_TOKEN — bearer token (or read from workspace)
@@ -14,6 +18,7 @@ import { Client, GatewayIntentBits, Partials } from "discord.js";
14
18
  import { createBridgeClient, installProcessGuards } from "@mulmobridge/client";
15
19
  import { parseCsvSet } from "@mulmoclaude/common";
16
20
  import { collectAttachments, resolveMessageText } from "./attachments.js";
21
+ import { buildExternalChatId, isChannelAllowed, parseGranularity, readChannelRef } from "./sessionId.js";
17
22
  const TRANSPORT_ID = "discord";
18
23
  installProcessGuards({ name: TRANSPORT_ID });
19
24
  const MAX_DISCORD_LENGTH = 2000;
@@ -24,6 +29,18 @@ if (!token) {
24
29
  }
25
30
  const allowedChannels = parseCsvSet(process.env.DISCORD_ALLOWED_CHANNELS);
26
31
  const allowAll = allowedChannels.size === 0;
32
+ // The explicit `T` return annotation is what lets TS see `process.exit`'s
33
+ // `never` and accept the catch branch as non-returning.
34
+ function parseEnvOrExit(parse) {
35
+ try {
36
+ return parse();
37
+ }
38
+ catch (err) {
39
+ console.error(`[discord] ${err instanceof Error ? err.message : String(err)}`);
40
+ }
41
+ return process.exit(1);
42
+ }
43
+ const granularity = parseEnvOrExit(() => parseGranularity(process.env.DISCORD_SESSION_GRANULARITY));
27
44
  const discord = new Client({
28
45
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent],
29
46
  // Partials.Channel is required to receive DM messageCreate events —
@@ -54,24 +71,25 @@ discord.on("messageCreate", (msg) => {
54
71
  async function onMessageCreate(msg) {
55
72
  if (msg.author.bot)
56
73
  return;
57
- const { channelId } = msg;
74
+ const channelRef = readChannelRef(msg.channelId, msg.channel);
58
75
  // Checked before the attachment downloads so a denied channel never
59
76
  // makes the bridge fetch anything.
60
- if (!allowAll && !allowedChannels.has(channelId))
77
+ if (!isChannelAllowed(channelRef, allowedChannels))
61
78
  return;
62
79
  const text = msg.content.trim();
63
80
  const files = [...msg.attachments.values()];
64
81
  if (text.length === 0 && files.length === 0)
65
82
  return;
66
- console.log(`[discord] message channel=${channelId} user=${msg.author.tag} len=${text.length} attachments=${files.length}`);
83
+ const externalChatId = buildExternalChatId(channelRef, granularity, allowedChannels);
84
+ console.log(`[discord] message channel=${channelRef.channelId} parent=${channelRef.parentChannel?.id ?? "-"} session=${externalChatId} user=${msg.author.tag} len=${text.length} attachments=${files.length}`);
67
85
  try {
68
- await relayMessage(msg, text, files);
86
+ await relayMessage(msg, externalChatId, text, files);
69
87
  }
70
88
  catch (err) {
71
89
  console.error(`[discord] message handling failed: ${err}`);
72
90
  }
73
91
  }
74
- async function relayMessage(msg, text, files) {
92
+ async function relayMessage(msg, externalChatId, text, files) {
75
93
  const { attachments, dropped } = await collectAttachments(files, { fetchFn: fetch, log: console });
76
94
  // Nothing survived on a file-only post: say so instead of relaying a
77
95
  // prompt about a file the agent never receives.
@@ -79,7 +97,7 @@ async function relayMessage(msg, text, files) {
79
97
  await msg.reply("Sorry, I could not download that attachment. Please try again.");
80
98
  return;
81
99
  }
82
- const ack = await mulmo.send(msg.channelId, resolveMessageText(text, dropped), attachments.length > 0 ? attachments : undefined);
100
+ const ack = await mulmo.send(externalChatId, resolveMessageText(text, dropped), attachments.length > 0 ? attachments : undefined);
83
101
  if (ack.ok) {
84
102
  await sendChunked(msg, ack.reply ?? "");
85
103
  }
@@ -113,6 +131,7 @@ discord.once("ready", () => {
113
131
  console.log("MulmoClaude Discord bridge");
114
132
  console.log(`Logged in as ${discord.user?.tag}`);
115
133
  console.log(`Channels: ${allowAll ? "(all)" : [...allowedChannels].join(", ")}`);
134
+ console.log(`Session granularity: ${granularity}`);
116
135
  });
117
136
  discord.login(token).catch((err) => {
118
137
  console.error("Fatal:", err);
@@ -0,0 +1,89 @@
1
+ export type SessionGranularity = "channel" | "thread";
2
+ /** Where a Discord message arrived.
3
+ *
4
+ * A Discord thread IS a channel — it carries its own snowflake, which is
5
+ * what `Message.channelId` holds. `parentChannel` is therefore present only
6
+ * for threads, and always names the text/forum channel the thread hangs
7
+ * off, never a category.
8
+ *
9
+ * `sendable` travels with the id because the two are only ever useful
10
+ * together: a forum channel is a legitimate allowlist entry but not a place
11
+ * a message can be posted, so folding a session onto it would strand the
12
+ * reply. Pairing them makes it impossible to record the id without saying
13
+ * which kind it is. */
14
+ export interface MessageChannelRef {
15
+ channelId: string;
16
+ parentChannel?: {
17
+ id: string;
18
+ sendable: boolean;
19
+ };
20
+ }
21
+ /** The slice of `Message.channel` read here. Both parent fields are optional
22
+ * because a DM channel has neither. */
23
+ interface ChannelLike {
24
+ isThread: () => boolean;
25
+ parentId?: string | null;
26
+ parent?: {
27
+ isSendable: () => boolean;
28
+ } | null;
29
+ }
30
+ /** Parse the DISCORD_SESSION_GRANULARITY env var into a safe enum value.
31
+ *
32
+ * Unset falls back to "thread", which is what the bridge has always done:
33
+ * it passes `msg.channelId` straight through, and for a message in a
34
+ * thread that id IS the thread's. Any explicit invalid value is rejected
35
+ * so operators notice the misconfiguration instead of silently getting
36
+ * some other mode. */
37
+ export declare function parseGranularity(raw: string | undefined): SessionGranularity;
38
+ /** Read the channel/parent pair off an incoming message.
39
+ *
40
+ * The `isThread()` gate is load-bearing rather than defensive: on a
41
+ * `GuildChannel`, `parentId` is the id of the CATEGORY the channel sits
42
+ * in, while on a `ThreadChannel` it is the parent text channel. Reading it
43
+ * ungated would hand a category id to the allowlist and to session
44
+ * keying.
45
+ *
46
+ * An uncached parent reads as not sendable. Unreachable as the bridge is
47
+ * configured — `Guild._patch` caches every channel in the GUILD_CREATE
48
+ * payload and the `Guilds` intent is always on — so this is only about which
49
+ * way a defensive branch fails. Not folding costs an extra session, which a
50
+ * user can see; folding onto a parent that turns out to be a forum costs the
51
+ * reply, which nobody sees. It never touches the allow decision, which runs
52
+ * off the id alone. */
53
+ export declare function readChannelRef(channelId: string, channel: ChannelLike): MessageChannelRef;
54
+ /** Is this message's channel covered by DISCORD_ALLOWED_CHANNELS?
55
+ *
56
+ * An empty allowlist is the "allow all" sentinel. A thread is admitted by
57
+ * its PARENT channel, which is the point: thread ids are minted fresh for
58
+ * every thread, so listing them is not an operable allowlist.
59
+ *
60
+ * The channel's own id still counts. For a thread that is the pre-existing
61
+ * workaround — paste the thread id into `.env` and restart — and dropping
62
+ * it would be a regression. */
63
+ export declare function isChannelAllowed(ref: MessageChannelRef, allowedChannels: ReadonlySet<string>): boolean;
64
+ /** The externalChatId the server keys a session by. Two messages with
65
+ * different ids get independent sessions.
66
+ *
67
+ * - "thread" → the channel's own id, so a thread is its own conversation.
68
+ * - "channel" → the parent channel for a thread, folding every thread
69
+ * under a channel into that channel's one conversation.
70
+ *
71
+ * Both are plain snowflakes, so the push path needs no reverse parse: the
72
+ * id the server hands back resolves through `channels.fetch()` whether it
73
+ * names a thread or a text channel.
74
+ *
75
+ * Folding is refused on two grounds, and both reduce to one invariant: a
76
+ * session id must name a channel the bridge may actually talk in.
77
+ *
78
+ * - A FORUM parent cannot be posted to at all (`isSendable()` is false —
79
+ * Discord has you open a post), so its id would key a session the server
80
+ * can never push a reply into; the push guard drops it with only a warn
81
+ * line. A forum post is its own conversation anyway.
82
+ * - A parent the ALLOWLIST does not cover is a channel the operator chose
83
+ * not to permit. A thread admitted by its own id (the legacy `.env`
84
+ * workaround) hangs off such a parent, and folding there would aim
85
+ * server-initiated pushes at it — `onPushEvent` does not consult the
86
+ * allowlist. Keeping the thread's own id is safe by construction: the
87
+ * message reached us, so that channel is permitted. */
88
+ export declare function buildExternalChatId(ref: MessageChannelRef, mode: SessionGranularity, allowedChannels: ReadonlySet<string>): string;
89
+ export {};
@@ -0,0 +1,89 @@
1
+ // Session-granularity helpers for @mulmobridge/discord.
2
+ //
3
+ // Kept separate from index.ts so they can be unit-tested without
4
+ // importing the bridge entrypoint (which opens a Gateway connection on
5
+ // load).
6
+ /** Parse the DISCORD_SESSION_GRANULARITY env var into a safe enum value.
7
+ *
8
+ * Unset falls back to "thread", which is what the bridge has always done:
9
+ * it passes `msg.channelId` straight through, and for a message in a
10
+ * thread that id IS the thread's. Any explicit invalid value is rejected
11
+ * so operators notice the misconfiguration instead of silently getting
12
+ * some other mode. */
13
+ export function parseGranularity(raw) {
14
+ const normalised = (raw ?? "thread").toLowerCase();
15
+ if (normalised === "channel" || normalised === "thread") {
16
+ return normalised;
17
+ }
18
+ throw new Error(`Invalid DISCORD_SESSION_GRANULARITY=${JSON.stringify(raw)}. Expected one of: channel, thread.`);
19
+ }
20
+ /** Read the channel/parent pair off an incoming message.
21
+ *
22
+ * The `isThread()` gate is load-bearing rather than defensive: on a
23
+ * `GuildChannel`, `parentId` is the id of the CATEGORY the channel sits
24
+ * in, while on a `ThreadChannel` it is the parent text channel. Reading it
25
+ * ungated would hand a category id to the allowlist and to session
26
+ * keying.
27
+ *
28
+ * An uncached parent reads as not sendable. Unreachable as the bridge is
29
+ * configured — `Guild._patch` caches every channel in the GUILD_CREATE
30
+ * payload and the `Guilds` intent is always on — so this is only about which
31
+ * way a defensive branch fails. Not folding costs an extra session, which a
32
+ * user can see; folding onto a parent that turns out to be a forum costs the
33
+ * reply, which nobody sees. It never touches the allow decision, which runs
34
+ * off the id alone. */
35
+ export function readChannelRef(channelId, channel) {
36
+ if (!channel.isThread())
37
+ return { channelId };
38
+ const parentId = channel.parentId ?? undefined;
39
+ if (parentId === undefined)
40
+ return { channelId };
41
+ return { channelId, parentChannel: { id: parentId, sendable: channel.parent?.isSendable() === true } };
42
+ }
43
+ /** Is this message's channel covered by DISCORD_ALLOWED_CHANNELS?
44
+ *
45
+ * An empty allowlist is the "allow all" sentinel. A thread is admitted by
46
+ * its PARENT channel, which is the point: thread ids are minted fresh for
47
+ * every thread, so listing them is not an operable allowlist.
48
+ *
49
+ * The channel's own id still counts. For a thread that is the pre-existing
50
+ * workaround — paste the thread id into `.env` and restart — and dropping
51
+ * it would be a regression. */
52
+ export function isChannelAllowed(ref, allowedChannels) {
53
+ if (allowedChannels.size === 0)
54
+ return true;
55
+ if (allowedChannels.has(ref.channelId))
56
+ return true;
57
+ return ref.parentChannel !== undefined && allowedChannels.has(ref.parentChannel.id);
58
+ }
59
+ /** The externalChatId the server keys a session by. Two messages with
60
+ * different ids get independent sessions.
61
+ *
62
+ * - "thread" → the channel's own id, so a thread is its own conversation.
63
+ * - "channel" → the parent channel for a thread, folding every thread
64
+ * under a channel into that channel's one conversation.
65
+ *
66
+ * Both are plain snowflakes, so the push path needs no reverse parse: the
67
+ * id the server hands back resolves through `channels.fetch()` whether it
68
+ * names a thread or a text channel.
69
+ *
70
+ * Folding is refused on two grounds, and both reduce to one invariant: a
71
+ * session id must name a channel the bridge may actually talk in.
72
+ *
73
+ * - A FORUM parent cannot be posted to at all (`isSendable()` is false —
74
+ * Discord has you open a post), so its id would key a session the server
75
+ * can never push a reply into; the push guard drops it with only a warn
76
+ * line. A forum post is its own conversation anyway.
77
+ * - A parent the ALLOWLIST does not cover is a channel the operator chose
78
+ * not to permit. A thread admitted by its own id (the legacy `.env`
79
+ * workaround) hangs off such a parent, and folding there would aim
80
+ * server-initiated pushes at it — `onPushEvent` does not consult the
81
+ * allowlist. Keeping the thread's own id is safe by construction: the
82
+ * message reached us, so that channel is permitted. */
83
+ export function buildExternalChatId(ref, mode, allowedChannels) {
84
+ const { channelId, parentChannel } = ref;
85
+ if (mode === "thread" || parentChannel === undefined)
86
+ return channelId;
87
+ const mayFold = parentChannel.sendable && isChannelAllowed({ channelId: parentChannel.id }, allowedChannels);
88
+ return mayFold ? parentChannel.id : channelId;
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/discord",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Discord bridge for MulmoBridge — connect a Discord bot to MulmoClaude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,14 +22,14 @@
22
22
  "license": "MIT",
23
23
  "author": "Receptron Team",
24
24
  "dependencies": {
25
- "@mulmobridge/client": "^1.2.0",
25
+ "@mulmobridge/client": "^1.3.0",
26
26
  "@mulmobridge/protocol": "^1.0.1",
27
27
  "@mulmoclaude/common": "^1.3.0",
28
28
  "discord.js": "^14.27.0",
29
29
  "dotenv": "^17.0.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/node": "^26.4.1",
32
+ "@types/node": "^26.6.2",
33
33
  "tsx": "^4.23.13",
34
34
  "typescript": "^6.0.3"
35
35
  },