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