@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 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
- const session = this.getOrCreateSession(channelId, channel);
369
- session.currentPrompt = transcript;
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
- session.claude.sendPrompt(stampPrompt(transcript, voiceUsername, msg.createdAt));
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", transcript, channelId);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {