@gonzih/cc-discord 0.1.0 → 0.1.2

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.js DELETED
@@ -1,798 +0,0 @@
1
- /**
2
- * Discord bot that routes messages to/from a Claude Code subprocess.
3
- * One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
4
- */
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";
8
- import https from "https";
9
- import http from "http";
10
- import { ClaudeProcess, extractText } from "./claude.js";
11
- import { transcribeVoice, isVoiceAvailable } from "./voice.js";
12
- import { formatForDiscord, splitLongMessage, stripAnsi } from "./formatter.js";
13
- import { getCurrentToken } from "./tokens.js";
14
- import { writeChatLog } from "./notifier.js";
15
- import { CronManager } from "./cron.js";
16
- import { parseRoutingTag, ensureMetaAgent, routeToMetaAgent } from "./router.js";
17
- import { metaAgentStatusKey } from "@gonzih/cc-wire";
18
- /** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
19
- function snowflakeToInt(id) {
20
- // Discord snowflakes are up to 2^63, beyond Number.MAX_SAFE_INTEGER.
21
- // Mask to 53 bits for safe integer range while maintaining per-channel consistency.
22
- return Number(BigInt(id) & BigInt(0x001FFFFFFFFFFFFF));
23
- }
24
- // Claude Sonnet 4.6 pricing (per 1M tokens)
25
- const PRICING = {
26
- inputPerM: 3.00,
27
- outputPerM: 15.00,
28
- cacheReadPerM: 0.30,
29
- cacheWritePerM: 3.75,
30
- };
31
- function computeCostUsd(usage) {
32
- return (usage.inputTokens * PRICING.inputPerM / 1_000_000 +
33
- usage.outputTokens * PRICING.outputPerM / 1_000_000 +
34
- usage.cacheReadTokens * PRICING.cacheReadPerM / 1_000_000 +
35
- usage.cacheWriteTokens * PRICING.cacheWritePerM / 1_000_000);
36
- }
37
- // Debounces streaming chunks. Resets on each chunk. Fires 800ms after last chunk.
38
- const FLUSH_DELAY_MS = 800;
39
- // Discord typing indicator: re-send every 9s (indicator expires after ~10s)
40
- const TYPING_INTERVAL_MS = 9000;
41
- /** Prepend [MM-DD HH:mm] so Claude knows when the message was received. */
42
- export function stampPrompt(text, now = new Date()) {
43
- const mm = String(now.getMonth() + 1).padStart(2, "0");
44
- const dd = String(now.getDate()).padStart(2, "0");
45
- const hh = String(now.getHours()).padStart(2, "0");
46
- const min = String(now.getMinutes()).padStart(2, "0");
47
- return `[${mm}-${dd} ${hh}:${min}] ${text}`;
48
- }
49
- function formatTokens(n) {
50
- if (n >= 1000)
51
- return `${(n / 1000).toFixed(1)}k`;
52
- return String(n);
53
- }
54
- /** Download a URL to a file on disk. */
55
- function downloadFile(url, dest) {
56
- return new Promise((resolve, reject) => {
57
- const file = createWriteStream(dest);
58
- const getter = url.startsWith("https") ? https : http;
59
- getter.get(url, (res) => {
60
- if (res.statusCode !== 200) {
61
- reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
62
- return;
63
- }
64
- res.pipe(file);
65
- file.on("finish", () => file.close(() => resolve()));
66
- file.on("error", reject);
67
- }).on("error", reject);
68
- });
69
- }
70
- /** Fetch a URL and return it as a base64 string. */
71
- async function fetchAsBase64(url) {
72
- return new Promise((resolve, reject) => {
73
- const getter = url.startsWith("https") ? https : http;
74
- getter.get(url, (res) => {
75
- if (res.statusCode !== 200) {
76
- reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
77
- return;
78
- }
79
- const chunks = [];
80
- res.on("data", (chunk) => chunks.push(chunk));
81
- res.on("end", () => resolve(Buffer.concat(chunks).toString("base64")));
82
- res.on("error", reject);
83
- }).on("error", reject);
84
- });
85
- }
86
- export class CcDiscordBot {
87
- client;
88
- sessions = new Map();
89
- costs = new Map();
90
- opts;
91
- redis;
92
- namespace;
93
- lastActiveChannelId;
94
- cron;
95
- /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
96
- mcpSession;
97
- constructor(opts) {
98
- this.opts = opts;
99
- this.redis = opts.redis;
100
- this.namespace = opts.namespace ?? "default";
101
- this.client = new Client({
102
- intents: [
103
- GatewayIntentBits.Guilds,
104
- GatewayIntentBits.GuildMessages,
105
- GatewayIntentBits.MessageContent,
106
- GatewayIntentBits.DirectMessages,
107
- ],
108
- });
109
- this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatIdNum, prompt, _jobId, done) => {
110
- // Reverse-lookup channelId from the stored integer
111
- const channelId = this.reverseSnowflakeLookup(chatIdNum);
112
- if (!channelId) {
113
- console.warn(`[cron] no channelId found for chatId=${chatIdNum}`);
114
- done();
115
- return;
116
- }
117
- this.runCronTask(channelId, prompt, done);
118
- });
119
- this.client.once(Events.ClientReady, (readyClient) => {
120
- console.log(`[discord] logged in as ${readyClient.user.tag}`);
121
- this.registerSlashCommands().catch((err) => {
122
- console.error("[discord] slash command registration failed:", err.message);
123
- });
124
- });
125
- this.client.on(Events.MessageCreate, (msg) => {
126
- void this.handleMessage(msg);
127
- });
128
- this.client.on(Events.InteractionCreate, (interaction) => {
129
- if (!interaction.isChatInputCommand())
130
- return;
131
- void this.handleSlashCommand(interaction);
132
- });
133
- this.client.on("error", (err) => {
134
- console.error("[discord] client error:", err.message);
135
- });
136
- void this.client.login(opts.discordToken);
137
- console.log("[discord] bot starting...");
138
- console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
139
- }
140
- /** Reverse-lookup: find the channelId string for a cron-stored integer */
141
- snowflakeMap = new Map();
142
- storeSnowflake(channelId) {
143
- const n = snowflakeToInt(channelId);
144
- this.snowflakeMap.set(n, channelId);
145
- return n;
146
- }
147
- reverseSnowflakeLookup(n) {
148
- return this.snowflakeMap.get(n);
149
- }
150
- /** Session key: "channelId" or "channelId:threadId" for threads */
151
- sessionKey(channelId, threadId) {
152
- return threadId ? `${channelId}:${threadId}` : channelId;
153
- }
154
- /** Get the channel/thread for sending messages */
155
- async getChannel(channelId) {
156
- try {
157
- const channel = await this.client.channels.fetch(channelId);
158
- if (!channel)
159
- return null;
160
- if (channel.type === ChannelType.GuildText ||
161
- channel.type === ChannelType.DM ||
162
- channel.type === ChannelType.GuildNews ||
163
- channel.type === ChannelType.PublicThread ||
164
- channel.type === ChannelType.PrivateThread ||
165
- channel.type === ChannelType.GuildVoice) {
166
- return channel;
167
- }
168
- return null;
169
- }
170
- catch {
171
- return null;
172
- }
173
- }
174
- /** Send text to a channel, splitting at 2000 chars, sending file attachments if detected. */
175
- async sendToChannel(channel, text) {
176
- // Check for file paths written by Claude tools (lines like: "File written: /path/to/file")
177
- const filePathMatch = text.match(/(?:^|\n)\s*(?:file written|wrote file|created file|saved to|output:)\s*[:\-]?\s*(\/[^\s\n]+)/im);
178
- if (filePathMatch) {
179
- const filePath = filePathMatch[1].trim();
180
- if (existsSync(filePath)) {
181
- try {
182
- const attachment = new AttachmentBuilder(filePath, { name: basename(filePath) });
183
- await channel.send({ files: [attachment] });
184
- return;
185
- }
186
- catch (err) {
187
- console.warn(`[bot] failed to send file ${filePath}:`, err.message);
188
- }
189
- }
190
- }
191
- const formatted = formatForDiscord(text);
192
- const chunks = splitLongMessage(formatted);
193
- for (const chunk of chunks) {
194
- if (!chunk.trim())
195
- continue;
196
- await channel.send(chunk).catch((err) => {
197
- console.error("[bot] send failed:", err.message);
198
- });
199
- }
200
- }
201
- /** Send to a channel by ID — used by notifier callbacks. */
202
- async sendToChannelById(channelId, text) {
203
- const channel = await this.getChannel(channelId);
204
- if (!channel) {
205
- console.warn(`[bot] sendToChannelById: channel ${channelId} not found`);
206
- return;
207
- }
208
- await this.sendToChannel(channel, text);
209
- }
210
- isAllowed(userId) {
211
- if (!this.opts.allowedUserIds?.length)
212
- return true;
213
- return this.opts.allowedUserIds.includes(userId);
214
- }
215
- async handleMessage(msg) {
216
- // Skip bots (including self)
217
- if (msg.author.bot)
218
- return;
219
- const userId = msg.author.id;
220
- if (!this.isAllowed(userId))
221
- return;
222
- // Track last active channel
223
- this.lastActiveChannelId = msg.channelId;
224
- const channelId = msg.channelId;
225
- const threadId = msg.channel.isThread() ? msg.channelId : undefined;
226
- // For threads, the parent channel is the actual channel
227
- const effectiveChannelId = threadId ?? channelId;
228
- const sessionKey = this.sessionKey(effectiveChannelId, threadId);
229
- // Store snowflake mapping for cron reverse-lookup
230
- this.storeSnowflake(effectiveChannelId);
231
- // Check for voice/audio attachments
232
- const audioAttachment = msg.attachments.find((att) => {
233
- const name = att.name?.toLowerCase() ?? "";
234
- const ct = att.contentType?.toLowerCase() ?? "";
235
- return (name.endsWith(".ogg") || name.endsWith(".mp3") || name.endsWith(".m4a") ||
236
- ct.includes("ogg") || ct.includes("mpeg") || ct.includes("mp4a"));
237
- });
238
- if (audioAttachment) {
239
- await this.handleVoice(msg, effectiveChannelId, audioAttachment.url, audioAttachment.name ?? "audio.ogg");
240
- return;
241
- }
242
- // Image attachments
243
- const imageAttachment = msg.attachments.find((att) => {
244
- const ct = att.contentType?.toLowerCase() ?? "";
245
- return ct.startsWith("image/");
246
- });
247
- if (imageAttachment) {
248
- await this.handleImage(msg, effectiveChannelId, imageAttachment.url, imageAttachment.contentType ?? "image/jpeg");
249
- return;
250
- }
251
- let text = msg.content.trim();
252
- if (!text)
253
- return;
254
- // Strip @mention
255
- text = text.replace(/<@!?\d+>/g, "").trim();
256
- if (!text)
257
- return;
258
- // #tag / #org/repo routing — delegate to meta-agent
259
- if (this.redis) {
260
- const routing = parseRoutingTag(text);
261
- if (routing) {
262
- const channel = msg.channel;
263
- await channel.send(`→ #${routing.namespace}`).catch(() => { });
264
- this.writeChatMessage("user", "discord", text, effectiveChannelId);
265
- this.opts.registerRoutedChannelId?.(routing.namespace, effectiveChannelId);
266
- try {
267
- await ensureMetaAgent(routing.namespace, routing.repoUrl, (toolName, args) => this.callCcAgentTool(toolName, args ?? {}), this.redis);
268
- await routeToMetaAgent(routing.namespace, routing.strippedMessage, this.redis);
269
- }
270
- catch (err) {
271
- await channel.send(`Failed to route to #${routing.namespace}: ${err.message}`).catch(() => { });
272
- }
273
- return;
274
- }
275
- }
276
- // Channel name → meta-agent namespace routing
277
- if (this.redis) {
278
- const channelName = msg.channel.name ?? "";
279
- if (channelName && channelName !== "general") {
280
- // Check if a meta-agent is running for this channel name as namespace
281
- const ns = channelName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
282
- try {
283
- const statusRaw = await this.redis.get(metaAgentStatusKey(ns));
284
- if (statusRaw) {
285
- const status = JSON.parse(statusRaw);
286
- if (status.status === "running" || status.status === "idle") {
287
- // Route to meta-agent
288
- const channel = msg.channel;
289
- await channel.send(`→ #${ns} (meta-agent)`).catch(() => { });
290
- this.writeChatMessage("user", "discord", text, effectiveChannelId);
291
- this.opts.registerRoutedChannelId?.(ns, effectiveChannelId);
292
- try {
293
- await routeToMetaAgent(ns, text, this.redis);
294
- }
295
- catch (err) {
296
- await channel.send(`Failed to route to #${ns}: ${err.message}`).catch(() => { });
297
- }
298
- return;
299
- }
300
- }
301
- }
302
- catch {
303
- // Redis error — fall through to local session
304
- }
305
- }
306
- }
307
- // Local Claude session
308
- const session = this.getOrCreateSession(effectiveChannelId, msg.channel);
309
- try {
310
- session.currentPrompt = text;
311
- session.claude.sendPrompt(stampPrompt(text));
312
- this.startTyping(effectiveChannelId, msg.channel, session);
313
- this.writeChatMessage("user", "discord", text, effectiveChannelId);
314
- }
315
- catch (err) {
316
- await msg.channel.send(`Error sending to Claude: ${err.message}`).catch(() => { });
317
- this.killSession(effectiveChannelId);
318
- }
319
- }
320
- async handleVoice(msg, channelId, audioUrl, _fileName) {
321
- const channel = msg.channel;
322
- await channel.sendTyping().catch(() => { });
323
- try {
324
- const transcript = await transcribeVoice(audioUrl);
325
- if (!transcript || transcript === "[empty transcription]") {
326
- await channel.send("Could not transcribe voice message.").catch(() => { });
327
- return;
328
- }
329
- const session = this.getOrCreateSession(channelId, channel);
330
- session.currentPrompt = transcript;
331
- session.claude.sendPrompt(stampPrompt(transcript));
332
- this.startTyping(channelId, channel, session);
333
- this.writeChatMessage("user", "discord", transcript, channelId);
334
- }
335
- catch (err) {
336
- const errMsg = err.message;
337
- let userMsg;
338
- if (errMsg.includes("whisper-cpp not found")) {
339
- userMsg = "Voice transcription unavailable — whisper-cpp not installed";
340
- }
341
- else if (errMsg.includes("No whisper model found")) {
342
- userMsg = "Voice transcription unavailable — no whisper model found";
343
- }
344
- else {
345
- userMsg = `Voice transcription failed: ${errMsg}`;
346
- }
347
- await channel.send(userMsg).catch(() => { });
348
- }
349
- }
350
- async handleImage(msg, channelId, imageUrl, contentType) {
351
- const channel = msg.channel;
352
- await channel.sendTyping().catch(() => { });
353
- try {
354
- const base64Data = await fetchAsBase64(imageUrl);
355
- const caption = msg.content.trim() || "";
356
- const session = this.getOrCreateSession(channelId, channel);
357
- session.claude.sendImage(base64Data, contentType, stampPrompt(caption));
358
- this.startTyping(channelId, channel, session);
359
- }
360
- catch (err) {
361
- await channel.send(`Failed to process image: ${err.message}`).catch(() => { });
362
- }
363
- }
364
- getOrCreateSession(channelId, channel) {
365
- const key = this.sessionKey(channelId);
366
- let session = this.sessions.get(key);
367
- if (session && !session.claude.exited)
368
- return session;
369
- if (session) {
370
- // Process exited — clean up
371
- if (session.flushTimer)
372
- clearTimeout(session.flushTimer);
373
- if (session.typingTimer)
374
- clearInterval(session.typingTimer);
375
- }
376
- const claude = new ClaudeProcess({
377
- cwd: this.opts.cwd ?? process.cwd(),
378
- token: this.opts.claudeToken ?? getCurrentToken(),
379
- });
380
- session = {
381
- claude,
382
- pendingText: "",
383
- flushTimer: null,
384
- typingTimer: null,
385
- writtenFiles: new Set(),
386
- currentPrompt: "",
387
- };
388
- claude.on("message", (msg) => {
389
- void this.onClaudeMessage(channelId, channel, session, msg);
390
- });
391
- claude.on("usage", (usage) => {
392
- this.addUsage(channelId, usage);
393
- });
394
- claude.on("error", (err) => {
395
- console.error(`[claude:${channelId}] error:`, err.message);
396
- });
397
- claude.on("exit", (code) => {
398
- console.log(`[claude:${channelId}] process exited (code=${code})`);
399
- if (session.typingTimer) {
400
- clearInterval(session.typingTimer);
401
- session.typingTimer = null;
402
- }
403
- });
404
- this.sessions.set(key, session);
405
- return session;
406
- }
407
- async onClaudeMessage(channelId, channel, session, msg) {
408
- if (msg.type === "assistant") {
409
- const text = extractText(msg);
410
- if (!text)
411
- return;
412
- // Detect file paths in output
413
- const filePathMatch = text.match(/(?:^|\n)\s*(?:file written|wrote file|created file|saved to|output:)\s*[:\-]?\s*(\/[^\s\n]+)/im);
414
- if (filePathMatch) {
415
- const filePath = filePathMatch[1].trim();
416
- if (existsSync(filePath)) {
417
- session.writtenFiles.add(filePath);
418
- }
419
- }
420
- // Accumulate streaming text and debounce flush
421
- session.pendingText += (session.pendingText ? "\n" : "") + text;
422
- if (session.flushTimer)
423
- clearTimeout(session.flushTimer);
424
- session.flushTimer = setTimeout(() => {
425
- void this.flushSession(channelId, channel, session);
426
- }, FLUSH_DELAY_MS);
427
- }
428
- else if (msg.type === "result") {
429
- // Final result — flush immediately
430
- if (session.flushTimer) {
431
- clearTimeout(session.flushTimer);
432
- session.flushTimer = null;
433
- }
434
- const resultText = extractText(msg);
435
- if (resultText && !session.pendingText) {
436
- session.pendingText = resultText;
437
- }
438
- await this.flushSession(channelId, channel, session);
439
- // Send any files written during this turn
440
- for (const filePath of session.writtenFiles) {
441
- if (existsSync(filePath)) {
442
- try {
443
- const attachment = new AttachmentBuilder(filePath, { name: basename(filePath) });
444
- await channel.send({ files: [attachment] });
445
- }
446
- catch (err) {
447
- console.warn(`[bot] failed to send file ${filePath}:`, err.message);
448
- }
449
- }
450
- }
451
- session.writtenFiles.clear();
452
- // Stop typing indicator
453
- if (session.typingTimer) {
454
- clearInterval(session.typingTimer);
455
- session.typingTimer = null;
456
- }
457
- this.getCost(channelId).messageCount++;
458
- }
459
- }
460
- async flushSession(channelId, channel, session) {
461
- const text = stripAnsi(session.pendingText.trim());
462
- session.pendingText = "";
463
- session.flushTimer = null;
464
- if (!text)
465
- return;
466
- this.writeChatMessage("assistant", "claude", text, channelId);
467
- await this.sendToChannel(channel, text);
468
- }
469
- startTyping(channelId, channel, session) {
470
- if (session.typingTimer)
471
- return; // already running
472
- // Send immediately
473
- channel.sendTyping().catch(() => { });
474
- session.typingTimer = setInterval(() => {
475
- channel.sendTyping().catch(() => { });
476
- }, TYPING_INTERVAL_MS);
477
- }
478
- killSession(channelId) {
479
- const key = this.sessionKey(channelId);
480
- const session = this.sessions.get(key);
481
- if (!session)
482
- return;
483
- if (session.flushTimer)
484
- clearTimeout(session.flushTimer);
485
- if (session.typingTimer)
486
- clearInterval(session.typingTimer);
487
- session.claude.kill();
488
- this.sessions.delete(key);
489
- }
490
- getCost(channelId) {
491
- let cost = this.costs.get(channelId);
492
- if (!cost) {
493
- cost = { totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, totalCostUsd: 0, messageCount: 0 };
494
- this.costs.set(channelId, cost);
495
- }
496
- return cost;
497
- }
498
- addUsage(channelId, usage) {
499
- const cost = this.getCost(channelId);
500
- cost.totalInputTokens += usage.inputTokens;
501
- cost.totalOutputTokens += usage.outputTokens;
502
- cost.totalCacheReadTokens += usage.cacheReadTokens;
503
- cost.totalCacheWriteTokens += usage.cacheWriteTokens;
504
- cost.totalCostUsd += computeCostUsd(usage);
505
- }
506
- buildCostEmbed(channelId) {
507
- const cost = this.getCost(channelId);
508
- const inputCost = cost.totalInputTokens * PRICING.inputPerM / 1_000_000;
509
- const outputCost = cost.totalOutputTokens * PRICING.outputPerM / 1_000_000;
510
- const cacheReadCost = cost.totalCacheReadTokens * PRICING.cacheReadPerM / 1_000_000;
511
- const cacheWriteCost = cost.totalCacheWriteTokens * PRICING.cacheWritePerM / 1_000_000;
512
- return new EmbedBuilder()
513
- .setTitle("Session Cost")
514
- .setColor(0x5865F2)
515
- .addFields({ name: "Messages", value: String(cost.messageCount), inline: true }, { name: "Total", value: `$${cost.totalCostUsd.toFixed(3)}`, inline: true }, { name: "\u200B", value: "\u200B", inline: false }, { name: "Input", value: `${formatTokens(cost.totalInputTokens)} tokens ($${inputCost.toFixed(3)})`, inline: true }, { name: "Output", value: `${formatTokens(cost.totalOutputTokens)} tokens ($${outputCost.toFixed(3)})`, inline: true }, { name: "Cache Read", value: `${formatTokens(cost.totalCacheReadTokens)} tokens ($${cacheReadCost.toFixed(3)})`, inline: true }, { name: "Cache Write", value: `${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`, inline: true });
516
- }
517
- async registerSlashCommands() {
518
- const commands = [
519
- new SlashCommandBuilder().setName("reset").setDescription("Reset Claude session for this channel"),
520
- new SlashCommandBuilder().setName("costs").setDescription("Show token usage and cost for this channel"),
521
- new SlashCommandBuilder().setName("mcp_status").setDescription("Check MCP server connection status"),
522
- new SlashCommandBuilder()
523
- .setName("crons")
524
- .setDescription("Manage cron jobs")
525
- .addSubcommand((sub) => sub.setName("list").setDescription("List cron jobs for this channel"))
526
- .addSubcommand((sub) => sub.setName("add")
527
- .setDescription("Add a cron job")
528
- .addStringOption((opt) => opt.setName("schedule").setDescription("Schedule (e.g. every 1h)").setRequired(true))
529
- .addStringOption((opt) => opt.setName("prompt").setDescription("Prompt to send").setRequired(true)))
530
- .addSubcommand((sub) => sub.setName("remove")
531
- .setDescription("Remove a cron job")
532
- .addStringOption((opt) => opt.setName("id").setDescription("Job ID").setRequired(true)))
533
- .addSubcommand((sub) => sub.setName("clear").setDescription("Clear all cron jobs for this channel")),
534
- new SlashCommandBuilder()
535
- .setName("wiki")
536
- .setDescription("Wiki page info (pass namespace to look up)")
537
- .addStringOption((opt) => opt.setName("namespace").setDescription("Namespace to look up").setRequired(false)),
538
- ].map((cmd) => cmd.toJSON());
539
- const rest = new REST().setToken(this.opts.discordToken);
540
- if (this.opts.guildIds?.length) {
541
- for (const guildId of this.opts.guildIds) {
542
- try {
543
- const appId = this.client.application?.id;
544
- if (!appId) {
545
- console.warn("[discord] application ID not available yet");
546
- return;
547
- }
548
- await rest.put(Routes.applicationGuildCommands(appId, guildId), { body: commands });
549
- console.log(`[discord] slash commands registered for guild ${guildId}`);
550
- }
551
- catch (err) {
552
- console.error(`[discord] slash command registration failed for guild ${guildId}:`, err.message);
553
- }
554
- }
555
- }
556
- else {
557
- // Global commands (can take up to 1hr to propagate)
558
- try {
559
- const appId = this.client.application?.id;
560
- if (!appId) {
561
- console.warn("[discord] application ID not available yet");
562
- return;
563
- }
564
- await rest.put(Routes.applicationCommands(appId), { body: commands });
565
- console.log("[discord] slash commands registered globally");
566
- }
567
- catch (err) {
568
- console.error("[discord] global slash command registration failed:", err.message);
569
- }
570
- }
571
- }
572
- async handleSlashCommand(interaction) {
573
- const channelId = interaction.channelId;
574
- const userId = interaction.user.id;
575
- if (!this.isAllowed(userId)) {
576
- await interaction.reply({ content: "Not authorized.", ephemeral: true });
577
- return;
578
- }
579
- this.lastActiveChannelId = channelId;
580
- switch (interaction.commandName) {
581
- case "reset": {
582
- this.killSession(channelId);
583
- await interaction.reply("Session reset. Send a message to start.");
584
- break;
585
- }
586
- case "costs": {
587
- const embed = this.buildCostEmbed(channelId);
588
- await interaction.reply({ embeds: [embed] });
589
- break;
590
- }
591
- case "mcp_status": {
592
- await interaction.deferReply();
593
- try {
594
- const result = await this.callCcAgentTool("get_version");
595
- await interaction.editReply(result ? `MCP connected. Version: ${result}` : "MCP connected (no version info).");
596
- }
597
- catch (err) {
598
- await interaction.editReply(`MCP unavailable: ${err.message}`);
599
- }
600
- break;
601
- }
602
- case "crons": {
603
- await this.handleCronsCommand(interaction, channelId);
604
- break;
605
- }
606
- case "wiki": {
607
- await interaction.deferReply();
608
- const ns = interaction.options.getString("namespace") ?? this.namespace;
609
- try {
610
- const result = await this.callCcAgentTool("get_wiki", { namespace: ns });
611
- if (result) {
612
- const chunks = splitLongMessage(result);
613
- await interaction.editReply(chunks[0]);
614
- for (const chunk of chunks.slice(1)) {
615
- await interaction.followUp(chunk);
616
- }
617
- }
618
- else {
619
- await interaction.editReply("No wiki content found.");
620
- }
621
- }
622
- catch (err) {
623
- await interaction.editReply(`Wiki lookup failed: ${err.message}`);
624
- }
625
- break;
626
- }
627
- }
628
- }
629
- async handleCronsCommand(interaction, channelId) {
630
- const sub = interaction.options.getSubcommand();
631
- const chatIdNum = this.storeSnowflake(channelId);
632
- switch (sub) {
633
- case "list": {
634
- const jobs = this.cron.list(chatIdNum);
635
- if (jobs.length === 0) {
636
- await interaction.reply("No cron jobs for this channel.");
637
- }
638
- else {
639
- const lines = jobs.map((j) => `• **${j.id}** ${j.schedule}: \`${j.prompt}\``);
640
- await interaction.reply(lines.join("\n"));
641
- }
642
- break;
643
- }
644
- case "add": {
645
- const schedule = interaction.options.getString("schedule", true);
646
- const prompt = interaction.options.getString("prompt", true);
647
- const job = this.cron.add(chatIdNum, schedule, prompt);
648
- if (!job) {
649
- await interaction.reply("Invalid schedule. Use format: `every 30m`, `every 2h`, `every 1d`");
650
- }
651
- else {
652
- await interaction.reply(`Cron job added: **${job.id}** (${job.schedule})`);
653
- }
654
- break;
655
- }
656
- case "remove": {
657
- const id = interaction.options.getString("id", true);
658
- const removed = this.cron.remove(chatIdNum, id);
659
- await interaction.reply(removed ? `Removed cron job ${id}.` : `Job ${id} not found.`);
660
- break;
661
- }
662
- case "clear": {
663
- const count = this.cron.clearAll(chatIdNum);
664
- await interaction.reply(`Cleared ${count} cron job(s).`);
665
- break;
666
- }
667
- default:
668
- await interaction.reply("Unknown subcommand.");
669
- }
670
- }
671
- /**
672
- * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
673
- * Returns the tool result as a string, or null on failure.
674
- */
675
- async callCcAgentTool(toolName, args = {}) {
676
- return new Promise((resolve) => {
677
- const prompt = `Use the ${toolName} tool with these arguments: ${JSON.stringify(args)}. Return only the raw result, no extra commentary.`;
678
- const claude = new ClaudeProcess({
679
- cwd: this.opts.cwd ?? process.cwd(),
680
- token: this.opts.claudeToken ?? getCurrentToken(),
681
- });
682
- let result = "";
683
- const timeout = setTimeout(() => {
684
- claude.kill();
685
- resolve(null);
686
- }, 30_000);
687
- claude.on("message", (msg) => {
688
- if (msg.type === "result") {
689
- result = extractText(msg) || result;
690
- }
691
- else if (msg.type === "assistant") {
692
- result += extractText(msg);
693
- }
694
- });
695
- claude.on("exit", () => {
696
- clearTimeout(timeout);
697
- resolve(result.trim() || null);
698
- });
699
- claude.on("error", () => {
700
- clearTimeout(timeout);
701
- resolve(null);
702
- });
703
- claude.sendPrompt(prompt);
704
- });
705
- }
706
- runCronTask(channelId, prompt, done) {
707
- const getChannel = this.getChannel.bind(this);
708
- void (async () => {
709
- const channel = await getChannel(channelId);
710
- if (!channel) {
711
- console.warn(`[cron] channel ${channelId} not found`);
712
- done();
713
- return;
714
- }
715
- const session = this.getOrCreateSession(channelId, channel);
716
- try {
717
- session.currentPrompt = prompt;
718
- session.claude.sendPrompt(stampPrompt(prompt));
719
- this.startTyping(channelId, channel, session);
720
- // Listen for result to call done()
721
- const onExit = () => { done(); };
722
- session.claude.once("exit", onExit);
723
- }
724
- catch (err) {
725
- console.error(`[cron:${channelId}] error:`, err.message);
726
- done();
727
- }
728
- })();
729
- }
730
- /** Write a message to the Redis chat log. Fire-and-forget. */
731
- writeChatMessage(role, source, content, channelId) {
732
- if (!this.redis)
733
- return;
734
- const msg = {
735
- id: crypto.randomUUID(),
736
- source,
737
- role,
738
- content,
739
- timestamp: new Date().toISOString(),
740
- chatId: snowflakeToInt(channelId),
741
- };
742
- writeChatLog(this.redis, this.namespace, msg);
743
- }
744
- /** Returns the last channelId that sent a message. */
745
- getLastActiveChannelId() {
746
- return this.lastActiveChannelId;
747
- }
748
- /**
749
- * Feed a text message into the active Claude session for the given channel.
750
- * Called by the notifier when a UI message arrives via Redis pub/sub.
751
- */
752
- async handleUserMessage(channelId, text) {
753
- const channel = await this.getChannel(channelId);
754
- if (!channel) {
755
- console.warn(`[bot] handleUserMessage: channel ${channelId} not found`);
756
- return;
757
- }
758
- const session = this.getOrCreateSession(channelId, channel);
759
- try {
760
- session.currentPrompt = text;
761
- session.claude.sendPrompt(stampPrompt(text));
762
- this.startTyping(channelId, channel, session);
763
- this.writeChatMessage("user", "ui", text, channelId);
764
- }
765
- catch (err) {
766
- await channel.send(`Error sending to Claude: ${err.message}`).catch(() => { });
767
- this.killSession(channelId);
768
- }
769
- }
770
- /**
771
- * Forward a cc-agent job notification into an existing Claude session.
772
- * Unlike handleUserMessage, this never creates a new session.
773
- */
774
- forwardNotification(channelId, text) {
775
- const key = this.sessionKey(channelId);
776
- const session = this.sessions.get(key);
777
- if (!session || session.claude.exited)
778
- return;
779
- try {
780
- session.claude.sendPrompt(stampPrompt(text));
781
- this.writeChatMessage("user", "cc-tg", text, channelId);
782
- }
783
- catch (err) {
784
- console.error(`[forwardNotification:${channelId}] failed:`, err.message);
785
- }
786
- }
787
- stop() {
788
- for (const [key, session] of this.sessions) {
789
- if (session.flushTimer)
790
- clearTimeout(session.flushTimer);
791
- if (session.typingTimer)
792
- clearInterval(session.typingTimer);
793
- session.claude.kill();
794
- this.sessions.delete(key);
795
- }
796
- void this.client.destroy();
797
- }
798
- }