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,83 @@
1
+ /**
2
+ * 媒体下载 + AES 解密
3
+ * 从 cc2wx.ts:31-106 搬迁,逻辑不变
4
+ */
5
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { createDecipheriv } from 'node:crypto';
8
+ import { SOCKET_DIR } from '../../shared/socket.js';
9
+ const MEDIA_DIR = join(SOCKET_DIR, 'media');
10
+ const CDN_DOWNLOAD_URL = 'https://novac2c.cdn.weixin.qq.com/c2c/download';
11
+ const MEDIA_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
12
+ export function cleanupMedia() {
13
+ try {
14
+ if (!existsSync(MEDIA_DIR))
15
+ return;
16
+ const now = Date.now();
17
+ let cleaned = 0;
18
+ for (const file of readdirSync(MEDIA_DIR)) {
19
+ const filepath = join(MEDIA_DIR, file);
20
+ const age = now - statSync(filepath).mtimeMs;
21
+ if (age > MEDIA_MAX_AGE_MS) {
22
+ unlinkSync(filepath);
23
+ cleaned++;
24
+ }
25
+ }
26
+ if (cleaned > 0)
27
+ console.log(`[hub] Cleaned ${cleaned} expired media files`);
28
+ }
29
+ catch (err) {
30
+ console.error(`[hub] Media cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
31
+ }
32
+ }
33
+ function parseAesKey(aesKeyB64) {
34
+ const hexStr = Buffer.from(aesKeyB64, 'base64').toString('utf8');
35
+ return Buffer.from(hexStr, 'hex');
36
+ }
37
+ function detectExt(buf) {
38
+ if (buf[0] === 0xff && buf[1] === 0xd8)
39
+ return 'jpg';
40
+ if (buf[0] === 0x89 && buf[1] === 0x50)
41
+ return 'png';
42
+ if (buf[0] === 0x47 && buf[1] === 0x49)
43
+ return 'gif';
44
+ if (buf[0] === 0x52 && buf[1] === 0x49)
45
+ return 'webp';
46
+ // MP4/MOV: ftyp box at offset 4
47
+ if (buf.length >= 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70)
48
+ return 'mp4';
49
+ if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46)
50
+ return 'pdf';
51
+ return 'bin';
52
+ }
53
+ export async function downloadMedia(item) {
54
+ const mediaItem = item?.image_item || item?.video_item || item?.file_item || item?.voice_item;
55
+ if (!mediaItem?.media?.encrypt_query_param)
56
+ return null;
57
+ const { encrypt_query_param, aes_key } = mediaItem.media;
58
+ try {
59
+ const cdnUrl = `${CDN_DOWNLOAD_URL}?encrypted_query_param=${encodeURIComponent(encrypt_query_param)}`;
60
+ const resp = await fetch(cdnUrl, { method: 'GET' });
61
+ if (!resp.ok) {
62
+ console.log(`[hub] CDN download failed: HTTP ${resp.status}`);
63
+ return null;
64
+ }
65
+ let buffer = Buffer.from(await resp.arrayBuffer());
66
+ if (aes_key) {
67
+ const key = parseAesKey(aes_key);
68
+ const decipher = createDecipheriv('aes-128-ecb', key, Buffer.alloc(0));
69
+ buffer = Buffer.concat([decipher.update(buffer), decipher.final()]);
70
+ }
71
+ mkdirSync(MEDIA_DIR, { recursive: true });
72
+ const ext = detectExt(buffer);
73
+ const filename = `${Date.now()}.${ext}`;
74
+ const filepath = join(MEDIA_DIR, filename);
75
+ writeFileSync(filepath, buffer);
76
+ console.log(`[hub] Media saved: ${filepath} (${buffer.length} bytes, ${ext})`);
77
+ return filepath;
78
+ }
79
+ catch (err) {
80
+ console.error(`[hub] Media download failed: ${err instanceof Error ? err.message : String(err)}`);
81
+ return null;
82
+ }
83
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Permission state management for channel-based approval flow.
3
+ * Tracks pending permission requests and matches incoming messages as verdicts.
4
+ *
5
+ * Channel-agnostic: accepts a sendFn callback for sending prompts,
6
+ * and a UserRef map for resolving target users.
7
+ */
8
+ import type { HubContext } from '../../shared/plugin.js';
9
+ /** Identifies a user on a specific channel */
10
+ export interface UserRef {
11
+ userId: string;
12
+ channelId: string;
13
+ }
14
+ export declare class PermissionManager {
15
+ private pending;
16
+ /** Handle a permission_request from a spoke */
17
+ handleRequest(agentId: string, msg: {
18
+ requestId: string;
19
+ toolName: string;
20
+ description: string;
21
+ inputPreview: string;
22
+ userId?: string;
23
+ }, ctx: HubContext, sendFn: (userId: string, text: string) => Promise<void>, lastUserByAgent: Map<string, UserRef>, lastGlobalUser: UserRef | null): Promise<void>;
24
+ /** Try to match an incoming WeChat message as a permission verdict. Returns true if handled. */
25
+ tryHandleVerdict(msg: {
26
+ type: string;
27
+ text?: string;
28
+ userId: string;
29
+ channelId?: string;
30
+ }, ctx: HubContext): boolean;
31
+ /** Handle permission_timeout from spoke */
32
+ handleTimeout(requestId: string): void;
33
+ /** Clean up stale permissions (called periodically) */
34
+ cleanup(): void;
35
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Permission state management for channel-based approval flow.
3
+ * Tracks pending permission requests and matches incoming messages as verdicts.
4
+ *
5
+ * Channel-agnostic: accepts a sendFn callback for sending prompts,
6
+ * and a UserRef map for resolving target users.
7
+ */
8
+ const PERMISSION_TTL_MS = 6 * 60 * 1000; // 6 minutes (spoke timeout is 5 min)
9
+ const SIMPLE_RE = /^\s*(y|yes|ok|好|批准|always|始终|总是|n|no|不|拒绝)\s*$/i;
10
+ export class PermissionManager {
11
+ pending = [];
12
+ /** Handle a permission_request from a spoke */
13
+ async handleRequest(agentId, msg, ctx, sendFn, lastUserByAgent, lastGlobalUser) {
14
+ const ref = msg.userId
15
+ ? { userId: msg.userId, channelId: lastUserByAgent.get(agentId)?.channelId || lastGlobalUser?.channelId || '' }
16
+ : lastUserByAgent.get(agentId) || lastGlobalUser;
17
+ const targetUserId = ref?.userId;
18
+ if (!targetUserId) {
19
+ console.log(`[hub] No user to forward permission request to`);
20
+ return;
21
+ }
22
+ this.pending.push({
23
+ requestId: msg.requestId,
24
+ agentId,
25
+ toolName: msg.toolName,
26
+ userId: targetUserId,
27
+ channelId: ref?.channelId || '',
28
+ createdAt: Date.now(),
29
+ });
30
+ let preview = msg.inputPreview;
31
+ try {
32
+ const parsed = JSON.parse(preview);
33
+ if (parsed.command)
34
+ preview = parsed.command;
35
+ else if (parsed.file_path)
36
+ preview = parsed.file_path;
37
+ else
38
+ preview = JSON.stringify(parsed, null, 2);
39
+ }
40
+ catch { /* keep original */ }
41
+ const prompt = [
42
+ `🔐 [${agentId}] Claude 请求权限`,
43
+ `工具: ${msg.toolName}`,
44
+ `说明: ${msg.description}`,
45
+ '',
46
+ preview.slice(0, 800),
47
+ '',
48
+ `回复 yes 批准 / always 始终批准 / no 拒绝`,
49
+ ].join('\n');
50
+ ctx.broadcastMonitor({ kind: 'permission_request', agentId, toolName: msg.toolName, timestamp: new Date().toISOString() });
51
+ await sendFn(targetUserId, prompt);
52
+ }
53
+ /** Try to match an incoming WeChat message as a permission verdict. Returns true if handled. */
54
+ tryHandleVerdict(msg, ctx) {
55
+ if (msg.type !== 'text' || !msg.text || this.pending.length === 0)
56
+ return false;
57
+ const simpleMatch = msg.text.match(SIMPLE_RE);
58
+ if (!simpleMatch)
59
+ return false;
60
+ const reply = simpleMatch[1].trim().toLowerCase();
61
+ const isAlways = /^(always|始终|总是)$/.test(reply);
62
+ const isAllow = isAlways || /^(y|yes|ok|好|批准)$/i.test(reply);
63
+ const idx = this.pending.findIndex(p => p.userId === msg.userId && (!msg.channelId || p.channelId === msg.channelId));
64
+ if (idx < 0)
65
+ return false;
66
+ const pending = this.pending.splice(idx, 1)[0];
67
+ const behavior = isAlways ? 'always' : isAllow ? 'allow' : 'deny';
68
+ ctx.deliverToAgent(pending.agentId, {
69
+ type: 'permission_verdict',
70
+ requestId: pending.requestId,
71
+ behavior,
72
+ toolName: pending.toolName,
73
+ });
74
+ console.log(`[hub] Permission verdict: ${pending.requestId} → ${behavior}`);
75
+ ctx.broadcastMonitor({ kind: 'permission_verdict', agentId: pending.agentId, behavior, timestamp: new Date().toISOString() });
76
+ return true;
77
+ }
78
+ /** Handle permission_timeout from spoke */
79
+ handleTimeout(requestId) {
80
+ const idx = this.pending.findIndex(p => p.requestId === requestId);
81
+ if (idx >= 0) {
82
+ this.pending.splice(idx, 1);
83
+ console.log(`[hub] Permission expired: ${requestId} (removed from queue)`);
84
+ }
85
+ }
86
+ /** Clean up stale permissions (called periodically) */
87
+ cleanup() {
88
+ const now = Date.now();
89
+ for (let i = this.pending.length - 1; i >= 0; i--) {
90
+ if (now - this.pending[i].createdAt > PERMISSION_TTL_MS) {
91
+ console.log(`[hub] Cleaning stale permission: ${this.pending[i].requestId}`);
92
+ this.pending.splice(i, 1);
93
+ }
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,23 @@
1
+ declare const CRED_DIR: string;
2
+ declare const CRED_PATH: string;
3
+ declare const POLL_INTERVAL = 2000;
4
+ export interface QrCode {
5
+ qrUrl: string;
6
+ qrDataUrl: string;
7
+ qrToken: string;
8
+ }
9
+ export interface QrCredentials {
10
+ token: string;
11
+ baseUrl: string;
12
+ accountId: string;
13
+ userId: string;
14
+ }
15
+ export type QrStatus = 'pending' | 'scanned' | 'confirmed' | 'expired';
16
+ export declare function fetchQrCode(): Promise<QrCode>;
17
+ export declare function checkQrStatus(qrToken: string): Promise<{
18
+ status: QrStatus;
19
+ credentials?: QrCredentials;
20
+ }>;
21
+ export declare function saveCredentials(creds: QrCredentials, channelId?: string): void;
22
+ export declare function loadCredentials(channelId?: string): QrCredentials | null;
23
+ export { CRED_DIR, CRED_PATH, POLL_INTERVAL };
@@ -0,0 +1,77 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import QRCode from 'qrcode';
5
+ const ILINK_BASE = 'https://ilinkai.weixin.qq.com';
6
+ const CRED_DIR = join(homedir(), '.weixin-bot');
7
+ const CRED_PATH = join(CRED_DIR, 'credentials.json');
8
+ const POLL_INTERVAL = 2000;
9
+ export async function fetchQrCode() {
10
+ const resp = await fetch(`${ILINK_BASE}/ilink/bot/get_bot_qrcode?bot_type=3`);
11
+ if (!resp.ok)
12
+ throw new Error(`iLink QR API error: ${resp.status}`);
13
+ const data = await resp.json();
14
+ if (!data.qrcode_img_content || !data.qrcode) {
15
+ throw new Error('iLink 返回的 QR 数据不完整');
16
+ }
17
+ const qrUrl = data.qrcode_img_content;
18
+ // Generate QR code as data URL (iLink returns a scan URL, not an image)
19
+ const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: 220, margin: 1 });
20
+ return { qrUrl, qrDataUrl, qrToken: data.qrcode };
21
+ }
22
+ export async function checkQrStatus(qrToken) {
23
+ const resp = await fetch(`${ILINK_BASE}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrToken)}`, { headers: { 'iLink-App-ClientVersion': '1' } });
24
+ if (!resp.ok)
25
+ throw new Error(`iLink status API error: ${resp.status}`);
26
+ const data = await resp.json();
27
+ if (data.status === 'scaned')
28
+ return { status: 'scanned' };
29
+ if (data.status === 'expired')
30
+ return { status: 'expired' };
31
+ if (data.status === 'confirmed') {
32
+ if (!data.bot_token || !data.ilink_bot_id || !data.ilink_user_id) {
33
+ throw new Error('授权成功但未返回凭证');
34
+ }
35
+ return {
36
+ status: 'confirmed',
37
+ credentials: {
38
+ token: data.bot_token,
39
+ baseUrl: data.baseurl || ILINK_BASE,
40
+ accountId: data.ilink_bot_id,
41
+ userId: data.ilink_user_id,
42
+ },
43
+ };
44
+ }
45
+ return { status: 'pending' };
46
+ }
47
+ /** Atomic write: write to .tmp then rename (prevents corruption on crash). */
48
+ function atomicWrite(path, data) {
49
+ const tmp = path + '.tmp';
50
+ writeFileSync(tmp, data, { mode: 0o600 });
51
+ renameSync(tmp, path);
52
+ }
53
+ export function saveCredentials(creds, channelId) {
54
+ mkdirSync(CRED_DIR, { recursive: true, mode: 0o700 });
55
+ const json = JSON.stringify(creds, null, 2) + '\n';
56
+ // Always write global file (backward compat for CLI `cc2im login`)
57
+ atomicWrite(CRED_PATH, json);
58
+ // Also write per-channel file when channelId is provided
59
+ if (channelId) {
60
+ atomicWrite(join(CRED_DIR, `credentials-${channelId}.json`), json);
61
+ }
62
+ }
63
+ export function loadCredentials(channelId) {
64
+ if (channelId) {
65
+ // Try per-channel file first
66
+ const channelPath = join(CRED_DIR, `credentials-${channelId}.json`);
67
+ if (existsSync(channelPath)) {
68
+ return JSON.parse(readFileSync(channelPath, 'utf8'));
69
+ }
70
+ }
71
+ // Fall back to global file
72
+ if (existsSync(CRED_PATH)) {
73
+ return JSON.parse(readFileSync(CRED_PATH, 'utf8'));
74
+ }
75
+ return null;
76
+ }
77
+ export { CRED_DIR, CRED_PATH, POLL_INTERVAL };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * WeixinChannel — wraps WeixinConnection as a Cc2imChannel implementation.
3
+ *
4
+ * Composition adapter: translates between the WeChat-specific WeixinConnection
5
+ * API and the platform-agnostic Cc2imChannel interface. Supports multi-instance
6
+ * via constructor-injected id/label (e.g. "weixin-alice", "weixin-bob").
7
+ */
8
+ import type { Cc2imChannel, ChannelStatus, IncomingChannelMessage } from '../../shared/channel.js';
9
+ export declare class WeixinChannel implements Cc2imChannel {
10
+ readonly id: string;
11
+ readonly type: "weixin";
12
+ readonly label: string;
13
+ private weixin;
14
+ private status;
15
+ private messageHandlers;
16
+ private statusHandlers;
17
+ constructor(id?: string, label?: string);
18
+ connect(): Promise<void>;
19
+ disconnect(): Promise<void>;
20
+ getStatus(): ChannelStatus;
21
+ sendText(userId: string, text: string): Promise<void>;
22
+ sendFile(userId: string, filePath: string): Promise<void>;
23
+ startTyping(userId: string): Promise<void>;
24
+ stopTyping(userId: string): Promise<void>;
25
+ onMessage(handler: (msg: IncomingChannelMessage) => Promise<void>): void;
26
+ onStatusChange(handler: (status: ChannelStatus, detail?: string) => void): void;
27
+ /**
28
+ * Wire WeixinConnection's single-handler callback into our multi-handler
29
+ * fan-out. Called once during connect(), before startListening().
30
+ */
31
+ private registerMessageBridge;
32
+ private setStatus;
33
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * WeixinChannel — wraps WeixinConnection as a Cc2imChannel implementation.
3
+ *
4
+ * Composition adapter: translates between the WeChat-specific WeixinConnection
5
+ * API and the platform-agnostic Cc2imChannel interface. Supports multi-instance
6
+ * via constructor-injected id/label (e.g. "weixin-alice", "weixin-bob").
7
+ */
8
+ import { WeixinConnection } from './connection.js';
9
+ const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']);
10
+ export class WeixinChannel {
11
+ id;
12
+ type = 'weixin';
13
+ label;
14
+ weixin;
15
+ status = 'disconnected';
16
+ messageHandlers = [];
17
+ statusHandlers = [];
18
+ constructor(id = 'weixin', label = '微信') {
19
+ this.id = id;
20
+ this.label = label;
21
+ this.weixin = new WeixinConnection();
22
+ }
23
+ // ── lifecycle ────────────────────────────────────────────────────
24
+ async connect() {
25
+ this.setStatus('connecting');
26
+ try {
27
+ await this.weixin.login(this.id);
28
+ this.weixin.restoreContextCache(this.id);
29
+ this.registerMessageBridge();
30
+ this.weixin.startListening();
31
+ // startPolling is a long-running loop — fire-and-forget.
32
+ // If it exits (session expired / network error), mark status.
33
+ this.weixin.startPolling().catch((err) => {
34
+ const msg = err instanceof Error ? err.message : String(err);
35
+ console.error(`[${this.id}] Polling error: ${msg}`);
36
+ this.setStatus('expired', msg);
37
+ });
38
+ this.setStatus('connected');
39
+ }
40
+ catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ console.error(`[${this.id}] Connect failed: ${msg}`);
43
+ this.setStatus('disconnected', msg);
44
+ throw err;
45
+ }
46
+ }
47
+ async disconnect() {
48
+ this.weixin.saveContextCache(this.id);
49
+ this.weixin.stop();
50
+ this.setStatus('disconnected');
51
+ }
52
+ getStatus() {
53
+ return this.status;
54
+ }
55
+ // ── outbound ─────────────────────────────────────────────────────
56
+ async sendText(userId, text) {
57
+ await this.weixin.send(userId, text);
58
+ }
59
+ async sendFile(userId, filePath) {
60
+ const ext = filePath.split('.').pop()?.toLowerCase() || '';
61
+ if (IMAGE_EXTS.has(ext)) {
62
+ await this.weixin.sendImage(userId, filePath);
63
+ }
64
+ else {
65
+ await this.weixin.sendFile(userId, filePath);
66
+ }
67
+ }
68
+ async startTyping(userId) {
69
+ await this.weixin.startTyping(userId);
70
+ }
71
+ async stopTyping(userId) {
72
+ await this.weixin.stopTyping(userId);
73
+ }
74
+ // ── inbound ──────────────────────────────────────────────────────
75
+ onMessage(handler) {
76
+ this.messageHandlers.push(handler);
77
+ }
78
+ // ── status events ────────────────────────────────────────────────
79
+ onStatusChange(handler) {
80
+ this.statusHandlers.push(handler);
81
+ }
82
+ // ── private ──────────────────────────────────────────────────────
83
+ /**
84
+ * Wire WeixinConnection's single-handler callback into our multi-handler
85
+ * fan-out. Called once during connect(), before startListening().
86
+ */
87
+ registerMessageBridge() {
88
+ this.weixin.setMessageHandler(async (msg) => {
89
+ const channelMsg = {
90
+ channelId: this.id,
91
+ channelType: this.type,
92
+ userId: msg.userId,
93
+ text: msg.text,
94
+ type: (msg.type || 'text'),
95
+ mediaPath: msg.mediaPath ?? undefined,
96
+ voiceText: msg.voiceText ?? undefined,
97
+ timestamp: msg.timestamp || new Date(),
98
+ raw: msg.raw,
99
+ };
100
+ for (const handler of this.messageHandlers) {
101
+ try {
102
+ await handler(channelMsg);
103
+ }
104
+ catch (err) {
105
+ const errMsg = err instanceof Error ? err.message : String(err);
106
+ console.error(`[${this.id}] Message handler error: ${errMsg}`);
107
+ }
108
+ }
109
+ });
110
+ }
111
+ setStatus(status, detail) {
112
+ this.status = status;
113
+ for (const handler of this.statusHandlers) {
114
+ try {
115
+ handler(status, detail);
116
+ }
117
+ catch (err) {
118
+ const errMsg = err instanceof Error ? err.message : String(err);
119
+ console.error(`[${this.id}] Status handler error: ${errMsg}`);
120
+ }
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,8 @@
1
+ import type { ChannelType } from './channel.js';
2
+ export interface ChannelConfig {
3
+ id: string;
4
+ type: ChannelType;
5
+ accountName: string;
6
+ }
7
+ export declare function loadChannelConfigs(): ChannelConfig[];
8
+ export declare function saveChannelConfigs(configs: ChannelConfig[]): void;
@@ -0,0 +1,14 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { SOCKET_DIR } from './socket.js';
4
+ const CHANNELS_JSON_PATH = join(SOCKET_DIR, 'channels.json');
5
+ export function loadChannelConfigs() {
6
+ if (!existsSync(CHANNELS_JSON_PATH)) {
7
+ // Backwards-compatible default: single weixin channel
8
+ return [{ id: 'weixin', type: 'weixin', accountName: '微信' }];
9
+ }
10
+ return JSON.parse(readFileSync(CHANNELS_JSON_PATH, 'utf8'));
11
+ }
12
+ export function saveChannelConfigs(configs) {
13
+ writeFileSync(CHANNELS_JSON_PATH, JSON.stringify(configs, null, 2));
14
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Channel abstraction — unified interface for IM platform instances.
3
+ *
4
+ * A "channel" is a platform INSTANCE (not type). You can have multiple
5
+ * WeChat channels (weixin-alice, weixin-bob) each with its own
6
+ * session, QR code, and user pool.
7
+ */
8
+ export type ChannelStatus = 'connected' | 'disconnected' | 'expired' | 'connecting';
9
+ export type ChannelType = 'weixin' | 'telegram' | 'slack' | 'discord';
10
+ export interface IncomingChannelMessage {
11
+ channelId: string;
12
+ channelType: ChannelType;
13
+ userId: string;
14
+ text?: string;
15
+ type: 'text' | 'image' | 'video' | 'voice' | 'file';
16
+ mediaPath?: string;
17
+ voiceText?: string;
18
+ timestamp: Date;
19
+ raw?: any;
20
+ }
21
+ export interface Cc2imChannel {
22
+ /** Instance ID, e.g. "weixin-alice", "weixin-bob" */
23
+ readonly id: string;
24
+ /** Platform type, e.g. "weixin". Determines UI icon and grouping */
25
+ readonly type: ChannelType;
26
+ /** Display label, e.g. "Alice·微信" */
27
+ readonly label: string;
28
+ connect(): Promise<void>;
29
+ disconnect(): Promise<void>;
30
+ getStatus(): ChannelStatus;
31
+ sendText(userId: string, text: string): Promise<void>;
32
+ sendFile(userId: string, filePath: string): Promise<void>;
33
+ startTyping(userId: string): Promise<void>;
34
+ stopTyping(userId: string): Promise<void>;
35
+ onMessage(handler: (msg: IncomingChannelMessage) => Promise<void>): void;
36
+ onStatusChange(handler: (status: ChannelStatus, detail?: string) => void): void;
37
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Channel abstraction — unified interface for IM platform instances.
3
+ *
4
+ * A "channel" is a platform INSTANCE (not type). You can have multiple
5
+ * WeChat channels (weixin-alice, weixin-bob) each with its own
6
+ * session, QR code, and user pool.
7
+ */
8
+ export {};
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared utility for managing .mcp.json in agent working directories
3
+ */
4
+ /** Ensure the cc2im spoke entry exists in the agent's .mcp.json */
5
+ export declare function ensureMcpJson(agentCwd: string, spokeScriptPath: string, agentId: string): void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared utility for managing .mcp.json in agent working directories
3
+ */
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join, dirname } from 'node:path';
6
+ /**
7
+ * Resolve the command + args for running the spoke script.
8
+ * - .ts files: use cc2im's own tsx binary (absolute path from node_modules/.bin/)
9
+ * - .js files: use node directly (process.execPath)
10
+ */
11
+ function resolveRunner(spokeScriptPath) {
12
+ if (spokeScriptPath.endsWith('.ts')) {
13
+ // Walk up from spoke script to find cc2im's node_modules/.bin/tsx
14
+ // spokeScriptPath is like /path/to/cc2im/src/spoke/index.ts
15
+ const cc2imRoot = dirname(dirname(dirname(spokeScriptPath))); // src/spoke/index.ts → cc2im root
16
+ const tsxBin = join(cc2imRoot, 'node_modules', '.bin', 'tsx');
17
+ if (existsSync(tsxBin)) {
18
+ return { command: tsxBin, args: [] };
19
+ }
20
+ // Fallback: try npx tsx (may not work on clean workspaces)
21
+ return { command: 'npx', args: ['tsx'] };
22
+ }
23
+ // .js files: node can run them directly
24
+ return { command: process.execPath, args: [] };
25
+ }
26
+ /** Ensure the cc2im spoke entry exists in the agent's .mcp.json */
27
+ export function ensureMcpJson(agentCwd, spokeScriptPath, agentId) {
28
+ const mcpPath = join(agentCwd, '.mcp.json');
29
+ const runner = resolveRunner(spokeScriptPath);
30
+ const entry = {
31
+ command: runner.command,
32
+ args: [...runner.args, spokeScriptPath, '--agent-id', agentId],
33
+ };
34
+ let config = { mcpServers: {} };
35
+ if (existsSync(mcpPath)) {
36
+ try {
37
+ config = JSON.parse(readFileSync(mcpPath, 'utf8'));
38
+ }
39
+ catch { }
40
+ config.mcpServers = config.mcpServers || {};
41
+ }
42
+ config.mcpServers['cc2im'] = entry;
43
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n');
44
+ }
@@ -0,0 +1,32 @@
1
+ import type { EventEmitter } from 'node:events';
2
+ import type { AgentManager } from '../hub/agent-manager.js';
3
+ import type { Router } from '../hub/router.js';
4
+ import type { AgentsConfig, HubToSpoke, HubEventData } from './types.js';
5
+ import type { Cc2imChannel } from './channel.js';
6
+ /** Hub services and events available to plugins */
7
+ export interface HubContext extends EventEmitter {
8
+ deliverToAgent(agentId: string, msg: HubToSpoke): boolean;
9
+ broadcastMonitor(event: HubEventData): void;
10
+ getConnectedAgents(): string[];
11
+ getAgentManager(): AgentManager;
12
+ getRouter(): Router;
13
+ getConfig(): AgentsConfig;
14
+ /** Register a channel (called by ChannelManager during init) */
15
+ registerChannel(channel: Cc2imChannel): void;
16
+ /** Look up a channel by its instance ID */
17
+ getChannel(channelId: string): Cc2imChannel | undefined;
18
+ /** Get all registered channels */
19
+ getChannels(): Cc2imChannel[];
20
+ /** Add a channel at runtime (persists to channels.json) */
21
+ addChannel(type: string, channelId: string, accountName: string): Promise<void>;
22
+ /** Remove a channel at runtime (persists to channels.json) */
23
+ removeChannel(channelId: string): Promise<void>;
24
+ /** Reconnect a channel (disconnect + connect with fresh credentials) */
25
+ reconnectChannel(channelId: string): Promise<void>;
26
+ }
27
+ /** Plugin definition */
28
+ export interface Cc2imPlugin {
29
+ name: string;
30
+ init(ctx: HubContext): Promise<void> | void;
31
+ destroy(): Promise<void> | void;
32
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export declare const SOCKET_DIR: string;
2
+ export declare const HUB_SOCKET_PATH: string;
3
+ export declare function ensureSocketDir(): void;
4
+ export declare function encodeFrame(data: unknown): Buffer;
5
+ export declare function createFrameParser(onFrame: (data: unknown) => void): (chunk: Buffer) => void;