@mulmobridge/discord 1.2.1 → 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 +66 -2
- package/dist/index.js +26 -7
- package/dist/sessionId.d.ts +89 -0
- package/dist/sessionId.js +89 -0
- package/package.json +2 -2
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
|
|
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 (!
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dotenv": "^17.0.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/node": "^26.
|
|
32
|
+
"@types/node": "^26.6.2",
|
|
33
33
|
"tsx": "^4.23.13",
|
|
34
34
|
"typescript": "^6.0.3"
|
|
35
35
|
},
|