@nextclaw/channel-extension-discord 0.1.1-beta.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NextClaw contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ export { };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/main.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/main.js ADDED
@@ -0,0 +1,12 @@
1
+ import { DiscordChannel } from "./services/discord-channel.service.js";
2
+ import { startBusChannelExtension, warnNcpEventError } from "@nextclaw/extension-sdk";
3
+ //#region src/main.ts
4
+ await startBusChannelExtension({
5
+ channelId: "discord",
6
+ createChannel: ({ config, bus, channel }) => new DiscordChannel(config, bus, channel.commands),
7
+ onChannelStartError: warnNcpEventError("discord")
8
+ });
9
+ //#endregion
10
+ export {};
11
+
12
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","names":[],"sources":["../src/main.ts"],"sourcesContent":["import type { Config, MessageBus } from \"@nextclaw/core\";\nimport { startBusChannelExtension, warnNcpEventError } from \"@nextclaw/extension-sdk\";\nimport { DiscordChannel } from \"./services/discord-channel.service.js\";\n\nawait startBusChannelExtension<Config[\"channels\"][\"discord\"], MessageBus>({\n channelId: \"discord\",\n createChannel: ({ config, bus, channel }) => new DiscordChannel(config, bus, channel.commands),\n onChannelStartError: warnNcpEventError(\"discord\"),\n});\n"],"mappings":";;;AAIA,MAAM,yBAAoE;CACxE,WAAW;CACX,gBAAgB,EAAE,QAAQ,KAAK,cAAc,IAAI,eAAe,QAAQ,KAAK,QAAQ,SAAS;CAC9F,qBAAqB,kBAAkB,UAAU;CAClD,CAAC"}
@@ -0,0 +1,479 @@
1
+ import { buildAttachmentSummary, chunkDiscordText, guessMimeFromName, resolveDiscordStreamingMode, resolveDraftChunkConfig, resolveTextChunkLimit, sanitizeAttachmentName, sendDiscordChunks } from "../utils/discord-text.utils.js";
2
+ import { mapCommandOptions } from "../utils/discord-command.utils.js";
3
+ import { sendDiscordDraftStreaming } from "./discord-draft-streaming.service.js";
4
+ import { ChannelTypingController } from "@nextclaw/extension-sdk";
5
+ import { BaseChannel, getDataPath, isTypingStopControlMessage } from "@nextclaw/core";
6
+ import { Client, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from "discord.js";
7
+ import { ProxyAgent, fetch } from "undici";
8
+ import { join } from "node:path";
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ //#region src/services/discord-channel.service.ts
11
+ const DEFAULT_MEDIA_MAX_MB = 8;
12
+ const MEDIA_FETCH_TIMEOUT_MS = 15e3;
13
+ const TYPING_HEARTBEAT_MS = 6e3;
14
+ const TYPING_AUTO_STOP_MS = 12e4;
15
+ const SLASH_GUILD_THRESHOLD = 10;
16
+ var DiscordChannel = class extends BaseChannel {
17
+ name = "discord";
18
+ client = null;
19
+ typingController;
20
+ constructor(config, bus, commands) {
21
+ super(config, bus);
22
+ this.commands = commands;
23
+ this.typingController = new ChannelTypingController({
24
+ heartbeatMs: TYPING_HEARTBEAT_MS,
25
+ autoStopMs: TYPING_AUTO_STOP_MS,
26
+ sendTyping: async (channelId) => {
27
+ if (!this.client) return;
28
+ const channel = this.client.channels.cache.get(channelId);
29
+ if (!channel || !channel.isTextBased()) return;
30
+ await channel.sendTyping();
31
+ }
32
+ });
33
+ }
34
+ start = async () => {
35
+ if (!this.config.token) throw new Error("Discord token not configured");
36
+ this.running = true;
37
+ this.client = new Client({
38
+ intents: this.config.intents ?? GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | GatewayIntentBits.DirectMessages,
39
+ partials: [Partials.Channel]
40
+ });
41
+ this.client.on("ready", () => {
42
+ console.log("Discord bot connected");
43
+ this.registerSlashCommands();
44
+ });
45
+ this.client.on("messageCreate", async (message) => {
46
+ await this.handleIncoming(message);
47
+ });
48
+ this.client.on("interactionCreate", async (interaction) => {
49
+ await this.handleInteraction(interaction);
50
+ });
51
+ await this.client.login(this.config.token);
52
+ };
53
+ stop = async () => {
54
+ this.running = false;
55
+ this.typingController.stopAll();
56
+ if (this.client) {
57
+ await this.client.destroy();
58
+ this.client = null;
59
+ }
60
+ };
61
+ handleControlMessage = async (msg) => {
62
+ if (!isTypingStopControlMessage(msg)) return false;
63
+ this.stopTyping(msg.chatId);
64
+ return true;
65
+ };
66
+ send = async (msg) => {
67
+ if (isTypingStopControlMessage(msg)) {
68
+ this.stopTyping(msg.chatId);
69
+ return;
70
+ }
71
+ if (!this.client) return;
72
+ const channel = await this.client.channels.fetch(msg.chatId);
73
+ if (!channel || !channel.isTextBased()) return;
74
+ this.stopTyping(msg.chatId);
75
+ const textChannel = channel;
76
+ const content = msg.content ?? "";
77
+ const textChunkLimit = resolveTextChunkLimit(this.config);
78
+ const chunks = chunkDiscordText(content, {
79
+ maxChars: textChunkLimit,
80
+ maxLines: 17
81
+ });
82
+ if (chunks.length === 0) return;
83
+ const flags = msg.metadata?.silent === true ? MessageFlags.SuppressNotifications : void 0;
84
+ const streamingMode = resolveDiscordStreamingMode(this.config);
85
+ if (streamingMode === "off") {
86
+ await sendDiscordChunks({
87
+ textChannel,
88
+ chunks,
89
+ replyTo: msg.replyTo ?? void 0,
90
+ flags
91
+ });
92
+ return;
93
+ }
94
+ await sendDiscordDraftStreaming({
95
+ textChannel,
96
+ chunks,
97
+ replyTo: msg.replyTo ?? void 0,
98
+ flags,
99
+ draftChunk: resolveDraftChunkConfig(this.config, textChunkLimit),
100
+ streamingMode
101
+ });
102
+ };
103
+ handleIncoming = async (message) => {
104
+ const context = this.resolveIncomingContext(message);
105
+ if (!context) return;
106
+ const { senderId, channelId, isGroup, mentionState } = context;
107
+ const payload = await this.buildIncomingPayload(message);
108
+ const replyTo = message.reference?.messageId ?? null;
109
+ this.startTyping(channelId);
110
+ try {
111
+ await this.handleMessage({
112
+ senderId,
113
+ chatId: channelId,
114
+ content: payload.content,
115
+ attachments: payload.attachments,
116
+ metadata: {
117
+ message_id: message.id,
118
+ channel_id: channelId,
119
+ guild_id: message.guildId,
120
+ reply_to: replyTo,
121
+ account_id: this.resolveAccountId(),
122
+ accountId: this.resolveAccountId(),
123
+ is_group: isGroup,
124
+ peer_kind: isGroup ? "channel" : "direct",
125
+ peer_id: isGroup ? channelId : senderId,
126
+ was_mentioned: mentionState.wasMentioned,
127
+ require_mention: mentionState.requireMention,
128
+ ...payload.attachmentIssues.length ? { attachment_issues: payload.attachmentIssues } : {}
129
+ }
130
+ });
131
+ } catch (error) {
132
+ this.stopTyping(channelId);
133
+ throw error;
134
+ }
135
+ };
136
+ resolveIncomingContext = (message) => {
137
+ const selfUserId = this.client?.user?.id;
138
+ if (selfUserId && message.author.id === selfUserId) return null;
139
+ if (message.author.bot && !this.config.allowBots) return null;
140
+ const senderId = message.author.id;
141
+ const channelId = message.channelId;
142
+ const isGroup = Boolean(message.guildId);
143
+ if (!this.isAllowedByPolicy({
144
+ senderId,
145
+ channelId,
146
+ isGroup
147
+ })) return null;
148
+ const mentionState = this.resolveMentionState({
149
+ message,
150
+ selfUserId,
151
+ channelId,
152
+ isGroup
153
+ });
154
+ if (mentionState.requireMention && !mentionState.wasMentioned) return null;
155
+ return {
156
+ senderId,
157
+ channelId,
158
+ isGroup,
159
+ mentionState
160
+ };
161
+ };
162
+ buildIncomingPayload = async (message) => {
163
+ const contentParts = message.content ? [message.content] : [];
164
+ const { attachments, attachmentIssues } = await this.resolveIncomingAttachments(message);
165
+ if (!message.content && attachments.length > 0) contentParts.push(buildAttachmentSummary(attachments));
166
+ return {
167
+ content: contentParts.length ? contentParts.join("\n") : "[empty message]",
168
+ attachments,
169
+ attachmentIssues
170
+ };
171
+ };
172
+ resolveIncomingAttachments = async (message) => {
173
+ const attachments = [];
174
+ const attachmentIssues = [];
175
+ if (!message.attachments.size) return {
176
+ attachments,
177
+ attachmentIssues
178
+ };
179
+ const mediaDir = join(getDataPath(), "media");
180
+ mkdirSync(mediaDir, { recursive: true });
181
+ const maxBytes = Math.max(1, this.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB) * 1024 * 1024;
182
+ const proxy = this.resolveProxyAgent();
183
+ for (const attachment of message.attachments.values()) {
184
+ const resolved = await this.resolveInboundAttachment({
185
+ attachment,
186
+ mediaDir,
187
+ maxBytes,
188
+ proxy
189
+ });
190
+ if (resolved.attachment) attachments.push(resolved.attachment);
191
+ if (resolved.issue) attachmentIssues.push(resolved.issue);
192
+ }
193
+ return {
194
+ attachments,
195
+ attachmentIssues
196
+ };
197
+ };
198
+ handleInteraction = async (interaction) => {
199
+ if (!interaction.isChatInputCommand()) return;
200
+ await this.handleSlashCommand(interaction);
201
+ };
202
+ handleSlashCommand = async (interaction) => {
203
+ if (!this.commands) {
204
+ await this.replyInteraction(interaction, "Slash commands are not available.", true);
205
+ return;
206
+ }
207
+ const channelId = interaction.channelId;
208
+ if (!channelId) {
209
+ await this.replyInteraction(interaction, "Slash commands are not available in this channel.", true);
210
+ return;
211
+ }
212
+ const senderId = interaction.user.id;
213
+ const isGroup = Boolean(interaction.guildId);
214
+ if (!this.isAllowedByPolicy({
215
+ senderId,
216
+ channelId,
217
+ isGroup
218
+ })) {
219
+ await this.replyInteraction(interaction, "You are not authorized to use commands here.", true);
220
+ return;
221
+ }
222
+ const args = {};
223
+ for (const option of interaction.options.data) if (typeof option.name === "string" && option.value !== void 0) args[option.name] = option.value;
224
+ try {
225
+ await interaction.deferReply({ ephemeral: true });
226
+ const result = await this.commands.execute({
227
+ commandName: interaction.commandName,
228
+ args,
229
+ conversationId: channelId,
230
+ senderId,
231
+ metadata: {
232
+ message_id: interaction.id,
233
+ channel_id: channelId,
234
+ guild_id: interaction.guildId,
235
+ account_id: this.resolveAccountId(),
236
+ accountId: this.resolveAccountId(),
237
+ is_group: isGroup,
238
+ peer_kind: isGroup ? "channel" : "direct",
239
+ peer_id: isGroup ? channelId : senderId
240
+ }
241
+ });
242
+ if (result.ephemeral === false) {
243
+ await interaction.editReply({ content: "Command executed." });
244
+ await interaction.followUp({
245
+ content: result.content,
246
+ ephemeral: false
247
+ });
248
+ return;
249
+ }
250
+ await interaction.editReply({ content: result.content });
251
+ } catch (error) {
252
+ await this.replyInteraction(interaction, "Command failed to execute.", true);
253
+ console.error(`Discord slash command error: ${String(error)}`);
254
+ }
255
+ };
256
+ replyInteraction = async (interaction, content, ephemeral) => {
257
+ const payload = {
258
+ content,
259
+ ephemeral
260
+ };
261
+ if (interaction.deferred || interaction.replied) {
262
+ await interaction.followUp(payload);
263
+ return;
264
+ }
265
+ await interaction.reply(payload);
266
+ };
267
+ registerSlashCommands = async () => {
268
+ if (!this.client || !this.commands) return;
269
+ const appId = this.client.application?.id ?? this.client.user?.id;
270
+ if (!appId) return;
271
+ const commands = await this.buildSlashCommandPayloads();
272
+ if (!commands.length) return;
273
+ const rest = new REST({ version: "10" }).setToken(this.config.token);
274
+ let guildIds = [];
275
+ try {
276
+ guildIds = [...(await this.client.guilds.fetch()).keys()];
277
+ } catch (error) {
278
+ console.error(`Failed to fetch Discord guild list: ${String(error)}`);
279
+ }
280
+ try {
281
+ if (guildIds.length > 0 && guildIds.length <= SLASH_GUILD_THRESHOLD) {
282
+ for (const guildId of guildIds) await rest.put(Routes.applicationGuildCommands(appId, guildId), { body: commands });
283
+ console.log(`Discord slash commands registered for ${guildIds.length} guild(s).`);
284
+ } else {
285
+ await rest.put(Routes.applicationCommands(appId), { body: commands });
286
+ console.log("Discord slash commands registered globally.");
287
+ }
288
+ } catch (error) {
289
+ console.error(`Failed to register Discord slash commands: ${String(error)}`);
290
+ }
291
+ };
292
+ buildSlashCommandPayloads = async () => {
293
+ return (await this.commands?.list() ?? []).map((spec) => ({
294
+ name: spec.name,
295
+ description: spec.description,
296
+ options: mapCommandOptions(spec.options)
297
+ }));
298
+ };
299
+ resolveProxyAgent = () => {
300
+ const proxy = this.config.proxy?.trim();
301
+ if (!proxy) return null;
302
+ try {
303
+ return new ProxyAgent(proxy);
304
+ } catch {
305
+ return null;
306
+ }
307
+ };
308
+ resolveAccountId = () => {
309
+ return this.config.accountId?.trim() || "default";
310
+ };
311
+ isAllowedByPolicy = (params) => {
312
+ const { senderId, channelId, isGroup } = params;
313
+ if (!isGroup) {
314
+ if (this.config.dmPolicy === "disabled") return false;
315
+ const allowFrom = this.config.allowFrom ?? [];
316
+ if (this.config.dmPolicy === "allowlist" || this.config.dmPolicy === "pairing") return this.isAllowed(senderId);
317
+ if (allowFrom.includes("*")) return true;
318
+ return allowFrom.length === 0 ? true : this.isAllowed(senderId);
319
+ }
320
+ if (this.config.groupPolicy === "disabled") return false;
321
+ if (this.config.groupPolicy === "allowlist") {
322
+ const allowFrom = this.config.groupAllowFrom ?? [];
323
+ return allowFrom.includes("*") || allowFrom.includes(channelId);
324
+ }
325
+ return true;
326
+ };
327
+ resolveMentionState = (params) => {
328
+ const { message, selfUserId, channelId, isGroup } = params;
329
+ if (!isGroup) return {
330
+ wasMentioned: false,
331
+ requireMention: false
332
+ };
333
+ const groups = this.config.groups ?? {};
334
+ const groupRule = groups[channelId] ?? groups["*"];
335
+ const requireMention = groupRule?.requireMention ?? this.config.requireMention ?? false;
336
+ if (!requireMention) return {
337
+ wasMentioned: false,
338
+ requireMention: false
339
+ };
340
+ const patterns = [...this.config.mentionPatterns ?? [], ...groupRule?.mentionPatterns ?? []].map((pattern) => pattern.trim()).filter(Boolean);
341
+ const content = message.content ?? "";
342
+ const wasMentionedByUserRef = Boolean(selfUserId) && message.mentions.users.has(selfUserId ?? "");
343
+ const wasMentionedByText = Boolean(selfUserId) && (content.includes(`<@${selfUserId}>`) || content.includes(`<@!${selfUserId}>`));
344
+ const wasMentionedByPattern = patterns.some((pattern) => {
345
+ try {
346
+ return new RegExp(pattern, "i").test(content);
347
+ } catch {
348
+ return content.toLowerCase().includes(pattern.toLowerCase());
349
+ }
350
+ });
351
+ return {
352
+ wasMentioned: wasMentionedByUserRef || wasMentionedByText || wasMentionedByPattern,
353
+ requireMention
354
+ };
355
+ };
356
+ resolveInboundAttachment = async (params) => {
357
+ const { attachment, mediaDir, maxBytes, proxy } = params;
358
+ const id = attachment.id;
359
+ const name = attachment.name ?? "file";
360
+ const url = attachment.url;
361
+ const mimeType = attachment.contentType ?? guessMimeFromName(name) ?? void 0;
362
+ if (!url) return { issue: {
363
+ id,
364
+ name,
365
+ code: "invalid_payload",
366
+ message: "attachment URL missing"
367
+ } };
368
+ if (attachment.size && attachment.size > maxBytes) return {
369
+ attachment: {
370
+ id,
371
+ name,
372
+ url,
373
+ mimeType,
374
+ size: attachment.size,
375
+ source: "discord",
376
+ status: "remote-only",
377
+ errorCode: "too_large"
378
+ },
379
+ issue: {
380
+ id,
381
+ name,
382
+ url,
383
+ code: "too_large",
384
+ message: `attachment size ${attachment.size} exceeds ${maxBytes}`
385
+ }
386
+ };
387
+ const controller = new AbortController();
388
+ const timeoutId = setTimeout(() => controller.abort(), MEDIA_FETCH_TIMEOUT_MS);
389
+ try {
390
+ const res = await fetch(url, {
391
+ signal: controller.signal,
392
+ dispatcher: proxy ?? void 0
393
+ });
394
+ if (!res.ok) return {
395
+ attachment: {
396
+ id,
397
+ name,
398
+ url,
399
+ mimeType,
400
+ size: attachment.size,
401
+ source: "discord",
402
+ status: "remote-only",
403
+ errorCode: "http_error"
404
+ },
405
+ issue: {
406
+ id,
407
+ name,
408
+ url,
409
+ code: "http_error",
410
+ message: `HTTP ${res.status}`
411
+ }
412
+ };
413
+ const buffer = Buffer.from(await res.arrayBuffer());
414
+ if (buffer.length > maxBytes) return {
415
+ attachment: {
416
+ id,
417
+ name,
418
+ url,
419
+ mimeType,
420
+ size: buffer.length,
421
+ source: "discord",
422
+ status: "remote-only",
423
+ errorCode: "too_large"
424
+ },
425
+ issue: {
426
+ id,
427
+ name,
428
+ url,
429
+ code: "too_large",
430
+ message: `downloaded payload ${buffer.length} exceeds ${maxBytes}`
431
+ }
432
+ };
433
+ const filePath = join(mediaDir, `${id}_${sanitizeAttachmentName(name)}`);
434
+ writeFileSync(filePath, buffer);
435
+ return { attachment: {
436
+ id,
437
+ name,
438
+ path: filePath,
439
+ url,
440
+ mimeType,
441
+ size: buffer.length,
442
+ source: "discord",
443
+ status: "ready"
444
+ } };
445
+ } catch (err) {
446
+ return {
447
+ attachment: {
448
+ id,
449
+ name,
450
+ url,
451
+ mimeType,
452
+ size: attachment.size,
453
+ source: "discord",
454
+ status: "remote-only",
455
+ errorCode: "download_failed"
456
+ },
457
+ issue: {
458
+ id,
459
+ name,
460
+ url,
461
+ code: "download_failed",
462
+ message: String(err)
463
+ }
464
+ };
465
+ } finally {
466
+ clearTimeout(timeoutId);
467
+ }
468
+ };
469
+ startTyping = (channelId) => {
470
+ this.typingController.start(channelId);
471
+ };
472
+ stopTyping = (channelId) => {
473
+ this.typingController.stop(channelId);
474
+ };
475
+ };
476
+ //#endregion
477
+ export { DiscordChannel };
478
+
479
+ //# sourceMappingURL=discord-channel.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discord-channel.service.js","names":[],"sources":["../../src/services/discord-channel.service.ts"],"sourcesContent":["import { BaseChannel, getDataPath, isTypingStopControlMessage, type Config, type InboundAttachment, type InboundAttachmentErrorCode, type MessageBus, type OutboundMessage, } from \"@nextclaw/core\";\nimport { ChannelTypingController, type ExtensionChannelCommands, } from \"@nextclaw/extension-sdk\";\nimport { Client, GatewayIntentBits, Partials, MessageFlags, REST, Routes, type Message as DiscordMessage, type Attachment, type ChatInputCommandInteraction, type Interaction, type TextBasedChannel, type TextBasedChannelFields } from \"discord.js\";\nimport {\n DISCORD_MAX_LINES_PER_MESSAGE,\n buildAttachmentSummary,\n chunkDiscordText,\n guessMimeFromName,\n resolveDiscordStreamingMode,\n resolveDraftChunkConfig,\n resolveTextChunkLimit,\n sanitizeAttachmentName,\n sendDiscordChunks\n} from \"../utils/discord-text.utils.js\";\nimport { mapCommandOptions } from \"../utils/discord-command.utils.js\";\nimport { sendDiscordDraftStreaming } from \"./discord-draft-streaming.service.js\";\nimport { ProxyAgent, fetch } from \"undici\";\nimport { join } from \"node:path\";\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nconst DEFAULT_MEDIA_MAX_MB = 8;\nconst MEDIA_FETCH_TIMEOUT_MS = 15000;\nconst TYPING_HEARTBEAT_MS = 6000;\nconst TYPING_AUTO_STOP_MS = 120000;\nconst SLASH_GUILD_THRESHOLD = 10;\ntype AttachmentIssue = {\n id?: string;\n name?: string;\n url?: string;\n code: InboundAttachmentErrorCode;\n message: string;\n};\nexport class DiscordChannel extends BaseChannel<Config[\"channels\"][\"discord\"]> {\n name = \"discord\";\n private client: Client | null = null;\n private readonly typingController: ChannelTypingController;\n constructor(config: Config[\"channels\"][\"discord\"], bus: MessageBus, private readonly commands?: ExtensionChannelCommands) {\n super(config, bus);\n this.typingController = new ChannelTypingController({\n heartbeatMs: TYPING_HEARTBEAT_MS,\n autoStopMs: TYPING_AUTO_STOP_MS,\n sendTyping: async (channelId) => {\n if (!this.client) {\n return;\n }\n const channel = this.client.channels.cache.get(channelId);\n if (!channel || !channel.isTextBased()) {\n return;\n }\n const textChannel = channel as TextBasedChannel & TextBasedChannelFields;\n await textChannel.sendTyping();\n }\n });\n }\n start = async (): Promise<void> => {\n if (!this.config.token) {\n throw new Error(\"Discord token not configured\");\n }\n this.running = true;\n this.client = new Client({\n intents: this.config.intents ?? (GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | GatewayIntentBits.DirectMessages),\n partials: [Partials.Channel]\n });\n this.client.on(\"ready\", () => {\n // eslint-disable-next-line no-console\n console.log(\"Discord bot connected\");\n void this.registerSlashCommands();\n });\n this.client.on(\"messageCreate\", async (message) => {\n await this.handleIncoming(message);\n });\n this.client.on(\"interactionCreate\", async (interaction) => {\n await this.handleInteraction(interaction);\n });\n await this.client.login(this.config.token);\n };\n stop = async (): Promise<void> => {\n this.running = false;\n this.typingController.stopAll();\n if (this.client) {\n await this.client.destroy();\n this.client = null;\n }\n };\n handleControlMessage = async (msg: OutboundMessage): Promise<boolean> => {\n if (!isTypingStopControlMessage(msg)) {\n return false;\n }\n this.stopTyping(msg.chatId);\n return true;\n };\n send = async (msg: OutboundMessage): Promise<void> => {\n if (isTypingStopControlMessage(msg)) {\n this.stopTyping(msg.chatId);\n return;\n }\n if (!this.client) {\n return;\n }\n const channel = await this.client.channels.fetch(msg.chatId);\n if (!channel || !channel.isTextBased()) {\n return;\n }\n this.stopTyping(msg.chatId);\n const textChannel = channel as TextBasedChannel & TextBasedChannelFields;\n const content = msg.content ?? \"\";\n const textChunkLimit = resolveTextChunkLimit(this.config);\n const chunks = chunkDiscordText(content, {\n maxChars: textChunkLimit,\n maxLines: DISCORD_MAX_LINES_PER_MESSAGE\n });\n if (chunks.length === 0) {\n return;\n }\n const flags = msg.metadata?.silent === true ? MessageFlags.SuppressNotifications : undefined;\n const streamingMode = resolveDiscordStreamingMode(this.config);\n if (streamingMode === \"off\") {\n await sendDiscordChunks({\n textChannel,\n chunks,\n replyTo: msg.replyTo ?? undefined,\n flags\n });\n return;\n }\n await sendDiscordDraftStreaming({\n textChannel,\n chunks,\n replyTo: msg.replyTo ?? undefined,\n flags,\n draftChunk: resolveDraftChunkConfig(this.config, textChunkLimit),\n streamingMode\n });\n };\n private handleIncoming = async (message: DiscordMessage): Promise<void> => {\n const context = this.resolveIncomingContext(message);\n if (!context) {\n return;\n }\n const { senderId, channelId, isGroup, mentionState } = context;\n const payload = await this.buildIncomingPayload(message);\n const replyTo = message.reference?.messageId ?? null;\n this.startTyping(channelId);\n try {\n await this.handleMessage({\n senderId,\n chatId: channelId,\n content: payload.content,\n attachments: payload.attachments,\n metadata: {\n message_id: message.id,\n channel_id: channelId,\n guild_id: message.guildId,\n reply_to: replyTo,\n account_id: this.resolveAccountId(),\n accountId: this.resolveAccountId(),\n is_group: isGroup,\n peer_kind: isGroup ? \"channel\" : \"direct\",\n peer_id: isGroup ? channelId : senderId,\n was_mentioned: mentionState.wasMentioned,\n require_mention: mentionState.requireMention,\n ...(payload.attachmentIssues.length ? { attachment_issues: payload.attachmentIssues } : {})\n }\n });\n }\n catch (error) {\n this.stopTyping(channelId);\n throw error;\n }\n };\n private resolveIncomingContext = (message: DiscordMessage): {\n senderId: string;\n channelId: string;\n isGroup: boolean;\n mentionState: {\n wasMentioned: boolean;\n requireMention: boolean;\n };\n } | null => {\n const selfUserId = this.client?.user?.id;\n if (selfUserId && message.author.id === selfUserId) {\n return null;\n }\n if (message.author.bot && !this.config.allowBots) {\n return null;\n }\n const senderId = message.author.id;\n const channelId = message.channelId;\n const isGroup = Boolean(message.guildId);\n if (!this.isAllowedByPolicy({ senderId, channelId, isGroup })) {\n return null;\n }\n const mentionState = this.resolveMentionState({ message, selfUserId, channelId, isGroup });\n if (mentionState.requireMention && !mentionState.wasMentioned) {\n return null;\n }\n return { senderId, channelId, isGroup, mentionState };\n };\n private buildIncomingPayload = async (message: DiscordMessage): Promise<{\n content: string;\n attachments: InboundAttachment[];\n attachmentIssues: AttachmentIssue[];\n }> => {\n const contentParts = message.content ? [message.content] : [];\n const { attachments, attachmentIssues } = await this.resolveIncomingAttachments(message);\n if (!message.content && attachments.length > 0) {\n contentParts.push(buildAttachmentSummary(attachments));\n }\n return {\n content: contentParts.length ? contentParts.join(\"\\n\") : \"[empty message]\",\n attachments,\n attachmentIssues\n };\n };\n private resolveIncomingAttachments = async (message: DiscordMessage): Promise<{\n attachments: InboundAttachment[];\n attachmentIssues: AttachmentIssue[];\n }> => {\n const attachments: InboundAttachment[] = [];\n const attachmentIssues: AttachmentIssue[] = [];\n if (!message.attachments.size) {\n return { attachments, attachmentIssues };\n }\n const mediaDir = join(getDataPath(), \"media\");\n mkdirSync(mediaDir, { recursive: true });\n const maxBytes = Math.max(1, this.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB) * 1024 * 1024;\n const proxy = this.resolveProxyAgent();\n for (const attachment of message.attachments.values()) {\n const resolved = await this.resolveInboundAttachment({ attachment, mediaDir, maxBytes, proxy });\n if (resolved.attachment) {\n attachments.push(resolved.attachment);\n }\n if (resolved.issue) {\n attachmentIssues.push(resolved.issue);\n }\n }\n return { attachments, attachmentIssues };\n };\n private handleInteraction = async (interaction: Interaction): Promise<void> => {\n if (!interaction.isChatInputCommand()) {\n return;\n }\n await this.handleSlashCommand(interaction);\n };\n private handleSlashCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => {\n if (!this.commands) {\n await this.replyInteraction(interaction, \"Slash commands are not available.\", true);\n return;\n }\n const channelId = interaction.channelId;\n if (!channelId) {\n await this.replyInteraction(interaction, \"Slash commands are not available in this channel.\", true);\n return;\n }\n const senderId = interaction.user.id;\n const isGroup = Boolean(interaction.guildId);\n if (!this.isAllowedByPolicy({ senderId, channelId, isGroup })) {\n await this.replyInteraction(interaction, \"You are not authorized to use commands here.\", true);\n return;\n }\n const args: Record<string, unknown> = {};\n for (const option of interaction.options.data) {\n if (typeof option.name === \"string\" && option.value !== undefined) {\n args[option.name] = option.value;\n }\n }\n try {\n await interaction.deferReply({ ephemeral: true });\n const result = await this.commands.execute({\n commandName: interaction.commandName,\n args,\n conversationId: channelId,\n senderId,\n metadata: {\n message_id: interaction.id,\n channel_id: channelId,\n guild_id: interaction.guildId,\n account_id: this.resolveAccountId(),\n accountId: this.resolveAccountId(),\n is_group: isGroup,\n peer_kind: isGroup ? \"channel\" : \"direct\",\n peer_id: isGroup ? channelId : senderId,\n },\n });\n if (result.ephemeral === false) {\n await interaction.editReply({ content: \"Command executed.\" });\n await interaction.followUp({ content: result.content, ephemeral: false });\n return;\n }\n await interaction.editReply({ content: result.content });\n }\n catch (error) {\n await this.replyInteraction(interaction, \"Command failed to execute.\", true);\n // eslint-disable-next-line no-console\n console.error(`Discord slash command error: ${String(error)}`);\n }\n };\n private replyInteraction = async (interaction: ChatInputCommandInteraction, content: string, ephemeral: boolean): Promise<void> => {\n const payload = { content, ephemeral };\n if (interaction.deferred || interaction.replied) {\n await interaction.followUp(payload);\n return;\n }\n await interaction.reply(payload);\n };\n private registerSlashCommands = async (): Promise<void> => {\n if (!this.client || !this.commands) {\n return;\n }\n const appId = this.client.application?.id ?? this.client.user?.id;\n if (!appId) {\n return;\n }\n const commands = await this.buildSlashCommandPayloads();\n if (!commands.length) {\n return;\n }\n const rest = new REST({ version: \"10\" }).setToken(this.config.token);\n let guildIds: string[] = [];\n try {\n const guilds = await this.client.guilds.fetch();\n guildIds = [...guilds.keys()];\n }\n catch (error) {\n // eslint-disable-next-line no-console\n console.error(`Failed to fetch Discord guild list: ${String(error)}`);\n }\n try {\n if (guildIds.length > 0 && guildIds.length <= SLASH_GUILD_THRESHOLD) {\n for (const guildId of guildIds) {\n await rest.put(Routes.applicationGuildCommands(appId, guildId), { body: commands });\n }\n // eslint-disable-next-line no-console\n console.log(`Discord slash commands registered for ${guildIds.length} guild(s).`);\n }\n else {\n await rest.put(Routes.applicationCommands(appId), { body: commands });\n // eslint-disable-next-line no-console\n console.log(\"Discord slash commands registered globally.\");\n }\n }\n catch (error) {\n // eslint-disable-next-line no-console\n console.error(`Failed to register Discord slash commands: ${String(error)}`);\n }\n };\n private buildSlashCommandPayloads = async (): Promise<Array<Record<string, unknown>>> => {\n const specs = await this.commands?.list() ?? [];\n return specs.map((spec) => ({\n name: spec.name,\n description: spec.description,\n options: mapCommandOptions(spec.options)\n }));\n };\n private resolveProxyAgent = (): ProxyAgent | null => {\n const proxy = this.config.proxy?.trim();\n if (!proxy) {\n return null;\n }\n try {\n return new ProxyAgent(proxy);\n }\n catch {\n return null;\n }\n };\n private resolveAccountId = (): string => {\n const accountId = this.config.accountId?.trim();\n return accountId || \"default\";\n };\n private isAllowedByPolicy = (params: {\n senderId: string;\n channelId: string;\n isGroup: boolean;\n }): boolean => {\n const { senderId, channelId, isGroup } = params;\n if (!isGroup) {\n if (this.config.dmPolicy === \"disabled\") {\n return false;\n }\n const allowFrom = this.config.allowFrom ?? [];\n if (this.config.dmPolicy === \"allowlist\" || this.config.dmPolicy === \"pairing\") {\n return this.isAllowed(senderId);\n }\n if (allowFrom.includes(\"*\")) {\n return true;\n }\n return allowFrom.length === 0 ? true : this.isAllowed(senderId);\n }\n if (this.config.groupPolicy === \"disabled\") {\n return false;\n }\n if (this.config.groupPolicy === \"allowlist\") {\n const allowFrom = this.config.groupAllowFrom ?? [];\n return allowFrom.includes(\"*\") || allowFrom.includes(channelId);\n }\n return true;\n };\n private resolveMentionState = (params: {\n message: DiscordMessage;\n selfUserId?: string;\n channelId: string;\n isGroup: boolean;\n }): {\n wasMentioned: boolean;\n requireMention: boolean;\n } => {\n const { message, selfUserId, channelId, isGroup } = params;\n if (!isGroup) {\n return { wasMentioned: false, requireMention: false };\n }\n const groups = this.config.groups ?? {};\n const groupRule = groups[channelId] ?? groups[\"*\"];\n const requireMention = groupRule?.requireMention ?? this.config.requireMention ?? false;\n if (!requireMention) {\n return { wasMentioned: false, requireMention: false };\n }\n const patterns = [\n ...(this.config.mentionPatterns ?? []),\n ...(groupRule?.mentionPatterns ?? [])\n ]\n .map((pattern) => pattern.trim())\n .filter(Boolean);\n const content = message.content ?? \"\";\n const wasMentionedByUserRef = Boolean(selfUserId) && message.mentions.users.has(selfUserId ?? \"\");\n const wasMentionedByText = Boolean(selfUserId) &&\n (content.includes(`<@${selfUserId}>`) || content.includes(`<@!${selfUserId}>`));\n const wasMentionedByPattern = patterns.some((pattern) => {\n try {\n return new RegExp(pattern, \"i\").test(content);\n }\n catch {\n return content.toLowerCase().includes(pattern.toLowerCase());\n }\n });\n return {\n wasMentioned: wasMentionedByUserRef || wasMentionedByText || wasMentionedByPattern,\n requireMention\n };\n };\n private resolveInboundAttachment = async (params: {\n attachment: Attachment;\n mediaDir: string;\n maxBytes: number;\n proxy: ProxyAgent | null;\n }): Promise<{\n attachment?: InboundAttachment;\n issue?: AttachmentIssue;\n }> => {\n const { attachment, mediaDir, maxBytes, proxy } = params;\n const id = attachment.id;\n const name = attachment.name ?? \"file\";\n const url = attachment.url;\n const mimeType = attachment.contentType ?? guessMimeFromName(name) ?? undefined;\n if (!url) {\n return {\n issue: {\n id,\n name,\n code: \"invalid_payload\",\n message: \"attachment URL missing\"\n }\n };\n }\n if (attachment.size && attachment.size > maxBytes) {\n return {\n attachment: {\n id,\n name,\n url,\n mimeType,\n size: attachment.size,\n source: \"discord\",\n status: \"remote-only\",\n errorCode: \"too_large\"\n },\n issue: {\n id,\n name,\n url,\n code: \"too_large\",\n message: `attachment size ${attachment.size} exceeds ${maxBytes}`\n }\n };\n }\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), MEDIA_FETCH_TIMEOUT_MS);\n try {\n const fetchInit: Parameters<typeof fetch>[1] = {\n signal: controller.signal,\n dispatcher: proxy ?? undefined\n };\n const res = await fetch(url, fetchInit);\n if (!res.ok) {\n return {\n attachment: {\n id,\n name,\n url,\n mimeType,\n size: attachment.size,\n source: \"discord\",\n status: \"remote-only\",\n errorCode: \"http_error\"\n },\n issue: {\n id,\n name,\n url,\n code: \"http_error\",\n message: `HTTP ${res.status}`\n }\n };\n }\n const buffer = Buffer.from(await res.arrayBuffer());\n if (buffer.length > maxBytes) {\n return {\n attachment: {\n id,\n name,\n url,\n mimeType,\n size: buffer.length,\n source: \"discord\",\n status: \"remote-only\",\n errorCode: \"too_large\"\n },\n issue: {\n id,\n name,\n url,\n code: \"too_large\",\n message: `downloaded payload ${buffer.length} exceeds ${maxBytes}`\n }\n };\n }\n const filename = `${id}_${sanitizeAttachmentName(name)}`;\n const filePath = join(mediaDir, filename);\n writeFileSync(filePath, buffer);\n return {\n attachment: {\n id,\n name,\n path: filePath,\n url,\n mimeType,\n size: buffer.length,\n source: \"discord\",\n status: \"ready\"\n }\n };\n }\n catch (err) {\n return {\n attachment: {\n id,\n name,\n url,\n mimeType,\n size: attachment.size,\n source: \"discord\",\n status: \"remote-only\",\n errorCode: \"download_failed\"\n },\n issue: {\n id,\n name,\n url,\n code: \"download_failed\",\n message: String(err)\n }\n };\n }\n finally {\n clearTimeout(timeoutId);\n }\n };\n private startTyping = (channelId: string): void => {\n this.typingController.start(channelId);\n };\n private stopTyping = (channelId: string): void => {\n this.typingController.stop(channelId);\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,130 @@
1
+ import { sendDiscordChunks, sendDiscordSingleChunk } from "../utils/discord-text.utils.js";
2
+ //#region src/services/discord-draft-streaming.service.ts
3
+ const STREAM_EDIT_MIN_INTERVAL_MS = 600;
4
+ async function sendDiscordDraftStreaming(params) {
5
+ await new DiscordDraftStreamingSender(params).send();
6
+ }
7
+ var DiscordDraftStreamingSender = class {
8
+ first = true;
9
+ effectiveDraftChunk;
10
+ constructor(params) {
11
+ this.params = params;
12
+ this.effectiveDraftChunk = resolveEffectiveDraftChunk(params.draftChunk, params.streamingMode);
13
+ }
14
+ send = async () => {
15
+ for (const chunk of this.params.chunks) await this.sendChunk(chunk);
16
+ };
17
+ sendChunk = async (chunk) => {
18
+ const draftChunks = splitDraftChunks(chunk, this.effectiveDraftChunk);
19
+ if (draftChunks.length === 0) return;
20
+ if (draftChunks.length > 40) {
21
+ await this.sendFallbackChunk(chunk);
22
+ return;
23
+ }
24
+ await this.sendDraftParts(chunk, draftChunks);
25
+ };
26
+ sendDraftParts = async (chunk, draftChunks) => {
27
+ let draftMessage = null;
28
+ let current = "";
29
+ let lastEditAt = 0;
30
+ for (const draftPart of draftChunks) {
31
+ current += draftPart;
32
+ if (!draftMessage) {
33
+ draftMessage = await this.createDraftMessage(current);
34
+ lastEditAt = Date.now();
35
+ continue;
36
+ }
37
+ if (!await this.editDraftMessage(draftMessage, current, lastEditAt)) {
38
+ await this.sendFallbackChunk(chunk);
39
+ return;
40
+ }
41
+ lastEditAt = Date.now();
42
+ }
43
+ };
44
+ createDraftMessage = async (content) => {
45
+ const message = await sendDiscordSingleChunk({
46
+ textChannel: this.params.textChannel,
47
+ chunk: content,
48
+ replyTo: this.first ? this.params.replyTo : void 0,
49
+ flags: this.params.flags
50
+ });
51
+ this.first = false;
52
+ return message;
53
+ };
54
+ editDraftMessage = async (draftMessage, content, lastEditAt) => {
55
+ const waitMs = Math.max(0, lastEditAt + STREAM_EDIT_MIN_INTERVAL_MS - Date.now());
56
+ if (waitMs > 0) await sleep(waitMs);
57
+ try {
58
+ await draftMessage.edit({ content });
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ };
64
+ sendFallbackChunk = async (chunk) => {
65
+ await sendDiscordChunks({
66
+ textChannel: this.params.textChannel,
67
+ chunks: [chunk],
68
+ replyTo: this.first ? this.params.replyTo : void 0,
69
+ flags: this.params.flags
70
+ });
71
+ this.first = false;
72
+ };
73
+ };
74
+ function findDraftBreakIndex(text, start, end, preference) {
75
+ const slice = text.slice(start, end);
76
+ if (slice.length === 0) return null;
77
+ const preferredBreak = findPreferredDraftBreak(slice, preference);
78
+ if (preferredBreak !== null) return start + preferredBreak;
79
+ return findWhitespaceDraftBreak(slice, start);
80
+ }
81
+ function findPreferredDraftBreak(slice, preference) {
82
+ if (preference === "paragraph") {
83
+ const idx = slice.lastIndexOf("\n\n");
84
+ if (idx >= 0) return idx + 2;
85
+ }
86
+ if (preference === "paragraph" || preference === "line") {
87
+ const idx = slice.lastIndexOf("\n");
88
+ if (idx >= 0) return idx + 1;
89
+ }
90
+ return null;
91
+ }
92
+ function findWhitespaceDraftBreak(slice, start) {
93
+ for (let i = slice.length - 1; i >= 0; i -= 1) if (/\s/.test(slice[i])) return start + i + 1;
94
+ return null;
95
+ }
96
+ function splitDraftChunks(text, config) {
97
+ const chunks = [];
98
+ if (!text) return chunks;
99
+ let cursor = 0;
100
+ const length = text.length;
101
+ while (cursor < length) {
102
+ const nextEnd = resolveDraftChunkEnd(text, cursor, length, config);
103
+ chunks.push(text.slice(cursor, nextEnd));
104
+ cursor = nextEnd;
105
+ }
106
+ return chunks;
107
+ }
108
+ function resolveDraftChunkEnd(text, cursor, length, config) {
109
+ if (length - cursor <= config.maxChars) return length;
110
+ const minEnd = Math.min(length, cursor + config.minChars);
111
+ const maxEnd = Math.min(length, cursor + config.maxChars);
112
+ const breakIndex = findDraftBreakIndex(text, minEnd, maxEnd, config.breakPreference);
113
+ if (breakIndex !== null && breakIndex > cursor) return breakIndex;
114
+ return maxEnd;
115
+ }
116
+ function resolveEffectiveDraftChunk(draftChunk, streamingMode) {
117
+ if (streamingMode === "block") return draftChunk;
118
+ return {
119
+ ...draftChunk,
120
+ minChars: Math.max(1, Math.floor(draftChunk.minChars / 2)),
121
+ maxChars: Math.max(draftChunk.minChars, Math.floor(draftChunk.maxChars / 2))
122
+ };
123
+ }
124
+ async function sleep(ms) {
125
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
126
+ }
127
+ //#endregion
128
+ export { sendDiscordDraftStreaming };
129
+
130
+ //# sourceMappingURL=discord-draft-streaming.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discord-draft-streaming.service.js","names":[],"sources":["../../src/services/discord-draft-streaming.service.ts"],"sourcesContent":["import type { Message as DiscordMessage, TextBasedChannel, TextBasedChannelFields } from \"discord.js\";\nimport {\n STREAM_MAX_UPDATES_PER_MESSAGE,\n type DiscordStreamMode,\n type DraftChunkConfig,\n sendDiscordChunks,\n sendDiscordSingleChunk\n} from \"../utils/discord-text.utils.js\";\n\nconst STREAM_EDIT_MIN_INTERVAL_MS = 600;\n\nexport async function sendDiscordDraftStreaming(params: {\n textChannel: TextBasedChannel & TextBasedChannelFields;\n chunks: string[];\n replyTo?: string;\n flags?: number;\n draftChunk: DraftChunkConfig;\n streamingMode: DiscordStreamMode;\n}): Promise<void> {\n const sender = new DiscordDraftStreamingSender(params);\n await sender.send();\n}\n\nclass DiscordDraftStreamingSender {\n private first = true;\n\n private readonly effectiveDraftChunk: DraftChunkConfig;\n\n constructor(private readonly params: {\n textChannel: TextBasedChannel & TextBasedChannelFields;\n chunks: string[];\n replyTo?: string;\n flags?: number;\n draftChunk: DraftChunkConfig;\n streamingMode: DiscordStreamMode;\n }) {\n this.effectiveDraftChunk = resolveEffectiveDraftChunk(params.draftChunk, params.streamingMode);\n }\n\n send = async (): Promise<void> => {\n for (const chunk of this.params.chunks) {\n await this.sendChunk(chunk);\n }\n };\n\n private sendChunk = async (chunk: string): Promise<void> => {\n const draftChunks = splitDraftChunks(chunk, this.effectiveDraftChunk);\n if (draftChunks.length === 0) {\n return;\n }\n if (draftChunks.length > STREAM_MAX_UPDATES_PER_MESSAGE) {\n await this.sendFallbackChunk(chunk);\n return;\n }\n await this.sendDraftParts(chunk, draftChunks);\n };\n\n private sendDraftParts = async (chunk: string, draftChunks: string[]): Promise<void> => {\n let draftMessage: DiscordMessage | null = null;\n let current = \"\";\n let lastEditAt = 0;\n for (const draftPart of draftChunks) {\n current += draftPart;\n if (!draftMessage) {\n draftMessage = await this.createDraftMessage(current);\n lastEditAt = Date.now();\n continue;\n }\n const edited = await this.editDraftMessage(draftMessage, current, lastEditAt);\n if (!edited) {\n await this.sendFallbackChunk(chunk);\n return;\n }\n lastEditAt = Date.now();\n }\n };\n\n private createDraftMessage = async (content: string): Promise<DiscordMessage> => {\n const message = await sendDiscordSingleChunk({\n textChannel: this.params.textChannel,\n chunk: content,\n replyTo: this.first ? this.params.replyTo : undefined,\n flags: this.params.flags\n });\n this.first = false;\n return message;\n };\n\n private editDraftMessage = async (\n draftMessage: DiscordMessage,\n content: string,\n lastEditAt: number\n ): Promise<boolean> => {\n const waitMs = Math.max(0, lastEditAt + STREAM_EDIT_MIN_INTERVAL_MS - Date.now());\n if (waitMs > 0) {\n await sleep(waitMs);\n }\n try {\n await draftMessage.edit({ content });\n return true;\n }\n catch {\n return false;\n }\n };\n\n private sendFallbackChunk = async (chunk: string): Promise<void> => {\n await sendDiscordChunks({\n textChannel: this.params.textChannel,\n chunks: [chunk],\n replyTo: this.first ? this.params.replyTo : undefined,\n flags: this.params.flags\n });\n this.first = false;\n };\n}\n\nfunction findDraftBreakIndex(\n text: string,\n start: number,\n end: number,\n preference: DraftChunkConfig[\"breakPreference\"]\n): number | null {\n const slice = text.slice(start, end);\n if (slice.length === 0) {\n return null;\n }\n const preferredBreak = findPreferredDraftBreak(slice, preference);\n if (preferredBreak !== null) {\n return start + preferredBreak;\n }\n return findWhitespaceDraftBreak(slice, start);\n}\n\nfunction findPreferredDraftBreak(slice: string, preference: DraftChunkConfig[\"breakPreference\"]): number | null {\n if (preference === \"paragraph\") {\n const idx = slice.lastIndexOf(\"\\n\\n\");\n if (idx >= 0) {\n return idx + 2;\n }\n }\n if (preference === \"paragraph\" || preference === \"line\") {\n const idx = slice.lastIndexOf(\"\\n\");\n if (idx >= 0) {\n return idx + 1;\n }\n }\n return null;\n}\n\nfunction findWhitespaceDraftBreak(slice: string, start: number): number | null {\n for (let i = slice.length - 1; i >= 0; i -= 1) {\n if (/\\s/.test(slice[i])) {\n return start + i + 1;\n }\n }\n return null;\n}\n\nfunction splitDraftChunks(text: string, config: DraftChunkConfig): string[] {\n const chunks: string[] = [];\n if (!text) {\n return chunks;\n }\n let cursor = 0;\n const length = text.length;\n while (cursor < length) {\n const nextEnd = resolveDraftChunkEnd(text, cursor, length, config);\n chunks.push(text.slice(cursor, nextEnd));\n cursor = nextEnd;\n }\n return chunks;\n}\n\nfunction resolveDraftChunkEnd(\n text: string,\n cursor: number,\n length: number,\n config: DraftChunkConfig\n): number {\n const remaining = length - cursor;\n if (remaining <= config.maxChars) {\n return length;\n }\n const minEnd = Math.min(length, cursor + config.minChars);\n const maxEnd = Math.min(length, cursor + config.maxChars);\n const breakIndex = findDraftBreakIndex(text, minEnd, maxEnd, config.breakPreference);\n if (breakIndex !== null && breakIndex > cursor) {\n return breakIndex;\n }\n return maxEnd;\n}\n\nfunction resolveEffectiveDraftChunk(\n draftChunk: DraftChunkConfig,\n streamingMode: DiscordStreamMode\n): DraftChunkConfig {\n if (streamingMode === \"block\") {\n return draftChunk;\n }\n return {\n ...draftChunk,\n minChars: Math.max(1, Math.floor(draftChunk.minChars / 2)),\n maxChars: Math.max(draftChunk.minChars, Math.floor(draftChunk.maxChars / 2))\n };\n}\n\nasync function sleep(ms: number): Promise<void> {\n await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));\n}\n"],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import { ApplicationCommandOptionType } from "discord.js";
2
+ //#region src/utils/discord-command.utils.ts
3
+ function mapCommandOptions(options) {
4
+ if (!options || options.length === 0) return;
5
+ return options.map((option) => ({
6
+ name: option.name,
7
+ description: option.description,
8
+ type: mapCommandOptionType(option.type),
9
+ required: option.required ?? false
10
+ }));
11
+ }
12
+ function mapCommandOptionType(type) {
13
+ switch (type) {
14
+ case "boolean": return ApplicationCommandOptionType.Boolean;
15
+ case "number": return ApplicationCommandOptionType.Number;
16
+ default: return ApplicationCommandOptionType.String;
17
+ }
18
+ }
19
+ //#endregion
20
+ export { mapCommandOptions };
21
+
22
+ //# sourceMappingURL=discord-command.utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discord-command.utils.js","names":[],"sources":["../../src/utils/discord-command.utils.ts"],"sourcesContent":["import type { ChannelCommandOption } from \"@nextclaw/extension-sdk\";\nimport { ApplicationCommandOptionType } from \"discord.js\";\n\nexport function mapCommandOptions(options?: ChannelCommandOption[]): Array<Record<string, unknown>> | undefined {\n if (!options || options.length === 0) {\n return undefined;\n }\n return options.map((option) => ({\n name: option.name,\n description: option.description,\n type: mapCommandOptionType(option.type),\n required: option.required ?? false\n }));\n}\n\nfunction mapCommandOptionType(type: ChannelCommandOption[\"type\"]): number {\n switch (type) {\n case \"boolean\":\n return ApplicationCommandOptionType.Boolean;\n case \"number\":\n return ApplicationCommandOptionType.Number;\n case \"string\":\n default:\n return ApplicationCommandOptionType.String;\n }\n}\n"],"mappings":";;AAGA,SAAgB,kBAAkB,SAA8E;AAC5G,KAAI,CAAC,WAAW,QAAQ,WAAW,EAC/B;AAEJ,QAAO,QAAQ,KAAK,YAAY;EAC5B,MAAM,OAAO;EACb,aAAa,OAAO;EACpB,MAAM,qBAAqB,OAAO,KAAK;EACvC,UAAU,OAAO,YAAY;EAChC,EAAE;;AAGP,SAAS,qBAAqB,MAA4C;AACtE,SAAQ,MAAR;EACI,KAAK,UACD,QAAO,6BAA6B;EACxC,KAAK,SACD,QAAO,6BAA6B;EAExC,QACI,QAAO,6BAA6B"}
@@ -0,0 +1,229 @@
1
+ import "discord.js";
2
+ //#region src/utils/discord-text.utils.ts
3
+ const DISCORD_TEXT_LIMIT = 2e3;
4
+ const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
5
+ function sanitizeAttachmentName(name) {
6
+ return name.replace(/[\\/:*?"<>|]/g, "_");
7
+ }
8
+ function guessMimeFromName(name) {
9
+ const lower = name.toLowerCase();
10
+ if (lower.endsWith(".png")) return "image/png";
11
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
12
+ if (lower.endsWith(".gif")) return "image/gif";
13
+ if (lower.endsWith(".webp")) return "image/webp";
14
+ if (lower.endsWith(".bmp")) return "image/bmp";
15
+ if (lower.endsWith(".tif") || lower.endsWith(".tiff")) return "image/tiff";
16
+ return null;
17
+ }
18
+ function buildAttachmentSummary(attachments) {
19
+ const count = attachments.length;
20
+ if (count === 0) return "";
21
+ if (attachments.every((entry) => isImageAttachment(entry))) return `<media:image> (${count} ${count === 1 ? "image" : "images"})`;
22
+ return `<media:document> (${count} ${count === 1 ? "file" : "files"})`;
23
+ }
24
+ function chunkDiscordText(text, opts = {}) {
25
+ const maxChars = Math.max(1, Math.floor(opts.maxChars ?? 2e3));
26
+ const maxLines = Math.max(1, Math.floor(opts.maxLines ?? 17));
27
+ const body = text ?? "";
28
+ if (!body) return [];
29
+ if (body.length <= maxChars && countLines(body) <= maxLines) return [body];
30
+ return new DiscordTextChunker({
31
+ maxChars,
32
+ maxLines
33
+ }).chunk(body);
34
+ }
35
+ function resolveTextChunkLimit(config) {
36
+ return clampInt(typeof config.textChunkLimit === "number" ? config.textChunkLimit : DISCORD_TEXT_LIMIT, 1, DISCORD_TEXT_LIMIT);
37
+ }
38
+ function resolveDiscordStreamingMode(config) {
39
+ const raw = config.streaming;
40
+ if (raw === true) return "partial";
41
+ if (raw === false || raw === void 0 || raw === null) return "off";
42
+ if (raw === "progress") return "partial";
43
+ if (raw === "partial" || raw === "block" || raw === "off") return raw;
44
+ return "off";
45
+ }
46
+ function resolveDraftChunkConfig(config, textChunkLimit) {
47
+ const raw = config.draftChunk ?? {};
48
+ const minChars = clampInt(raw.minChars ?? 200, 1, textChunkLimit);
49
+ return {
50
+ minChars,
51
+ maxChars: clampInt(raw.maxChars ?? 800, minChars, textChunkLimit),
52
+ breakPreference: raw.breakPreference === "line" || raw.breakPreference === "none" ? raw.breakPreference : "paragraph"
53
+ };
54
+ }
55
+ async function sendDiscordChunks(params) {
56
+ const { textChannel, chunks, replyTo, flags } = params;
57
+ let first = true;
58
+ for (const chunk of chunks) {
59
+ await sendDiscordSingleChunk({
60
+ textChannel,
61
+ chunk,
62
+ replyTo: first ? replyTo : void 0,
63
+ flags
64
+ });
65
+ first = false;
66
+ }
67
+ }
68
+ var DiscordTextChunker = class {
69
+ chunks = [];
70
+ current = "";
71
+ currentLines = 0;
72
+ openFence = null;
73
+ constructor(limits) {
74
+ this.limits = limits;
75
+ }
76
+ chunk = (body) => {
77
+ for (const line of body.split("\n")) this.appendLine(line);
78
+ this.flush();
79
+ return this.chunks;
80
+ };
81
+ appendLine = (line) => {
82
+ const fenceInfo = parseFenceLine(line);
83
+ const wasInsideFence = this.openFence !== null;
84
+ const nextOpenFence = this.nextFence(fenceInfo);
85
+ const { charLimit, lineLimit } = this.resolveLimits(nextOpenFence);
86
+ const prefixLength = this.current.length > 0 ? this.current.length + 1 : 0;
87
+ const segments = splitLongLine(line, Math.max(1, charLimit - prefixLength), { preserveWhitespace: wasInsideFence });
88
+ this.appendSegments({
89
+ segments,
90
+ charLimit,
91
+ lineLimit
92
+ });
93
+ this.openFence = nextOpenFence;
94
+ };
95
+ appendSegments = (params) => {
96
+ const { segments, charLimit, lineLimit } = params;
97
+ segments.forEach((segment, index) => {
98
+ this.appendSegment({
99
+ segment,
100
+ isContinuation: index > 0,
101
+ charLimit,
102
+ lineLimit
103
+ });
104
+ });
105
+ };
106
+ appendSegment = (params) => {
107
+ const { segment, isContinuation, charLimit, lineLimit } = params;
108
+ const addition = `${isContinuation ? "" : this.current.length > 0 ? "\n" : ""}${segment}`;
109
+ const nextLength = this.current.length + addition.length;
110
+ const nextLineCount = this.currentLines + (isContinuation ? 0 : 1);
111
+ if ((nextLength > charLimit || nextLineCount > lineLimit) && this.current.length > 0) this.flush();
112
+ this.addToCurrent(segment, addition, isContinuation);
113
+ };
114
+ addToCurrent = (segment, addition, isContinuation) => {
115
+ if (this.current.length > 0) {
116
+ this.current += addition;
117
+ this.currentLines += isContinuation ? 0 : 1;
118
+ return;
119
+ }
120
+ this.current = segment;
121
+ this.currentLines = 1;
122
+ };
123
+ nextFence = (fenceInfo) => {
124
+ if (!fenceInfo) return this.openFence;
125
+ if (!this.openFence) return fenceInfo;
126
+ return this.openFence.markerChar === fenceInfo.markerChar && fenceInfo.markerLen >= this.openFence.markerLen ? null : this.openFence;
127
+ };
128
+ resolveLimits = (nextOpenFence) => {
129
+ const reserveChars = nextOpenFence ? closeFenceLine(nextOpenFence).length + 1 : 0;
130
+ const reserveLines = nextOpenFence ? 1 : 0;
131
+ return {
132
+ charLimit: Math.max(1, this.limits.maxChars - reserveChars),
133
+ lineLimit: Math.max(1, this.limits.maxLines - reserveLines)
134
+ };
135
+ };
136
+ flush = () => {
137
+ if (!this.current) return;
138
+ const payload = closeFenceIfNeeded(this.current, this.openFence);
139
+ if (payload.trim().length) this.chunks.push(payload);
140
+ this.current = "";
141
+ this.currentLines = 0;
142
+ if (this.openFence) {
143
+ this.current = this.openFence.openLine;
144
+ this.currentLines = 1;
145
+ }
146
+ };
147
+ };
148
+ function isImageAttachment(attachment) {
149
+ if (attachment.mimeType?.startsWith("image/")) return true;
150
+ return Boolean(attachment.name && guessMimeFromName(attachment.name));
151
+ }
152
+ function countLines(text) {
153
+ if (!text) return 0;
154
+ return text.split("\n").length;
155
+ }
156
+ function parseFenceLine(line) {
157
+ const match = line.match(FENCE_RE);
158
+ if (!match) return null;
159
+ const indent = match[1] ?? "";
160
+ const marker = match[2] ?? "";
161
+ return {
162
+ indent,
163
+ markerChar: marker[0] ?? "`",
164
+ markerLen: marker.length,
165
+ openLine: line
166
+ };
167
+ }
168
+ function closeFenceLine(openFence) {
169
+ return `${openFence.indent}${openFence.markerChar.repeat(openFence.markerLen)}`;
170
+ }
171
+ function closeFenceIfNeeded(text, openFence) {
172
+ if (!openFence) return text;
173
+ const closeLine = closeFenceLine(openFence);
174
+ if (!text) return closeLine;
175
+ if (!text.endsWith("\n")) return `${text}\n${closeLine}`;
176
+ return `${text}${closeLine}`;
177
+ }
178
+ function splitLongLine(line, maxChars, opts) {
179
+ const limit = Math.max(1, Math.floor(maxChars));
180
+ if (line.length <= limit) return [line];
181
+ return new LongLineSplitter({
182
+ line,
183
+ limit,
184
+ preserveWhitespace: opts.preserveWhitespace
185
+ }).split();
186
+ }
187
+ var LongLineSplitter = class {
188
+ chunks = [];
189
+ remaining;
190
+ constructor(params) {
191
+ this.params = params;
192
+ this.remaining = params.line;
193
+ }
194
+ split = () => {
195
+ while (this.remaining.length > this.params.limit) this.pushNextChunk();
196
+ if (this.remaining.length) this.chunks.push(this.remaining);
197
+ return this.chunks;
198
+ };
199
+ pushNextChunk = () => {
200
+ if (this.params.preserveWhitespace) {
201
+ this.chunks.push(this.remaining.slice(0, this.params.limit));
202
+ this.remaining = this.remaining.slice(this.params.limit);
203
+ return;
204
+ }
205
+ const breakIndex = this.resolveBreakIndex();
206
+ this.chunks.push(this.remaining.slice(0, breakIndex));
207
+ this.remaining = this.remaining.slice(breakIndex);
208
+ };
209
+ resolveBreakIndex = () => {
210
+ const window = this.remaining.slice(0, this.params.limit);
211
+ for (let index = window.length - 1; index >= 0; index -= 1) if (/\s/.test(window[index])) return index > 0 ? index : this.params.limit;
212
+ return this.params.limit;
213
+ };
214
+ };
215
+ function clampInt(value, min, max) {
216
+ if (Number.isNaN(value)) return min;
217
+ return Math.min(max, Math.max(min, Math.floor(value)));
218
+ }
219
+ async function sendDiscordSingleChunk(params) {
220
+ const { textChannel, chunk, replyTo, flags } = params;
221
+ const payload = { content: chunk };
222
+ if (replyTo) payload.reply = { messageReference: replyTo };
223
+ if (flags !== void 0) payload.flags = flags;
224
+ return await textChannel.send(payload);
225
+ }
226
+ //#endregion
227
+ export { buildAttachmentSummary, chunkDiscordText, guessMimeFromName, resolveDiscordStreamingMode, resolveDraftChunkConfig, resolveTextChunkLimit, sanitizeAttachmentName, sendDiscordChunks, sendDiscordSingleChunk };
228
+
229
+ //# sourceMappingURL=discord-text.utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discord-text.utils.js","names":[],"sources":["../../src/utils/discord-text.utils.ts"],"sourcesContent":["import type { Config, InboundAttachment } from \"@nextclaw/core\";\nimport {\n type Message as DiscordMessage,\n type TextBasedChannel,\n type TextBasedChannelFields\n} from \"discord.js\";\n\nexport const DISCORD_TEXT_LIMIT = 2000;\nexport const DISCORD_MAX_LINES_PER_MESSAGE = 17;\nexport const STREAM_MAX_UPDATES_PER_MESSAGE = 40;\n\nconst FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;\n\ntype OpenFence = {\n indent: string;\n markerChar: string;\n markerLen: number;\n openLine: string;\n};\n\nexport type DraftChunkConfig = {\n minChars: number;\n maxChars: number;\n breakPreference: \"paragraph\" | \"line\" | \"none\";\n};\n\nexport type DiscordStreamMode = \"off\" | \"partial\" | \"block\";\n\nexport function sanitizeAttachmentName(name: string): string {\n return name.replace(/[\\\\/:*?\"<>|]/g, \"_\");\n}\n\nexport function guessMimeFromName(name: string): string | null {\n const lower = name.toLowerCase();\n if (lower.endsWith(\".png\"))\n return \"image/png\";\n if (lower.endsWith(\".jpg\") || lower.endsWith(\".jpeg\"))\n return \"image/jpeg\";\n if (lower.endsWith(\".gif\"))\n return \"image/gif\";\n if (lower.endsWith(\".webp\"))\n return \"image/webp\";\n if (lower.endsWith(\".bmp\"))\n return \"image/bmp\";\n if (lower.endsWith(\".tif\") || lower.endsWith(\".tiff\"))\n return \"image/tiff\";\n return null;\n}\n\nexport function buildAttachmentSummary(attachments: InboundAttachment[]): string {\n const count = attachments.length;\n if (count === 0) {\n return \"\";\n }\n const allImages = attachments.every((entry) => isImageAttachment(entry));\n if (allImages) {\n return `<media:image> (${count} ${count === 1 ? \"image\" : \"images\"})`;\n }\n return `<media:document> (${count} ${count === 1 ? \"file\" : \"files\"})`;\n}\n\nexport function chunkDiscordText(text: string, opts: {\n maxChars?: number;\n maxLines?: number;\n} = {}): string[] {\n const maxChars = Math.max(1, Math.floor(opts.maxChars ?? DISCORD_TEXT_LIMIT));\n const maxLines = Math.max(1, Math.floor(opts.maxLines ?? DISCORD_MAX_LINES_PER_MESSAGE));\n const body = text ?? \"\";\n if (!body) {\n return [];\n }\n if (body.length <= maxChars && countLines(body) <= maxLines) {\n return [body];\n }\n return new DiscordTextChunker({ maxChars, maxLines }).chunk(body);\n}\n\nexport function resolveTextChunkLimit(config: Config[\"channels\"][\"discord\"]): number {\n const configured = typeof config.textChunkLimit === \"number\" ? config.textChunkLimit : DISCORD_TEXT_LIMIT;\n return clampInt(configured, 1, DISCORD_TEXT_LIMIT);\n}\n\nexport function resolveDiscordStreamingMode(config: Config[\"channels\"][\"discord\"]): DiscordStreamMode {\n const raw = config.streaming;\n if (raw === true) {\n return \"partial\";\n }\n if (raw === false || raw === undefined || raw === null) {\n return \"off\";\n }\n if (raw === \"progress\") {\n return \"partial\";\n }\n if (raw === \"partial\" || raw === \"block\" || raw === \"off\") {\n return raw;\n }\n return \"off\";\n}\n\nexport function resolveDraftChunkConfig(\n config: Config[\"channels\"][\"discord\"],\n textChunkLimit: number\n): DraftChunkConfig {\n const raw = config.draftChunk ?? {};\n const minChars = clampInt((raw as DraftChunkConfig).minChars ?? 200, 1, textChunkLimit);\n const maxChars = clampInt((raw as DraftChunkConfig).maxChars ?? 800, minChars, textChunkLimit);\n const breakPreference = (raw as DraftChunkConfig).breakPreference === \"line\" || (raw as DraftChunkConfig).breakPreference === \"none\"\n ? (raw as DraftChunkConfig).breakPreference\n : \"paragraph\";\n return { minChars, maxChars, breakPreference };\n}\n\nexport async function sendDiscordChunks(params: {\n textChannel: TextBasedChannel & TextBasedChannelFields;\n chunks: string[];\n replyTo?: string;\n flags?: number;\n}): Promise<void> {\n const { textChannel, chunks, replyTo, flags } = params;\n let first = true;\n for (const chunk of chunks) {\n await sendDiscordSingleChunk({\n textChannel,\n chunk,\n replyTo: first ? replyTo : undefined,\n flags\n });\n first = false;\n }\n}\n\nclass DiscordTextChunker {\n private readonly chunks: string[] = [];\n private current = \"\";\n private currentLines = 0;\n private openFence: OpenFence | null = null;\n\n constructor(private readonly limits: {\n maxChars: number;\n maxLines: number;\n }) { }\n\n chunk = (body: string): string[] => {\n for (const line of body.split(\"\\n\")) {\n this.appendLine(line);\n }\n this.flush();\n return this.chunks;\n };\n\n private appendLine = (line: string): void => {\n const fenceInfo = parseFenceLine(line);\n const wasInsideFence = this.openFence !== null;\n const nextOpenFence = this.nextFence(fenceInfo);\n const { charLimit, lineLimit } = this.resolveLimits(nextOpenFence);\n const prefixLength = this.current.length > 0 ? this.current.length + 1 : 0;\n const segments = splitLongLine(line, Math.max(1, charLimit - prefixLength), {\n preserveWhitespace: wasInsideFence\n });\n this.appendSegments({ segments, charLimit, lineLimit });\n this.openFence = nextOpenFence;\n };\n\n private appendSegments = (params: {\n segments: string[];\n charLimit: number;\n lineLimit: number;\n }): void => {\n const { segments, charLimit, lineLimit } = params;\n segments.forEach((segment, index) => {\n this.appendSegment({\n segment,\n isContinuation: index > 0,\n charLimit,\n lineLimit\n });\n });\n };\n\n private appendSegment = (params: {\n segment: string;\n isContinuation: boolean;\n charLimit: number;\n lineLimit: number;\n }): void => {\n const { segment, isContinuation, charLimit, lineLimit } = params;\n const delimiter = isContinuation ? \"\" : this.current.length > 0 ? \"\\n\" : \"\";\n const addition = `${delimiter}${segment}`;\n const nextLength = this.current.length + addition.length;\n const nextLineCount = this.currentLines + (isContinuation ? 0 : 1);\n if ((nextLength > charLimit || nextLineCount > lineLimit) && this.current.length > 0) {\n this.flush();\n }\n this.addToCurrent(segment, addition, isContinuation);\n };\n\n private addToCurrent = (segment: string, addition: string, isContinuation: boolean): void => {\n if (this.current.length > 0) {\n this.current += addition;\n this.currentLines += isContinuation ? 0 : 1;\n return;\n }\n this.current = segment;\n this.currentLines = 1;\n };\n\n private nextFence = (fenceInfo: OpenFence | null): OpenFence | null => {\n if (!fenceInfo) {\n return this.openFence;\n }\n if (!this.openFence) {\n return fenceInfo;\n }\n const closesCurrent = this.openFence.markerChar === fenceInfo.markerChar &&\n fenceInfo.markerLen >= this.openFence.markerLen;\n return closesCurrent ? null : this.openFence;\n };\n\n private resolveLimits = (nextOpenFence: OpenFence | null): {\n charLimit: number;\n lineLimit: number;\n } => {\n const reserveChars = nextOpenFence ? closeFenceLine(nextOpenFence).length + 1 : 0;\n const reserveLines = nextOpenFence ? 1 : 0;\n return {\n charLimit: Math.max(1, this.limits.maxChars - reserveChars),\n lineLimit: Math.max(1, this.limits.maxLines - reserveLines)\n };\n };\n\n private flush = (): void => {\n if (!this.current) {\n return;\n }\n const payload = closeFenceIfNeeded(this.current, this.openFence);\n if (payload.trim().length) {\n this.chunks.push(payload);\n }\n this.current = \"\";\n this.currentLines = 0;\n if (this.openFence) {\n this.current = this.openFence.openLine;\n this.currentLines = 1;\n }\n };\n}\n\nfunction isImageAttachment(attachment: InboundAttachment): boolean {\n if (attachment.mimeType?.startsWith(\"image/\")) {\n return true;\n }\n return Boolean(attachment.name && guessMimeFromName(attachment.name));\n}\n\nfunction countLines(text: string): number {\n if (!text) {\n return 0;\n }\n return text.split(\"\\n\").length;\n}\n\nfunction parseFenceLine(line: string): OpenFence | null {\n const match = line.match(FENCE_RE);\n if (!match) {\n return null;\n }\n const indent = match[1] ?? \"\";\n const marker = match[2] ?? \"\";\n return {\n indent,\n markerChar: marker[0] ?? \"`\",\n markerLen: marker.length,\n openLine: line\n };\n}\n\nfunction closeFenceLine(openFence: OpenFence): string {\n return `${openFence.indent}${openFence.markerChar.repeat(openFence.markerLen)}`;\n}\n\nfunction closeFenceIfNeeded(text: string, openFence: OpenFence | null): string {\n if (!openFence) {\n return text;\n }\n const closeLine = closeFenceLine(openFence);\n if (!text) {\n return closeLine;\n }\n if (!text.endsWith(\"\\n\")) {\n return `${text}\\n${closeLine}`;\n }\n return `${text}${closeLine}`;\n}\n\nfunction splitLongLine(line: string, maxChars: number, opts: {\n preserveWhitespace: boolean;\n}): string[] {\n const limit = Math.max(1, Math.floor(maxChars));\n if (line.length <= limit) {\n return [line];\n }\n return new LongLineSplitter({ line, limit, preserveWhitespace: opts.preserveWhitespace }).split();\n}\n\nclass LongLineSplitter {\n private readonly chunks: string[] = [];\n\n private remaining: string;\n\n constructor(private readonly params: {\n line: string;\n limit: number;\n preserveWhitespace: boolean;\n }) {\n this.remaining = params.line;\n }\n\n split = (): string[] => {\n while (this.remaining.length > this.params.limit) {\n this.pushNextChunk();\n }\n if (this.remaining.length) {\n this.chunks.push(this.remaining);\n }\n return this.chunks;\n };\n\n private pushNextChunk = (): void => {\n if (this.params.preserveWhitespace) {\n this.chunks.push(this.remaining.slice(0, this.params.limit));\n this.remaining = this.remaining.slice(this.params.limit);\n return;\n }\n const breakIndex = this.resolveBreakIndex();\n this.chunks.push(this.remaining.slice(0, breakIndex));\n this.remaining = this.remaining.slice(breakIndex);\n };\n\n private resolveBreakIndex = (): number => {\n const window = this.remaining.slice(0, this.params.limit);\n for (let index = window.length - 1; index >= 0; index -= 1) {\n if (/\\s/.test(window[index])) {\n return index > 0 ? index : this.params.limit;\n }\n }\n return this.params.limit;\n };\n}\n\nfunction clampInt(value: number, min: number, max: number): number {\n if (Number.isNaN(value)) {\n return min;\n }\n return Math.min(max, Math.max(min, Math.floor(value)));\n}\n\nexport async function sendDiscordSingleChunk(params: {\n textChannel: TextBasedChannel & TextBasedChannelFields;\n chunk: string;\n replyTo?: string;\n flags?: number;\n}): Promise<DiscordMessage> {\n const { textChannel, chunk, replyTo, flags } = params;\n const payload: {\n content: string;\n reply?: {\n messageReference: string;\n };\n flags?: number;\n } = {\n content: chunk\n };\n if (replyTo) {\n payload.reply = { messageReference: replyTo };\n }\n if (flags !== undefined) {\n payload.flags = flags;\n }\n return await textChannel.send(payload as unknown as Parameters<TextBasedChannelFields[\"send\"]>[0]) as DiscordMessage;\n}\n"],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ {
2
+ "id": "nextclaw-channel-extension-discord",
3
+ "name": "NextClaw Discord Channel Extension",
4
+ "version": "0.1.0",
5
+ "server": {
6
+ "type": "stdio",
7
+ "command": "node",
8
+ "args": ["dist/main.js"]
9
+ },
10
+ "contributes": {
11
+ "channels": [
12
+ {
13
+ "id": "discord",
14
+ "name": "Discord",
15
+ "description": "Discord bot channel",
16
+ "configSchema": {
17
+ "type": "object",
18
+ "additionalProperties": true,
19
+ "properties": {
20
+ "enabled": { "type": "boolean" },
21
+ "token": { "type": "string" },
22
+ "allowBots": { "type": "boolean" },
23
+ "gatewayUrl": { "type": "string" },
24
+ "intents": { "type": "number" },
25
+ "proxy": { "type": ["string", "null"] },
26
+ "mediaMaxMb": { "type": "number" },
27
+ "streaming": {},
28
+ "textChunkLimit": { "type": "number" },
29
+ "accountId": { "type": "string" },
30
+ "dmPolicy": { "type": "string" },
31
+ "groupPolicy": { "type": "string" },
32
+ "requireMention": { "type": "boolean" },
33
+ "allowFrom": {
34
+ "type": "array",
35
+ "items": { "type": "string" }
36
+ },
37
+ "groupAllowFrom": {
38
+ "type": "array",
39
+ "items": { "type": "string" }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ]
45
+ }
46
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@nextclaw/channel-extension-discord",
3
+ "version": "0.1.1-beta.0",
4
+ "private": false,
5
+ "description": "NextClaw Discord channel extension process.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "development": "./src/index.ts",
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "nextclaw.extension.json"
18
+ ],
19
+ "dependencies": {
20
+ "discord.js": "^14.16.3",
21
+ "undici": "^6.21.0",
22
+ "@nextclaw/core": "0.12.25-beta.0",
23
+ "@nextclaw/extension-sdk": "0.1.12-beta.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.17.6",
27
+ "tsx": "^4.19.2",
28
+ "typescript": "^5.6.3"
29
+ },
30
+ "scripts": {
31
+ "build": "tsdown src/index.ts src/main.ts --dts.sourcemap --clean --target es2022 --no-fixedExtension --unbundle",
32
+ "lint": "eslint src --max-warnings=0",
33
+ "tsc": "tsc -p tsconfig.json"
34
+ }
35
+ }