@gonzih/cc-discord 0.2.26 → 0.2.27
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 -13
- package/dist/bot.js +0 -118
- package/dist/index.js +0 -2
- package/dist/notifier.js +6 -3
- package/package.json +2 -2
- package/dist/loop-engine.d.ts +0 -110
- package/dist/loop-engine.js +0 -312
package/dist/bot.d.ts
CHANGED
|
@@ -36,7 +36,6 @@ export declare class CcDiscordBot {
|
|
|
36
36
|
private lastActiveChannelId?;
|
|
37
37
|
private cron;
|
|
38
38
|
private cronEngine?;
|
|
39
|
-
private loopEngine?;
|
|
40
39
|
private metaAgentManager;
|
|
41
40
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
42
41
|
private mcpSession?;
|
|
@@ -101,12 +100,6 @@ export declare class CcDiscordBot {
|
|
|
101
100
|
* Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
|
|
102
101
|
*/
|
|
103
102
|
private handleCronEngineCommand;
|
|
104
|
-
/**
|
|
105
|
-
* Handle the /loop command group — backed by LoopEngine (Redis-persisted, interval-based).
|
|
106
|
-
* Loops fire immediately on creation and on startup, then repeat every intervalMs ms.
|
|
107
|
-
* Namespace is derived from the channel's registered namespace.
|
|
108
|
-
*/
|
|
109
|
-
private handleLoopEngineCommand;
|
|
110
103
|
/**
|
|
111
104
|
* Call a cc-agent MCP tool via a dedicated ClaudeProcess.
|
|
112
105
|
* Returns the tool result as a string, or null on failure.
|
|
@@ -157,12 +150,6 @@ export declare class CcDiscordBot {
|
|
|
157
150
|
* Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
|
|
158
151
|
*/
|
|
159
152
|
startCronEngine(): void;
|
|
160
|
-
/**
|
|
161
|
-
* Start the LoopEngine — loads all active loops from Redis, fires each immediately,
|
|
162
|
-
* then starts their repeat timers.
|
|
163
|
-
* Called from index.ts after startup migrations complete (alongside startCronEngine).
|
|
164
|
-
*/
|
|
165
|
-
startLoopEngine(): void;
|
|
166
153
|
/**
|
|
167
154
|
* Resolve the namespace for a given channelId.
|
|
168
155
|
* Routed channels use their registered namespace; everything else uses the bot's primary namespace.
|
package/dist/bot.js
CHANGED
|
@@ -16,7 +16,6 @@ import { getCurrentToken } from "./tokens.js";
|
|
|
16
16
|
import { writeChatLog } from "./notifier.js";
|
|
17
17
|
import { CronManager } from "./cron.js";
|
|
18
18
|
import { CronEngine, cronPendingKey, CRON_MESSAGE_TTL } from "./cron-engine.js";
|
|
19
|
-
import { LoopEngine, parseIntervalMs } from "./loop-engine.js";
|
|
20
19
|
import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
|
|
21
20
|
import { createMetaAgentManager } from "./meta-agent-manager.js";
|
|
22
21
|
/** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
|
|
@@ -123,7 +122,6 @@ export class CcDiscordBot {
|
|
|
123
122
|
lastActiveChannelId;
|
|
124
123
|
cron;
|
|
125
124
|
cronEngine;
|
|
126
|
-
loopEngine;
|
|
127
125
|
metaAgentManager;
|
|
128
126
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
129
127
|
mcpSession;
|
|
@@ -134,7 +132,6 @@ export class CcDiscordBot {
|
|
|
134
132
|
if (opts.redis) {
|
|
135
133
|
this.wire = createCcWire(opts.redis);
|
|
136
134
|
this.cronEngine = new CronEngine(opts.redis);
|
|
137
|
-
this.loopEngine = new LoopEngine(opts.redis);
|
|
138
135
|
}
|
|
139
136
|
this.metaAgentManager = createMetaAgentManager();
|
|
140
137
|
this.client = new Client({
|
|
@@ -845,28 +842,6 @@ export class CcDiscordBot {
|
|
|
845
842
|
.setName("delete")
|
|
846
843
|
.setDescription("Delete a cron job permanently")
|
|
847
844
|
.addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true))),
|
|
848
|
-
new SlashCommandBuilder()
|
|
849
|
-
.setName("loop")
|
|
850
|
-
.setDescription("Manage Redis-persisted interval loops for this channel's namespace")
|
|
851
|
-
.addSubcommand((sub) => sub
|
|
852
|
-
.setName("add")
|
|
853
|
-
.setDescription("Add a loop (fires immediately, then repeats at fixed interval)")
|
|
854
|
-
.addStringOption((opt) => opt.setName("interval").setDescription("Interval e.g. '30m', '1h', '20m', '30s'").setRequired(true))
|
|
855
|
-
.addStringOption((opt) => opt.setName("message").setDescription("Message to push to the meta-agent input queue").setRequired(true))
|
|
856
|
-
.addIntegerOption((opt) => opt.setName("compact_every").setDescription("Push /compact every N fires (default: 10, 0 = never)").setRequired(false)))
|
|
857
|
-
.addSubcommand((sub) => sub.setName("list").setDescription("List all loops for this namespace"))
|
|
858
|
-
.addSubcommand((sub) => sub
|
|
859
|
-
.setName("pause")
|
|
860
|
-
.setDescription("Pause a loop")
|
|
861
|
-
.addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true)))
|
|
862
|
-
.addSubcommand((sub) => sub
|
|
863
|
-
.setName("resume")
|
|
864
|
-
.setDescription("Resume a paused loop (fires immediately)")
|
|
865
|
-
.addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true)))
|
|
866
|
-
.addSubcommand((sub) => sub
|
|
867
|
-
.setName("delete")
|
|
868
|
-
.setDescription("Delete a loop permanently")
|
|
869
|
-
.addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true))),
|
|
870
845
|
].map((cmd) => cmd.toJSON());
|
|
871
846
|
const rest = new REST().setToken(this.opts.discordToken);
|
|
872
847
|
if (this.opts.guildIds?.length) {
|
|
@@ -1017,10 +992,6 @@ export class CcDiscordBot {
|
|
|
1017
992
|
await this.handleCronEngineCommand(interaction, channelId);
|
|
1018
993
|
break;
|
|
1019
994
|
}
|
|
1020
|
-
case "loop": {
|
|
1021
|
-
await this.handleLoopEngineCommand(interaction, channelId);
|
|
1022
|
-
break;
|
|
1023
|
-
}
|
|
1024
995
|
}
|
|
1025
996
|
}
|
|
1026
997
|
async handleCronsCommand(interaction, channelId) {
|
|
@@ -1142,82 +1113,6 @@ export class CcDiscordBot {
|
|
|
1142
1113
|
await interaction.reply("Unknown /cron subcommand.");
|
|
1143
1114
|
}
|
|
1144
1115
|
}
|
|
1145
|
-
/**
|
|
1146
|
-
* Handle the /loop command group — backed by LoopEngine (Redis-persisted, interval-based).
|
|
1147
|
-
* Loops fire immediately on creation and on startup, then repeat every intervalMs ms.
|
|
1148
|
-
* Namespace is derived from the channel's registered namespace.
|
|
1149
|
-
*/
|
|
1150
|
-
async handleLoopEngineCommand(interaction, channelId) {
|
|
1151
|
-
if (!this.loopEngine) {
|
|
1152
|
-
await interaction.reply({ content: "Loop engine unavailable (no Redis connection).", ephemeral: true });
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
const ns = this.resolveNamespaceForChannel(channelId);
|
|
1156
|
-
const sub = interaction.options.getSubcommand();
|
|
1157
|
-
switch (sub) {
|
|
1158
|
-
case "add": {
|
|
1159
|
-
const intervalStr = interaction.options.getString("interval", true);
|
|
1160
|
-
const message = interaction.options.getString("message", true);
|
|
1161
|
-
const compactEvery = interaction.options.getInteger("compact_every") ?? 10;
|
|
1162
|
-
const intervalMs = parseIntervalMs(intervalStr);
|
|
1163
|
-
if (intervalMs === null || intervalMs <= 0) {
|
|
1164
|
-
await interaction.reply(`Invalid interval: \`${intervalStr}\`\nUse format: \`30s\`, \`5m\`, \`20m\`, \`1h\`, \`2h\`, \`1d\``);
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
1167
|
-
await interaction.deferReply();
|
|
1168
|
-
const rec = await this.loopEngine.add(ns, intervalMs, message, compactEvery);
|
|
1169
|
-
const humanInterval = intervalStr;
|
|
1170
|
-
await interaction.editReply(`Loop added for namespace **${ns}**\nID: \`${rec.id}\`\nInterval: \`${humanInterval}\` (${intervalMs}ms)\nCompact every: ${rec.compactEvery} fires\n(Fired immediately on creation.)`);
|
|
1171
|
-
break;
|
|
1172
|
-
}
|
|
1173
|
-
case "list": {
|
|
1174
|
-
await interaction.deferReply();
|
|
1175
|
-
const allLoops = await this.loopEngine.list();
|
|
1176
|
-
const nsLoops = allLoops.filter((r) => r.namespace === ns);
|
|
1177
|
-
if (nsLoops.length === 0) {
|
|
1178
|
-
await interaction.editReply(`No loops for namespace **${ns}**.`);
|
|
1179
|
-
}
|
|
1180
|
-
else {
|
|
1181
|
-
const embed = new EmbedBuilder()
|
|
1182
|
-
.setTitle(`Loops — ${ns}`)
|
|
1183
|
-
.setColor(0x57F287)
|
|
1184
|
-
.setDescription(nsLoops
|
|
1185
|
-
.map((r) => {
|
|
1186
|
-
const statusEmoji = r.status === "active" ? "🟢" : "⏸️";
|
|
1187
|
-
const last = r.lastRun ? `last run ${r.lastRun}` : "never run";
|
|
1188
|
-
const intervalSec = Math.round(r.intervalMs / 1000);
|
|
1189
|
-
return `${statusEmoji} **\`${r.id}\`** [${r.status}]\nInterval: ${intervalSec}s | Fires: ${r.fireCount} | ${last}\nMessage: \`${r.message.slice(0, 80)}${r.message.length > 80 ? "…" : ""}\``;
|
|
1190
|
-
})
|
|
1191
|
-
.join("\n\n"));
|
|
1192
|
-
await interaction.editReply({ embeds: [embed] });
|
|
1193
|
-
}
|
|
1194
|
-
break;
|
|
1195
|
-
}
|
|
1196
|
-
case "pause": {
|
|
1197
|
-
const id = interaction.options.getString("id", true);
|
|
1198
|
-
await interaction.deferReply();
|
|
1199
|
-
const ok = await this.loopEngine.pause(id);
|
|
1200
|
-
await interaction.editReply(ok ? `Loop \`${id}\` paused.` : `Loop \`${id}\` not found.`);
|
|
1201
|
-
break;
|
|
1202
|
-
}
|
|
1203
|
-
case "resume": {
|
|
1204
|
-
const id = interaction.options.getString("id", true);
|
|
1205
|
-
await interaction.deferReply();
|
|
1206
|
-
const ok = await this.loopEngine.resume(id);
|
|
1207
|
-
await interaction.editReply(ok ? `Loop \`${id}\` resumed (fired immediately).` : `Loop \`${id}\` not found.`);
|
|
1208
|
-
break;
|
|
1209
|
-
}
|
|
1210
|
-
case "delete": {
|
|
1211
|
-
const id = interaction.options.getString("id", true);
|
|
1212
|
-
await interaction.deferReply();
|
|
1213
|
-
const ok = await this.loopEngine.delete(id);
|
|
1214
|
-
await interaction.editReply(ok ? `Loop \`${id}\` deleted.` : `Loop \`${id}\` not found.`);
|
|
1215
|
-
break;
|
|
1216
|
-
}
|
|
1217
|
-
default:
|
|
1218
|
-
await interaction.reply("Unknown /loop subcommand.");
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
1116
|
/**
|
|
1222
1117
|
* Call a cc-agent MCP tool via a dedicated ClaudeProcess.
|
|
1223
1118
|
* Returns the tool result as a string, or null on failure.
|
|
@@ -1461,18 +1356,6 @@ export class CcDiscordBot {
|
|
|
1461
1356
|
console.warn("[bot] cronEngine.start() failed:", err.message);
|
|
1462
1357
|
});
|
|
1463
1358
|
}
|
|
1464
|
-
/**
|
|
1465
|
-
* Start the LoopEngine — loads all active loops from Redis, fires each immediately,
|
|
1466
|
-
* then starts their repeat timers.
|
|
1467
|
-
* Called from index.ts after startup migrations complete (alongside startCronEngine).
|
|
1468
|
-
*/
|
|
1469
|
-
startLoopEngine() {
|
|
1470
|
-
if (!this.loopEngine)
|
|
1471
|
-
return;
|
|
1472
|
-
this.loopEngine.start().catch((err) => {
|
|
1473
|
-
console.warn("[bot] loopEngine.start() failed:", err.message);
|
|
1474
|
-
});
|
|
1475
|
-
}
|
|
1476
1359
|
/**
|
|
1477
1360
|
* Resolve the namespace for a given channelId.
|
|
1478
1361
|
* Routed channels use their registered namespace; everything else uses the bot's primary namespace.
|
|
@@ -1569,7 +1452,6 @@ export class CcDiscordBot {
|
|
|
1569
1452
|
this.stopMetaAgentTyping(channelId);
|
|
1570
1453
|
}
|
|
1571
1454
|
this.metaAgentManager.stop();
|
|
1572
|
-
this.loopEngine?.stop();
|
|
1573
1455
|
void this.client.destroy();
|
|
1574
1456
|
}
|
|
1575
1457
|
}
|
package/dist/index.js
CHANGED
|
@@ -145,8 +145,6 @@ async function runStartupMigrations() {
|
|
|
145
145
|
bot.startMetaAgentPolling();
|
|
146
146
|
// 4. Start cron engine — loads and schedules all Redis-persisted crons
|
|
147
147
|
bot.startCronEngine();
|
|
148
|
-
// 5. Start loop engine — loads all active loops, fires each immediately, then starts timers
|
|
149
|
-
bot.startLoopEngine();
|
|
150
148
|
}
|
|
151
149
|
// Mutable placeholder closures — filled in once `bot` is created below
|
|
152
150
|
let getLastActiveChannelIdFn = () => undefined;
|
package/dist/notifier.js
CHANGED
|
@@ -112,14 +112,17 @@ export function parseNotification(raw) {
|
|
|
112
112
|
let isCron = false;
|
|
113
113
|
try {
|
|
114
114
|
const parsed = JSON.parse(raw);
|
|
115
|
+
// Accept 'targets' as alias for 'routing' (coordinator sessions use this format)
|
|
116
|
+
const routingArr = parsed.routing ?? parsed.targets;
|
|
115
117
|
// routing: absent/empty → all transports; non-empty → only listed transports
|
|
116
|
-
if (
|
|
118
|
+
if (routingArr && routingArr.length > 0 && !routingArr.includes("discord")) {
|
|
117
119
|
return null;
|
|
118
120
|
}
|
|
119
121
|
if (parsed.is_cron === true)
|
|
120
122
|
return null;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
// Accept 'message' as alias for 'text'
|
|
124
|
+
if (parsed.text ?? parsed.message)
|
|
125
|
+
text = (parsed.text ?? parsed.message);
|
|
123
126
|
driver = parsed.driver;
|
|
124
127
|
model = parsed.model;
|
|
125
128
|
if (typeof parsed.cost === "number")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-discord",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"description": "Claude Code Discord bot — chat with Claude Code via Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"dist/"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@gonzih/cc-wire": "^0.4.
|
|
22
|
+
"@gonzih/cc-wire": "^0.4.0",
|
|
23
23
|
"@types/node-cron": "^3.0.11",
|
|
24
24
|
"discord.js": "^14.0.0",
|
|
25
25
|
"ioredis": "^5.0.0",
|
package/dist/loop-engine.d.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LoopEngine — Redis-persisted, interval-based meta-agent messaging.
|
|
3
|
-
*
|
|
4
|
-
* Loops differ from crons:
|
|
5
|
-
* - Fire IMMEDIATELY on creation and on startup
|
|
6
|
-
* - Repeat at a fixed millisecond interval (not a cron expression)
|
|
7
|
-
*
|
|
8
|
-
* Redis key schema:
|
|
9
|
-
* cca:discord:loop:list — SET of loop IDs
|
|
10
|
-
* cca:discord:loop:{id} — HASH with fields:
|
|
11
|
-
* id, namespace, message, interval_ms,
|
|
12
|
-
* fire_count, compact_every, status,
|
|
13
|
-
* last_run, created_at
|
|
14
|
-
*
|
|
15
|
-
* On each fire:
|
|
16
|
-
* 1. Increment fire_count
|
|
17
|
-
* 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
|
|
18
|
-
* — skip if the exact message is already queued
|
|
19
|
-
* 3. If fire_count % compact_every === 0: RPUSH /compact first
|
|
20
|
-
* 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
|
|
21
|
-
* 5. Update last_run
|
|
22
|
-
*/
|
|
23
|
-
import type { Redis } from "ioredis";
|
|
24
|
-
/**
|
|
25
|
-
* Temporary key written by the loop engine when it fires a loop.
|
|
26
|
-
* Value is the loopId that fired. TTL 300s.
|
|
27
|
-
* The bot reads this after sending a Discord message to link the message to the loop.
|
|
28
|
-
*/
|
|
29
|
-
export declare const loopPendingKey: (ns: string) => string;
|
|
30
|
-
/**
|
|
31
|
-
* Key that maps a sent Discord messageId → loopId.
|
|
32
|
-
* Used by the reaction handler to look up which loop to disable.
|
|
33
|
-
* TTL 86400s (24h).
|
|
34
|
-
*/
|
|
35
|
-
export declare const LOOP_MESSAGE_TTL = 86400;
|
|
36
|
-
/**
|
|
37
|
-
* Parse a human-readable interval string to milliseconds.
|
|
38
|
-
* Supports: 30s, 5m, 20m, 1h, 2h, 1d, 7d, etc.
|
|
39
|
-
* Returns null if the format is unrecognised.
|
|
40
|
-
*/
|
|
41
|
-
export declare function parseIntervalMs(input: string): number | null;
|
|
42
|
-
export interface LoopRecord {
|
|
43
|
-
id: string;
|
|
44
|
-
namespace: string;
|
|
45
|
-
message: string;
|
|
46
|
-
intervalMs: number;
|
|
47
|
-
fireCount: number;
|
|
48
|
-
compactEvery: number;
|
|
49
|
-
status: "active" | "paused";
|
|
50
|
-
lastRun: string;
|
|
51
|
-
createdAt: string;
|
|
52
|
-
}
|
|
53
|
-
export declare class LoopEngine {
|
|
54
|
-
private redis;
|
|
55
|
-
/** Map of loop ID → NodeJS.Timeout handle */
|
|
56
|
-
private timers;
|
|
57
|
-
constructor(redis: Redis);
|
|
58
|
-
/**
|
|
59
|
-
* Add a new loop.
|
|
60
|
-
* Fires immediately, then repeats every intervalMs milliseconds.
|
|
61
|
-
*
|
|
62
|
-
* @param namespace The meta-agent namespace to push messages to.
|
|
63
|
-
* @param intervalMs Milliseconds between fires.
|
|
64
|
-
* @param message Text content to push into the meta-agent input queue.
|
|
65
|
-
* @param compactEvery Push /compact every N fires. Defaults to 10. 0 = never.
|
|
66
|
-
* @returns The created LoopRecord.
|
|
67
|
-
*/
|
|
68
|
-
add(namespace: string, intervalMs: number, message: string, compactEvery?: number): Promise<LoopRecord>;
|
|
69
|
-
/**
|
|
70
|
-
* List all loops stored in Redis.
|
|
71
|
-
*/
|
|
72
|
-
list(): Promise<LoopRecord[]>;
|
|
73
|
-
/**
|
|
74
|
-
* Pause a loop (sets status=paused and clears the timer).
|
|
75
|
-
*/
|
|
76
|
-
pause(id: string): Promise<boolean>;
|
|
77
|
-
/**
|
|
78
|
-
* Resume a paused loop (sets status=active, fires immediately, then restarts timer).
|
|
79
|
-
*/
|
|
80
|
-
resume(id: string): Promise<boolean>;
|
|
81
|
-
/**
|
|
82
|
-
* Delete a loop (clears timer and removes from Redis).
|
|
83
|
-
*/
|
|
84
|
-
delete(id: string): Promise<boolean>;
|
|
85
|
-
/**
|
|
86
|
-
* Load all active loops from Redis, fire each immediately, then start their timers.
|
|
87
|
-
* Call once after the bot connects and Redis is ready.
|
|
88
|
-
*/
|
|
89
|
-
start(): Promise<void>;
|
|
90
|
-
/**
|
|
91
|
-
* Stop all running timers. Does not modify Redis.
|
|
92
|
-
*/
|
|
93
|
-
stop(): void;
|
|
94
|
-
private startTimer;
|
|
95
|
-
private clearTimer;
|
|
96
|
-
/**
|
|
97
|
-
* Execute one loop tick:
|
|
98
|
-
* 1. Reload record (picks up any pause between schedule and fire)
|
|
99
|
-
* 2. Increment fireCount
|
|
100
|
-
* 3. Duplicate check against the input queue
|
|
101
|
-
* 4. Optionally push /compact
|
|
102
|
-
* 5. Push the message
|
|
103
|
-
* 6. Update lastRun
|
|
104
|
-
*/
|
|
105
|
-
fire(id: string): Promise<void>;
|
|
106
|
-
/** Write a LoopRecord to Redis as a HASH. */
|
|
107
|
-
private saveRecord;
|
|
108
|
-
/** Load a LoopRecord from Redis. Returns null if the key doesn't exist. */
|
|
109
|
-
private loadRecord;
|
|
110
|
-
}
|
package/dist/loop-engine.js
DELETED
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LoopEngine — Redis-persisted, interval-based meta-agent messaging.
|
|
3
|
-
*
|
|
4
|
-
* Loops differ from crons:
|
|
5
|
-
* - Fire IMMEDIATELY on creation and on startup
|
|
6
|
-
* - Repeat at a fixed millisecond interval (not a cron expression)
|
|
7
|
-
*
|
|
8
|
-
* Redis key schema:
|
|
9
|
-
* cca:discord:loop:list — SET of loop IDs
|
|
10
|
-
* cca:discord:loop:{id} — HASH with fields:
|
|
11
|
-
* id, namespace, message, interval_ms,
|
|
12
|
-
* fire_count, compact_every, status,
|
|
13
|
-
* last_run, created_at
|
|
14
|
-
*
|
|
15
|
-
* On each fire:
|
|
16
|
-
* 1. Increment fire_count
|
|
17
|
-
* 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
|
|
18
|
-
* — skip if the exact message is already queued
|
|
19
|
-
* 3. If fire_count % compact_every === 0: RPUSH /compact first
|
|
20
|
-
* 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
|
|
21
|
-
* 5. Update last_run
|
|
22
|
-
*/
|
|
23
|
-
import { randomUUID } from "crypto";
|
|
24
|
-
import { loopListKey, loopHashKey, discordMetaInputKey as metaInputKey, } from "@gonzih/cc-wire";
|
|
25
|
-
// ─── Redis key helpers ────────────────────────────────────────────────────────
|
|
26
|
-
const LOOP_LIST_KEY = loopListKey();
|
|
27
|
-
/**
|
|
28
|
-
* Temporary key written by the loop engine when it fires a loop.
|
|
29
|
-
* Value is the loopId that fired. TTL 300s.
|
|
30
|
-
* The bot reads this after sending a Discord message to link the message to the loop.
|
|
31
|
-
*/
|
|
32
|
-
export const loopPendingKey = (ns) => `cca:discord:loop-pending:${ns}`;
|
|
33
|
-
/**
|
|
34
|
-
* Key that maps a sent Discord messageId → loopId.
|
|
35
|
-
* Used by the reaction handler to look up which loop to disable.
|
|
36
|
-
* TTL 86400s (24h).
|
|
37
|
-
*/
|
|
38
|
-
export const LOOP_MESSAGE_TTL = 86400;
|
|
39
|
-
// ─── Human-readable interval parsing ─────────────────────────────────────────
|
|
40
|
-
/**
|
|
41
|
-
* Parse a human-readable interval string to milliseconds.
|
|
42
|
-
* Supports: 30s, 5m, 20m, 1h, 2h, 1d, 7d, etc.
|
|
43
|
-
* Returns null if the format is unrecognised.
|
|
44
|
-
*/
|
|
45
|
-
export function parseIntervalMs(input) {
|
|
46
|
-
const trimmed = input.trim().toLowerCase();
|
|
47
|
-
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/);
|
|
48
|
-
if (!match)
|
|
49
|
-
return null;
|
|
50
|
-
const value = parseFloat(match[1]);
|
|
51
|
-
const unit = match[2];
|
|
52
|
-
if (value <= 0 || !isFinite(value))
|
|
53
|
-
return null;
|
|
54
|
-
switch (unit) {
|
|
55
|
-
case "ms": return Math.round(value);
|
|
56
|
-
case "s": return Math.round(value * 1_000);
|
|
57
|
-
case "m": return Math.round(value * 60_000);
|
|
58
|
-
case "h": return Math.round(value * 3_600_000);
|
|
59
|
-
case "d": return Math.round(value * 86_400_000);
|
|
60
|
-
default: return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
// ─── LoopEngine ───────────────────────────────────────────────────────────────
|
|
64
|
-
export class LoopEngine {
|
|
65
|
-
redis;
|
|
66
|
-
/** Map of loop ID → NodeJS.Timeout handle */
|
|
67
|
-
timers = new Map();
|
|
68
|
-
constructor(redis) {
|
|
69
|
-
this.redis = redis;
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Add a new loop.
|
|
73
|
-
* Fires immediately, then repeats every intervalMs milliseconds.
|
|
74
|
-
*
|
|
75
|
-
* @param namespace The meta-agent namespace to push messages to.
|
|
76
|
-
* @param intervalMs Milliseconds between fires.
|
|
77
|
-
* @param message Text content to push into the meta-agent input queue.
|
|
78
|
-
* @param compactEvery Push /compact every N fires. Defaults to 10. 0 = never.
|
|
79
|
-
* @returns The created LoopRecord.
|
|
80
|
-
*/
|
|
81
|
-
async add(namespace, intervalMs, message, compactEvery = 10) {
|
|
82
|
-
const id = randomUUID();
|
|
83
|
-
const now = new Date().toISOString();
|
|
84
|
-
const record = {
|
|
85
|
-
id,
|
|
86
|
-
namespace,
|
|
87
|
-
message,
|
|
88
|
-
intervalMs,
|
|
89
|
-
fireCount: 0,
|
|
90
|
-
compactEvery,
|
|
91
|
-
status: "active",
|
|
92
|
-
lastRun: "",
|
|
93
|
-
createdAt: now,
|
|
94
|
-
};
|
|
95
|
-
await this.saveRecord(record);
|
|
96
|
-
await this.redis.sadd(LOOP_LIST_KEY, id);
|
|
97
|
-
// Fire immediately, then schedule repeating interval
|
|
98
|
-
await this.fire(id);
|
|
99
|
-
this.startTimer(record);
|
|
100
|
-
console.log(`[loop-engine] added loop id=${id} ns=${namespace} intervalMs=${intervalMs}`);
|
|
101
|
-
return record;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* List all loops stored in Redis.
|
|
105
|
-
*/
|
|
106
|
-
async list() {
|
|
107
|
-
const ids = await this.redis.smembers(LOOP_LIST_KEY);
|
|
108
|
-
const records = [];
|
|
109
|
-
for (const id of ids) {
|
|
110
|
-
const rec = await this.loadRecord(id);
|
|
111
|
-
if (rec)
|
|
112
|
-
records.push(rec);
|
|
113
|
-
}
|
|
114
|
-
return records;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Pause a loop (sets status=paused and clears the timer).
|
|
118
|
-
*/
|
|
119
|
-
async pause(id) {
|
|
120
|
-
const rec = await this.loadRecord(id);
|
|
121
|
-
if (!rec)
|
|
122
|
-
return false;
|
|
123
|
-
rec.status = "paused";
|
|
124
|
-
await this.saveRecord(rec);
|
|
125
|
-
this.clearTimer(id);
|
|
126
|
-
console.log(`[loop-engine] paused loop id=${id}`);
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Resume a paused loop (sets status=active, fires immediately, then restarts timer).
|
|
131
|
-
*/
|
|
132
|
-
async resume(id) {
|
|
133
|
-
const rec = await this.loadRecord(id);
|
|
134
|
-
if (!rec)
|
|
135
|
-
return false;
|
|
136
|
-
rec.status = "active";
|
|
137
|
-
await this.saveRecord(rec);
|
|
138
|
-
// Fire immediately then start interval
|
|
139
|
-
await this.fire(id);
|
|
140
|
-
this.startTimer(rec);
|
|
141
|
-
console.log(`[loop-engine] resumed loop id=${id}`);
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Delete a loop (clears timer and removes from Redis).
|
|
146
|
-
*/
|
|
147
|
-
async delete(id) {
|
|
148
|
-
const rec = await this.loadRecord(id);
|
|
149
|
-
if (!rec)
|
|
150
|
-
return false;
|
|
151
|
-
this.clearTimer(id);
|
|
152
|
-
await this.redis.srem(LOOP_LIST_KEY, id);
|
|
153
|
-
await this.redis.del(loopHashKey(id));
|
|
154
|
-
console.log(`[loop-engine] deleted loop id=${id}`);
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Load all active loops from Redis, fire each immediately, then start their timers.
|
|
159
|
-
* Call once after the bot connects and Redis is ready.
|
|
160
|
-
*/
|
|
161
|
-
async start() {
|
|
162
|
-
const ids = await this.redis.smembers(LOOP_LIST_KEY);
|
|
163
|
-
let started = 0;
|
|
164
|
-
for (const id of ids) {
|
|
165
|
-
const rec = await this.loadRecord(id);
|
|
166
|
-
if (!rec)
|
|
167
|
-
continue;
|
|
168
|
-
if (rec.status === "active") {
|
|
169
|
-
await this.fire(id);
|
|
170
|
-
this.startTimer(rec);
|
|
171
|
-
started++;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
console.log(`[loop-engine] started — loaded ${ids.length} loops, started ${started}`);
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Stop all running timers. Does not modify Redis.
|
|
178
|
-
*/
|
|
179
|
-
stop() {
|
|
180
|
-
for (const [id] of this.timers) {
|
|
181
|
-
this.clearTimer(id);
|
|
182
|
-
}
|
|
183
|
-
console.log("[loop-engine] stopped all timers");
|
|
184
|
-
}
|
|
185
|
-
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
186
|
-
startTimer(rec) {
|
|
187
|
-
// Clear any existing timer for this ID first
|
|
188
|
-
this.clearTimer(rec.id);
|
|
189
|
-
const handle = setInterval(async () => {
|
|
190
|
-
await this.fire(rec.id);
|
|
191
|
-
}, rec.intervalMs);
|
|
192
|
-
this.timers.set(rec.id, handle);
|
|
193
|
-
}
|
|
194
|
-
clearTimer(id) {
|
|
195
|
-
const handle = this.timers.get(id);
|
|
196
|
-
if (handle !== undefined) {
|
|
197
|
-
clearInterval(handle);
|
|
198
|
-
this.timers.delete(id);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Execute one loop tick:
|
|
203
|
-
* 1. Reload record (picks up any pause between schedule and fire)
|
|
204
|
-
* 2. Increment fireCount
|
|
205
|
-
* 3. Duplicate check against the input queue
|
|
206
|
-
* 4. Optionally push /compact
|
|
207
|
-
* 5. Push the message
|
|
208
|
-
* 6. Update lastRun
|
|
209
|
-
*/
|
|
210
|
-
async fire(id) {
|
|
211
|
-
const rec = await this.loadRecord(id);
|
|
212
|
-
if (!rec) {
|
|
213
|
-
console.warn(`[loop-engine] fire: record not found for id=${id}`);
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
if (rec.status !== "active") {
|
|
217
|
-
console.log(`[loop-engine] fire: loop id=${id} is paused — skipping`);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
const ns = rec.namespace;
|
|
221
|
-
const inputKey = metaInputKey(ns);
|
|
222
|
-
// Increment fireCount
|
|
223
|
-
rec.fireCount += 1;
|
|
224
|
-
// Duplicate check: skip if the message is already queued
|
|
225
|
-
try {
|
|
226
|
-
const queued = await this.redis.lrange(inputKey, 0, -1);
|
|
227
|
-
const alreadyQueued = queued.some((entry) => {
|
|
228
|
-
try {
|
|
229
|
-
const parsed = JSON.parse(entry);
|
|
230
|
-
return parsed.content === rec.message;
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
if (alreadyQueued) {
|
|
237
|
-
console.log(`[loop-engine] fire: duplicate detected, skipping push (id=${id} ns=${ns})`);
|
|
238
|
-
await this.saveRecord(rec);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
catch (err) {
|
|
243
|
-
console.warn(`[loop-engine] fire: duplicate check failed (id=${id}):`, err.message);
|
|
244
|
-
}
|
|
245
|
-
// Auto-compact every N fires
|
|
246
|
-
if (rec.compactEvery > 0 && rec.fireCount % rec.compactEvery === 0) {
|
|
247
|
-
const compactEntry = JSON.stringify({
|
|
248
|
-
id: randomUUID(),
|
|
249
|
-
content: "/compact",
|
|
250
|
-
timestamp: new Date().toISOString(),
|
|
251
|
-
source: "loop",
|
|
252
|
-
});
|
|
253
|
-
try {
|
|
254
|
-
await this.redis.rpush(inputKey, compactEntry);
|
|
255
|
-
console.log(`[loop-engine] fire: pushed /compact (id=${id} ns=${ns} fireCount=${rec.fireCount})`);
|
|
256
|
-
}
|
|
257
|
-
catch (err) {
|
|
258
|
-
console.warn(`[loop-engine] fire: /compact push failed (id=${id}):`, err.message);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
// Push the scheduled message
|
|
262
|
-
const entry = JSON.stringify({
|
|
263
|
-
id: randomUUID(),
|
|
264
|
-
content: rec.message,
|
|
265
|
-
timestamp: new Date().toISOString(),
|
|
266
|
-
source: "loop",
|
|
267
|
-
});
|
|
268
|
-
try {
|
|
269
|
-
await this.redis.rpush(inputKey, entry);
|
|
270
|
-
rec.lastRun = new Date().toISOString();
|
|
271
|
-
console.log(`[loop-engine] fire: pushed message (id=${id} ns=${ns} fireCount=${rec.fireCount})`);
|
|
272
|
-
// Record which loop fired for this namespace
|
|
273
|
-
await this.redis.set(loopPendingKey(ns), id, "EX", 300);
|
|
274
|
-
}
|
|
275
|
-
catch (err) {
|
|
276
|
-
console.warn(`[loop-engine] fire: push failed (id=${id}):`, err.message);
|
|
277
|
-
}
|
|
278
|
-
await this.saveRecord(rec);
|
|
279
|
-
}
|
|
280
|
-
/** Write a LoopRecord to Redis as a HASH. */
|
|
281
|
-
async saveRecord(rec) {
|
|
282
|
-
const key = loopHashKey(rec.id);
|
|
283
|
-
await this.redis.hset(key, {
|
|
284
|
-
id: rec.id,
|
|
285
|
-
namespace: rec.namespace,
|
|
286
|
-
message: rec.message,
|
|
287
|
-
interval_ms: String(rec.intervalMs),
|
|
288
|
-
fire_count: String(rec.fireCount),
|
|
289
|
-
compact_every: String(rec.compactEvery),
|
|
290
|
-
status: rec.status,
|
|
291
|
-
last_run: rec.lastRun,
|
|
292
|
-
created_at: rec.createdAt,
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
/** Load a LoopRecord from Redis. Returns null if the key doesn't exist. */
|
|
296
|
-
async loadRecord(id) {
|
|
297
|
-
const data = await this.redis.hgetall(loopHashKey(id));
|
|
298
|
-
if (!data || !data.id)
|
|
299
|
-
return null;
|
|
300
|
-
return {
|
|
301
|
-
id: data.id,
|
|
302
|
-
namespace: data.namespace,
|
|
303
|
-
message: data.message,
|
|
304
|
-
intervalMs: parseInt(data.interval_ms ?? "0", 10),
|
|
305
|
-
fireCount: parseInt(data.fire_count ?? "0", 10),
|
|
306
|
-
compactEvery: parseInt(data.compact_every ?? "10", 10),
|
|
307
|
-
status: (data.status === "paused" ? "paused" : "active"),
|
|
308
|
-
lastRun: data.last_run ?? "",
|
|
309
|
-
createdAt: data.created_at ?? "",
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
}
|