@gonzih/cc-tg 0.6.5 ā 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot.d.ts +0 -4
- package/dist/bot.js +1 -225
- package/dist/index.js +18 -8
- package/dist/notifier.d.ts +36 -0
- package/dist/notifier.js +105 -0
- package/package.json +1 -1
- package/dist/cc-agent-events.d.ts +0 -98
- package/dist/cc-agent-events.js +0 -617
- package/dist/cron.d.ts +0 -39
- package/dist/cron.js +0 -148
package/dist/bot.d.ts
CHANGED
|
@@ -15,7 +15,6 @@ export declare class CcTgBot {
|
|
|
15
15
|
private sessions;
|
|
16
16
|
private pendingRetries;
|
|
17
17
|
private opts;
|
|
18
|
-
private cron;
|
|
19
18
|
private costStore;
|
|
20
19
|
private botUsername;
|
|
21
20
|
private botId;
|
|
@@ -45,9 +44,6 @@ export declare class CcTgBot {
|
|
|
45
44
|
private isSensitiveFile;
|
|
46
45
|
private uploadMentionedFiles;
|
|
47
46
|
private extractToolName;
|
|
48
|
-
private runCronTask;
|
|
49
|
-
private handleCron;
|
|
50
|
-
private handleCronEdit;
|
|
51
47
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
52
48
|
private findCcAgentPids;
|
|
53
49
|
/** Kill cc-agent PIDs with SIGTERM. Returns the list of killed PIDs. */
|
package/dist/bot.js
CHANGED
|
@@ -11,7 +11,6 @@ import https from "https";
|
|
|
11
11
|
import http from "http";
|
|
12
12
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
13
13
|
import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
14
|
-
import { CronManager } from "./cron.js";
|
|
15
14
|
import { formatForTelegram, splitLongMessage } from "./formatter.js";
|
|
16
15
|
import { detectUsageLimit } from "./usage-limit.js";
|
|
17
16
|
import { getCurrentToken, rotateToken, getTokenIndex, getTokenCount } from "./tokens.js";
|
|
@@ -21,7 +20,6 @@ const BOT_COMMANDS = [
|
|
|
21
20
|
{ command: "stop", description: "Stop the current Claude task" },
|
|
22
21
|
{ command: "status", description: "Check if a session is active" },
|
|
23
22
|
{ command: "help", description: "Show all available commands" },
|
|
24
|
-
{ command: "cron", description: "Manage cron jobs ā add/list/edit/remove/clear" },
|
|
25
23
|
{ command: "reload_mcp", description: "Restart the cc-agent MCP server process" },
|
|
26
24
|
{ command: "mcp_status", description: "Check MCP server connection status" },
|
|
27
25
|
{ command: "mcp_version", description: "Show cc-agent npm version and npx cache info" },
|
|
@@ -66,10 +64,6 @@ function formatCostReport(cost) {
|
|
|
66
64
|
` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
|
|
67
65
|
].join("\n");
|
|
68
66
|
}
|
|
69
|
-
function formatCronCostFooter(usage) {
|
|
70
|
-
const cost = computeCostUsd(usage);
|
|
71
|
-
return `\nš° Cron cost: $${cost.toFixed(4)} (${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out tokens)`;
|
|
72
|
-
}
|
|
73
67
|
function formatAgentCostSummary(text) {
|
|
74
68
|
try {
|
|
75
69
|
const data = JSON.parse(text);
|
|
@@ -156,7 +150,6 @@ export class CcTgBot {
|
|
|
156
150
|
sessions = new Map();
|
|
157
151
|
pendingRetries = new Map();
|
|
158
152
|
opts;
|
|
159
|
-
cron;
|
|
160
153
|
costStore;
|
|
161
154
|
botUsername = "";
|
|
162
155
|
botId = 0;
|
|
@@ -170,12 +163,6 @@ export class CcTgBot {
|
|
|
170
163
|
this.botId = me.id;
|
|
171
164
|
console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
|
|
172
165
|
}).catch((err) => console.error("[tg] getMe failed:", err.message));
|
|
173
|
-
// Cron manager ā fires each task into an isolated ClaudeProcess.
|
|
174
|
-
// The `done` callback is passed through to runCronTask so the cron manager
|
|
175
|
-
// knows when a task finishes and can allow the next tick to run.
|
|
176
|
-
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt, jobId, done) => {
|
|
177
|
-
this.runCronTask(chatId, prompt, done);
|
|
178
|
-
});
|
|
179
166
|
this.costStore = new CostStore(opts.cwd ?? process.cwd());
|
|
180
167
|
this.registerBotCommands();
|
|
181
168
|
console.log("cc-tg bot started");
|
|
@@ -305,11 +292,6 @@ export class CcTgBot {
|
|
|
305
292
|
await this.replyToChat(chatId, status, threadId);
|
|
306
293
|
return;
|
|
307
294
|
}
|
|
308
|
-
// /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
|
|
309
|
-
if (text.startsWith("/cron")) {
|
|
310
|
-
await this.handleCron(chatId, text, threadId);
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
295
|
// /reload_mcp ā kill cc-agent process so Claude Code auto-restarts it
|
|
314
296
|
if (text === "/reload_mcp") {
|
|
315
297
|
await this.handleReloadMcp(chatId, threadId);
|
|
@@ -814,210 +796,6 @@ export class CcTgBot {
|
|
|
814
796
|
const toolUse = content.find((b) => b.type === "tool_use");
|
|
815
797
|
return toolUse?.name ?? "";
|
|
816
798
|
}
|
|
817
|
-
runCronTask(chatId, prompt, done = () => { }) {
|
|
818
|
-
// Fresh isolated Claude session ā never touches main conversation
|
|
819
|
-
const cronProcess = new ClaudeProcess({
|
|
820
|
-
cwd: this.opts.cwd,
|
|
821
|
-
token: this.opts.claudeToken,
|
|
822
|
-
});
|
|
823
|
-
const taskPrompt = [
|
|
824
|
-
"You are handling a scheduled background task.",
|
|
825
|
-
"This is NOT part of the user's ongoing conversation.",
|
|
826
|
-
"Be concise. Report results only. No greetings or pleasantries.",
|
|
827
|
-
"If there is nothing to report, say so in one sentence.",
|
|
828
|
-
"DEDUP RULE: If this task involves resuming or restarting interrupted agents/jobs,",
|
|
829
|
-
" skip any job whose task description already starts with 'RESUMING' (it is already",
|
|
830
|
-
" a resume attempt). Also skip any job that has a non-empty 'resumed_by' field.",
|
|
831
|
-
" Only spawn a resume agent for a job if resume_count < 2 (when that field exists).",
|
|
832
|
-
" This prevents exponential job growth when a cron re-discovers its own spawned agents.",
|
|
833
|
-
"",
|
|
834
|
-
`SCHEDULED TASK: ${prompt}`,
|
|
835
|
-
].join("\n");
|
|
836
|
-
let output = "";
|
|
837
|
-
const cronUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
838
|
-
cronProcess.on("usage", (usage) => {
|
|
839
|
-
cronUsage.inputTokens += usage.inputTokens;
|
|
840
|
-
cronUsage.outputTokens += usage.outputTokens;
|
|
841
|
-
cronUsage.cacheReadTokens += usage.cacheReadTokens;
|
|
842
|
-
cronUsage.cacheWriteTokens += usage.cacheWriteTokens;
|
|
843
|
-
});
|
|
844
|
-
cronProcess.on("message", (msg) => {
|
|
845
|
-
if (msg.type === "result") {
|
|
846
|
-
const text = extractText(msg);
|
|
847
|
-
if (text)
|
|
848
|
-
output += text;
|
|
849
|
-
const result = output.trim();
|
|
850
|
-
if (result) {
|
|
851
|
-
let footer = "";
|
|
852
|
-
try {
|
|
853
|
-
footer = formatCronCostFooter(cronUsage);
|
|
854
|
-
}
|
|
855
|
-
catch (err) {
|
|
856
|
-
console.error(`[cron] cost footer error:`, err.message);
|
|
857
|
-
}
|
|
858
|
-
const cronFormatted = formatForTelegram(`š ${result}${footer}`);
|
|
859
|
-
const chunks = splitLongMessage(cronFormatted);
|
|
860
|
-
(async () => {
|
|
861
|
-
for (const chunk of chunks) {
|
|
862
|
-
try {
|
|
863
|
-
await this.bot.sendMessage(chatId, chunk, { parse_mode: "HTML" });
|
|
864
|
-
}
|
|
865
|
-
catch {
|
|
866
|
-
// HTML parse failed ā retry as plain text
|
|
867
|
-
try {
|
|
868
|
-
await this.bot.sendMessage(chatId, chunk);
|
|
869
|
-
}
|
|
870
|
-
catch (err) {
|
|
871
|
-
console.error(`[cron] failed to send result to chat=${chatId}:`, err.message);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
})();
|
|
876
|
-
}
|
|
877
|
-
cronProcess.kill();
|
|
878
|
-
}
|
|
879
|
-
});
|
|
880
|
-
cronProcess.on("error", (err) => {
|
|
881
|
-
console.error(`[cron] task error for chat=${chatId}:`, err.message);
|
|
882
|
-
cronProcess.kill();
|
|
883
|
-
done();
|
|
884
|
-
});
|
|
885
|
-
cronProcess.on("exit", () => {
|
|
886
|
-
console.log(`[cron] task complete for chat=${chatId}`);
|
|
887
|
-
done();
|
|
888
|
-
});
|
|
889
|
-
cronProcess.sendPrompt(taskPrompt);
|
|
890
|
-
}
|
|
891
|
-
async handleCron(chatId, text, threadId) {
|
|
892
|
-
const args = text.slice("/cron".length).trim();
|
|
893
|
-
// /cron list
|
|
894
|
-
if (args === "list" || args === "") {
|
|
895
|
-
const jobs = this.cron.list(chatId);
|
|
896
|
-
if (!jobs.length) {
|
|
897
|
-
await this.replyToChat(chatId, "No cron jobs.", threadId);
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
const lines = jobs.map((j, i) => {
|
|
901
|
-
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "ā¦" : j.prompt;
|
|
902
|
-
return `#${i + 1} ${j.schedule} ā "${short}"`;
|
|
903
|
-
});
|
|
904
|
-
await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
// /cron clear
|
|
908
|
-
if (args === "clear") {
|
|
909
|
-
const n = this.cron.clearAll(chatId);
|
|
910
|
-
await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
|
|
911
|
-
return;
|
|
912
|
-
}
|
|
913
|
-
// /cron remove <id>
|
|
914
|
-
if (args.startsWith("remove ")) {
|
|
915
|
-
const id = args.slice("remove ".length).trim();
|
|
916
|
-
const ok = this.cron.remove(chatId, id);
|
|
917
|
-
await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
// /cron edit [<#> ...]
|
|
921
|
-
if (args === "edit" || args.startsWith("edit ")) {
|
|
922
|
-
await this.handleCronEdit(chatId, args.slice("edit".length).trim(), threadId);
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
// /cron every 1h <prompt>
|
|
926
|
-
const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
927
|
-
if (!scheduleMatch) {
|
|
928
|
-
await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron edit\n/cron remove <id>\n/cron clear", threadId);
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
const schedule = scheduleMatch[1];
|
|
932
|
-
const prompt = scheduleMatch[2];
|
|
933
|
-
const job = this.cron.add(chatId, schedule, prompt);
|
|
934
|
-
if (!job) {
|
|
935
|
-
await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} ā "${prompt}"`, threadId);
|
|
939
|
-
}
|
|
940
|
-
async handleCronEdit(chatId, editArgs, threadId) {
|
|
941
|
-
const jobs = this.cron.list(chatId);
|
|
942
|
-
// No args ā show numbered list with edit instructions
|
|
943
|
-
if (!editArgs) {
|
|
944
|
-
if (!jobs.length) {
|
|
945
|
-
await this.replyToChat(chatId, "No cron jobs to edit.", threadId);
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
const lines = jobs.map((j, i) => {
|
|
949
|
-
const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "ā¦" : j.prompt;
|
|
950
|
-
return `#${i + 1} ${j.schedule} ā "${short}"`;
|
|
951
|
-
});
|
|
952
|
-
await this.replyToChat(chatId, `Cron jobs:\n${lines.join("\n")}\n\n` +
|
|
953
|
-
"Edit options:\n" +
|
|
954
|
-
"/cron edit <#> every <N><unit> <new prompt>\n" +
|
|
955
|
-
"/cron edit <#> schedule every <N><unit>\n" +
|
|
956
|
-
"/cron edit <#> prompt <new prompt>", threadId);
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
// Expect: <index> <rest>
|
|
960
|
-
const indexMatch = editArgs.match(/^(\d+)\s+(.+)$/);
|
|
961
|
-
if (!indexMatch) {
|
|
962
|
-
await this.replyToChat(chatId, "Usage: /cron edit <#> every <N><unit> <new prompt>", threadId);
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
const index = parseInt(indexMatch[1], 10) - 1;
|
|
966
|
-
if (index < 0 || index >= jobs.length) {
|
|
967
|
-
await this.replyToChat(chatId, `Invalid job number. Use /cron edit to see the list.`, threadId);
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
const job = jobs[index];
|
|
971
|
-
const editCmd = indexMatch[2];
|
|
972
|
-
// /cron edit <#> schedule every <N><unit>
|
|
973
|
-
if (editCmd.startsWith("schedule ")) {
|
|
974
|
-
const newSchedule = editCmd.slice("schedule ".length).trim();
|
|
975
|
-
const result = this.cron.update(chatId, job.id, { schedule: newSchedule });
|
|
976
|
-
if (result === null) {
|
|
977
|
-
await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
|
|
978
|
-
}
|
|
979
|
-
else if (result === false) {
|
|
980
|
-
await this.replyToChat(chatId, "Job not found.", threadId);
|
|
981
|
-
}
|
|
982
|
-
else {
|
|
983
|
-
await this.replyToChat(chatId, `#${index + 1} schedule updated to ${newSchedule}.`, threadId);
|
|
984
|
-
}
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
// /cron edit <#> prompt <new-prompt>
|
|
988
|
-
if (editCmd.startsWith("prompt ")) {
|
|
989
|
-
const newPrompt = editCmd.slice("prompt ".length).trim();
|
|
990
|
-
const result = this.cron.update(chatId, job.id, { prompt: newPrompt });
|
|
991
|
-
if (result === false) {
|
|
992
|
-
await this.replyToChat(chatId, "Job not found.", threadId);
|
|
993
|
-
}
|
|
994
|
-
else {
|
|
995
|
-
await this.replyToChat(chatId, `#${index + 1} prompt updated to "${newPrompt}".`, threadId);
|
|
996
|
-
}
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
// /cron edit <#> every <N><unit> <new-prompt>
|
|
1000
|
-
const fullMatch = editCmd.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
|
|
1001
|
-
if (fullMatch) {
|
|
1002
|
-
const newSchedule = fullMatch[1];
|
|
1003
|
-
const newPrompt = fullMatch[2];
|
|
1004
|
-
const result = this.cron.update(chatId, job.id, { schedule: newSchedule, prompt: newPrompt });
|
|
1005
|
-
if (result === null) {
|
|
1006
|
-
await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
|
|
1007
|
-
}
|
|
1008
|
-
else if (result === false) {
|
|
1009
|
-
await this.replyToChat(chatId, "Job not found.", threadId);
|
|
1010
|
-
}
|
|
1011
|
-
else {
|
|
1012
|
-
await this.replyToChat(chatId, `#${index + 1} updated: ${newSchedule} ā "${newPrompt}"`, threadId);
|
|
1013
|
-
}
|
|
1014
|
-
return;
|
|
1015
|
-
}
|
|
1016
|
-
await this.replyToChat(chatId, "Edit options:\n" +
|
|
1017
|
-
"/cron edit <#> every <N><unit> <new prompt>\n" +
|
|
1018
|
-
"/cron edit <#> schedule every <N><unit>\n" +
|
|
1019
|
-
"/cron edit <#> prompt <new prompt>", threadId);
|
|
1020
|
-
}
|
|
1021
799
|
/** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
|
|
1022
800
|
findCcAgentPids() {
|
|
1023
801
|
try {
|
|
@@ -1239,7 +1017,7 @@ export class CcTgBot {
|
|
|
1239
1017
|
proc.on("exit", () => { clearTimeout(timeout); done(null); });
|
|
1240
1018
|
});
|
|
1241
1019
|
}
|
|
1242
|
-
killSession(chatId,
|
|
1020
|
+
killSession(chatId, _keepCrons = true, threadId) {
|
|
1243
1021
|
const key = this.sessionKey(chatId, threadId);
|
|
1244
1022
|
const session = this.sessions.get(key);
|
|
1245
1023
|
if (session) {
|
|
@@ -1247,8 +1025,6 @@ export class CcTgBot {
|
|
|
1247
1025
|
session.claude.kill();
|
|
1248
1026
|
this.sessions.delete(key);
|
|
1249
1027
|
}
|
|
1250
|
-
if (!keepCrons)
|
|
1251
|
-
this.cron.clearAll(chatId);
|
|
1252
1028
|
}
|
|
1253
1029
|
getMe() {
|
|
1254
1030
|
return this.bot.getMe();
|
package/dist/index.js
CHANGED
|
@@ -20,11 +20,12 @@ import { tmpdir } from "os";
|
|
|
20
20
|
import os from "os";
|
|
21
21
|
import { join, dirname } from "path";
|
|
22
22
|
import { fileURLToPath } from "url";
|
|
23
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
23
24
|
import { CcTgBot } from "./bot.js";
|
|
24
25
|
import { loadTokens } from "./tokens.js";
|
|
25
26
|
import { Registry, startControlServer } from "@gonzih/agent-ops";
|
|
26
27
|
import { Redis } from "ioredis";
|
|
27
|
-
import {
|
|
28
|
+
import { startNotifier } from "./notifier.js";
|
|
28
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
30
|
const __dirname = dirname(__filename);
|
|
30
31
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
@@ -120,11 +121,13 @@ const bot = new CcTgBot({
|
|
|
120
121
|
groupChatIds,
|
|
121
122
|
});
|
|
122
123
|
// agent-ops: optional self-registration + HTTP control endpoint
|
|
124
|
+
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
|
125
|
+
const namespace = process.env.CC_AGENT_NAMESPACE || "default";
|
|
126
|
+
let sharedRedis = null;
|
|
123
127
|
if (process.env.CC_AGENT_OPS_PORT) {
|
|
124
128
|
const botInfo = await bot.getMe();
|
|
125
|
-
|
|
126
|
-
const registry = new Registry(
|
|
127
|
-
const namespace = process.env.CC_AGENT_NAMESPACE || "default";
|
|
129
|
+
sharedRedis = new Redis(redisUrl);
|
|
130
|
+
const registry = new Registry(sharedRedis);
|
|
128
131
|
await registry.register({
|
|
129
132
|
namespace,
|
|
130
133
|
hostname: os.hostname(),
|
|
@@ -144,10 +147,17 @@ if (process.env.CC_AGENT_OPS_PORT) {
|
|
|
144
147
|
});
|
|
145
148
|
console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
|
|
146
149
|
}
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
// Notifier ā subscribe to cca:notify:{namespace} and cca:chat:incoming:{namespace}
|
|
151
|
+
const notifyChatId = process.env.CC_AGENT_NOTIFY_CHAT_ID
|
|
152
|
+
? Number(process.env.CC_AGENT_NOTIFY_CHAT_ID)
|
|
153
|
+
: null;
|
|
154
|
+
if (notifyChatId) {
|
|
155
|
+
if (!sharedRedis)
|
|
156
|
+
sharedRedis = new Redis(redisUrl);
|
|
157
|
+
const notifierBot = new TelegramBot(telegramToken, { polling: false });
|
|
158
|
+
startNotifier(notifierBot, notifyChatId, namespace, sharedRedis);
|
|
159
|
+
console.log(`[notifier] started for namespace=${namespace} chatId=${notifyChatId}`);
|
|
160
|
+
}
|
|
151
161
|
process.on("SIGINT", () => {
|
|
152
162
|
console.log("\nShutting down...");
|
|
153
163
|
bot.stop();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifier ā subscribes to Redis pub/sub channels and bridges messages to Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Channels:
|
|
5
|
+
* cca:notify:{namespace} ā job completion notifications from cc-agent ā forward to Telegram
|
|
6
|
+
* cca:chat:incoming:{namespace} ā messages from the web UI ā echo to Telegram + feed into Claude session
|
|
7
|
+
*
|
|
8
|
+
* All messages (Telegram incoming, Claude responses) are also written to:
|
|
9
|
+
* cca:chat:log:{namespace} ā LPUSH + LTRIM 0 499 (last 500 messages)
|
|
10
|
+
* cca:chat:outgoing:{namespace} ā PUBLISH for web UI to consume
|
|
11
|
+
*/
|
|
12
|
+
import { Redis } from "ioredis";
|
|
13
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
14
|
+
export interface ChatMessage {
|
|
15
|
+
id: string;
|
|
16
|
+
source: "telegram" | "ui" | "claude";
|
|
17
|
+
role: "user" | "assistant";
|
|
18
|
+
content: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
chatId: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Write a message to the chat log in Redis.
|
|
24
|
+
* Fire-and-forget ā errors are logged but not thrown.
|
|
25
|
+
*/
|
|
26
|
+
export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
|
|
27
|
+
/**
|
|
28
|
+
* Start the notifier.
|
|
29
|
+
*
|
|
30
|
+
* @param bot - Telegram bot instance (for sending messages)
|
|
31
|
+
* @param chatId - Telegram chat ID to forward notifications to
|
|
32
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
33
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
34
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
35
|
+
*/
|
|
36
|
+
export declare function startNotifier(bot: TelegramBot, chatId: number, namespace: string, redis: Redis, handleUserMessage?: (chatId: number, text: string) => void): void;
|
package/dist/notifier.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifier ā subscribes to Redis pub/sub channels and bridges messages to Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Channels:
|
|
5
|
+
* cca:notify:{namespace} ā job completion notifications from cc-agent ā forward to Telegram
|
|
6
|
+
* cca:chat:incoming:{namespace} ā messages from the web UI ā echo to Telegram + feed into Claude session
|
|
7
|
+
*
|
|
8
|
+
* All messages (Telegram incoming, Claude responses) are also written to:
|
|
9
|
+
* cca:chat:log:{namespace} ā LPUSH + LTRIM 0 499 (last 500 messages)
|
|
10
|
+
* cca:chat:outgoing:{namespace} ā PUBLISH for web UI to consume
|
|
11
|
+
*/
|
|
12
|
+
function log(level, ...args) {
|
|
13
|
+
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
14
|
+
fn("[notifier]", ...args);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Write a message to the chat log in Redis.
|
|
18
|
+
* Fire-and-forget ā errors are logged but not thrown.
|
|
19
|
+
*/
|
|
20
|
+
export function writeChatLog(redis, namespace, msg) {
|
|
21
|
+
const logKey = `cca:chat:log:${namespace}`;
|
|
22
|
+
const outKey = `cca:chat:outgoing:${namespace}`;
|
|
23
|
+
const payload = JSON.stringify(msg);
|
|
24
|
+
redis.lpush(logKey, payload).catch((err) => {
|
|
25
|
+
log("warn", "writeChatLog lpush failed:", err.message);
|
|
26
|
+
});
|
|
27
|
+
redis.ltrim(logKey, 0, 499).catch((err) => {
|
|
28
|
+
log("warn", "writeChatLog ltrim failed:", err.message);
|
|
29
|
+
});
|
|
30
|
+
redis.publish(outKey, payload).catch((err) => {
|
|
31
|
+
log("warn", "writeChatLog publish failed:", err.message);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Start the notifier.
|
|
36
|
+
*
|
|
37
|
+
* @param bot - Telegram bot instance (for sending messages)
|
|
38
|
+
* @param chatId - Telegram chat ID to forward notifications to
|
|
39
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
40
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
41
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
42
|
+
*/
|
|
43
|
+
export function startNotifier(bot, chatId, namespace, redis, handleUserMessage) {
|
|
44
|
+
const sub = redis.duplicate();
|
|
45
|
+
sub.on("error", (err) => {
|
|
46
|
+
log("warn", "subscriber error:", err.message);
|
|
47
|
+
});
|
|
48
|
+
// cca:notify:{namespace} ā forward job completion notifications to Telegram
|
|
49
|
+
sub.subscribe(`cca:notify:${namespace}`, (err) => {
|
|
50
|
+
if (err) {
|
|
51
|
+
log("error", `subscribe cca:notify:${namespace} failed:`, err.message);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
log("info", `subscribed to cca:notify:${namespace}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// cca:chat:incoming:{namespace} ā messages from UI
|
|
58
|
+
sub.subscribe(`cca:chat:incoming:${namespace}`, (err) => {
|
|
59
|
+
if (err) {
|
|
60
|
+
log("error", `subscribe cca:chat:incoming:${namespace} failed:`, err.message);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
log("info", `subscribed to cca:chat:incoming:${namespace}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
sub.on("message", (channel, message) => {
|
|
67
|
+
const notifyChannel = `cca:notify:${namespace}`;
|
|
68
|
+
const incomingChannel = `cca:chat:incoming:${namespace}`;
|
|
69
|
+
if (channel === notifyChannel) {
|
|
70
|
+
bot.sendMessage(chatId, message).catch((err) => {
|
|
71
|
+
log("warn", "sendMessage failed:", err.message);
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (channel === incomingChannel) {
|
|
76
|
+
let content = message;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(message);
|
|
79
|
+
if (parsed.content)
|
|
80
|
+
content = parsed.content;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// raw string message ā use as-is
|
|
84
|
+
}
|
|
85
|
+
// Echo to Telegram so the user sees UI messages in the chat
|
|
86
|
+
bot.sendMessage(chatId, `š± [from UI]: ${content}`).catch((err) => {
|
|
87
|
+
log("warn", "sendMessage (UI echo) failed:", err.message);
|
|
88
|
+
});
|
|
89
|
+
// Log the incoming message
|
|
90
|
+
const inMsg = {
|
|
91
|
+
id: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
92
|
+
source: "ui",
|
|
93
|
+
role: "user",
|
|
94
|
+
content,
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
chatId,
|
|
97
|
+
};
|
|
98
|
+
writeChatLog(redis, namespace, inMsg);
|
|
99
|
+
// Feed into active Claude session as if user typed it
|
|
100
|
+
if (handleUserMessage) {
|
|
101
|
+
handleUserMessage(chatId, content);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cc-agent Redis event subscriber.
|
|
3
|
-
*
|
|
4
|
-
* Listens to the `cca:events` pub/sub channel for job completion events,
|
|
5
|
-
* asks Claude to decide what to do, and acts accordingly:
|
|
6
|
-
* NOTIFY_ONLY ā send a Telegram message to the configured chat
|
|
7
|
-
* SPAWN_FOLLOWUP ā spawn a follow-up cc-agent job via MCP + notify Telegram
|
|
8
|
-
* SILENT ā log and do nothing
|
|
9
|
-
*
|
|
10
|
-
* Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
|
|
11
|
-
* Requires CC_AGENT_NOTIFY_CHAT_ID to send Telegram notifications.
|
|
12
|
-
*/
|
|
13
|
-
import { Redis } from "ioredis";
|
|
14
|
-
export interface JobEvent {
|
|
15
|
-
jobId: string;
|
|
16
|
-
status: "done" | "failed" | "interrupted" | "running" | "cancelled";
|
|
17
|
-
title: string;
|
|
18
|
-
repoUrl: string;
|
|
19
|
-
lastLines: string[];
|
|
20
|
-
score?: number;
|
|
21
|
-
timestamp: number;
|
|
22
|
-
}
|
|
23
|
-
export interface CoordinatorPlan {
|
|
24
|
-
nextStep?: {
|
|
25
|
-
repo_url: string;
|
|
26
|
-
task: string;
|
|
27
|
-
};
|
|
28
|
-
summary: string;
|
|
29
|
-
}
|
|
30
|
-
export interface DecisionResult {
|
|
31
|
-
action: "NOTIFY_ONLY" | "SPAWN_FOLLOWUP" | "SILENT";
|
|
32
|
-
message?: string;
|
|
33
|
-
followup?: {
|
|
34
|
-
repo_url: string;
|
|
35
|
-
task: string;
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
/** Injectable dependencies for testability */
|
|
39
|
-
export interface HandlerDeps {
|
|
40
|
-
askClaude: (prompt: string) => Promise<string>;
|
|
41
|
-
sendTelegramMessage: (chatId: number, text: string) => Promise<void>;
|
|
42
|
-
spawnFollowupAgent: (repoUrl: string, task: string) => Promise<void>;
|
|
43
|
-
readJobOutput: (jobId: string) => Promise<string[]>;
|
|
44
|
-
readCoordinatorPlan: (jobId: string) => Promise<CoordinatorPlan | null>;
|
|
45
|
-
getRunningJobCount: () => Promise<number>;
|
|
46
|
-
getActiveChatIds: () => Promise<number[]>;
|
|
47
|
-
}
|
|
48
|
-
export declare function buildDecisionPrompt(event: JobEvent, last40lines: string[], coordinatorPlan: CoordinatorPlan | null): string;
|
|
49
|
-
export declare function parseDecision(raw: string): DecisionResult;
|
|
50
|
-
/**
|
|
51
|
-
* Ask Claude to make a decision about a completed job.
|
|
52
|
-
* Returns the raw text response from Claude.
|
|
53
|
-
*/
|
|
54
|
-
export declare function defaultAskClaude(prompt: string): Promise<string>;
|
|
55
|
-
export declare function defaultSendTelegramMessage(chatId: number, text: string): Promise<void>;
|
|
56
|
-
export declare function defaultSpawnFollowupAgent(repoUrl: string, task: string): Promise<void>;
|
|
57
|
-
export declare function defaultReadJobOutput(jobId: string): Promise<string[]>;
|
|
58
|
-
export declare function defaultReadCoordinatorPlan(jobId: string): Promise<CoordinatorPlan | null>;
|
|
59
|
-
export declare function defaultGetRunningJobCount(): Promise<number>;
|
|
60
|
-
/**
|
|
61
|
-
* Returns chat IDs to notify about job events.
|
|
62
|
-
* Reads unique chatIds from the cron jobs file (same users who set up cron jobs).
|
|
63
|
-
* Falls back to CC_AGENT_NOTIFY_CHAT_ID env var for backward compatibility.
|
|
64
|
-
*/
|
|
65
|
-
export declare function defaultGetActiveChatIds(): Promise<number[]>;
|
|
66
|
-
/**
|
|
67
|
-
* Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
|
|
68
|
-
* Call this when spawning a job that has a planned follow-up.
|
|
69
|
-
* TTL: 7 days.
|
|
70
|
-
*/
|
|
71
|
-
export declare function writeCoordinatorPlan(jobId: string, plan: {
|
|
72
|
-
nextStep?: {
|
|
73
|
-
repo_url: string;
|
|
74
|
-
task: string;
|
|
75
|
-
};
|
|
76
|
-
summary: string;
|
|
77
|
-
}): Promise<void>;
|
|
78
|
-
/**
|
|
79
|
-
* Handle a single job event message from Redis pub/sub.
|
|
80
|
-
* Exported for testability ā production code passes defaultDeps.
|
|
81
|
-
*/
|
|
82
|
-
export declare function handleJobEvent(message: string, deps: HandlerDeps): Promise<void>;
|
|
83
|
-
/** Parse flat key-value field array from a Redis Stream entry into a record. */
|
|
84
|
-
export declare function parseStreamFields(fields: string[]): Record<string, string>;
|
|
85
|
-
/** Convert stream entry fields to a JobEvent JSON string for handleJobEvent. */
|
|
86
|
-
export declare function streamEntryToMessage(fields: Record<string, string>): string | null;
|
|
87
|
-
/**
|
|
88
|
-
* Replay events from the Redis Stream that were missed since last-seen ID.
|
|
89
|
-
* Uses `cca:event-stream:last-id:{botName}` in Redis to track position.
|
|
90
|
-
* Exported for testability ā pass a real or mock Redis instance.
|
|
91
|
-
*/
|
|
92
|
-
export declare function replayStreamEvents(redis: Redis, deps: HandlerDeps, botName?: string): Promise<void>;
|
|
93
|
-
/**
|
|
94
|
-
* Connect to Redis and subscribe to cca:events.
|
|
95
|
-
* Reconnects automatically on disconnect.
|
|
96
|
-
* Call once at startup.
|
|
97
|
-
*/
|
|
98
|
-
export declare function connectEventSubscriber(): Promise<void>;
|