@mulmobridge/discord 1.2.1 → 1.4.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
@@ -4,6 +4,10 @@
4
4
 
5
5
  Discord bridge for [MulmoClaude](https://github.com/receptron/mulmoclaude). The bot responds to messages in channels it has access to.
6
6
 
7
+ ## Typing indicator
8
+
9
+ From the moment a message arrives until the reply is posted, the bot shows "typing…" in the channel, refreshed before Discord lets it lapse, so a long agent turn reads as "working" rather than "bridge is down". It needs no extra permission or setting. A failed refresh is only logged.
10
+
7
11
  ## Attachments
8
12
 
9
13
  Files posted with a message are downloaded from Discord's CDN and forwarded to MulmoClaude, so you can paste a screenshot and ask about it. A post with **only** files and no text is forwarded too, with `Describe / analyze this file.` as the body.
@@ -12,6 +16,67 @@ Files posted with a message are downloaded from Discord's CDN and forwarded to M
12
16
  - Images and PDFs reach Claude directly; text, DOCX, XLSX and PPTX are converted server-side. Other types are skipped with a log line.
13
17
  - Attachments arrive over the **Message Content Intent**, the same privileged intent the bot already needs to read text.
14
18
 
19
+ ## Threads
20
+
21
+ 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.
22
+
23
+ > **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).
24
+
25
+ ### The allowlist judges a thread by its parent channel
26
+
27
+ `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.
28
+
29
+ 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.
30
+
31
+ ### `DISCORD_SESSION_GRANULARITY` — one session per thread, or per channel
32
+
33
+ > **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.
34
+
35
+ #### 🧵 `thread` (default) — a thread is its own conversation
36
+
37
+ ```text
38
+ #ai-help
39
+ ├── Alice: "Summarize yesterday's standup" → session: #ai-help
40
+ │
41
+ ├── 🧵 Deploy question
42
+ │ ├── Bob: "What's the rollback procedure?" ┐
43
+ │ └── Bob: "And who approves it?" ┘ → its own session
44
+ │
45
+ └── 🧵 Translation task
46
+ ├── Alice: "Translate the release notes" ┐
47
+ └── Alice: "Now make it shorter" ┘ → its own session
48
+ ```
49
+
50
+ 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.
51
+
52
+ #### 🗂 `channel` — every thread joins its parent channel's conversation
53
+
54
+ ```text
55
+ #ai-help
56
+ ├── Alice: "Summarize yesterday's standup" ┐
57
+ ├── 🧵 Deploy question │
58
+ │ └── Bob: "What's the rollback procedure?" ├ one session, keyed by #ai-help
59
+ └── 🧵 Translation task │
60
+ └── Alice: "Translate the release notes" ┘
61
+ ```
62
+
63
+ 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.
64
+
65
+ | | `thread` *(default)* | `channel` |
66
+ |---|---|---|
67
+ | Message in a channel | channel session | channel session |
68
+ | Message in a thread | **thread session** | **parent channel session** |
69
+ | Post in a **forum** channel | thread session | thread session |
70
+ | DM | one session per DM | one session per DM |
71
+
72
+ Notes:
73
+
74
+ - **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`.
75
+ - **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.
76
+ - 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.
77
+ - 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.
78
+ - 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.
79
+
15
80
  ## Setup
16
81
 
17
82
  ### 1. Create a Discord Application
@@ -29,7 +94,9 @@ Files posted with a message are downloaded from Discord's CDN and forwarded to M
29
94
 
30
95
  **OAuth2** → **URL Generator**:
31
96
  - Scopes: `bot`
32
- - Permissions: `Send Messages`, `Read Message History`
97
+ - Permissions: `Send Messages`, `Send Messages in Threads`, `Read Message History`
98
+
99
+ `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
100
 
34
101
  Copy the generated URL and open it in your browser to invite the bot.
35
102
 
@@ -52,7 +119,8 @@ npx @mulmobridge/discord
52
119
  | Variable | Required | Description |
53
120
  |---|---|---|
54
121
  | `DISCORD_BOT_TOKEN` | Yes | Bot token from Developer Portal |
55
- | `DISCORD_ALLOWED_CHANNELS` | No | CSV of channel IDs to restrict (empty = all) |
122
+ | `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) |
123
+ | `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
124
  | `MULMOCLAUDE_API_URL` | No | Default: auto (`.server-port`; waits if nothing is published) |
57
125
  | `MULMOCLAUDE_AUTH_TOKEN` | No | Bearer token (auto-read from workspace) |
58
126
  | `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,8 @@ 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";
22
+ import { replyAfterTyping } from "./typing.js";
17
23
  const TRANSPORT_ID = "discord";
18
24
  installProcessGuards({ name: TRANSPORT_ID });
19
25
  const MAX_DISCORD_LENGTH = 2000;
@@ -24,6 +30,18 @@ if (!token) {
24
30
  }
25
31
  const allowedChannels = parseCsvSet(process.env.DISCORD_ALLOWED_CHANNELS);
26
32
  const allowAll = allowedChannels.size === 0;
33
+ // The explicit `T` return annotation is what lets TS see `process.exit`'s
34
+ // `never` and accept the catch branch as non-returning.
35
+ function parseEnvOrExit(parse) {
36
+ try {
37
+ return parse();
38
+ }
39
+ catch (err) {
40
+ console.error(`[discord] ${err instanceof Error ? err.message : String(err)}`);
41
+ }
42
+ return process.exit(1);
43
+ }
44
+ const granularity = parseEnvOrExit(() => parseGranularity(process.env.DISCORD_SESSION_GRANULARITY));
27
45
  const discord = new Client({
28
46
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent],
29
47
  // Partials.Channel is required to receive DM messageCreate events —
@@ -54,39 +72,46 @@ discord.on("messageCreate", (msg) => {
54
72
  async function onMessageCreate(msg) {
55
73
  if (msg.author.bot)
56
74
  return;
57
- const { channelId } = msg;
75
+ const channelRef = readChannelRef(msg.channelId, msg.channel);
58
76
  // Checked before the attachment downloads so a denied channel never
59
77
  // makes the bridge fetch anything.
60
- if (!allowAll && !allowedChannels.has(channelId))
78
+ if (!isChannelAllowed(channelRef, allowedChannels))
61
79
  return;
62
80
  const text = msg.content.trim();
63
81
  const files = [...msg.attachments.values()];
64
82
  if (text.length === 0 && files.length === 0)
65
83
  return;
66
- console.log(`[discord] message channel=${channelId} user=${msg.author.tag} len=${text.length} attachments=${files.length}`);
84
+ const externalChatId = buildExternalChatId(channelRef, granularity, allowedChannels);
85
+ console.log(`[discord] message channel=${channelRef.channelId} parent=${channelRef.parentChannel?.id ?? "-"} session=${externalChatId} user=${msg.author.tag} len=${text.length} attachments=${files.length}`);
67
86
  try {
68
- await relayMessage(msg, text, files);
87
+ await relayMessage(msg, externalChatId, text, files);
69
88
  }
70
89
  catch (err) {
71
90
  console.error(`[discord] message handling failed: ${err}`);
72
91
  }
73
92
  }
74
- async function relayMessage(msg, text, files) {
93
+ async function relayMessage(msg, externalChatId, text, files) {
94
+ await replyAfterTyping(() => sendTypingTo(msg), {
95
+ produce: () => produceReply(externalChatId, text, files),
96
+ post: (reply) => (reply.chunked ? sendChunked(msg, reply.text) : msg.reply(reply.text)),
97
+ }, { log: console });
98
+ }
99
+ async function sendTypingTo(msg) {
100
+ if (msg.channel.isSendable())
101
+ await msg.channel.sendTyping();
102
+ }
103
+ async function produceReply(externalChatId, text, files) {
75
104
  const { attachments, dropped } = await collectAttachments(files, { fetchFn: fetch, log: console });
76
105
  // Nothing survived on a file-only post: say so instead of relaying a
77
106
  // prompt about a file the agent never receives.
78
107
  if (text.length === 0 && attachments.length === 0) {
79
- await msg.reply("Sorry, I could not download that attachment. Please try again.");
80
- return;
81
- }
82
- const ack = await mulmo.send(msg.channelId, resolveMessageText(text, dropped), attachments.length > 0 ? attachments : undefined);
83
- if (ack.ok) {
84
- await sendChunked(msg, ack.reply ?? "");
85
- }
86
- else {
87
- const status = ack.status ? ` (${ack.status})` : "";
88
- await msg.reply(`Error${status}: ${ack.error ?? "unknown"}`);
108
+ return { text: "Sorry, I could not download that attachment. Please try again.", chunked: false };
89
109
  }
110
+ const ack = await mulmo.send(externalChatId, resolveMessageText(text, dropped), attachments.length > 0 ? attachments : undefined);
111
+ if (ack.ok)
112
+ return { text: ack.reply ?? "", chunked: true };
113
+ const status = ack.status ? ` (${ack.status})` : "";
114
+ return { text: `Error${status}: ${ack.error ?? "unknown"}`, chunked: false };
90
115
  }
91
116
  async function sendChunked(msg, text) {
92
117
  if (text.length === 0) {
@@ -113,6 +138,7 @@ discord.once("ready", () => {
113
138
  console.log("MulmoClaude Discord bridge");
114
139
  console.log(`Logged in as ${discord.user?.tag}`);
115
140
  console.log(`Channels: ${allowAll ? "(all)" : [...allowedChannels].join(", ")}`);
141
+ console.log(`Session granularity: ${granularity}`);
116
142
  });
117
143
  discord.login(token).catch((err) => {
118
144
  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
+ }
@@ -0,0 +1,29 @@
1
+ export declare const TYPING_REFRESH_MS = 8000;
2
+ export declare const STOP_WAIT_MS = 2000;
3
+ export type SendTyping = () => Promise<unknown>;
4
+ type Cancel = () => void;
5
+ export interface TypingTimers {
6
+ every: (callback: () => void, delay_ms: number) => Cancel;
7
+ after: (callback: () => void, delay_ms: number) => Cancel;
8
+ }
9
+ export interface TypingIndicatorOptions {
10
+ log: {
11
+ warn: (message: string) => void;
12
+ };
13
+ timers?: TypingTimers;
14
+ refreshMs?: number;
15
+ stopWaitMs?: number;
16
+ }
17
+ export interface TypingIndicator {
18
+ /** Resolves once no typing request is in flight, or after the wait cap. */
19
+ stop: () => Promise<void>;
20
+ }
21
+ export declare function startTypingIndicator(sendTyping: SendTyping, options: TypingIndicatorOptions): TypingIndicator;
22
+ /** Runs `work` with the indicator up; it is fully down by the time this resolves or rejects. */
23
+ export declare function runWhileTyping<T>(sendTyping: SendTyping, work: () => Promise<T>, options: TypingIndicatorOptions): Promise<T>;
24
+ /** Posts only once the indicator is fully down: a typing request that crosses the post lands after it and leaves "typing…" up on an answered message. */
25
+ export declare function replyAfterTyping<T>(sendTyping: SendTyping, steps: {
26
+ produce: () => Promise<T>;
27
+ post: (reply: T) => Promise<unknown>;
28
+ }, options: TypingIndicatorOptions): Promise<void>;
29
+ export {};
package/dist/typing.js ADDED
@@ -0,0 +1,67 @@
1
+ // Keeps Discord's "typing…" indicator up while the bridge waits for a reply.
2
+ // Discord drops the indicator about 10 s after each send.
3
+ export const TYPING_REFRESH_MS = 8_000;
4
+ // A hung typing request must never hold the reply back.
5
+ export const STOP_WAIT_MS = 2_000;
6
+ const nodeTimers = {
7
+ every: (callback, delay_ms) => {
8
+ const handle = setInterval(callback, delay_ms);
9
+ return () => clearInterval(handle);
10
+ },
11
+ after: (callback, delay_ms) => {
12
+ const handle = setTimeout(callback, delay_ms);
13
+ return () => clearTimeout(handle);
14
+ },
15
+ };
16
+ export function startTypingIndicator(sendTyping, options) {
17
+ const timers = options.timers ?? nodeTimers;
18
+ let inFlight = null;
19
+ let stopped = false;
20
+ const tick = () => {
21
+ if (stopped || inFlight)
22
+ return;
23
+ inFlight = sendTypingSafely(sendTyping, options.log).finally(() => {
24
+ inFlight = null;
25
+ });
26
+ };
27
+ tick();
28
+ const cancelRefresh = timers.every(tick, options.refreshMs ?? TYPING_REFRESH_MS);
29
+ const stop = async () => {
30
+ stopped = true;
31
+ cancelRefresh();
32
+ if (inFlight)
33
+ await waitAtMost(inFlight, options.stopWaitMs ?? STOP_WAIT_MS, timers);
34
+ };
35
+ return { stop };
36
+ }
37
+ /** Runs `work` with the indicator up; it is fully down by the time this resolves or rejects. */
38
+ export async function runWhileTyping(sendTyping, work, options) {
39
+ const typing = startTypingIndicator(sendTyping, options);
40
+ try {
41
+ return await work();
42
+ }
43
+ finally {
44
+ await typing.stop();
45
+ }
46
+ }
47
+ /** Posts only once the indicator is fully down: a typing request that crosses the post lands after it and leaves "typing…" up on an answered message. */
48
+ export async function replyAfterTyping(sendTyping, steps, options) {
49
+ const reply = await runWhileTyping(sendTyping, steps.produce, options);
50
+ await steps.post(reply);
51
+ }
52
+ async function sendTypingSafely(sendTyping, log) {
53
+ try {
54
+ await sendTyping();
55
+ }
56
+ catch (err) {
57
+ log.warn(`[discord] sendTyping failed: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ }
60
+ async function waitAtMost(pending, limit_ms, timers) {
61
+ let cancelGiveUp = () => { };
62
+ const giveUp = new Promise((resolve) => {
63
+ cancelGiveUp = timers.after(resolve, limit_ms);
64
+ });
65
+ await Promise.race([pending, giveUp]);
66
+ cancelGiveUp();
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/discord",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Discord bridge for MulmoBridge — connect a Discord bot to MulmoClaude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,22 +15,22 @@
15
15
  "scripts": {
16
16
  "build": "tsc",
17
17
  "prepack": "yarn build",
18
- "typecheck": "tsc --noEmit",
18
+ "typecheck": "tsc -p tsconfig.typecheck.json",
19
19
  "test": "tsx --test test/test_*.ts",
20
20
  "lint": "eslint src test"
21
21
  },
22
22
  "license": "MIT",
23
23
  "author": "Receptron Team",
24
24
  "dependencies": {
25
- "@mulmobridge/client": "^1.3.0",
26
- "@mulmobridge/protocol": "^1.0.1",
27
- "@mulmoclaude/common": "^1.3.0",
25
+ "@mulmobridge/client": "^1.4.2",
26
+ "@mulmobridge/protocol": "^1.1.0",
27
+ "@mulmoclaude/common": "^1.5.0",
28
28
  "discord.js": "^14.27.0",
29
- "dotenv": "^17.0.0"
29
+ "dotenv": "^18.0.8"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/node": "^26.5.1",
33
- "tsx": "^4.23.13",
32
+ "@types/node": "^26.6.5",
33
+ "tsx": "^4.23.15",
34
34
  "typescript": "^6.0.3"
35
35
  },
36
36
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/bridges/discord#readme",