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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +120 -0
  3. package/README.md +120 -0
  4. package/dist/cli.d.ts +16 -0
  5. package/dist/cli.js +314 -0
  6. package/dist/hub/agent-manager.d.ts +63 -0
  7. package/dist/hub/agent-manager.js +311 -0
  8. package/dist/hub/hub-context.d.ts +27 -0
  9. package/dist/hub/hub-context.js +57 -0
  10. package/dist/hub/index.d.ts +6 -0
  11. package/dist/hub/index.js +234 -0
  12. package/dist/hub/launchd.d.ts +7 -0
  13. package/dist/hub/launchd.js +151 -0
  14. package/dist/hub/plugin-manager.d.ts +7 -0
  15. package/dist/hub/plugin-manager.js +29 -0
  16. package/dist/hub/router.d.ts +21 -0
  17. package/dist/hub/router.js +35 -0
  18. package/dist/hub/socket-server.d.ts +23 -0
  19. package/dist/hub/socket-server.js +191 -0
  20. package/dist/plugins/channel-manager/index.d.ts +10 -0
  21. package/dist/plugins/channel-manager/index.js +387 -0
  22. package/dist/plugins/cron-scheduler/db.d.ts +12 -0
  23. package/dist/plugins/cron-scheduler/db.js +160 -0
  24. package/dist/plugins/cron-scheduler/index.d.ts +4 -0
  25. package/dist/plugins/cron-scheduler/index.js +22 -0
  26. package/dist/plugins/cron-scheduler/scheduler.d.ts +20 -0
  27. package/dist/plugins/cron-scheduler/scheduler.js +129 -0
  28. package/dist/plugins/persistence/db.d.ts +24 -0
  29. package/dist/plugins/persistence/db.js +121 -0
  30. package/dist/plugins/persistence/index.d.ts +2 -0
  31. package/dist/plugins/persistence/index.js +93 -0
  32. package/dist/plugins/web-monitor/api-routes.d.ts +33 -0
  33. package/dist/plugins/web-monitor/api-routes.js +474 -0
  34. package/dist/plugins/web-monitor/index.d.ts +2 -0
  35. package/dist/plugins/web-monitor/index.js +21 -0
  36. package/dist/plugins/web-monitor/log-tailer.d.ts +13 -0
  37. package/dist/plugins/web-monitor/log-tailer.js +74 -0
  38. package/dist/plugins/web-monitor/monitor-client.d.ts +17 -0
  39. package/dist/plugins/web-monitor/monitor-client.js +68 -0
  40. package/dist/plugins/web-monitor/server.d.ts +14 -0
  41. package/dist/plugins/web-monitor/server.js +205 -0
  42. package/dist/plugins/web-monitor/stats-reader.d.ts +22 -0
  43. package/dist/plugins/web-monitor/stats-reader.js +17 -0
  44. package/dist/plugins/web-monitor/token-stats.d.ts +19 -0
  45. package/dist/plugins/web-monitor/token-stats.js +86 -0
  46. package/dist/plugins/web-monitor/usage-stats.d.ts +13 -0
  47. package/dist/plugins/web-monitor/usage-stats.js +56 -0
  48. package/dist/plugins/weixin/chunker.d.ts +16 -0
  49. package/dist/plugins/weixin/chunker.js +142 -0
  50. package/dist/plugins/weixin/connection.d.ts +46 -0
  51. package/dist/plugins/weixin/connection.js +270 -0
  52. package/dist/plugins/weixin/index.d.ts +10 -0
  53. package/dist/plugins/weixin/index.js +198 -0
  54. package/dist/plugins/weixin/media-upload.d.ts +22 -0
  55. package/dist/plugins/weixin/media-upload.js +134 -0
  56. package/dist/plugins/weixin/media.d.ts +6 -0
  57. package/dist/plugins/weixin/media.js +83 -0
  58. package/dist/plugins/weixin/permission.d.ts +35 -0
  59. package/dist/plugins/weixin/permission.js +96 -0
  60. package/dist/plugins/weixin/qr-login.d.ts +23 -0
  61. package/dist/plugins/weixin/qr-login.js +77 -0
  62. package/dist/plugins/weixin/weixin-channel.d.ts +33 -0
  63. package/dist/plugins/weixin/weixin-channel.js +123 -0
  64. package/dist/shared/channel-config.d.ts +8 -0
  65. package/dist/shared/channel-config.js +14 -0
  66. package/dist/shared/channel.d.ts +37 -0
  67. package/dist/shared/channel.js +8 -0
  68. package/dist/shared/mcp-config.d.ts +5 -0
  69. package/dist/shared/mcp-config.js +44 -0
  70. package/dist/shared/plugin.d.ts +32 -0
  71. package/dist/shared/plugin.js +1 -0
  72. package/dist/shared/socket.d.ts +5 -0
  73. package/dist/shared/socket.js +31 -0
  74. package/dist/shared/types.d.ts +136 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/spoke/channel-server.d.ts +48 -0
  77. package/dist/spoke/channel-server.js +383 -0
  78. package/dist/spoke/index.d.ts +13 -0
  79. package/dist/spoke/index.js +115 -0
  80. package/dist/spoke/permission.d.ts +28 -0
  81. package/dist/spoke/permission.js +142 -0
  82. package/dist/spoke/socket-client.d.ts +22 -0
  83. package/dist/spoke/socket-client.js +83 -0
  84. package/dist/web-frontend/assets/index-CU9vxw8F.js +9 -0
  85. package/dist/web-frontend/index.html +82 -0
  86. 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,2 @@
1
+ import type { Cc2imPlugin } from '../../shared/plugin.js';
2
+ export declare function createPersistencePlugin(): Cc2imPlugin;
@@ -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;