@gonzih/cc-discord 0.2.17 → 0.2.19
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 +11 -0
- package/dist/bot.js +113 -0
- package/dist/cron-engine.d.ts +81 -0
- package/dist/cron-engine.js +271 -0
- package/dist/index.js +2 -0
- package/dist/notifier.js +2 -0
- package/package.json +4 -2
package/dist/bot.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export declare class CcDiscordBot {
|
|
|
36
36
|
private namespace;
|
|
37
37
|
private lastActiveChannelId?;
|
|
38
38
|
private cron;
|
|
39
|
+
private cronEngine?;
|
|
39
40
|
private metaAgentManager;
|
|
40
41
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
41
42
|
private mcpSession?;
|
|
@@ -90,6 +91,11 @@ export declare class CcDiscordBot {
|
|
|
90
91
|
private registerSlashCommands;
|
|
91
92
|
private handleSlashCommand;
|
|
92
93
|
private handleCronsCommand;
|
|
94
|
+
/**
|
|
95
|
+
* Handle the /cron command group — backed by CronEngine (Redis-persisted, node-cron scheduled).
|
|
96
|
+
* Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
|
|
97
|
+
*/
|
|
98
|
+
private handleCronEngineCommand;
|
|
93
99
|
/**
|
|
94
100
|
* Call a cc-agent MCP tool via a dedicated ClaudeProcess.
|
|
95
101
|
* Returns the tool result as a string, or null on failure.
|
|
@@ -140,6 +146,11 @@ export declare class CcDiscordBot {
|
|
|
140
146
|
* Passes a live getter for the set of registered namespaces.
|
|
141
147
|
*/
|
|
142
148
|
startMetaAgentPolling(): void;
|
|
149
|
+
/**
|
|
150
|
+
* Start the CronEngine — loads all enabled crons from Redis and schedules them.
|
|
151
|
+
* Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
|
|
152
|
+
*/
|
|
153
|
+
startCronEngine(): void;
|
|
143
154
|
/**
|
|
144
155
|
* Resolve the namespace for a given channelId.
|
|
145
156
|
* Routed channels use their registered namespace; everything else uses the bot's primary namespace.
|
package/dist/bot.js
CHANGED
|
@@ -16,6 +16,7 @@ import { getCurrentToken } from "./tokens.js";
|
|
|
16
16
|
import { writeChatLog } from "./notifier.js";
|
|
17
17
|
import { LoopManager, isGoalMessage } from "./loop-manager.js";
|
|
18
18
|
import { CronManager } from "./cron.js";
|
|
19
|
+
import { CronEngine } from "./cron-engine.js";
|
|
19
20
|
import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
|
|
20
21
|
import { createMetaAgentManager } from "./meta-agent-manager.js";
|
|
21
22
|
/** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
|
|
@@ -121,6 +122,7 @@ export class CcDiscordBot {
|
|
|
121
122
|
namespace;
|
|
122
123
|
lastActiveChannelId;
|
|
123
124
|
cron;
|
|
125
|
+
cronEngine;
|
|
124
126
|
metaAgentManager;
|
|
125
127
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
126
128
|
mcpSession;
|
|
@@ -132,6 +134,7 @@ export class CcDiscordBot {
|
|
|
132
134
|
if (opts.redis) {
|
|
133
135
|
this.wire = createCcWire(opts.redis);
|
|
134
136
|
this.loopManager = new LoopManager(opts.redis);
|
|
137
|
+
this.cronEngine = new CronEngine(opts.redis);
|
|
135
138
|
}
|
|
136
139
|
this.metaAgentManager = createMetaAgentManager();
|
|
137
140
|
this.client = new Client({
|
|
@@ -826,6 +829,28 @@ export class CcDiscordBot {
|
|
|
826
829
|
new SlashCommandBuilder()
|
|
827
830
|
.setName("compact")
|
|
828
831
|
.setDescription("Compact the Claude session context for this channel's namespace"),
|
|
832
|
+
new SlashCommandBuilder()
|
|
833
|
+
.setName("cron")
|
|
834
|
+
.setDescription("Manage Redis-persisted cron jobs for this channel's namespace")
|
|
835
|
+
.addSubcommand((sub) => sub
|
|
836
|
+
.setName("add")
|
|
837
|
+
.setDescription("Add a cron job (standard 5-field cron expression)")
|
|
838
|
+
.addStringOption((opt) => opt.setName("schedule").setDescription("Cron expression e.g. '0 * * * *'").setRequired(true))
|
|
839
|
+
.addStringOption((opt) => opt.setName("message").setDescription("Message to push to the meta-agent input queue").setRequired(true))
|
|
840
|
+
.addIntegerOption((opt) => opt.setName("compact_every").setDescription("Push /compact every N fires (default: 10, 0 = never)").setRequired(false)))
|
|
841
|
+
.addSubcommand((sub) => sub.setName("list").setDescription("List all cron jobs"))
|
|
842
|
+
.addSubcommand((sub) => sub
|
|
843
|
+
.setName("pause")
|
|
844
|
+
.setDescription("Pause a cron job")
|
|
845
|
+
.addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true)))
|
|
846
|
+
.addSubcommand((sub) => sub
|
|
847
|
+
.setName("resume")
|
|
848
|
+
.setDescription("Resume a paused cron job")
|
|
849
|
+
.addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true)))
|
|
850
|
+
.addSubcommand((sub) => sub
|
|
851
|
+
.setName("delete")
|
|
852
|
+
.setDescription("Delete a cron job permanently")
|
|
853
|
+
.addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true))),
|
|
829
854
|
].map((cmd) => cmd.toJSON());
|
|
830
855
|
const rest = new REST().setToken(this.opts.discordToken);
|
|
831
856
|
if (this.opts.guildIds?.length) {
|
|
@@ -972,6 +997,10 @@ export class CcDiscordBot {
|
|
|
972
997
|
});
|
|
973
998
|
break;
|
|
974
999
|
}
|
|
1000
|
+
case "cron": {
|
|
1001
|
+
await this.handleCronEngineCommand(interaction, channelId);
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
975
1004
|
}
|
|
976
1005
|
}
|
|
977
1006
|
async handleCronsCommand(interaction, channelId) {
|
|
@@ -1020,6 +1049,79 @@ export class CcDiscordBot {
|
|
|
1020
1049
|
await interaction.reply("Unknown subcommand.");
|
|
1021
1050
|
}
|
|
1022
1051
|
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Handle the /cron command group — backed by CronEngine (Redis-persisted, node-cron scheduled).
|
|
1054
|
+
* Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
|
|
1055
|
+
*/
|
|
1056
|
+
async handleCronEngineCommand(interaction, channelId) {
|
|
1057
|
+
if (!this.cronEngine) {
|
|
1058
|
+
await interaction.reply({ content: "Cron engine unavailable (no Redis connection).", ephemeral: true });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const ns = this.resolveNamespaceForChannel(channelId);
|
|
1062
|
+
const sub = interaction.options.getSubcommand();
|
|
1063
|
+
switch (sub) {
|
|
1064
|
+
case "add": {
|
|
1065
|
+
const cronSchedule = interaction.options.getString("schedule", true);
|
|
1066
|
+
const message = interaction.options.getString("message", true);
|
|
1067
|
+
const compactEvery = interaction.options.getInteger("compact_every") ?? 10;
|
|
1068
|
+
await interaction.deferReply();
|
|
1069
|
+
const rec = await this.cronEngine.add(ns, cronSchedule, message, compactEvery);
|
|
1070
|
+
if (!rec) {
|
|
1071
|
+
await interaction.editReply(`Invalid cron expression: \`${cronSchedule}\`\nUse standard 5-field format, e.g. \`0 * * * *\` (hourly) or \`*/30 * * * *\` (every 30 min).`);
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
await interaction.editReply(`Cron added for namespace **${ns}**\nID: \`${rec.id}\`\nSchedule: \`${rec.schedule}\`\nCompact every: ${rec.compact_every} fires`);
|
|
1075
|
+
}
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
case "list": {
|
|
1079
|
+
await interaction.deferReply();
|
|
1080
|
+
const allCrons = await this.cronEngine.list();
|
|
1081
|
+
const nsCrons = allCrons.filter((r) => r.namespace === ns);
|
|
1082
|
+
if (nsCrons.length === 0) {
|
|
1083
|
+
await interaction.editReply(`No cron jobs for namespace **${ns}**.`);
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
const embed = new EmbedBuilder()
|
|
1087
|
+
.setTitle(`Cron jobs — ${ns}`)
|
|
1088
|
+
.setColor(0x5865F2)
|
|
1089
|
+
.setDescription(nsCrons
|
|
1090
|
+
.map((r) => {
|
|
1091
|
+
const status = r.enabled ? "enabled" : "paused";
|
|
1092
|
+
const last = r.last_fired_at ? `last fired ${r.last_fired_at}` : "never fired";
|
|
1093
|
+
return `**\`${r.id}\`** [${status}]\nSchedule: \`${r.schedule}\` | Fires: ${r.fire_count} | ${last}\nMessage: \`${r.message.slice(0, 80)}${r.message.length > 80 ? "…" : ""}\``;
|
|
1094
|
+
})
|
|
1095
|
+
.join("\n\n"));
|
|
1096
|
+
await interaction.editReply({ embeds: [embed] });
|
|
1097
|
+
}
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
case "pause": {
|
|
1101
|
+
const id = interaction.options.getString("id", true);
|
|
1102
|
+
await interaction.deferReply();
|
|
1103
|
+
const ok = await this.cronEngine.pause(id);
|
|
1104
|
+
await interaction.editReply(ok ? `Cron \`${id}\` paused.` : `Cron \`${id}\` not found.`);
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
case "resume": {
|
|
1108
|
+
const id = interaction.options.getString("id", true);
|
|
1109
|
+
await interaction.deferReply();
|
|
1110
|
+
const ok = await this.cronEngine.resume(id);
|
|
1111
|
+
await interaction.editReply(ok ? `Cron \`${id}\` resumed.` : `Cron \`${id}\` not found.`);
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
case "delete": {
|
|
1115
|
+
const id = interaction.options.getString("id", true);
|
|
1116
|
+
await interaction.deferReply();
|
|
1117
|
+
const ok = await this.cronEngine.delete(id);
|
|
1118
|
+
await interaction.editReply(ok ? `Cron \`${id}\` deleted.` : `Cron \`${id}\` not found.`);
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
default:
|
|
1122
|
+
await interaction.reply("Unknown /cron subcommand.");
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1023
1125
|
/**
|
|
1024
1126
|
* Call a cc-agent MCP tool via a dedicated ClaudeProcess.
|
|
1025
1127
|
* Returns the tool result as a string, or null on failure.
|
|
@@ -1326,6 +1428,17 @@ export class CcDiscordBot {
|
|
|
1326
1428
|
return;
|
|
1327
1429
|
this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()), this.opts.instanceId);
|
|
1328
1430
|
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Start the CronEngine — loads all enabled crons from Redis and schedules them.
|
|
1433
|
+
* Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
|
|
1434
|
+
*/
|
|
1435
|
+
startCronEngine() {
|
|
1436
|
+
if (!this.cronEngine)
|
|
1437
|
+
return;
|
|
1438
|
+
this.cronEngine.start().catch((err) => {
|
|
1439
|
+
console.warn("[bot] cronEngine.start() failed:", err.message);
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1329
1442
|
/**
|
|
1330
1443
|
* Resolve the namespace for a given channelId.
|
|
1331
1444
|
* Routed channels use their registered namespace; everything else uses the bot's primary namespace.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CronEngine — Redis-persisted, node-cron–scheduled meta-agent messaging.
|
|
3
|
+
*
|
|
4
|
+
* Redis key schema:
|
|
5
|
+
* cca:discord:cron:list — SET of cron IDs
|
|
6
|
+
* cca:discord:cron:{id} — HASH with fields:
|
|
7
|
+
* namespace, schedule, message, enabled,
|
|
8
|
+
* fire_count, compact_every,
|
|
9
|
+
* created_at, last_fired_at
|
|
10
|
+
*
|
|
11
|
+
* On each fire:
|
|
12
|
+
* 1. Increment fire_count
|
|
13
|
+
* 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
|
|
14
|
+
* — skip if the exact message is already queued
|
|
15
|
+
* 3. If fire_count % compact_every === 0: RPUSH /compact first
|
|
16
|
+
* 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
|
|
17
|
+
* 5. Update last_fired_at
|
|
18
|
+
*/
|
|
19
|
+
import type { Redis } from "ioredis";
|
|
20
|
+
export interface CronRecord {
|
|
21
|
+
id: string;
|
|
22
|
+
namespace: string;
|
|
23
|
+
schedule: string;
|
|
24
|
+
message: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
fire_count: number;
|
|
27
|
+
compact_every: number;
|
|
28
|
+
created_at: string;
|
|
29
|
+
last_fired_at: string;
|
|
30
|
+
}
|
|
31
|
+
export declare class CronEngine {
|
|
32
|
+
private redis;
|
|
33
|
+
/** Map of cron ID → active node-cron ScheduledTask */
|
|
34
|
+
private tasks;
|
|
35
|
+
constructor(redis: Redis);
|
|
36
|
+
/**
|
|
37
|
+
* Add a new cron.
|
|
38
|
+
* @param namespace The meta-agent namespace to push messages to.
|
|
39
|
+
* @param cronSchedule A standard 5-field cron expression (e.g. "0 * * * *").
|
|
40
|
+
* @param message Text content to push into the meta-agent input queue.
|
|
41
|
+
* @param compact_every Push /compact every N fires. Defaults to 10.
|
|
42
|
+
* @returns The created CronRecord, or null if the schedule is invalid.
|
|
43
|
+
*/
|
|
44
|
+
add(namespace: string, cronSchedule: string, message: string, compact_every?: number): Promise<CronRecord | null>;
|
|
45
|
+
/**
|
|
46
|
+
* List all crons stored in Redis.
|
|
47
|
+
*/
|
|
48
|
+
list(): Promise<CronRecord[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Pause a cron (sets enabled=false and stops the node-cron task).
|
|
51
|
+
*/
|
|
52
|
+
pause(id: string): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Resume a paused cron (sets enabled=true and re-schedules the task).
|
|
55
|
+
*/
|
|
56
|
+
resume(id: string): Promise<boolean>;
|
|
57
|
+
/**
|
|
58
|
+
* Delete a cron (stops the task and removes from Redis).
|
|
59
|
+
*/
|
|
60
|
+
delete(id: string): Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Load all enabled crons from Redis and schedule them.
|
|
63
|
+
* Call once after the bot connects and Redis is ready.
|
|
64
|
+
*/
|
|
65
|
+
start(): Promise<void>;
|
|
66
|
+
private scheduleTask;
|
|
67
|
+
/**
|
|
68
|
+
* Execute one cron tick:
|
|
69
|
+
* 1. Reload record (picks up any pause between schedule and fire)
|
|
70
|
+
* 2. Increment fire_count
|
|
71
|
+
* 3. Duplicate check against the input queue
|
|
72
|
+
* 4. Optionally push /compact
|
|
73
|
+
* 5. Push the message
|
|
74
|
+
* 6. Update last_fired_at
|
|
75
|
+
*/
|
|
76
|
+
private fire;
|
|
77
|
+
/** Write a CronRecord to Redis as a HASH. */
|
|
78
|
+
private saveRecord;
|
|
79
|
+
/** Load a CronRecord from Redis. Returns null if the key doesn't exist. */
|
|
80
|
+
private loadRecord;
|
|
81
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CronEngine — Redis-persisted, node-cron–scheduled meta-agent messaging.
|
|
3
|
+
*
|
|
4
|
+
* Redis key schema:
|
|
5
|
+
* cca:discord:cron:list — SET of cron IDs
|
|
6
|
+
* cca:discord:cron:{id} — HASH with fields:
|
|
7
|
+
* namespace, schedule, message, enabled,
|
|
8
|
+
* fire_count, compact_every,
|
|
9
|
+
* created_at, last_fired_at
|
|
10
|
+
*
|
|
11
|
+
* On each fire:
|
|
12
|
+
* 1. Increment fire_count
|
|
13
|
+
* 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
|
|
14
|
+
* — skip if the exact message is already queued
|
|
15
|
+
* 3. If fire_count % compact_every === 0: RPUSH /compact first
|
|
16
|
+
* 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
|
|
17
|
+
* 5. Update last_fired_at
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "crypto";
|
|
20
|
+
import { schedule, validate } from "node-cron";
|
|
21
|
+
// ─── Redis key helpers ────────────────────────────────────────────────────────
|
|
22
|
+
const CRON_LIST_KEY = "cca:discord:cron:list";
|
|
23
|
+
const cronHashKey = (id) => `cca:discord:cron:${id}`;
|
|
24
|
+
const metaInputKey = (ns) => `cca:discord:meta:${ns}:input`;
|
|
25
|
+
// ─── CronEngine ──────────────────────────────────────────────────────────────
|
|
26
|
+
export class CronEngine {
|
|
27
|
+
redis;
|
|
28
|
+
/** Map of cron ID → active node-cron ScheduledTask */
|
|
29
|
+
tasks = new Map();
|
|
30
|
+
constructor(redis) {
|
|
31
|
+
this.redis = redis;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Add a new cron.
|
|
35
|
+
* @param namespace The meta-agent namespace to push messages to.
|
|
36
|
+
* @param cronSchedule A standard 5-field cron expression (e.g. "0 * * * *").
|
|
37
|
+
* @param message Text content to push into the meta-agent input queue.
|
|
38
|
+
* @param compact_every Push /compact every N fires. Defaults to 10.
|
|
39
|
+
* @returns The created CronRecord, or null if the schedule is invalid.
|
|
40
|
+
*/
|
|
41
|
+
async add(namespace, cronSchedule, message, compact_every = 10) {
|
|
42
|
+
if (!validate(cronSchedule))
|
|
43
|
+
return null;
|
|
44
|
+
const id = randomUUID();
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
const record = {
|
|
47
|
+
id,
|
|
48
|
+
namespace,
|
|
49
|
+
schedule: cronSchedule,
|
|
50
|
+
message,
|
|
51
|
+
enabled: true,
|
|
52
|
+
fire_count: 0,
|
|
53
|
+
compact_every,
|
|
54
|
+
created_at: now,
|
|
55
|
+
last_fired_at: "",
|
|
56
|
+
};
|
|
57
|
+
await this.saveRecord(record);
|
|
58
|
+
await this.redis.sadd(CRON_LIST_KEY, id);
|
|
59
|
+
this.scheduleTask(record);
|
|
60
|
+
console.log(`[cron-engine] added cron id=${id} ns=${namespace} schedule="${cronSchedule}"`);
|
|
61
|
+
return record;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* List all crons stored in Redis.
|
|
65
|
+
*/
|
|
66
|
+
async list() {
|
|
67
|
+
const ids = await this.redis.smembers(CRON_LIST_KEY);
|
|
68
|
+
const records = [];
|
|
69
|
+
for (const id of ids) {
|
|
70
|
+
const rec = await this.loadRecord(id);
|
|
71
|
+
if (rec)
|
|
72
|
+
records.push(rec);
|
|
73
|
+
}
|
|
74
|
+
return records;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Pause a cron (sets enabled=false and stops the node-cron task).
|
|
78
|
+
*/
|
|
79
|
+
async pause(id) {
|
|
80
|
+
const rec = await this.loadRecord(id);
|
|
81
|
+
if (!rec)
|
|
82
|
+
return false;
|
|
83
|
+
rec.enabled = false;
|
|
84
|
+
await this.saveRecord(rec);
|
|
85
|
+
const task = this.tasks.get(id);
|
|
86
|
+
if (task) {
|
|
87
|
+
await task.stop();
|
|
88
|
+
}
|
|
89
|
+
console.log(`[cron-engine] paused cron id=${id}`);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resume a paused cron (sets enabled=true and re-schedules the task).
|
|
94
|
+
*/
|
|
95
|
+
async resume(id) {
|
|
96
|
+
const rec = await this.loadRecord(id);
|
|
97
|
+
if (!rec)
|
|
98
|
+
return false;
|
|
99
|
+
rec.enabled = true;
|
|
100
|
+
await this.saveRecord(rec);
|
|
101
|
+
// Re-schedule if not already running
|
|
102
|
+
if (!this.tasks.has(id)) {
|
|
103
|
+
this.scheduleTask(rec);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const task = this.tasks.get(id);
|
|
107
|
+
await task.start();
|
|
108
|
+
}
|
|
109
|
+
console.log(`[cron-engine] resumed cron id=${id}`);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Delete a cron (stops the task and removes from Redis).
|
|
114
|
+
*/
|
|
115
|
+
async delete(id) {
|
|
116
|
+
const rec = await this.loadRecord(id);
|
|
117
|
+
if (!rec)
|
|
118
|
+
return false;
|
|
119
|
+
const task = this.tasks.get(id);
|
|
120
|
+
if (task) {
|
|
121
|
+
await task.stop();
|
|
122
|
+
await task.destroy();
|
|
123
|
+
this.tasks.delete(id);
|
|
124
|
+
}
|
|
125
|
+
await this.redis.srem(CRON_LIST_KEY, id);
|
|
126
|
+
await this.redis.del(cronHashKey(id));
|
|
127
|
+
console.log(`[cron-engine] deleted cron id=${id}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Load all enabled crons from Redis and schedule them.
|
|
132
|
+
* Call once after the bot connects and Redis is ready.
|
|
133
|
+
*/
|
|
134
|
+
async start() {
|
|
135
|
+
const ids = await this.redis.smembers(CRON_LIST_KEY);
|
|
136
|
+
let scheduled = 0;
|
|
137
|
+
for (const id of ids) {
|
|
138
|
+
const rec = await this.loadRecord(id);
|
|
139
|
+
if (!rec)
|
|
140
|
+
continue;
|
|
141
|
+
if (rec.enabled) {
|
|
142
|
+
this.scheduleTask(rec);
|
|
143
|
+
scheduled++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
console.log(`[cron-engine] started — loaded ${ids.length} crons, scheduled ${scheduled}`);
|
|
147
|
+
}
|
|
148
|
+
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
149
|
+
scheduleTask(rec) {
|
|
150
|
+
// Destroy any existing task for this ID
|
|
151
|
+
const existing = this.tasks.get(rec.id);
|
|
152
|
+
if (existing) {
|
|
153
|
+
void existing.stop();
|
|
154
|
+
void existing.destroy();
|
|
155
|
+
this.tasks.delete(rec.id);
|
|
156
|
+
}
|
|
157
|
+
const task = schedule(rec.schedule, async () => {
|
|
158
|
+
await this.fire(rec.id);
|
|
159
|
+
}, { noOverlap: true });
|
|
160
|
+
this.tasks.set(rec.id, task);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Execute one cron tick:
|
|
164
|
+
* 1. Reload record (picks up any pause between schedule and fire)
|
|
165
|
+
* 2. Increment fire_count
|
|
166
|
+
* 3. Duplicate check against the input queue
|
|
167
|
+
* 4. Optionally push /compact
|
|
168
|
+
* 5. Push the message
|
|
169
|
+
* 6. Update last_fired_at
|
|
170
|
+
*/
|
|
171
|
+
async fire(id) {
|
|
172
|
+
const rec = await this.loadRecord(id);
|
|
173
|
+
if (!rec) {
|
|
174
|
+
console.warn(`[cron-engine] fire: record not found for id=${id}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!rec.enabled) {
|
|
178
|
+
console.log(`[cron-engine] fire: cron id=${id} is disabled — skipping`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const ns = rec.namespace;
|
|
182
|
+
const inputKey = metaInputKey(ns);
|
|
183
|
+
// Increment fire_count
|
|
184
|
+
rec.fire_count += 1;
|
|
185
|
+
// Duplicate check: skip if the message is already queued
|
|
186
|
+
try {
|
|
187
|
+
const queued = await this.redis.lrange(inputKey, 0, -1);
|
|
188
|
+
const alreadyQueued = queued.some((entry) => {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(entry);
|
|
191
|
+
return parsed.content === rec.message;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
if (alreadyQueued) {
|
|
198
|
+
console.log(`[cron-engine] fire: duplicate detected, skipping push (id=${id} ns=${ns})`);
|
|
199
|
+
await this.saveRecord(rec);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.warn(`[cron-engine] fire: duplicate check failed (id=${id}):`, err.message);
|
|
205
|
+
}
|
|
206
|
+
// Auto-compact every N fires
|
|
207
|
+
if (rec.compact_every > 0 && rec.fire_count % rec.compact_every === 0) {
|
|
208
|
+
const compactEntry = JSON.stringify({
|
|
209
|
+
id: randomUUID(),
|
|
210
|
+
content: "/compact",
|
|
211
|
+
timestamp: new Date().toISOString(),
|
|
212
|
+
source: "cron",
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
await this.redis.rpush(inputKey, compactEntry);
|
|
216
|
+
console.log(`[cron-engine] fire: pushed /compact (id=${id} ns=${ns} fire_count=${rec.fire_count})`);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
console.warn(`[cron-engine] fire: /compact push failed (id=${id}):`, err.message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Push the scheduled message
|
|
223
|
+
const entry = JSON.stringify({
|
|
224
|
+
id: randomUUID(),
|
|
225
|
+
content: rec.message,
|
|
226
|
+
timestamp: new Date().toISOString(),
|
|
227
|
+
source: "cron",
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
await this.redis.rpush(inputKey, entry);
|
|
231
|
+
rec.last_fired_at = new Date().toISOString();
|
|
232
|
+
console.log(`[cron-engine] fire: pushed message (id=${id} ns=${ns} fire_count=${rec.fire_count})`);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
console.warn(`[cron-engine] fire: push failed (id=${id}):`, err.message);
|
|
236
|
+
}
|
|
237
|
+
await this.saveRecord(rec);
|
|
238
|
+
}
|
|
239
|
+
/** Write a CronRecord to Redis as a HASH. */
|
|
240
|
+
async saveRecord(rec) {
|
|
241
|
+
const key = cronHashKey(rec.id);
|
|
242
|
+
await this.redis.hset(key, {
|
|
243
|
+
id: rec.id,
|
|
244
|
+
namespace: rec.namespace,
|
|
245
|
+
schedule: rec.schedule,
|
|
246
|
+
message: rec.message,
|
|
247
|
+
enabled: rec.enabled ? "1" : "0",
|
|
248
|
+
fire_count: String(rec.fire_count),
|
|
249
|
+
compact_every: String(rec.compact_every),
|
|
250
|
+
created_at: rec.created_at,
|
|
251
|
+
last_fired_at: rec.last_fired_at,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/** Load a CronRecord from Redis. Returns null if the key doesn't exist. */
|
|
255
|
+
async loadRecord(id) {
|
|
256
|
+
const data = await this.redis.hgetall(cronHashKey(id));
|
|
257
|
+
if (!data || !data.id)
|
|
258
|
+
return null;
|
|
259
|
+
return {
|
|
260
|
+
id: data.id,
|
|
261
|
+
namespace: data.namespace,
|
|
262
|
+
schedule: data.schedule,
|
|
263
|
+
message: data.message,
|
|
264
|
+
enabled: data.enabled === "1",
|
|
265
|
+
fire_count: parseInt(data.fire_count ?? "0", 10),
|
|
266
|
+
compact_every: parseInt(data.compact_every ?? "10", 10),
|
|
267
|
+
created_at: data.created_at ?? "",
|
|
268
|
+
last_fired_at: data.last_fired_at ?? "",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -144,6 +144,8 @@ async function runStartupMigrations() {
|
|
|
144
144
|
console.log("[cc-discord] startup migrations complete");
|
|
145
145
|
// 3. Start meta-agent polling now that data is migrated
|
|
146
146
|
bot.startMetaAgentPolling();
|
|
147
|
+
// 4. Start cron engine — loads and schedules all Redis-persisted crons
|
|
148
|
+
bot.startCronEngine();
|
|
147
149
|
}
|
|
148
150
|
// Mutable placeholder closures — filled in once `bot` is created below
|
|
149
151
|
let getLastActiveChannelIdFn = () => undefined;
|
package/dist/notifier.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-discord",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19",
|
|
4
4
|
"description": "Claude Code Discord bot — chat with Claude Code via Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@gonzih/cc-wire": "^0.3.0",
|
|
22
|
+
"@types/node-cron": "^3.0.11",
|
|
22
23
|
"discord.js": "^14.0.0",
|
|
23
|
-
"ioredis": "^5.0.0"
|
|
24
|
+
"ioredis": "^5.0.0",
|
|
25
|
+
"node-cron": "^4.4.1"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
26
28
|
"@types/node": "^22.0.0",
|