@mulmobridge/discord 1.0.2 → 1.1.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 +8 -0
- package/dist/attachments.d.ts +44 -0
- package/dist/attachments.js +182 -0
- package/dist/index.js +26 -12
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
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
|
+
## Attachments
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
- Up to 10 files per message, 8 MB each — anything larger is skipped and noted in the message MulmoClaude receives.
|
|
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
|
+
- Attachments arrive over the **Message Content Intent**, the same privileged intent the bot already needs to read text.
|
|
14
|
+
|
|
7
15
|
## Setup
|
|
8
16
|
|
|
9
17
|
### 1. Create a Discord Application
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Attachment } from "@mulmobridge/protocol";
|
|
2
|
+
/** The fields of discord.js' `Attachment` this bridge reads. */
|
|
3
|
+
export interface DiscordAttachmentLike {
|
|
4
|
+
url: string;
|
|
5
|
+
name: string;
|
|
6
|
+
size: number;
|
|
7
|
+
contentType: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface CollectedAttachments {
|
|
10
|
+
attachments: Attachment[];
|
|
11
|
+
/** Attachments on the message that could not be forwarded — over the
|
|
12
|
+
* cap, past the count limit, or the download failed. */
|
|
13
|
+
dropped: number;
|
|
14
|
+
}
|
|
15
|
+
export type FetchFn = (url: string, init: RequestInit) => Promise<Response>;
|
|
16
|
+
export interface DownloadDeps {
|
|
17
|
+
fetchFn: FetchFn;
|
|
18
|
+
log?: {
|
|
19
|
+
warn: (message: string) => void;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Per-file byte cap. chat-service's own gate is 20 MB of base64
|
|
23
|
+
* across the whole message (~15 MB raw), so one file stays well
|
|
24
|
+
* under it. Discord's free-tier upload limit is 10 MB. */
|
|
25
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
26
|
+
/** chat-service's `parseAttachments` drops everything past the tenth
|
|
27
|
+
* entry, so downloading more than that is wasted bandwidth. */
|
|
28
|
+
export declare const MAX_ATTACHMENT_COUNT = 10;
|
|
29
|
+
/** …and it drops whatever pushes the message past this much base64,
|
|
30
|
+
* silently. Mirrored here so the bridge can say what it left out. */
|
|
31
|
+
export declare const MAX_TOTAL_BASE64_CHARS: number;
|
|
32
|
+
export declare function isDiscordCdnUrl(raw: string): boolean;
|
|
33
|
+
/** Discord's `contentType` carries parameters (`text/plain; charset=utf-8`)
|
|
34
|
+
* and is null for types it cannot sniff, so fall back to the filename. */
|
|
35
|
+
export declare function resolveMimeType(file: DiscordAttachmentLike): string;
|
|
36
|
+
export declare function downloadAttachment(file: DiscordAttachmentLike, deps: DownloadDeps): Promise<Attachment | null>;
|
|
37
|
+
/** Length of the base64 a payload of `byteLength` bytes encodes to —
|
|
38
|
+
* 4 characters per 3-byte group, rounded up. That string length, not
|
|
39
|
+
* the raw size, is what the server's per-message budget counts. */
|
|
40
|
+
export declare function base64Length(byteLength: number): number;
|
|
41
|
+
export declare function collectAttachments(files: DiscordAttachmentLike[], deps: DownloadDeps): Promise<CollectedAttachments>;
|
|
42
|
+
/** The body sent to MulmoClaude: a placeholder when the user posted
|
|
43
|
+
* only files, plus a note when some of them were lost. */
|
|
44
|
+
export declare function resolveMessageText(text: string, dropped: number): string;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Attachment handling for the Discord bridge (#2939). Kept out of
|
|
2
|
+
// `index.ts` so the download rules can be exercised with a stubbed
|
|
3
|
+
// fetch — no gateway, no CDN, no env reads.
|
|
4
|
+
import { mimeFromExtension } from "@mulmobridge/client";
|
|
5
|
+
// Discord serves message attachments from its own CDN. A URL pointing
|
|
6
|
+
// anywhere else means the payload was tampered with, so it is refused
|
|
7
|
+
// rather than turned into a server-side request forgery primitive.
|
|
8
|
+
const ALLOWED_HOST_SUFFIXES = [".discordapp.com", ".discordapp.net", ".discord.com"];
|
|
9
|
+
/** Per-file byte cap. chat-service's own gate is 20 MB of base64
|
|
10
|
+
* across the whole message (~15 MB raw), so one file stays well
|
|
11
|
+
* under it. Discord's free-tier upload limit is 10 MB. */
|
|
12
|
+
export const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
/** chat-service's `parseAttachments` drops everything past the tenth
|
|
14
|
+
* entry, so downloading more than that is wasted bandwidth. */
|
|
15
|
+
export const MAX_ATTACHMENT_COUNT = 10;
|
|
16
|
+
/** …and it drops whatever pushes the message past this much base64,
|
|
17
|
+
* silently. Mirrored here so the bridge can say what it left out. */
|
|
18
|
+
export const MAX_TOTAL_BASE64_CHARS = 20 * 1024 * 1024;
|
|
19
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
20
|
+
const FALLBACK_MIME = "application/octet-stream";
|
|
21
|
+
/** chat-service rejects an empty `text`, so an attachment-only message
|
|
22
|
+
* needs a body. Same wording as the Telegram and LINE bridges — an
|
|
23
|
+
* instruction, so the agent treats the file as the subject. */
|
|
24
|
+
const ATTACHMENT_ONLY_PROMPT = "Describe / analyze this file.";
|
|
25
|
+
const LOG_LABEL_MAX_CHARS = 100;
|
|
26
|
+
const UNPRINTABLE_RE = /[^\p{L}\p{N}\p{P}\p{S}\p{Zs}]/gu;
|
|
27
|
+
const defaultLog = { warn: (message) => console.warn(message) };
|
|
28
|
+
export function isDiscordCdnUrl(raw) {
|
|
29
|
+
const url = parseUrl(raw);
|
|
30
|
+
if (url === null || url.protocol !== "https:")
|
|
31
|
+
return false;
|
|
32
|
+
const hostname = url.hostname.toLowerCase();
|
|
33
|
+
return ALLOWED_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
|
|
34
|
+
}
|
|
35
|
+
function parseUrl(raw) {
|
|
36
|
+
try {
|
|
37
|
+
return new URL(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Discord's `contentType` carries parameters (`text/plain; charset=utf-8`)
|
|
44
|
+
* and is null for types it cannot sniff, so fall back to the filename. */
|
|
45
|
+
export function resolveMimeType(file) {
|
|
46
|
+
const declared = (file.contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
47
|
+
if (declared.length > 0)
|
|
48
|
+
return declared;
|
|
49
|
+
const dot = file.name.lastIndexOf(".");
|
|
50
|
+
if (dot < 0 || dot === file.name.length - 1)
|
|
51
|
+
return FALLBACK_MIME;
|
|
52
|
+
return mimeFromExtension(file.name.slice(dot + 1), FALLBACK_MIME);
|
|
53
|
+
}
|
|
54
|
+
/** Why this file cannot be fetched at all, or null when it may be. */
|
|
55
|
+
function refuseReason(file) {
|
|
56
|
+
if (!isDiscordCdnUrl(file.url))
|
|
57
|
+
return "not served from a Discord CDN host";
|
|
58
|
+
if (file.size > MAX_ATTACHMENT_BYTES)
|
|
59
|
+
return `${file.size} bytes is over the ${MAX_ATTACHMENT_BYTES} byte cap`;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** A filename reaches the log verbatim; control characters in it would
|
|
63
|
+
* forge log lines, so keep only printable categories. */
|
|
64
|
+
function logLabel(name) {
|
|
65
|
+
return name.replace(UNPRINTABLE_RE, " ").slice(0, LOG_LABEL_MAX_CHARS);
|
|
66
|
+
}
|
|
67
|
+
async function drainCapped(reader, maxBytes) {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let total = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const { done, value } = await reader.read();
|
|
72
|
+
if (done)
|
|
73
|
+
return Buffer.concat(chunks);
|
|
74
|
+
total += value.byteLength;
|
|
75
|
+
if (total > maxBytes) {
|
|
76
|
+
await reader.cancel();
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
chunks.push(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Read at most `maxBytes`, aborting mid-stream rather than after the
|
|
83
|
+
* fact — `arrayBuffer()` would have already materialised the body. */
|
|
84
|
+
async function readCappedBody(res, maxBytes) {
|
|
85
|
+
const { body } = res;
|
|
86
|
+
if (body === null)
|
|
87
|
+
return null;
|
|
88
|
+
const declared = Number(res.headers.get("content-length") ?? "");
|
|
89
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
90
|
+
// Returning without draining would leave the connection open until
|
|
91
|
+
// the socket times out, one leak per oversized attachment.
|
|
92
|
+
await body.cancel();
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return drainCapped(body.getReader(), maxBytes);
|
|
96
|
+
}
|
|
97
|
+
export async function downloadAttachment(file, deps) {
|
|
98
|
+
const log = deps.log ?? defaultLog;
|
|
99
|
+
const refused = refuseReason(file);
|
|
100
|
+
if (refused !== null) {
|
|
101
|
+
log.warn(`[discord] attachment skipped: ${logLabel(file.name)} — ${refused}`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const res = await deps.fetchFn(file.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: "error" });
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
log.warn(`[discord] attachment fetch failed: ${logLabel(file.name)} — HTTP ${res.status}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const bytes = await readCappedBody(res, MAX_ATTACHMENT_BYTES);
|
|
111
|
+
if (bytes === null) {
|
|
112
|
+
log.warn(`[discord] attachment skipped: ${logLabel(file.name)} — body over the ${MAX_ATTACHMENT_BYTES} byte cap`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return { mimeType: resolveMimeType(file), data: bytes.toString("base64"), filename: file.name };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
log.warn(`[discord] attachment download failed: ${logLabel(file.name)} — ${err}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Length of the base64 a payload of `byteLength` bytes encodes to —
|
|
123
|
+
* 4 characters per 3-byte group, rounded up. That string length, not
|
|
124
|
+
* the raw size, is what the server's per-message budget counts. */
|
|
125
|
+
export function base64Length(byteLength) {
|
|
126
|
+
return Math.ceil(byteLength / 3) * 4;
|
|
127
|
+
}
|
|
128
|
+
/** Spend the server's per-message budget before anything is downloaded —
|
|
129
|
+
* Discord states each file's size up front. Without this the bridge
|
|
130
|
+
* would ship attachments that `parseAttachments` silently truncates,
|
|
131
|
+
* so the user is never told a file went missing. A file that does not
|
|
132
|
+
* fit is skipped rather than ending the loop: a smaller one after it
|
|
133
|
+
* may still fit. */
|
|
134
|
+
function withinBudget(files) {
|
|
135
|
+
const considered = [];
|
|
136
|
+
const skipped = [];
|
|
137
|
+
let remaining = MAX_TOTAL_BASE64_CHARS;
|
|
138
|
+
files.forEach((file) => {
|
|
139
|
+
const cost = base64Length(file.size);
|
|
140
|
+
if (considered.length >= MAX_ATTACHMENT_COUNT || cost > remaining) {
|
|
141
|
+
skipped.push(file);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
remaining -= cost;
|
|
145
|
+
considered.push(file);
|
|
146
|
+
});
|
|
147
|
+
return { considered, skipped };
|
|
148
|
+
}
|
|
149
|
+
/** Re-check the budget against the bytes actually in hand. `withinBudget`
|
|
150
|
+
* spends it on Discord's declared sizes; this is what makes the guarantee
|
|
151
|
+
* hold if a declared size ever under-reports, since the server would
|
|
152
|
+
* otherwise truncate the overflow without telling anyone. */
|
|
153
|
+
function fitBudget(attachments) {
|
|
154
|
+
const kept = [];
|
|
155
|
+
let remaining = MAX_TOTAL_BASE64_CHARS;
|
|
156
|
+
attachments.forEach((attachment) => {
|
|
157
|
+
const cost = attachment.data?.length ?? 0;
|
|
158
|
+
if (cost > remaining)
|
|
159
|
+
return;
|
|
160
|
+
remaining -= cost;
|
|
161
|
+
kept.push(attachment);
|
|
162
|
+
});
|
|
163
|
+
return kept;
|
|
164
|
+
}
|
|
165
|
+
export async function collectAttachments(files, deps) {
|
|
166
|
+
const log = deps.log ?? defaultLog;
|
|
167
|
+
const { considered, skipped } = withinBudget(files);
|
|
168
|
+
skipped.forEach((file) => log.warn(`[discord] attachment skipped: ${logLabel(file.name)} — over what one message may carry`));
|
|
169
|
+
const results = await Promise.all(considered.map((file) => downloadAttachment(file, deps)));
|
|
170
|
+
const attachments = fitBudget(results.filter((entry) => entry !== null));
|
|
171
|
+
return { attachments, dropped: files.length - attachments.length };
|
|
172
|
+
}
|
|
173
|
+
/** The body sent to MulmoClaude: a placeholder when the user posted
|
|
174
|
+
* only files, plus a note when some of them were lost. */
|
|
175
|
+
export function resolveMessageText(text, dropped) {
|
|
176
|
+
const trimmed = text.trim();
|
|
177
|
+
const body = trimmed.length > 0 ? trimmed : ATTACHMENT_ONLY_PROMPT;
|
|
178
|
+
if (dropped <= 0)
|
|
179
|
+
return body;
|
|
180
|
+
const plural = dropped === 1 ? "file" : "files";
|
|
181
|
+
return `${body}\n\n(note: ${dropped} attached ${plural} could not be downloaded)`;
|
|
182
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import "dotenv/config";
|
|
|
12
12
|
import { Client, GatewayIntentBits, Partials } from "discord.js";
|
|
13
13
|
import { createBridgeClient } from "@mulmobridge/client";
|
|
14
14
|
import { parseCsvSet } from "@mulmoclaude/common";
|
|
15
|
+
import { collectAttachments, resolveMessageText } from "./attachments.js";
|
|
15
16
|
const TRANSPORT_ID = "discord";
|
|
16
17
|
const MAX_DISCORD_LENGTH = 2000;
|
|
17
18
|
const token = process.env.DISCORD_BOT_TOKEN;
|
|
@@ -52,26 +53,39 @@ async function onMessageCreate(msg) {
|
|
|
52
53
|
if (msg.author.bot)
|
|
53
54
|
return;
|
|
54
55
|
const { channelId } = msg;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return;
|
|
56
|
+
// Checked before the attachment downloads so a denied channel never
|
|
57
|
+
// makes the bridge fetch anything.
|
|
58
58
|
if (!allowAll && !allowedChannels.has(channelId))
|
|
59
59
|
return;
|
|
60
|
-
|
|
60
|
+
const text = msg.content.trim();
|
|
61
|
+
const files = [...msg.attachments.values()];
|
|
62
|
+
if (text.length === 0 && files.length === 0)
|
|
63
|
+
return;
|
|
64
|
+
console.log(`[discord] message channel=${channelId} user=${msg.author.tag} len=${text.length} attachments=${files.length}`);
|
|
61
65
|
try {
|
|
62
|
-
|
|
63
|
-
if (ack.ok) {
|
|
64
|
-
await sendChunked(msg, ack.reply ?? "");
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
const status = ack.status ? ` (${ack.status})` : "";
|
|
68
|
-
await msg.reply(`Error${status}: ${ack.error ?? "unknown"}`);
|
|
69
|
-
}
|
|
66
|
+
await relayMessage(msg, text, files);
|
|
70
67
|
}
|
|
71
68
|
catch (err) {
|
|
72
69
|
console.error(`[discord] message handling failed: ${err}`);
|
|
73
70
|
}
|
|
74
71
|
}
|
|
72
|
+
async function relayMessage(msg, text, files) {
|
|
73
|
+
const { attachments, dropped } = await collectAttachments(files, { fetchFn: fetch, log: console });
|
|
74
|
+
// Nothing survived on a file-only post: say so instead of relaying a
|
|
75
|
+
// prompt about a file the agent never receives.
|
|
76
|
+
if (text.length === 0 && attachments.length === 0) {
|
|
77
|
+
await msg.reply("Sorry, I could not download that attachment. Please try again.");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const ack = await mulmo.send(msg.channelId, resolveMessageText(text, dropped), attachments.length > 0 ? attachments : undefined);
|
|
81
|
+
if (ack.ok) {
|
|
82
|
+
await sendChunked(msg, ack.reply ?? "");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const status = ack.status ? ` (${ack.status})` : "";
|
|
86
|
+
await msg.reply(`Error${status}: ${ack.error ?? "unknown"}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
75
89
|
async function sendChunked(msg, text) {
|
|
76
90
|
if (text.length === 0) {
|
|
77
91
|
await msg.reply("(empty reply)");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmobridge/discord",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Discord bridge for MulmoBridge — connect a Discord bot to MulmoClaude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,20 +16,21 @@
|
|
|
16
16
|
"build": "tsc",
|
|
17
17
|
"prepack": "yarn build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"test": "
|
|
20
|
-
"lint": "eslint src"
|
|
19
|
+
"test": "tsx --test test/test_*.ts",
|
|
20
|
+
"lint": "eslint src test"
|
|
21
21
|
},
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"author": "Receptron Team",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@mulmobridge/client": "^1.0.
|
|
25
|
+
"@mulmobridge/client": "^1.0.2",
|
|
26
26
|
"@mulmobridge/protocol": "^1.0.1",
|
|
27
|
-
"@mulmoclaude/common": "^1.
|
|
27
|
+
"@mulmoclaude/common": "^1.2.0",
|
|
28
28
|
"discord.js": "^14.27.0",
|
|
29
29
|
"dotenv": "^17.0.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^26.1.2",
|
|
33
|
+
"tsx": "^4.23.5",
|
|
33
34
|
"typescript": "^6.0.3"
|
|
34
35
|
},
|
|
35
36
|
"homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/bridges/discord#readme",
|