cc2im 0.2.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/LICENSE +21 -0
- package/README.en.md +120 -0
- package/README.md +120 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +314 -0
- package/dist/hub/agent-manager.d.ts +63 -0
- package/dist/hub/agent-manager.js +311 -0
- package/dist/hub/hub-context.d.ts +27 -0
- package/dist/hub/hub-context.js +57 -0
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +234 -0
- package/dist/hub/launchd.d.ts +7 -0
- package/dist/hub/launchd.js +151 -0
- package/dist/hub/plugin-manager.d.ts +7 -0
- package/dist/hub/plugin-manager.js +29 -0
- package/dist/hub/router.d.ts +21 -0
- package/dist/hub/router.js +35 -0
- package/dist/hub/socket-server.d.ts +23 -0
- package/dist/hub/socket-server.js +191 -0
- package/dist/plugins/channel-manager/index.d.ts +10 -0
- package/dist/plugins/channel-manager/index.js +387 -0
- package/dist/plugins/cron-scheduler/db.d.ts +12 -0
- package/dist/plugins/cron-scheduler/db.js +160 -0
- package/dist/plugins/cron-scheduler/index.d.ts +4 -0
- package/dist/plugins/cron-scheduler/index.js +22 -0
- package/dist/plugins/cron-scheduler/scheduler.d.ts +20 -0
- package/dist/plugins/cron-scheduler/scheduler.js +129 -0
- package/dist/plugins/persistence/db.d.ts +24 -0
- package/dist/plugins/persistence/db.js +121 -0
- package/dist/plugins/persistence/index.d.ts +2 -0
- package/dist/plugins/persistence/index.js +93 -0
- package/dist/plugins/web-monitor/api-routes.d.ts +33 -0
- package/dist/plugins/web-monitor/api-routes.js +474 -0
- package/dist/plugins/web-monitor/index.d.ts +2 -0
- package/dist/plugins/web-monitor/index.js +21 -0
- package/dist/plugins/web-monitor/log-tailer.d.ts +13 -0
- package/dist/plugins/web-monitor/log-tailer.js +74 -0
- package/dist/plugins/web-monitor/monitor-client.d.ts +17 -0
- package/dist/plugins/web-monitor/monitor-client.js +68 -0
- package/dist/plugins/web-monitor/server.d.ts +14 -0
- package/dist/plugins/web-monitor/server.js +205 -0
- package/dist/plugins/web-monitor/stats-reader.d.ts +22 -0
- package/dist/plugins/web-monitor/stats-reader.js +17 -0
- package/dist/plugins/web-monitor/token-stats.d.ts +19 -0
- package/dist/plugins/web-monitor/token-stats.js +86 -0
- package/dist/plugins/web-monitor/usage-stats.d.ts +13 -0
- package/dist/plugins/web-monitor/usage-stats.js +56 -0
- package/dist/plugins/weixin/chunker.d.ts +16 -0
- package/dist/plugins/weixin/chunker.js +142 -0
- package/dist/plugins/weixin/connection.d.ts +46 -0
- package/dist/plugins/weixin/connection.js +270 -0
- package/dist/plugins/weixin/index.d.ts +10 -0
- package/dist/plugins/weixin/index.js +198 -0
- package/dist/plugins/weixin/media-upload.d.ts +22 -0
- package/dist/plugins/weixin/media-upload.js +134 -0
- package/dist/plugins/weixin/media.d.ts +6 -0
- package/dist/plugins/weixin/media.js +83 -0
- package/dist/plugins/weixin/permission.d.ts +35 -0
- package/dist/plugins/weixin/permission.js +96 -0
- package/dist/plugins/weixin/qr-login.d.ts +23 -0
- package/dist/plugins/weixin/qr-login.js +77 -0
- package/dist/plugins/weixin/weixin-channel.d.ts +33 -0
- package/dist/plugins/weixin/weixin-channel.js +123 -0
- package/dist/shared/channel-config.d.ts +8 -0
- package/dist/shared/channel-config.js +14 -0
- package/dist/shared/channel.d.ts +37 -0
- package/dist/shared/channel.js +8 -0
- package/dist/shared/mcp-config.d.ts +5 -0
- package/dist/shared/mcp-config.js +44 -0
- package/dist/shared/plugin.d.ts +32 -0
- package/dist/shared/plugin.js +1 -0
- package/dist/shared/socket.d.ts +5 -0
- package/dist/shared/socket.js +31 -0
- package/dist/shared/types.d.ts +136 -0
- package/dist/shared/types.js +1 -0
- package/dist/spoke/channel-server.d.ts +48 -0
- package/dist/spoke/channel-server.js +383 -0
- package/dist/spoke/index.d.ts +13 -0
- package/dist/spoke/index.js +115 -0
- package/dist/spoke/permission.d.ts +28 -0
- package/dist/spoke/permission.js +142 -0
- package/dist/spoke/socket-client.d.ts +22 -0
- package/dist/spoke/socket-client.js +83 -0
- package/dist/web-frontend/assets/index-CU9vxw8F.js +9 -0
- package/dist/web-frontend/index.html +82 -0
- package/package.json +54 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
2
|
+
import { getEnabledJobs, updateJob, recordRun, cleanupRuns } from './db.js';
|
|
3
|
+
const TICK_INTERVAL_MS = 10_000; // check every 10s
|
|
4
|
+
const CLEANUP_INTERVAL_MS = 60 * 60_000; // cleanup old runs every hour
|
|
5
|
+
export class CronScheduler {
|
|
6
|
+
ctx;
|
|
7
|
+
tickTimer = null;
|
|
8
|
+
cleanupTimer = null;
|
|
9
|
+
constructor(ctx) {
|
|
10
|
+
this.ctx = ctx;
|
|
11
|
+
}
|
|
12
|
+
start() {
|
|
13
|
+
this.recalcAll();
|
|
14
|
+
this.tickTimer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
|
|
15
|
+
this.cleanupTimer = setInterval(() => {
|
|
16
|
+
const deleted = cleanupRuns();
|
|
17
|
+
if (deleted > 0) {
|
|
18
|
+
console.log(`[cron] cleaned up ${deleted} old run records`);
|
|
19
|
+
}
|
|
20
|
+
}, CLEANUP_INTERVAL_MS);
|
|
21
|
+
console.log('[cron] scheduler started');
|
|
22
|
+
}
|
|
23
|
+
stop() {
|
|
24
|
+
if (this.tickTimer) {
|
|
25
|
+
clearInterval(this.tickTimer);
|
|
26
|
+
this.tickTimer = null;
|
|
27
|
+
}
|
|
28
|
+
if (this.cleanupTimer) {
|
|
29
|
+
clearInterval(this.cleanupTimer);
|
|
30
|
+
this.cleanupTimer = null;
|
|
31
|
+
}
|
|
32
|
+
console.log('[cron] scheduler stopped');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Calculate next run time for a given schedule.
|
|
36
|
+
* Public so hub management handler can use it when creating/updating jobs.
|
|
37
|
+
*/
|
|
38
|
+
calcNextRun(type, value, timezone) {
|
|
39
|
+
switch (type) {
|
|
40
|
+
case 'cron': {
|
|
41
|
+
try {
|
|
42
|
+
const job = new Cron(value, { timezone });
|
|
43
|
+
const next = job.nextRun();
|
|
44
|
+
return next ? next.toISOString() : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
case 'once': {
|
|
51
|
+
const d = new Date(value);
|
|
52
|
+
return d.getTime() > Date.now() ? d.toISOString() : null;
|
|
53
|
+
}
|
|
54
|
+
case 'interval': {
|
|
55
|
+
const ms = parseInt(value, 10);
|
|
56
|
+
if (isNaN(ms) || ms <= 0)
|
|
57
|
+
return null;
|
|
58
|
+
return new Date(Date.now() + ms).toISOString();
|
|
59
|
+
}
|
|
60
|
+
default:
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Recalculate nextRun for all enabled jobs. Called on startup.
|
|
66
|
+
*/
|
|
67
|
+
recalcAll() {
|
|
68
|
+
const jobs = getEnabledJobs();
|
|
69
|
+
let updated = 0;
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
for (const job of jobs) {
|
|
72
|
+
// Interval jobs: keep existing nextRun if still in the future
|
|
73
|
+
if (job.scheduleType === 'interval' && job.nextRun && job.nextRun > now)
|
|
74
|
+
continue;
|
|
75
|
+
const nextRun = this.calcNextRun(job.scheduleType, job.scheduleValue, job.timezone);
|
|
76
|
+
if (nextRun !== job.nextRun) {
|
|
77
|
+
updateJob(job.id, { nextRun });
|
|
78
|
+
updated++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (updated > 0) {
|
|
82
|
+
console.log(`[cron] recalculated nextRun for ${updated} jobs`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// --- private ---
|
|
86
|
+
tick() {
|
|
87
|
+
const now = new Date().toISOString();
|
|
88
|
+
const jobs = getEnabledJobs();
|
|
89
|
+
for (const job of jobs) {
|
|
90
|
+
if (!job.nextRun)
|
|
91
|
+
continue;
|
|
92
|
+
if (job.nextRun > now)
|
|
93
|
+
continue; // ISO string comparison works for future dates
|
|
94
|
+
this.fire(job);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
fire(job) {
|
|
98
|
+
const msg = {
|
|
99
|
+
type: 'message',
|
|
100
|
+
userId: `cron:${job.name}`,
|
|
101
|
+
text: job.message,
|
|
102
|
+
msgType: 'text',
|
|
103
|
+
timestamp: new Date().toISOString(),
|
|
104
|
+
};
|
|
105
|
+
const delivered = this.ctx.deliverToAgent(job.agentId, msg);
|
|
106
|
+
const status = delivered ? 'delivered' : 'queued';
|
|
107
|
+
recordRun(job.id, status, delivered ? undefined : 'agent offline, queued for replay');
|
|
108
|
+
console.log(`[cron] fired job "${job.name}" → ${job.agentId} (${status})`);
|
|
109
|
+
// Recalculate next run
|
|
110
|
+
let nextRun;
|
|
111
|
+
if (job.scheduleType === 'once') {
|
|
112
|
+
// One-shot job: disable after firing
|
|
113
|
+
nextRun = null;
|
|
114
|
+
updateJob(job.id, { nextRun, enabled: false });
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
nextRun = this.calcNextRun(job.scheduleType, job.scheduleValue, job.timezone);
|
|
118
|
+
updateJob(job.id, { nextRun });
|
|
119
|
+
}
|
|
120
|
+
// Broadcast monitor event
|
|
121
|
+
this.ctx.broadcastMonitor({
|
|
122
|
+
kind: 'cron_fired',
|
|
123
|
+
agentId: job.agentId,
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
userId: `cron:${job.name}`,
|
|
126
|
+
text: job.message,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
export declare function openDb(): Database.Database;
|
|
3
|
+
export declare function closeDb(): void;
|
|
4
|
+
export declare function storeInbound(agentId: string, userId: string, text: string, msgType: string, mediaPath?: string, channelId?: string): string;
|
|
5
|
+
export declare function storeOutbound(agentId: string, userId: string, text: string, channelId?: string): string;
|
|
6
|
+
export declare function markDelivered(messageId: string): void;
|
|
7
|
+
export declare function getPending(agentId: string): Array<{
|
|
8
|
+
id: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
text: string;
|
|
11
|
+
msgType: string;
|
|
12
|
+
mediaPath: string | null;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function cleanup(): {
|
|
16
|
+
expired: number;
|
|
17
|
+
deleted: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function getNicknames(): Array<{
|
|
20
|
+
channelId: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
nickname: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function setNickname(channelId: string, userId: string, nickname: string): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { SOCKET_DIR } from '../../shared/socket.js';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
const DB_PATH = join(SOCKET_DIR, 'cc2im.db');
|
|
6
|
+
const DELIVERY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const HISTORY_TTL_DAYS = 30;
|
|
8
|
+
const MAX_ROWS = 100_000;
|
|
9
|
+
let db = null;
|
|
10
|
+
export function openDb() {
|
|
11
|
+
if (db)
|
|
12
|
+
return db;
|
|
13
|
+
db = new Database(DB_PATH);
|
|
14
|
+
db.pragma('journal_mode = WAL');
|
|
15
|
+
db.pragma('wal_autocheckpoint = 1000');
|
|
16
|
+
db.pragma('busy_timeout = 5000');
|
|
17
|
+
db.exec(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
|
|
21
|
+
agent_id TEXT NOT NULL,
|
|
22
|
+
user_id TEXT NOT NULL,
|
|
23
|
+
text TEXT NOT NULL,
|
|
24
|
+
msg_type TEXT DEFAULT 'text',
|
|
25
|
+
media_path TEXT,
|
|
26
|
+
created_at TEXT NOT NULL,
|
|
27
|
+
delivered_at TEXT,
|
|
28
|
+
expired INTEGER DEFAULT 0
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_pending
|
|
31
|
+
ON messages(agent_id, delivered_at) WHERE delivered_at IS NULL AND expired = 0;
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_created
|
|
33
|
+
ON messages(created_at);
|
|
34
|
+
`);
|
|
35
|
+
// Idempotent column add (SQLite doesn't support IF NOT EXISTS for columns)
|
|
36
|
+
try {
|
|
37
|
+
db.exec(`ALTER TABLE messages ADD COLUMN channel_id TEXT`);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// column already exists, ignore
|
|
41
|
+
}
|
|
42
|
+
db.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS nicknames (
|
|
44
|
+
channel_id TEXT NOT NULL,
|
|
45
|
+
user_id TEXT NOT NULL,
|
|
46
|
+
nickname TEXT NOT NULL,
|
|
47
|
+
updated_at TEXT NOT NULL,
|
|
48
|
+
PRIMARY KEY (channel_id, user_id)
|
|
49
|
+
);
|
|
50
|
+
`);
|
|
51
|
+
return db;
|
|
52
|
+
}
|
|
53
|
+
export function closeDb() {
|
|
54
|
+
db?.close();
|
|
55
|
+
db = null;
|
|
56
|
+
}
|
|
57
|
+
export function storeInbound(agentId, userId, text, msgType, mediaPath, channelId) {
|
|
58
|
+
const id = randomUUID();
|
|
59
|
+
openDb().prepare(`
|
|
60
|
+
INSERT INTO messages (id, direction, agent_id, user_id, text, msg_type, media_path, channel_id, created_at)
|
|
61
|
+
VALUES (?, 'inbound', ?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
`).run(id, agentId, userId, text, msgType, mediaPath ?? null, channelId ?? null, new Date().toISOString());
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
export function storeOutbound(agentId, userId, text, channelId) {
|
|
66
|
+
const id = randomUUID();
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
openDb().prepare(`
|
|
69
|
+
INSERT INTO messages (id, direction, agent_id, user_id, text, channel_id, created_at, delivered_at)
|
|
70
|
+
VALUES (?, 'outbound', ?, ?, ?, ?, ?, ?)
|
|
71
|
+
`).run(id, agentId, userId, text, channelId ?? null, now, now);
|
|
72
|
+
return id;
|
|
73
|
+
}
|
|
74
|
+
export function markDelivered(messageId) {
|
|
75
|
+
openDb().prepare(`UPDATE messages SET delivered_at = ? WHERE id = ?`)
|
|
76
|
+
.run(new Date().toISOString(), messageId);
|
|
77
|
+
}
|
|
78
|
+
export function getPending(agentId) {
|
|
79
|
+
return openDb().prepare(`
|
|
80
|
+
SELECT id, user_id as userId, text, msg_type as msgType, media_path as mediaPath, created_at as createdAt
|
|
81
|
+
FROM messages
|
|
82
|
+
WHERE agent_id = ? AND direction = 'inbound' AND delivered_at IS NULL AND expired = 0
|
|
83
|
+
ORDER BY created_at ASC
|
|
84
|
+
`).all(agentId);
|
|
85
|
+
}
|
|
86
|
+
export function cleanup() {
|
|
87
|
+
const d = openDb();
|
|
88
|
+
const cutoff = new Date(Date.now() - DELIVERY_TTL_MS).toISOString();
|
|
89
|
+
const expired = d.prepare(`
|
|
90
|
+
UPDATE messages SET expired = 1
|
|
91
|
+
WHERE delivered_at IS NULL AND expired = 0 AND created_at < ?
|
|
92
|
+
`).run(cutoff).changes;
|
|
93
|
+
const histCutoff = new Date();
|
|
94
|
+
histCutoff.setDate(histCutoff.getDate() - HISTORY_TTL_DAYS);
|
|
95
|
+
const deleted = d.prepare(`DELETE FROM messages WHERE created_at < ?`)
|
|
96
|
+
.run(histCutoff.toISOString()).changes;
|
|
97
|
+
const count = d.prepare('SELECT COUNT(*) as c FROM messages').get().c;
|
|
98
|
+
let extraDeleted = 0;
|
|
99
|
+
if (count > MAX_ROWS) {
|
|
100
|
+
extraDeleted = d.prepare(`
|
|
101
|
+
DELETE FROM messages WHERE id IN (
|
|
102
|
+
SELECT id FROM messages ORDER BY created_at ASC LIMIT ?
|
|
103
|
+
)
|
|
104
|
+
`).run(count - MAX_ROWS).changes;
|
|
105
|
+
}
|
|
106
|
+
if (expired > 0 || deleted > 0 || extraDeleted > 0) {
|
|
107
|
+
d.exec('VACUUM');
|
|
108
|
+
}
|
|
109
|
+
return { expired, deleted: deleted + extraDeleted };
|
|
110
|
+
}
|
|
111
|
+
export function getNicknames() {
|
|
112
|
+
return openDb().prepare(`SELECT channel_id AS channelId, user_id AS userId, nickname FROM nicknames`).all();
|
|
113
|
+
}
|
|
114
|
+
export function setNickname(channelId, userId, nickname) {
|
|
115
|
+
const now = new Date().toISOString();
|
|
116
|
+
openDb().prepare(`
|
|
117
|
+
INSERT INTO nicknames (channel_id, user_id, nickname, updated_at)
|
|
118
|
+
VALUES (?, ?, ?, ?)
|
|
119
|
+
ON CONFLICT(channel_id, user_id) DO UPDATE SET nickname = excluded.nickname, updated_at = excluded.updated_at
|
|
120
|
+
`).run(channelId, userId, nickname, now);
|
|
121
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { openDb, closeDb, storeInbound, storeOutbound, markDelivered, getPending, cleanup } from './db.js';
|
|
2
|
+
const REPLAY_DELAY_MS = 500;
|
|
3
|
+
const CLEANUP_INTERVAL = 60 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Replay pending messages for an agent. Safe to call multiple times —
|
|
6
|
+
* only replays messages not yet marked as delivered.
|
|
7
|
+
*/
|
|
8
|
+
async function doReplay(ctx, agentId) {
|
|
9
|
+
const pending = getPending(agentId);
|
|
10
|
+
if (pending.length === 0)
|
|
11
|
+
return 0;
|
|
12
|
+
console.log(`[persistence] Replaying ${pending.length} queued message(s) to "${agentId}"`);
|
|
13
|
+
ctx.deliverToAgent(agentId, {
|
|
14
|
+
type: 'message',
|
|
15
|
+
userId: 'system',
|
|
16
|
+
text: `[系统] 你离线期间收到 ${pending.length} 条消息,正在回放:`,
|
|
17
|
+
msgType: 'text',
|
|
18
|
+
timestamp: new Date().toISOString(),
|
|
19
|
+
});
|
|
20
|
+
for (const msg of pending) {
|
|
21
|
+
await new Promise(r => setTimeout(r, REPLAY_DELAY_MS));
|
|
22
|
+
const ok = ctx.deliverToAgent(agentId, {
|
|
23
|
+
type: 'message',
|
|
24
|
+
userId: msg.userId,
|
|
25
|
+
text: msg.text,
|
|
26
|
+
msgType: msg.msgType,
|
|
27
|
+
mediaPath: msg.mediaPath ?? undefined,
|
|
28
|
+
timestamp: msg.createdAt,
|
|
29
|
+
});
|
|
30
|
+
if (ok)
|
|
31
|
+
markDelivered(msg.id);
|
|
32
|
+
}
|
|
33
|
+
console.log(`[persistence] Replay complete for "${agentId}"`);
|
|
34
|
+
return pending.length;
|
|
35
|
+
}
|
|
36
|
+
export function createPersistencePlugin() {
|
|
37
|
+
let cleanupTimer;
|
|
38
|
+
const pendingDeliveries = new Map();
|
|
39
|
+
return {
|
|
40
|
+
name: 'persistence',
|
|
41
|
+
init(ctx) {
|
|
42
|
+
openDb();
|
|
43
|
+
console.log('[persistence] SQLite opened');
|
|
44
|
+
// Store inbound messages before delivery (skip system messages)
|
|
45
|
+
ctx.on('deliver:before', (agentId, msg, deliveryId) => {
|
|
46
|
+
if (msg.type !== 'message')
|
|
47
|
+
return;
|
|
48
|
+
if (msg.userId === 'system')
|
|
49
|
+
return;
|
|
50
|
+
const m = msg;
|
|
51
|
+
const dbId = storeInbound(agentId, m.userId, m.text, m.msgType, m.mediaPath, m.channelId);
|
|
52
|
+
pendingDeliveries.set(deliveryId, { agentId, messageId: dbId });
|
|
53
|
+
});
|
|
54
|
+
// Mark delivered after successful socket send
|
|
55
|
+
ctx.on('deliver:after', (deliveryId, delivered) => {
|
|
56
|
+
const entry = pendingDeliveries.get(deliveryId);
|
|
57
|
+
if (!entry)
|
|
58
|
+
return;
|
|
59
|
+
pendingDeliveries.delete(deliveryId);
|
|
60
|
+
if (delivered) {
|
|
61
|
+
markDelivered(entry.messageId);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
// Store outbound replies for history
|
|
65
|
+
ctx.on('spoke:message', (_agentId, msg) => {
|
|
66
|
+
if (msg.type === 'reply') {
|
|
67
|
+
storeOutbound(msg.agentId, msg.userId, msg.text);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
// Primary replay: when agent comes online
|
|
71
|
+
// Works when WeChat context tokens are restored from cache
|
|
72
|
+
ctx.on('agent:online', (agentId) => {
|
|
73
|
+
doReplay(ctx, agentId).catch((err) => {
|
|
74
|
+
console.error(`[persistence] Replay failed for "${agentId}":`, err instanceof Error ? err.message : String(err));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// Periodic cleanup
|
|
78
|
+
cleanupTimer = setInterval(() => {
|
|
79
|
+
const { expired, deleted } = cleanup();
|
|
80
|
+
if (expired > 0 || deleted > 0) {
|
|
81
|
+
console.log(`[persistence] Cleanup: ${expired} expired, ${deleted} deleted`);
|
|
82
|
+
}
|
|
83
|
+
}, CLEANUP_INTERVAL);
|
|
84
|
+
cleanup();
|
|
85
|
+
},
|
|
86
|
+
destroy() {
|
|
87
|
+
if (cleanupTimer)
|
|
88
|
+
clearInterval(cleanupTimer);
|
|
89
|
+
closeDb();
|
|
90
|
+
console.log('[persistence] SQLite closed');
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc2im Web — REST API route handler
|
|
3
|
+
*
|
|
4
|
+
* All /api/* routes for the dashboard. Extracted from server.ts
|
|
5
|
+
* so each concern lives in its own file.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
8
|
+
import type { HubEventData } from '../../shared/types.js';
|
|
9
|
+
import type { HubContext } from '../../shared/plugin.js';
|
|
10
|
+
export interface ApiHandlerDeps {
|
|
11
|
+
agentsJsonPath: string;
|
|
12
|
+
mediaDir: string;
|
|
13
|
+
messageHistory: Array<{
|
|
14
|
+
event: HubEventData;
|
|
15
|
+
receivedAt: string;
|
|
16
|
+
}>;
|
|
17
|
+
monitor: {
|
|
18
|
+
isConnected(): boolean;
|
|
19
|
+
};
|
|
20
|
+
wsClients: {
|
|
21
|
+
size: number;
|
|
22
|
+
};
|
|
23
|
+
ctx?: HubContext;
|
|
24
|
+
activeQrPolls: Map<string, ReturnType<typeof setInterval>>;
|
|
25
|
+
broadcastWs: (msg: any) => void;
|
|
26
|
+
frontendDir?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Create the HTTP request handler used by the web dashboard.
|
|
30
|
+
* Extracted so integration tests can exercise the real routing logic
|
|
31
|
+
* with mock dependencies (no SQLite, no hub socket, no filesystem).
|
|
32
|
+
*/
|
|
33
|
+
export declare function createApiHandler(deps: ApiHandlerDeps): (req: IncomingMessage, res: ServerResponse) => void;
|