@mulmobridge/discord 1.3.0 → 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 +4 -0
- package/dist/index.js +16 -9
- package/dist/typing.d.ts +29 -0
- package/dist/typing.js +67 -0
- package/package.json +8 -8
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.
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { createBridgeClient, installProcessGuards } from "@mulmobridge/client";
|
|
|
19
19
|
import { parseCsvSet } from "@mulmoclaude/common";
|
|
20
20
|
import { collectAttachments, resolveMessageText } from "./attachments.js";
|
|
21
21
|
import { buildExternalChatId, isChannelAllowed, parseGranularity, readChannelRef } from "./sessionId.js";
|
|
22
|
+
import { replyAfterTyping } from "./typing.js";
|
|
22
23
|
const TRANSPORT_ID = "discord";
|
|
23
24
|
installProcessGuards({ name: TRANSPORT_ID });
|
|
24
25
|
const MAX_DISCORD_LENGTH = 2000;
|
|
@@ -90,21 +91,27 @@ async function onMessageCreate(msg) {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
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) {
|
|
93
104
|
const { attachments, dropped } = await collectAttachments(files, { fetchFn: fetch, log: console });
|
|
94
105
|
// Nothing survived on a file-only post: say so instead of relaying a
|
|
95
106
|
// prompt about a file the agent never receives.
|
|
96
107
|
if (text.length === 0 && attachments.length === 0) {
|
|
97
|
-
|
|
98
|
-
return;
|
|
108
|
+
return { text: "Sorry, I could not download that attachment. Please try again.", chunked: false };
|
|
99
109
|
}
|
|
100
110
|
const ack = await mulmo.send(externalChatId, resolveMessageText(text, dropped), attachments.length > 0 ? attachments : undefined);
|
|
101
|
-
if (ack.ok)
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const status = ack.status ? ` (${ack.status})` : "";
|
|
106
|
-
await msg.reply(`Error${status}: ${ack.error ?? "unknown"}`);
|
|
107
|
-
}
|
|
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 };
|
|
108
115
|
}
|
|
109
116
|
async function sendChunked(msg, text) {
|
|
110
117
|
if (text.length === 0) {
|
package/dist/typing.d.ts
ADDED
|
@@ -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.
|
|
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
|
|
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.
|
|
26
|
-
"@mulmobridge/protocol": "^1.0
|
|
27
|
-
"@mulmoclaude/common": "^1.
|
|
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": "^
|
|
29
|
+
"dotenv": "^18.0.8"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/node": "^26.6.
|
|
33
|
-
"tsx": "^4.23.
|
|
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",
|