@gonzih/cc-discord 0.1.10 → 0.1.12
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/dist/bot.d.ts +5 -0
- package/dist/bot.js +125 -14
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
|
|
4
4
|
*/
|
|
5
5
|
import { Redis } from "ioredis";
|
|
6
|
+
/** Returns true if the attachment name/contentType indicates an audio file. */
|
|
7
|
+
export declare function isAudioAttachment(name: string, contentType: string): boolean;
|
|
8
|
+
/** Build the prompt text for a file/document attachment, optionally with caption. */
|
|
9
|
+
export declare function buildAttachmentPrompt(caption: string, fileName: string, filePath: string): string;
|
|
6
10
|
/** Prepend [DayOfWeek HH:MM] username: so Claude knows when the message was received and from whom. */
|
|
7
11
|
export declare function stampPrompt(text: string, username?: string, now?: Date): string;
|
|
8
12
|
export interface DiscordBotOptions {
|
|
@@ -56,6 +60,7 @@ export declare class CcDiscordBot {
|
|
|
56
60
|
private handleMessage;
|
|
57
61
|
private handleVoice;
|
|
58
62
|
private handleImage;
|
|
63
|
+
private handleDocument;
|
|
59
64
|
private getOrCreateSession;
|
|
60
65
|
private onClaudeMessage;
|
|
61
66
|
private flushSession;
|
package/dist/bot.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
|
|
4
4
|
*/
|
|
5
5
|
import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, EmbedBuilder, AttachmentBuilder, Events, ChannelType, } from "discord.js";
|
|
6
|
-
import { existsSync, createWriteStream } from "fs";
|
|
7
|
-
import { basename } from "path";
|
|
6
|
+
import { existsSync, createWriteStream, mkdirSync } from "fs";
|
|
7
|
+
import { resolve, basename, join } from "path";
|
|
8
8
|
import https from "https";
|
|
9
9
|
import http from "http";
|
|
10
10
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
@@ -42,6 +42,20 @@ const FLUSH_DELAY_MS = 800;
|
|
|
42
42
|
// Discord typing indicator: re-send every 9s (indicator expires after ~10s)
|
|
43
43
|
const TYPING_INTERVAL_MS = 9000;
|
|
44
44
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
45
|
+
/** Returns true if the attachment name/contentType indicates an audio file. */
|
|
46
|
+
export function isAudioAttachment(name, contentType) {
|
|
47
|
+
const n = name.toLowerCase();
|
|
48
|
+
const ct = contentType.toLowerCase();
|
|
49
|
+
return (ct.startsWith("audio/") ||
|
|
50
|
+
n.endsWith(".ogg") || n.endsWith(".mp3") || n.endsWith(".m4a") ||
|
|
51
|
+
n.endsWith(".wav") || n.endsWith(".webm") ||
|
|
52
|
+
ct.includes("ogg") || ct.includes("mpeg") || ct.includes("mp4a"));
|
|
53
|
+
}
|
|
54
|
+
/** Build the prompt text for a file/document attachment, optionally with caption. */
|
|
55
|
+
export function buildAttachmentPrompt(caption, fileName, filePath) {
|
|
56
|
+
const ref = `[${fileName}](${filePath})`;
|
|
57
|
+
return caption ? `${caption}\n\nATTACHMENTS: ${ref}` : `ATTACHMENTS: ${ref}`;
|
|
58
|
+
}
|
|
45
59
|
/** Prepend [DayOfWeek HH:MM] username: so Claude knows when the message was received and from whom. */
|
|
46
60
|
export function stampPrompt(text, username, now = new Date()) {
|
|
47
61
|
const day = DAYS[now.getDay()];
|
|
@@ -294,12 +308,7 @@ export class CcDiscordBot {
|
|
|
294
308
|
// Store snowflake mapping for cron reverse-lookup
|
|
295
309
|
this.storeSnowflake(effectiveChannelId);
|
|
296
310
|
// Check for voice/audio attachments
|
|
297
|
-
const audioAttachment = msg.attachments.find((att) =>
|
|
298
|
-
const name = att.name?.toLowerCase() ?? "";
|
|
299
|
-
const ct = att.contentType?.toLowerCase() ?? "";
|
|
300
|
-
return (name.endsWith(".ogg") || name.endsWith(".mp3") || name.endsWith(".m4a") ||
|
|
301
|
-
ct.includes("ogg") || ct.includes("mpeg") || ct.includes("mp4a"));
|
|
302
|
-
});
|
|
311
|
+
const audioAttachment = msg.attachments.find((att) => isAudioAttachment(att.name ?? "", att.contentType ?? ""));
|
|
303
312
|
if (audioAttachment) {
|
|
304
313
|
await this.handleVoice(msg, effectiveChannelId, audioAttachment.url, audioAttachment.name ?? "audio.ogg");
|
|
305
314
|
return;
|
|
@@ -313,6 +322,12 @@ export class CcDiscordBot {
|
|
|
313
322
|
await this.handleImage(msg, effectiveChannelId, imageAttachment.url, imageAttachment.contentType ?? "image/jpeg");
|
|
314
323
|
return;
|
|
315
324
|
}
|
|
325
|
+
// Other file/document attachments
|
|
326
|
+
const docAttachment = msg.attachments.first();
|
|
327
|
+
if (docAttachment) {
|
|
328
|
+
await this.handleDocument(msg, effectiveChannelId, docAttachment.url, docAttachment.name ?? "file");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
316
331
|
let text = msg.content.trim();
|
|
317
332
|
if (!text)
|
|
318
333
|
return;
|
|
@@ -320,6 +335,20 @@ export class CcDiscordBot {
|
|
|
320
335
|
text = text.replace(/<@!?\d+>/g, "").trim();
|
|
321
336
|
if (!text)
|
|
322
337
|
return;
|
|
338
|
+
// Prepend replied-to message content so Claude can resurrect context
|
|
339
|
+
if (msg.reference?.messageId) {
|
|
340
|
+
try {
|
|
341
|
+
const referenced = await msg.channel.messages.fetch(msg.reference.messageId);
|
|
342
|
+
const refContent = referenced.content.length > 300
|
|
343
|
+
? referenced.content.slice(0, 300) + "…"
|
|
344
|
+
: referenced.content;
|
|
345
|
+
const refAuthor = referenced.member?.displayName ?? referenced.author.username;
|
|
346
|
+
text = `> [replying to ${refAuthor}]: ${refContent}\n${text}`;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// Referenced message unavailable — proceed without context
|
|
350
|
+
}
|
|
351
|
+
}
|
|
323
352
|
// Natural-language channel creation: "channel for https://github.com/org/repo"
|
|
324
353
|
if (this.redis) {
|
|
325
354
|
const intent = parseChannelCreateIntent(text);
|
|
@@ -365,12 +394,29 @@ export class CcDiscordBot {
|
|
|
365
394
|
await channel.send("Could not transcribe voice message.").catch(() => { });
|
|
366
395
|
return;
|
|
367
396
|
}
|
|
368
|
-
|
|
369
|
-
|
|
397
|
+
// Combine transcript with caption text if present
|
|
398
|
+
const caption = msg.content.trim().replace(/<@!?\d+>/g, "").trim();
|
|
399
|
+
const fullText = caption ? `${caption}\n\n${transcript}` : transcript;
|
|
370
400
|
const voiceUsername = msg.member?.displayName ?? msg.author.username;
|
|
371
|
-
|
|
401
|
+
const prompt = stampPrompt(fullText, voiceUsername, msg.createdAt);
|
|
402
|
+
// Meta-agent routing
|
|
403
|
+
const mappedNs = this.channelNamespaceMap.get(channelId);
|
|
404
|
+
if (mappedNs && this.redis) {
|
|
405
|
+
this.writeChatMessage("user", "discord", fullText, channelId, mappedNs.namespace);
|
|
406
|
+
this.opts.registerRoutedChannelId?.(mappedNs.namespace, channelId);
|
|
407
|
+
try {
|
|
408
|
+
await routeToMetaAgent(mappedNs.namespace, prompt, this.redis);
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
await channel.send(`Failed to route to ${mappedNs.namespace}: ${err.message}`).catch(() => { });
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const session = this.getOrCreateSession(channelId, channel);
|
|
416
|
+
session.currentPrompt = fullText;
|
|
417
|
+
session.claude.sendPrompt(prompt);
|
|
372
418
|
this.startTyping(channelId, channel, session);
|
|
373
|
-
this.writeChatMessage("user", "discord",
|
|
419
|
+
this.writeChatMessage("user", "discord", fullText, channelId);
|
|
374
420
|
}
|
|
375
421
|
catch (err) {
|
|
376
422
|
const errMsg = err.message;
|
|
@@ -390,18 +436,83 @@ export class CcDiscordBot {
|
|
|
390
436
|
async handleImage(msg, channelId, imageUrl, contentType) {
|
|
391
437
|
const channel = msg.channel;
|
|
392
438
|
await channel.sendTyping().catch(() => { });
|
|
439
|
+
const caption = msg.content.trim().replace(/<@!?\d+>/g, "").trim();
|
|
440
|
+
const imgUsername = msg.member?.displayName ?? msg.author.username;
|
|
393
441
|
try {
|
|
442
|
+
// Meta-agent routing: save to disk and send as ATTACHMENTS path reference
|
|
443
|
+
const mappedNs = this.channelNamespaceMap.get(channelId);
|
|
444
|
+
if (mappedNs && this.redis) {
|
|
445
|
+
const ext = contentType.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
|
|
446
|
+
const fileName = `image_${crypto.randomUUID()}.${ext}`;
|
|
447
|
+
const uploadsDir = resolve(this.opts.cwd ?? process.cwd(), ".cc-discord", "uploads");
|
|
448
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
449
|
+
const dest = join(uploadsDir, fileName);
|
|
450
|
+
await downloadFile(imageUrl, dest);
|
|
451
|
+
const fullText = buildAttachmentPrompt(caption, fileName, dest);
|
|
452
|
+
const prompt = stampPrompt(fullText, imgUsername, msg.createdAt);
|
|
453
|
+
this.writeChatMessage("user", "discord", fullText, channelId, mappedNs.namespace);
|
|
454
|
+
this.opts.registerRoutedChannelId?.(mappedNs.namespace, channelId);
|
|
455
|
+
try {
|
|
456
|
+
await routeToMetaAgent(mappedNs.namespace, prompt, this.redis);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
await channel.send(`Failed to route to ${mappedNs.namespace}: ${err.message}`).catch(() => { });
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Local Claude session: send as base64
|
|
394
464
|
const base64Data = await fetchAsBase64(imageUrl);
|
|
395
|
-
const caption = msg.content.trim() || "";
|
|
396
|
-
const imgUsername = msg.member?.displayName ?? msg.author.username;
|
|
397
465
|
const session = this.getOrCreateSession(channelId, channel);
|
|
398
466
|
session.claude.sendImage(base64Data, contentType, stampPrompt(caption, imgUsername, msg.createdAt));
|
|
399
467
|
this.startTyping(channelId, channel, session);
|
|
468
|
+
this.writeChatMessage("user", "discord", caption || "[image]", channelId);
|
|
400
469
|
}
|
|
401
470
|
catch (err) {
|
|
402
471
|
await channel.send(`Failed to process image: ${err.message}`).catch(() => { });
|
|
403
472
|
}
|
|
404
473
|
}
|
|
474
|
+
async handleDocument(msg, channelId, fileUrl, fileName) {
|
|
475
|
+
const channel = msg.channel;
|
|
476
|
+
await channel.sendTyping().catch(() => { });
|
|
477
|
+
const uploadsDir = resolve(this.opts.cwd ?? process.cwd(), ".cc-discord", "uploads");
|
|
478
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
479
|
+
const dest = join(uploadsDir, fileName);
|
|
480
|
+
try {
|
|
481
|
+
await downloadFile(fileUrl, dest);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
await channel.send(`Failed to download file: ${err.message}`).catch(() => { });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const caption = msg.content.trim().replace(/<@!?\d+>/g, "").trim();
|
|
488
|
+
const fullText = buildAttachmentPrompt(caption, fileName, dest);
|
|
489
|
+
const username = msg.member?.displayName ?? msg.author.username;
|
|
490
|
+
const prompt = stampPrompt(fullText, username, msg.createdAt);
|
|
491
|
+
// Meta-agent routing
|
|
492
|
+
const mappedNs = this.channelNamespaceMap.get(channelId);
|
|
493
|
+
if (mappedNs && this.redis) {
|
|
494
|
+
this.writeChatMessage("user", "discord", fullText, channelId, mappedNs.namespace);
|
|
495
|
+
this.opts.registerRoutedChannelId?.(mappedNs.namespace, channelId);
|
|
496
|
+
try {
|
|
497
|
+
await routeToMetaAgent(mappedNs.namespace, prompt, this.redis);
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
await channel.send(`Failed to route to ${mappedNs.namespace}: ${err.message}`).catch(() => { });
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// Local Claude session
|
|
505
|
+
const session = this.getOrCreateSession(channelId, channel);
|
|
506
|
+
try {
|
|
507
|
+
session.currentPrompt = fullText;
|
|
508
|
+
session.claude.sendPrompt(prompt);
|
|
509
|
+
this.startTyping(channelId, channel, session);
|
|
510
|
+
this.writeChatMessage("user", "discord", fullText, channelId);
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
await channel.send(`Failed to process file: ${err.message}`).catch(() => { });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
405
516
|
getOrCreateSession(channelId, channel) {
|
|
406
517
|
const key = this.sessionKey(channelId);
|
|
407
518
|
let session = this.sessions.get(key);
|