@mulmobridge/discord 1.0.2 → 1.2.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 +13 -3
- package/dist/attachments.d.ts +44 -0
- package/dist/attachments.js +182 -0
- package/dist/index.js +30 -14
- package/package.json +7 -6
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
|
|
@@ -45,16 +53,18 @@ npx @mulmobridge/discord
|
|
|
45
53
|
|---|---|---|
|
|
46
54
|
| `DISCORD_BOT_TOKEN` | Yes | Bot token from Developer Portal |
|
|
47
55
|
| `DISCORD_ALLOWED_CHANNELS` | No | CSV of channel IDs to restrict (empty = all) |
|
|
48
|
-
| `MULMOCLAUDE_API_URL` | No | Default
|
|
56
|
+
| `MULMOCLAUDE_API_URL` | No | Default: auto (`.server-port`; waits if nothing is published) |
|
|
49
57
|
| `MULMOCLAUDE_AUTH_TOKEN` | No | Bearer token (auto-read from workspace) |
|
|
50
58
|
| `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. |
|
|
51
59
|
| `BRIDGE_DEFAULT_ROLE` | No | Same as above but shared across every bridge. Transport-specific `DISCORD_BRIDGE_DEFAULT_ROLE` wins when both are set. |
|
|
52
60
|
|
|
53
61
|
### Auth token persistence across server restarts
|
|
54
62
|
|
|
55
|
-
The MulmoClaude server regenerates a fresh bearer token on every startup and writes it to
|
|
63
|
+
The MulmoClaude server regenerates a fresh bearer token on every startup and writes it to `<workspace>/.session-token` (`$MULMOCLAUDE_WORKSPACE_PATH`, or `~/mulmoclaude` when unset), alongside the port it bound in `.server-port`.
|
|
64
|
+
|
|
65
|
+
**The bridge follows a restart on its own.** When the connection fails it re-reads both files, and if the server came back as a different generation — new token, new port, or both — it rebuilds its socket against it (#3078). You do not have to restart the bridge.
|
|
56
66
|
|
|
57
|
-
**
|
|
67
|
+
Pinning the token is still useful when the bridge runs **on a different machine** from the server, where it cannot read the workspace at all: set `MULMOCLAUDE_AUTH_TOKEN` to the same long random value on both sides. The server then uses it verbatim instead of regenerating.
|
|
58
68
|
|
|
59
69
|
```bash
|
|
60
70
|
# Server (one-time setup — same value across restarts)
|
|
@@ -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
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Optional:
|
|
8
8
|
// DISCORD_ALLOWED_CHANNELS — CSV of channel IDs (empty = allow all)
|
|
9
|
-
// MULMOCLAUDE_API_URL — default
|
|
9
|
+
// MULMOCLAUDE_API_URL — default: the port in <workspace>/.server-port.
|
|
10
|
+
// With none published the client waits (#3078)
|
|
10
11
|
// MULMOCLAUDE_AUTH_TOKEN — bearer token (or read from workspace)
|
|
11
12
|
import "dotenv/config";
|
|
12
13
|
import { Client, GatewayIntentBits, Partials } from "discord.js";
|
|
13
|
-
import { createBridgeClient } from "@mulmobridge/client";
|
|
14
|
+
import { createBridgeClient, installProcessGuards } from "@mulmobridge/client";
|
|
14
15
|
import { parseCsvSet } from "@mulmoclaude/common";
|
|
16
|
+
import { collectAttachments, resolveMessageText } from "./attachments.js";
|
|
15
17
|
const TRANSPORT_ID = "discord";
|
|
18
|
+
installProcessGuards({ name: TRANSPORT_ID });
|
|
16
19
|
const MAX_DISCORD_LENGTH = 2000;
|
|
17
20
|
const token = process.env.DISCORD_BOT_TOKEN;
|
|
18
21
|
if (!token) {
|
|
@@ -52,26 +55,39 @@ async function onMessageCreate(msg) {
|
|
|
52
55
|
if (msg.author.bot)
|
|
53
56
|
return;
|
|
54
57
|
const { channelId } = msg;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return;
|
|
58
|
+
// Checked before the attachment downloads so a denied channel never
|
|
59
|
+
// makes the bridge fetch anything.
|
|
58
60
|
if (!allowAll && !allowedChannels.has(channelId))
|
|
59
61
|
return;
|
|
60
|
-
|
|
62
|
+
const text = msg.content.trim();
|
|
63
|
+
const files = [...msg.attachments.values()];
|
|
64
|
+
if (text.length === 0 && files.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
console.log(`[discord] message channel=${channelId} user=${msg.author.tag} len=${text.length} attachments=${files.length}`);
|
|
61
67
|
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
|
-
}
|
|
68
|
+
await relayMessage(msg, text, files);
|
|
70
69
|
}
|
|
71
70
|
catch (err) {
|
|
72
71
|
console.error(`[discord] message handling failed: ${err}`);
|
|
73
72
|
}
|
|
74
73
|
}
|
|
74
|
+
async function relayMessage(msg, text, files) {
|
|
75
|
+
const { attachments, dropped } = await collectAttachments(files, { fetchFn: fetch, log: console });
|
|
76
|
+
// Nothing survived on a file-only post: say so instead of relaying a
|
|
77
|
+
// prompt about a file the agent never receives.
|
|
78
|
+
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"}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
75
91
|
async function sendChunked(msg, text) {
|
|
76
92
|
if (text.length === 0) {
|
|
77
93
|
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.2.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.2.0",
|
|
26
26
|
"@mulmobridge/protocol": "^1.0.1",
|
|
27
|
-
"@mulmoclaude/common": "^1.
|
|
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.1
|
|
32
|
+
"@types/node": "^26.4.1",
|
|
33
|
+
"tsx": "^4.23.13",
|
|
33
34
|
"typescript": "^6.0.3"
|
|
34
35
|
},
|
|
35
36
|
"homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/bridges/discord#readme",
|