@wu529778790/open-im 1.8.2 → 1.8.3-beta.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/dist/config.d.ts +11 -0
- package/dist/config.js +27 -2
- package/dist/wechat/client.d.ts +10 -3
- package/dist/wechat/client.js +70 -248
- package/dist/wechat/qclaw-transport.d.ts +66 -0
- package/dist/wechat/qclaw-transport.js +303 -0
- package/dist/wechat/transport.d.ts +41 -0
- package/dist/wechat/transport.js +5 -0
- package/dist/wechat/workbuddy-transport.d.ts +33 -0
- package/dist/wechat/workbuddy-transport.js +145 -0
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -64,6 +64,7 @@ export interface Config {
|
|
|
64
64
|
wechat?: {
|
|
65
65
|
enabled: boolean;
|
|
66
66
|
aiCommand?: AiCommand;
|
|
67
|
+
loginMode?: 'qclaw' | 'workbuddy';
|
|
67
68
|
wsUrl?: string;
|
|
68
69
|
token?: string;
|
|
69
70
|
jwtToken?: string;
|
|
@@ -71,6 +72,10 @@ export interface Config {
|
|
|
71
72
|
guid?: string;
|
|
72
73
|
userId?: string;
|
|
73
74
|
allowedUserIds: string[];
|
|
75
|
+
workbuddyAccessToken?: string;
|
|
76
|
+
workbuddyRefreshToken?: string;
|
|
77
|
+
workbuddyBaseUrl?: string;
|
|
78
|
+
workbuddyHostId?: string;
|
|
74
79
|
};
|
|
75
80
|
wework?: {
|
|
76
81
|
enabled: boolean;
|
|
@@ -122,6 +127,8 @@ interface FilePlatformWechat {
|
|
|
122
127
|
appId?: string;
|
|
123
128
|
appSecret?: string;
|
|
124
129
|
aiCommand?: AiCommand;
|
|
130
|
+
/** 连接模式:qclaw(QClaw JPRX 网关)或 workbuddy(Centrifuge) */
|
|
131
|
+
loginMode?: 'qclaw' | 'workbuddy';
|
|
125
132
|
token?: string;
|
|
126
133
|
jwtToken?: string;
|
|
127
134
|
loginKey?: string;
|
|
@@ -129,6 +136,10 @@ interface FilePlatformWechat {
|
|
|
129
136
|
userId?: string;
|
|
130
137
|
wsUrl?: string;
|
|
131
138
|
allowedUserIds?: string[];
|
|
139
|
+
workbuddyAccessToken?: string;
|
|
140
|
+
workbuddyRefreshToken?: string;
|
|
141
|
+
workbuddyBaseUrl?: string;
|
|
142
|
+
workbuddyHostId?: string;
|
|
132
143
|
}
|
|
133
144
|
export interface FilePlatformWework {
|
|
134
145
|
enabled?: boolean;
|
package/dist/config.js
CHANGED
|
@@ -251,6 +251,7 @@ export function loadConfig() {
|
|
|
251
251
|
// 微信支持两种协议:
|
|
252
252
|
// 1. AGP 协议:token + guid + userId(推荐)
|
|
253
253
|
// 2. 标准协议:appId + appSecret
|
|
254
|
+
const wechatLoginMode = fileWechat?.loginMode ?? 'qclaw';
|
|
254
255
|
const wechatToken = process.env.WECHAT_TOKEN ??
|
|
255
256
|
fileWechat?.token;
|
|
256
257
|
const wechatJwtToken = fileWechat?.jwtToken;
|
|
@@ -265,6 +266,15 @@ export function loadConfig() {
|
|
|
265
266
|
fileWechat?.appSecret;
|
|
266
267
|
const wechatWsUrl = process.env.WECHAT_WS_URL ??
|
|
267
268
|
fileWechat?.wsUrl;
|
|
269
|
+
// 微信 WorkBuddy 模式凭证(loginMode === 'workbuddy' 时使用)
|
|
270
|
+
const wechatWorkbuddyAccessToken = process.env.WECHAT_WORKBUDDY_ACCESS_TOKEN ??
|
|
271
|
+
fileWechat?.workbuddyAccessToken;
|
|
272
|
+
const wechatWorkbuddyRefreshToken = process.env.WECHAT_WORKBUDDY_REFRESH_TOKEN ??
|
|
273
|
+
fileWechat?.workbuddyRefreshToken;
|
|
274
|
+
const wechatWorkbuddyBaseUrl = process.env.WECHAT_WORKBUDDY_BASE_URL ??
|
|
275
|
+
fileWechat?.workbuddyBaseUrl;
|
|
276
|
+
const wechatWorkbuddyHostId = process.env.WECHAT_WORKBUDDY_HOST_ID ??
|
|
277
|
+
fileWechat?.workbuddyHostId;
|
|
268
278
|
const weworkCorpId = process.env.WEWORK_CORP_ID ??
|
|
269
279
|
fileWework?.corpId;
|
|
270
280
|
const weworkSecret = process.env.WEWORK_SECRET ??
|
|
@@ -302,10 +312,15 @@ export function loadConfig() {
|
|
|
302
312
|
const telegramEnabled = !!telegramBotToken && (telegramEnabledFlag !== false);
|
|
303
313
|
const feishuEnabled = !!(feishuAppId && feishuAppSecret) && (feishuEnabledFlag !== false);
|
|
304
314
|
const qqEnabled = !!(qqAppId && qqSecret) && (qqEnabledFlag !== false);
|
|
305
|
-
// 微信启用条件:
|
|
315
|
+
// 微信启用条件:
|
|
316
|
+
// - qclaw 模式:AGP 协议凭证(token + guid + userId)或 标准协议凭证(appId + appSecret)
|
|
317
|
+
// - workbuddy 模式:workbuddy OAuth 凭证
|
|
306
318
|
const hasWechatAGPCreds = !!(wechatToken && wechatGuid && wechatUserId);
|
|
307
319
|
const hasWechatStandardCreds = !!(wechatAppId && wechatAppSecret);
|
|
308
|
-
const
|
|
320
|
+
const hasWechatWorkbuddyCreds = !!(wechatWorkbuddyAccessToken && wechatWorkbuddyRefreshToken);
|
|
321
|
+
const wechatEnabled = (wechatLoginMode === 'workbuddy'
|
|
322
|
+
? hasWechatWorkbuddyCreds
|
|
323
|
+
: (hasWechatAGPCreds || hasWechatStandardCreds)) && (wechatEnabledFlag !== false);
|
|
309
324
|
// 企业微信只需要 corpId (botId) 和 secret
|
|
310
325
|
const weworkEnabled = !!(weworkCorpId && weworkSecret) && (weworkEnabledFlag !== false);
|
|
311
326
|
const dingtalkEnabled = !!(dingtalkClientId && dingtalkClientSecret) && (dingtalkEnabledFlag !== false);
|
|
@@ -558,6 +573,7 @@ export function loadConfig() {
|
|
|
558
573
|
? {
|
|
559
574
|
enabled: true,
|
|
560
575
|
aiCommand: normalizeAiCommand(file.platforms?.wechat?.aiCommand, aiCommand),
|
|
576
|
+
loginMode: wechatLoginMode,
|
|
561
577
|
wsUrl: wechatWsUrl,
|
|
562
578
|
token: wechatToken,
|
|
563
579
|
jwtToken: wechatJwtToken,
|
|
@@ -565,10 +581,15 @@ export function loadConfig() {
|
|
|
565
581
|
guid: wechatGuid,
|
|
566
582
|
userId: wechatUserId,
|
|
567
583
|
allowedUserIds: wechatAllowedUserIds,
|
|
584
|
+
workbuddyAccessToken: wechatWorkbuddyAccessToken,
|
|
585
|
+
workbuddyRefreshToken: wechatWorkbuddyRefreshToken,
|
|
586
|
+
workbuddyBaseUrl: wechatWorkbuddyBaseUrl,
|
|
587
|
+
workbuddyHostId: wechatWorkbuddyHostId,
|
|
568
588
|
}
|
|
569
589
|
: {
|
|
570
590
|
enabled: false,
|
|
571
591
|
aiCommand: normalizeAiCommand(file.platforms?.wechat?.aiCommand, aiCommand),
|
|
592
|
+
loginMode: wechatLoginMode,
|
|
572
593
|
wsUrl: wechatWsUrl,
|
|
573
594
|
token: wechatToken,
|
|
574
595
|
jwtToken: wechatJwtToken,
|
|
@@ -576,6 +597,10 @@ export function loadConfig() {
|
|
|
576
597
|
guid: wechatGuid,
|
|
577
598
|
userId: wechatUserId,
|
|
578
599
|
allowedUserIds: wechatAllowedUserIds,
|
|
600
|
+
workbuddyAccessToken: wechatWorkbuddyAccessToken,
|
|
601
|
+
workbuddyRefreshToken: wechatWorkbuddyRefreshToken,
|
|
602
|
+
workbuddyBaseUrl: wechatWorkbuddyBaseUrl,
|
|
603
|
+
workbuddyHostId: wechatWorkbuddyHostId,
|
|
579
604
|
},
|
|
580
605
|
wework: weworkEnabled
|
|
581
606
|
? {
|
package/dist/wechat/client.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WeChat Client -
|
|
2
|
+
* WeChat Client - 薄封装层,根据 loginMode 委托给对应 transport
|
|
3
|
+
*
|
|
4
|
+
* 支持两种通道:
|
|
5
|
+
* - qclaw: 直连腾讯 JPRX 网关(默认)
|
|
6
|
+
* - workbuddy: 通过 Centrifuge WebSocket 连接
|
|
3
7
|
*/
|
|
4
8
|
import type { Config } from '../config.js';
|
|
5
9
|
import type { AGPEnvelope, WeChatChannelState, WeChatToken } from './types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Dispatch incoming AGP envelope to the event handler
|
|
12
|
+
*/
|
|
6
13
|
export declare function dispatchIncomingAGPEnvelope(envelope: AGPEnvelope, handler: ((data: unknown) => Promise<void>) | null): Promise<void>;
|
|
7
14
|
/**
|
|
8
15
|
* Get current channel state
|
|
@@ -13,11 +20,11 @@ export declare function getChannelState(): WeChatChannelState;
|
|
|
13
20
|
*/
|
|
14
21
|
export declare function getCurrentToken(): WeChatToken | null;
|
|
15
22
|
/**
|
|
16
|
-
* Initialize WeChat client
|
|
23
|
+
* Initialize WeChat client
|
|
17
24
|
*/
|
|
18
25
|
export declare function initWeChat(config: Config, eventHandler: (data: unknown) => Promise<void>, onStateChange?: (state: WeChatChannelState) => void): Promise<void>;
|
|
19
26
|
/**
|
|
20
|
-
* Send AGP message through
|
|
27
|
+
* Send AGP message through the transport
|
|
21
28
|
*/
|
|
22
29
|
export declare function sendAGPMessage<T>(method: string, payload: T, replyTo?: string): void;
|
|
23
30
|
/**
|
package/dist/wechat/client.js
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WeChat Client -
|
|
2
|
+
* WeChat Client - 薄封装层,根据 loginMode 委托给对应 transport
|
|
3
|
+
*
|
|
4
|
+
* 支持两种通道:
|
|
5
|
+
* - qclaw: 直连腾讯 JPRX 网关(默认)
|
|
6
|
+
* - workbuddy: 通过 Centrifuge WebSocket 连接
|
|
3
7
|
*/
|
|
4
|
-
import { WebSocket } from 'ws';
|
|
5
8
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
6
9
|
import { join } from 'node:path';
|
|
7
10
|
import { createLogger } from '../logger.js';
|
|
11
|
+
import { QClawTransport } from './qclaw-transport.js';
|
|
12
|
+
import { WorkBuddyTransport } from './workbuddy-transport.js';
|
|
8
13
|
const log = createLogger('WeChat');
|
|
9
14
|
const TOKEN_FILE = 'wechat-token.json';
|
|
10
|
-
const DEFAULT_WECHAT_WS_URL = 'wss://openclau-wechat.henryxiaoyang.workers.dev';
|
|
11
|
-
const PONG_TIMEOUT_FACTOR = 3; // 3倍心跳间隔无响应则判定连接死亡
|
|
12
15
|
// Global state
|
|
13
|
-
let
|
|
16
|
+
let transport = null;
|
|
14
17
|
let channelState = 'disconnected';
|
|
15
|
-
let reconnectTimer = null;
|
|
16
|
-
let heartbeatTimer = null;
|
|
17
|
-
let reconnectAttempts = 0;
|
|
18
18
|
let currentToken = null;
|
|
19
19
|
let tokenStoragePath = null;
|
|
20
|
-
let
|
|
21
|
-
let wsConfigRef = null; // 保存配置供心跳重连使用
|
|
22
|
-
let isStopping = false; // 防止 stop 后重连定时器继续触发
|
|
20
|
+
let isStopping = false;
|
|
23
21
|
// Event handlers
|
|
24
22
|
let messageHandler = null;
|
|
25
23
|
let stateChangeHandler = null;
|
|
24
|
+
/**
|
|
25
|
+
* Dispatch incoming AGP envelope to the event handler
|
|
26
|
+
*/
|
|
26
27
|
export async function dispatchIncomingAGPEnvelope(envelope, handler) {
|
|
27
28
|
switch (envelope.method) {
|
|
28
29
|
case 'ping':
|
|
29
|
-
// Respond to ping with pong
|
|
30
|
-
|
|
30
|
+
// Respond to ping with pong via transport
|
|
31
|
+
if (transport) {
|
|
32
|
+
transport.send('ping', { timestamp: Date.now() }, envelope.msg_id);
|
|
33
|
+
}
|
|
31
34
|
break;
|
|
32
35
|
case 'session.prompt':
|
|
33
36
|
case 'session.update':
|
|
@@ -37,7 +40,6 @@ export async function dispatchIncomingAGPEnvelope(envelope, handler) {
|
|
|
37
40
|
}
|
|
38
41
|
break;
|
|
39
42
|
case 'session.promptResponse':
|
|
40
|
-
// Handle response to our prompt
|
|
41
43
|
log.debug('Received prompt response:', envelope.payload);
|
|
42
44
|
break;
|
|
43
45
|
default:
|
|
@@ -57,21 +59,9 @@ export function getCurrentToken() {
|
|
|
57
59
|
return currentToken;
|
|
58
60
|
}
|
|
59
61
|
/**
|
|
60
|
-
* Initialize WeChat client
|
|
62
|
+
* Initialize WeChat client
|
|
61
63
|
*/
|
|
62
64
|
export async function initWeChat(config, eventHandler, onStateChange) {
|
|
63
|
-
// AGP 协议使用 token + guid,标准协议使用 appId + appSecret
|
|
64
|
-
const hasAGPCreds = config.wechatToken && config.wechatGuid;
|
|
65
|
-
const hasStandardCreds = config.wechatAppId && config.wechatAppSecret;
|
|
66
|
-
if (!hasAGPCreds && !hasStandardCreds) {
|
|
67
|
-
throw new Error('WeChat credentials required: AGP (token + guid) or standard (appId + appSecret)');
|
|
68
|
-
}
|
|
69
|
-
if (hasAGPCreds) {
|
|
70
|
-
log.info('Using AGP protocol for WeChat');
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
log.info('Using standard OAuth protocol for WeChat');
|
|
74
|
-
}
|
|
75
65
|
messageHandler = eventHandler;
|
|
76
66
|
stateChangeHandler = onStateChange ?? null;
|
|
77
67
|
isStopping = false;
|
|
@@ -83,227 +73,77 @@ export async function initWeChat(config, eventHandler, onStateChange) {
|
|
|
83
73
|
}
|
|
84
74
|
// Load existing token if available
|
|
85
75
|
await loadToken();
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
heartbeatInterval: 30000,
|
|
92
|
-
};
|
|
93
|
-
await connectWebSocket(wsConfig);
|
|
94
|
-
log.info('WeChat client initialized');
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Connect to AGP WebSocket server
|
|
98
|
-
*/
|
|
99
|
-
async function connectWebSocket(config) {
|
|
100
|
-
wsConfigRef = config;
|
|
101
|
-
if (channelState === 'connecting') {
|
|
102
|
-
log.warn('WebSocket connection already in progress');
|
|
103
|
-
return;
|
|
76
|
+
// Determine login mode from config
|
|
77
|
+
const loginMode = config.platforms.wechat?.loginMode ?? 'qclaw';
|
|
78
|
+
log.info(`Initializing WeChat with loginMode: ${loginMode}`);
|
|
79
|
+
if (loginMode === 'workbuddy') {
|
|
80
|
+
transport = createWorkBuddyTransport(config);
|
|
104
81
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let settled = false;
|
|
108
|
-
// Connection timeout to prevent promise from hanging forever
|
|
109
|
-
const connectionTimeout = setTimeout(() => {
|
|
110
|
-
if (settled)
|
|
111
|
-
return;
|
|
112
|
-
settled = true;
|
|
113
|
-
const err = new Error('WeChat WebSocket connection timeout');
|
|
114
|
-
log.error(err.message);
|
|
115
|
-
updateState('error');
|
|
116
|
-
try {
|
|
117
|
-
ws?.close();
|
|
118
|
-
}
|
|
119
|
-
catch { /* ignore */ }
|
|
120
|
-
reject(err);
|
|
121
|
-
}, 30000);
|
|
122
|
-
try {
|
|
123
|
-
ws = new WebSocket(config.url);
|
|
124
|
-
ws.on('open', () => {
|
|
125
|
-
if (settled)
|
|
126
|
-
return;
|
|
127
|
-
settled = true;
|
|
128
|
-
clearTimeout(connectionTimeout);
|
|
129
|
-
log.info('WeChat WebSocket connected');
|
|
130
|
-
reconnectAttempts = 0;
|
|
131
|
-
updateState('connected');
|
|
132
|
-
startHeartbeat(config.heartbeatInterval ?? 30000);
|
|
133
|
-
resolve();
|
|
134
|
-
});
|
|
135
|
-
ws.on('message', async (data) => {
|
|
136
|
-
lastServerResponseTime = Date.now();
|
|
137
|
-
try {
|
|
138
|
-
const envelope = JSON.parse(data.toString());
|
|
139
|
-
log.debug('Received AGP message:', envelope.method);
|
|
140
|
-
await handleAGPMessage(envelope);
|
|
141
|
-
}
|
|
142
|
-
catch (err) {
|
|
143
|
-
log.error('Error parsing WebSocket message:', err);
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
ws.on('error', (err) => {
|
|
147
|
-
if (settled) {
|
|
148
|
-
// Late error after connection was established — just log it
|
|
149
|
-
log.error('WeChat WebSocket error (after open):', err);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
settled = true;
|
|
153
|
-
clearTimeout(connectionTimeout);
|
|
154
|
-
log.error('WeChat WebSocket error:', err);
|
|
155
|
-
updateState('error');
|
|
156
|
-
reject(err);
|
|
157
|
-
});
|
|
158
|
-
ws.on('close', () => {
|
|
159
|
-
clearTimeout(connectionTimeout);
|
|
160
|
-
log.info('WeChat WebSocket closed');
|
|
161
|
-
stopHeartbeat();
|
|
162
|
-
updateState('disconnected');
|
|
163
|
-
if (!settled) {
|
|
164
|
-
settled = true;
|
|
165
|
-
reject(new Error('WeChat WebSocket closed before open'));
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
scheduleReconnect(config);
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
settled = true;
|
|
173
|
-
clearTimeout(connectionTimeout);
|
|
174
|
-
log.error('Error creating WebSocket connection:', err);
|
|
175
|
-
updateState('error');
|
|
176
|
-
reject(err);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Handle incoming AGP messages
|
|
182
|
-
*/
|
|
183
|
-
async function handleAGPMessage(envelope) {
|
|
184
|
-
try {
|
|
185
|
-
await dispatchIncomingAGPEnvelope(envelope, messageHandler);
|
|
82
|
+
else {
|
|
83
|
+
transport = createQClawTransport(config);
|
|
186
84
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
85
|
+
// Wire up transport callbacks
|
|
86
|
+
transport.onMessage(async (envelope) => {
|
|
87
|
+
await dispatchIncomingAGPEnvelope(envelope, messageHandler);
|
|
88
|
+
});
|
|
89
|
+
transport.onStateChange((state) => {
|
|
90
|
+
channelState = state;
|
|
91
|
+
if (stateChangeHandler) {
|
|
92
|
+
stateChangeHandler(state);
|
|
191
93
|
}
|
|
192
|
-
|
|
193
|
-
|
|
94
|
+
});
|
|
95
|
+
await transport.start();
|
|
96
|
+
log.info(`WeChat client initialized (${loginMode} mode)`);
|
|
194
97
|
}
|
|
195
98
|
/**
|
|
196
|
-
*
|
|
99
|
+
* Create QClaw transport from config
|
|
197
100
|
*/
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
payload,
|
|
101
|
+
function createQClawTransport(config) {
|
|
102
|
+
const qclawConfig = {
|
|
103
|
+
channelToken: config.wechatToken,
|
|
104
|
+
jwtToken: config.wechatJwtToken ?? config.platforms.wechat?.jwtToken,
|
|
105
|
+
loginKey: config.wechatLoginKey ?? config.platforms.wechat?.loginKey,
|
|
106
|
+
guid: config.wechatGuid,
|
|
107
|
+
userId: config.wechatUserId,
|
|
108
|
+
wsUrl: config.wechatWsUrl,
|
|
207
109
|
};
|
|
208
|
-
|
|
209
|
-
ws.send(JSON.stringify(envelope));
|
|
210
|
-
log.debug('Sent AGP message:', method);
|
|
211
|
-
}
|
|
212
|
-
catch (err) {
|
|
213
|
-
log.error('Error sending AGP message:', err);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Generate unique message ID
|
|
218
|
-
*/
|
|
219
|
-
function generateMsgId() {
|
|
220
|
-
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
221
|
-
}
|
|
222
|
-
/**
|
|
223
|
-
* Update channel state and notify listeners
|
|
224
|
-
*/
|
|
225
|
-
function updateState(state) {
|
|
226
|
-
channelState = state;
|
|
227
|
-
if (stateChangeHandler) {
|
|
228
|
-
stateChangeHandler(state);
|
|
229
|
-
}
|
|
230
|
-
log.debug('Channel state:', state);
|
|
110
|
+
return new QClawTransport(qclawConfig);
|
|
231
111
|
}
|
|
232
112
|
/**
|
|
233
|
-
*
|
|
234
|
-
* 同时检测服务端是否响应,超时无响应则主动断开触发重连
|
|
113
|
+
* Create WorkBuddy transport from config
|
|
235
114
|
*/
|
|
236
|
-
function
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
stopHeartbeat();
|
|
247
|
-
if (ws) {
|
|
248
|
-
try {
|
|
249
|
-
ws.removeAllListeners();
|
|
250
|
-
ws.close();
|
|
251
|
-
}
|
|
252
|
-
catch {
|
|
253
|
-
/* ignore */
|
|
254
|
-
}
|
|
255
|
-
ws = null;
|
|
256
|
-
}
|
|
257
|
-
updateState('disconnected');
|
|
258
|
-
if (wsConfigRef) {
|
|
259
|
-
scheduleReconnect(wsConfigRef);
|
|
260
|
-
}
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
sendAGPMessage('ping', { timestamp: Date.now() });
|
|
264
|
-
}
|
|
265
|
-
}, interval);
|
|
115
|
+
function createWorkBuddyTransport(config) {
|
|
116
|
+
const wp = config.platforms.wechat;
|
|
117
|
+
const workbuddyConfig = {
|
|
118
|
+
accessToken: wp?.workbuddyAccessToken ?? '',
|
|
119
|
+
refreshToken: wp?.workbuddyRefreshToken ?? '',
|
|
120
|
+
userId: config.wechatUserId ?? wp?.userId ?? '',
|
|
121
|
+
hostId: wp?.workbuddyHostId,
|
|
122
|
+
baseUrl: wp?.workbuddyBaseUrl,
|
|
123
|
+
};
|
|
124
|
+
return new WorkBuddyTransport(workbuddyConfig);
|
|
266
125
|
}
|
|
267
126
|
/**
|
|
268
|
-
*
|
|
127
|
+
* Send AGP message through the transport
|
|
269
128
|
*/
|
|
270
|
-
function
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
129
|
+
export function sendAGPMessage(method, payload, replyTo) {
|
|
130
|
+
if (!transport) {
|
|
131
|
+
log.warn('Cannot send message: transport not initialized');
|
|
132
|
+
return;
|
|
274
133
|
}
|
|
134
|
+
transport.send(method, payload, replyTo);
|
|
275
135
|
}
|
|
276
136
|
/**
|
|
277
|
-
*
|
|
278
|
-
* 超过 maxAttempts 后自动重置计数器继续重试,避免永久断连
|
|
137
|
+
* Stop WeChat client
|
|
279
138
|
*/
|
|
280
|
-
function
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
// 超过最大重试次数后重置计数器,降低频率继续重试
|
|
288
|
-
if (reconnectAttempts >= maxAttempts) {
|
|
289
|
-
log.warn(`Max reconnect attempts (${maxAttempts}) reached, resetting counter and retrying at lower frequency`);
|
|
290
|
-
reconnectAttempts = 0;
|
|
139
|
+
export function stopWeChat() {
|
|
140
|
+
isStopping = true;
|
|
141
|
+
if (transport) {
|
|
142
|
+
transport.stop();
|
|
143
|
+
transport = null;
|
|
291
144
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const backoff = Math.min(baseInterval * Math.pow(1.5, Math.floor(reconnectAttempts / 3)), 60000);
|
|
295
|
-
const interval = Math.round(backoff);
|
|
296
|
-
reconnectTimer = setTimeout(async () => {
|
|
297
|
-
reconnectTimer = null;
|
|
298
|
-
reconnectAttempts++;
|
|
299
|
-
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts} (interval: ${interval}ms)`);
|
|
300
|
-
try {
|
|
301
|
-
await connectWebSocket(config);
|
|
302
|
-
}
|
|
303
|
-
catch (err) {
|
|
304
|
-
log.error('Reconnection failed:', err);
|
|
305
|
-
}
|
|
306
|
-
}, interval);
|
|
145
|
+
channelState = 'disconnected';
|
|
146
|
+
log.info('WeChat client stopped');
|
|
307
147
|
}
|
|
308
148
|
/**
|
|
309
149
|
* Load token from storage
|
|
@@ -316,7 +156,6 @@ async function loadToken() {
|
|
|
316
156
|
try {
|
|
317
157
|
const data = readFileSync(tokenPath, 'utf-8');
|
|
318
158
|
currentToken = JSON.parse(data);
|
|
319
|
-
// Check if token is expired
|
|
320
159
|
if (currentToken.expires_at < Date.now()) {
|
|
321
160
|
log.info('Token expired, need to re-authenticate');
|
|
322
161
|
currentToken = null;
|
|
@@ -346,20 +185,3 @@ function saveToken() {
|
|
|
346
185
|
log.error('Error saving token:', err);
|
|
347
186
|
}
|
|
348
187
|
}
|
|
349
|
-
/**
|
|
350
|
-
* Stop WeChat client
|
|
351
|
-
*/
|
|
352
|
-
export function stopWeChat() {
|
|
353
|
-
isStopping = true;
|
|
354
|
-
stopHeartbeat();
|
|
355
|
-
if (reconnectTimer) {
|
|
356
|
-
clearTimeout(reconnectTimer);
|
|
357
|
-
reconnectTimer = null;
|
|
358
|
-
}
|
|
359
|
-
if (ws) {
|
|
360
|
-
ws.close();
|
|
361
|
-
ws = null;
|
|
362
|
-
}
|
|
363
|
-
updateState('disconnected');
|
|
364
|
-
log.info('WeChat client stopped');
|
|
365
|
-
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QClaw Transport - 通过腾讯 JPRX 网关直连微信
|
|
3
|
+
*
|
|
4
|
+
* 连接 wss://mmgrcalltoken.3g.qq.com/agentwss,
|
|
5
|
+
* 使用 QClaw API 获取 channelToken 进行认证。
|
|
6
|
+
* 支持消息去重、WS ping/pong 心跳、指数退避重连。
|
|
7
|
+
*/
|
|
8
|
+
import type { WeChatChannelState } from './types.js';
|
|
9
|
+
import type { WeChatTransport, MessageHandler, StateChangeHandler } from './transport.js';
|
|
10
|
+
export interface QClawTransportConfig {
|
|
11
|
+
/** QClaw 环境:production 或 test */
|
|
12
|
+
environment?: string;
|
|
13
|
+
/** QClaw wxAppId */
|
|
14
|
+
wxAppId?: string;
|
|
15
|
+
/** AGP channel token(连接凭证) */
|
|
16
|
+
channelToken?: string;
|
|
17
|
+
/** JWT Token(用于刷新 channelToken) */
|
|
18
|
+
jwtToken?: string;
|
|
19
|
+
/** loginKey(用于 API 调用) */
|
|
20
|
+
loginKey?: string;
|
|
21
|
+
/** 设备 GUID */
|
|
22
|
+
guid?: string;
|
|
23
|
+
/** 用户 ID */
|
|
24
|
+
userId?: string;
|
|
25
|
+
/** 自定义 WebSocket URL(覆盖默认 QClaw 地址) */
|
|
26
|
+
wsUrl?: string;
|
|
27
|
+
/** 心跳间隔 ms */
|
|
28
|
+
heartbeatInterval?: number;
|
|
29
|
+
/** 重连间隔 ms */
|
|
30
|
+
reconnectInterval?: number;
|
|
31
|
+
/** 最大重连次数 */
|
|
32
|
+
maxReconnectAttempts?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare class QClawTransport implements WeChatTransport {
|
|
35
|
+
private config;
|
|
36
|
+
private state;
|
|
37
|
+
private ws;
|
|
38
|
+
private reconnectTimer;
|
|
39
|
+
private heartbeatTimer;
|
|
40
|
+
private reconnectAttempts;
|
|
41
|
+
private isStopping;
|
|
42
|
+
private lastServerResponseTime;
|
|
43
|
+
private wsConfigRef;
|
|
44
|
+
private processedMsgIds;
|
|
45
|
+
private static readonly MAX_MSG_ID_CACHE;
|
|
46
|
+
private messageHandler;
|
|
47
|
+
private stateChangeHandler;
|
|
48
|
+
private channelToken;
|
|
49
|
+
private jwtToken;
|
|
50
|
+
private guid;
|
|
51
|
+
constructor(config: QClawTransportConfig);
|
|
52
|
+
start(): Promise<void>;
|
|
53
|
+
stop(): void;
|
|
54
|
+
send(method: string, payload: unknown, replyTo?: string): void;
|
|
55
|
+
onMessage(handler: MessageHandler): void;
|
|
56
|
+
onStateChange(handler: StateChangeHandler): void;
|
|
57
|
+
getState(): WeChatChannelState;
|
|
58
|
+
private connectWebSocket;
|
|
59
|
+
private handleAGPMessage;
|
|
60
|
+
private startHeartbeat;
|
|
61
|
+
private stopHeartbeat;
|
|
62
|
+
private scheduleReconnect;
|
|
63
|
+
private updateState;
|
|
64
|
+
private generateMsgId;
|
|
65
|
+
private cleanMsgIdCache;
|
|
66
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QClaw Transport - 通过腾讯 JPRX 网关直连微信
|
|
3
|
+
*
|
|
4
|
+
* 连接 wss://mmgrcalltoken.3g.qq.com/agentwss,
|
|
5
|
+
* 使用 QClaw API 获取 channelToken 进行认证。
|
|
6
|
+
* 支持消息去重、WS ping/pong 心跳、指数退避重连。
|
|
7
|
+
*/
|
|
8
|
+
import { WebSocket } from 'ws';
|
|
9
|
+
import { createLogger } from '../logger.js';
|
|
10
|
+
import { QClawAPI } from './auth/qclaw-api.js';
|
|
11
|
+
import { getEnvironment } from './auth/environments.js';
|
|
12
|
+
import { getDeviceGuid } from './auth/device-guid.js';
|
|
13
|
+
const log = createLogger('WeChat:QClaw');
|
|
14
|
+
const PONG_TIMEOUT_FACTOR = 3;
|
|
15
|
+
export class QClawTransport {
|
|
16
|
+
config;
|
|
17
|
+
state = 'disconnected';
|
|
18
|
+
ws = null;
|
|
19
|
+
reconnectTimer = null;
|
|
20
|
+
heartbeatTimer = null;
|
|
21
|
+
reconnectAttempts = 0;
|
|
22
|
+
isStopping = false;
|
|
23
|
+
lastServerResponseTime = 0;
|
|
24
|
+
wsConfigRef = null;
|
|
25
|
+
processedMsgIds = new Set();
|
|
26
|
+
static MAX_MSG_ID_CACHE = 1000;
|
|
27
|
+
messageHandler = null;
|
|
28
|
+
stateChangeHandler = null;
|
|
29
|
+
// Token management
|
|
30
|
+
channelToken;
|
|
31
|
+
jwtToken;
|
|
32
|
+
guid;
|
|
33
|
+
constructor(config) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.channelToken = config.channelToken ?? '';
|
|
36
|
+
this.jwtToken = config.jwtToken ?? '';
|
|
37
|
+
this.guid = config.guid || getDeviceGuid();
|
|
38
|
+
}
|
|
39
|
+
async start() {
|
|
40
|
+
this.isStopping = false;
|
|
41
|
+
// Refresh channel token before connecting
|
|
42
|
+
if (this.jwtToken && this.config.loginKey) {
|
|
43
|
+
try {
|
|
44
|
+
const env = getEnvironment(this.config.environment ?? 'production', this.config.wxAppId ?? 'wx9d11056dd75b7240');
|
|
45
|
+
const api = new QClawAPI(env, this.guid, this.jwtToken);
|
|
46
|
+
api.loginKey = this.config.loginKey;
|
|
47
|
+
const freshToken = await api.refreshChannelToken();
|
|
48
|
+
if (freshToken) {
|
|
49
|
+
this.channelToken = freshToken;
|
|
50
|
+
log.info('Channel token refreshed successfully');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
log.warn('Failed to refresh channel token, using existing:', err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const wsUrl = this.config.wsUrl
|
|
58
|
+
?? `wss://mmgrcalltoken.3g.qq.com/agentwss?token=${encodeURIComponent(this.channelToken)}&guid=${encodeURIComponent(this.guid)}&user_id=${encodeURIComponent(this.config.userId ?? '')}`;
|
|
59
|
+
const wsConfig = {
|
|
60
|
+
url: wsUrl,
|
|
61
|
+
reconnectInterval: this.config.reconnectInterval ?? 3000,
|
|
62
|
+
maxReconnectAttempts: this.config.maxReconnectAttempts ?? 10,
|
|
63
|
+
heartbeatInterval: this.config.heartbeatInterval ?? 20000,
|
|
64
|
+
};
|
|
65
|
+
await this.connectWebSocket(wsConfig);
|
|
66
|
+
}
|
|
67
|
+
stop() {
|
|
68
|
+
this.isStopping = true;
|
|
69
|
+
this.stopHeartbeat();
|
|
70
|
+
if (this.reconnectTimer) {
|
|
71
|
+
clearTimeout(this.reconnectTimer);
|
|
72
|
+
this.reconnectTimer = null;
|
|
73
|
+
}
|
|
74
|
+
if (this.ws) {
|
|
75
|
+
this.ws.removeAllListeners();
|
|
76
|
+
this.ws.close();
|
|
77
|
+
this.ws = null;
|
|
78
|
+
}
|
|
79
|
+
this.updateState('disconnected');
|
|
80
|
+
log.info('QClaw transport stopped');
|
|
81
|
+
}
|
|
82
|
+
send(method, payload, replyTo) {
|
|
83
|
+
if (!this.ws || this.state !== 'connected') {
|
|
84
|
+
log.warn('Cannot send message: not connected');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const envelope = {
|
|
88
|
+
msg_id: replyTo ?? this.generateMsgId(),
|
|
89
|
+
guid: this.guid,
|
|
90
|
+
user_id: this.config.userId,
|
|
91
|
+
method: method,
|
|
92
|
+
payload,
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
this.ws.send(JSON.stringify(envelope));
|
|
96
|
+
log.debug('Sent AGP message:', method);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
log.error('Error sending AGP message:', err);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
onMessage(handler) {
|
|
103
|
+
this.messageHandler = handler;
|
|
104
|
+
}
|
|
105
|
+
onStateChange(handler) {
|
|
106
|
+
this.stateChangeHandler = handler;
|
|
107
|
+
}
|
|
108
|
+
getState() {
|
|
109
|
+
return this.state;
|
|
110
|
+
}
|
|
111
|
+
async connectWebSocket(config) {
|
|
112
|
+
this.wsConfigRef = config;
|
|
113
|
+
if (this.state === 'connecting') {
|
|
114
|
+
log.warn('WebSocket connection already in progress');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.updateState('connecting');
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
let settled = false;
|
|
120
|
+
const connectionTimeout = setTimeout(() => {
|
|
121
|
+
if (settled)
|
|
122
|
+
return;
|
|
123
|
+
settled = true;
|
|
124
|
+
log.error('WebSocket connection timeout');
|
|
125
|
+
this.updateState('error');
|
|
126
|
+
try {
|
|
127
|
+
this.ws?.close();
|
|
128
|
+
}
|
|
129
|
+
catch { /* ignore */ }
|
|
130
|
+
reject(new Error('WeChat QClaw WebSocket connection timeout'));
|
|
131
|
+
}, 30000);
|
|
132
|
+
try {
|
|
133
|
+
this.ws = new WebSocket(config.url);
|
|
134
|
+
this.ws.on('open', () => {
|
|
135
|
+
if (settled)
|
|
136
|
+
return;
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(connectionTimeout);
|
|
139
|
+
log.info('QClaw WebSocket connected');
|
|
140
|
+
this.reconnectAttempts = 0;
|
|
141
|
+
this.lastServerResponseTime = Date.now();
|
|
142
|
+
this.updateState('connected');
|
|
143
|
+
this.startHeartbeat(config.heartbeatInterval ?? 20000);
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
this.ws.on('message', async (data) => {
|
|
147
|
+
this.lastServerResponseTime = Date.now();
|
|
148
|
+
try {
|
|
149
|
+
const envelope = JSON.parse(data.toString());
|
|
150
|
+
// Dedup
|
|
151
|
+
if (envelope.msg_id) {
|
|
152
|
+
if (this.processedMsgIds.has(envelope.msg_id)) {
|
|
153
|
+
log.debug('Duplicate message, skipping:', envelope.msg_id);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.processedMsgIds.add(envelope.msg_id);
|
|
157
|
+
this.cleanMsgIdCache();
|
|
158
|
+
}
|
|
159
|
+
await this.handleAGPMessage(envelope);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
log.error('Error parsing WebSocket message:', err);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
this.ws.on('error', (err) => {
|
|
166
|
+
if (settled) {
|
|
167
|
+
log.error('QClaw WebSocket error (after open):', err);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
settled = true;
|
|
171
|
+
clearTimeout(connectionTimeout);
|
|
172
|
+
log.error('QClaw WebSocket error:', err);
|
|
173
|
+
this.updateState('error');
|
|
174
|
+
reject(err);
|
|
175
|
+
});
|
|
176
|
+
this.ws.on('close', () => {
|
|
177
|
+
clearTimeout(connectionTimeout);
|
|
178
|
+
log.info('QClaw WebSocket closed');
|
|
179
|
+
this.stopHeartbeat();
|
|
180
|
+
this.updateState('disconnected');
|
|
181
|
+
if (!settled) {
|
|
182
|
+
settled = true;
|
|
183
|
+
reject(new Error('QClaw WebSocket closed before open'));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.scheduleReconnect(config);
|
|
187
|
+
});
|
|
188
|
+
// WS-level ping/pong for heartbeat
|
|
189
|
+
this.ws.on('ping', () => {
|
|
190
|
+
this.lastServerResponseTime = Date.now();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
settled = true;
|
|
195
|
+
clearTimeout(connectionTimeout);
|
|
196
|
+
log.error('Error creating WebSocket connection:', err);
|
|
197
|
+
this.updateState('error');
|
|
198
|
+
reject(err);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async handleAGPMessage(envelope) {
|
|
203
|
+
switch (envelope.method) {
|
|
204
|
+
case 'ping':
|
|
205
|
+
this.send('ping', { timestamp: Date.now() }, envelope.msg_id);
|
|
206
|
+
break;
|
|
207
|
+
case 'session.prompt':
|
|
208
|
+
case 'session.update':
|
|
209
|
+
case 'session.cancel':
|
|
210
|
+
if (this.messageHandler) {
|
|
211
|
+
await this.messageHandler(envelope);
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
case 'session.promptResponse':
|
|
215
|
+
log.debug('Received prompt response:', envelope.payload);
|
|
216
|
+
break;
|
|
217
|
+
default:
|
|
218
|
+
log.warn('Unknown AGP method:', envelope.method);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
startHeartbeat(interval) {
|
|
222
|
+
this.stopHeartbeat();
|
|
223
|
+
this.lastServerResponseTime = Date.now();
|
|
224
|
+
this.heartbeatTimer = setInterval(() => {
|
|
225
|
+
if (this.state !== 'connected')
|
|
226
|
+
return;
|
|
227
|
+
const elapsed = Date.now() - this.lastServerResponseTime;
|
|
228
|
+
const pongTimeout = interval * PONG_TIMEOUT_FACTOR;
|
|
229
|
+
if (this.lastServerResponseTime > 0 && elapsed > pongTimeout) {
|
|
230
|
+
log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
231
|
+
this.stopHeartbeat();
|
|
232
|
+
if (this.ws) {
|
|
233
|
+
try {
|
|
234
|
+
this.ws.removeAllListeners();
|
|
235
|
+
this.ws.close();
|
|
236
|
+
}
|
|
237
|
+
catch { /* ignore */ }
|
|
238
|
+
this.ws = null;
|
|
239
|
+
}
|
|
240
|
+
this.updateState('disconnected');
|
|
241
|
+
if (this.wsConfigRef) {
|
|
242
|
+
this.scheduleReconnect(this.wsConfigRef);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// WS-level ping
|
|
247
|
+
if (this.ws) {
|
|
248
|
+
try {
|
|
249
|
+
this.ws.ping();
|
|
250
|
+
}
|
|
251
|
+
catch { /* ignore */ }
|
|
252
|
+
}
|
|
253
|
+
}, interval);
|
|
254
|
+
}
|
|
255
|
+
stopHeartbeat() {
|
|
256
|
+
if (this.heartbeatTimer) {
|
|
257
|
+
clearInterval(this.heartbeatTimer);
|
|
258
|
+
this.heartbeatTimer = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
scheduleReconnect(config) {
|
|
262
|
+
if (this.isStopping)
|
|
263
|
+
return;
|
|
264
|
+
if (this.reconnectTimer)
|
|
265
|
+
return;
|
|
266
|
+
const maxAttempts = config.maxReconnectAttempts ?? 10;
|
|
267
|
+
if (this.reconnectAttempts >= maxAttempts) {
|
|
268
|
+
log.warn(`Max reconnect attempts (${maxAttempts}) reached, resetting counter`);
|
|
269
|
+
this.reconnectAttempts = 0;
|
|
270
|
+
}
|
|
271
|
+
const baseInterval = config.reconnectInterval ?? 3000;
|
|
272
|
+
const backoff = Math.min(baseInterval * Math.pow(1.5, Math.floor(this.reconnectAttempts / 3)), 25000);
|
|
273
|
+
const interval = Math.round(backoff);
|
|
274
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
275
|
+
this.reconnectTimer = null;
|
|
276
|
+
this.reconnectAttempts++;
|
|
277
|
+
log.info(`Reconnecting... Attempt ${this.reconnectAttempts}/${maxAttempts} (${interval}ms)`);
|
|
278
|
+
try {
|
|
279
|
+
await this.start();
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
log.error('Reconnection failed:', err);
|
|
283
|
+
}
|
|
284
|
+
}, interval);
|
|
285
|
+
}
|
|
286
|
+
updateState(state) {
|
|
287
|
+
this.state = state;
|
|
288
|
+
this.stateChangeHandler?.(state);
|
|
289
|
+
log.debug('Channel state:', state);
|
|
290
|
+
}
|
|
291
|
+
generateMsgId() {
|
|
292
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
293
|
+
}
|
|
294
|
+
cleanMsgIdCache() {
|
|
295
|
+
if (this.processedMsgIds.size > QClawTransport.MAX_MSG_ID_CACHE) {
|
|
296
|
+
const entries = [...this.processedMsgIds];
|
|
297
|
+
this.processedMsgIds.clear();
|
|
298
|
+
entries.slice(-QClawTransport.MAX_MSG_ID_CACHE / 2).forEach((id) => {
|
|
299
|
+
this.processedMsgIds.add(id);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat Transport Interface
|
|
3
|
+
* 抽象 QClaw 和 WorkBuddy 两种 WebSocket 传输方式
|
|
4
|
+
*/
|
|
5
|
+
import type { AGPEnvelope } from './types.js';
|
|
6
|
+
import type { WeChatChannelState } from './types.js';
|
|
7
|
+
/** 消息回调处理函数 */
|
|
8
|
+
export type MessageHandler = (envelope: AGPEnvelope) => Promise<void>;
|
|
9
|
+
/** 状态变更回调 */
|
|
10
|
+
export type StateChangeHandler = (state: WeChatChannelState) => void;
|
|
11
|
+
/**
|
|
12
|
+
* WeChat 传输接口
|
|
13
|
+
*
|
|
14
|
+
* 所有传输方式(QClaw WebSocket、WorkBuddy Centrifuge)都实现此接口。
|
|
15
|
+
* client.ts 通过此接口与传输层交互,无需关心底层协议。
|
|
16
|
+
*/
|
|
17
|
+
export interface WeChatTransport {
|
|
18
|
+
/** 连接到服务端 */
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
/** 断开连接并释放资源 */
|
|
21
|
+
stop(): void;
|
|
22
|
+
/**
|
|
23
|
+
* 发送 AGP 消息
|
|
24
|
+
* @param method AGP 方法名(如 session.promptResponse, session.update)
|
|
25
|
+
* @param payload 消息负载
|
|
26
|
+
* @param replyTo 回复的 msg_id(可选)
|
|
27
|
+
*/
|
|
28
|
+
send(method: string, payload: unknown, replyTo?: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* 注册消息回调
|
|
31
|
+
* @param handler 收到 AGP envelope 时的回调
|
|
32
|
+
*/
|
|
33
|
+
onMessage(handler: MessageHandler): void;
|
|
34
|
+
/**
|
|
35
|
+
* 注册状态变更回调
|
|
36
|
+
* @param handler 连接状态变更时的回调
|
|
37
|
+
*/
|
|
38
|
+
onStateChange(handler: StateChangeHandler): void;
|
|
39
|
+
/** 获取当前连接状态 */
|
|
40
|
+
getState(): WeChatChannelState;
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy Transport - 通过 Centrifuge WebSocket 连接微信
|
|
3
|
+
*
|
|
4
|
+
* 使用 CodeBuddy/WorkBuddy OAuth 获取 Centrifuge 凭证,
|
|
5
|
+
* 订阅频道接收消息,通过 HTTP POST 发送回复。
|
|
6
|
+
*/
|
|
7
|
+
import type { WeChatChannelState } from './types.js';
|
|
8
|
+
import type { WeChatTransport, MessageHandler, StateChangeHandler } from './transport.js';
|
|
9
|
+
export interface WorkBuddyTransportConfig {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
userId: string;
|
|
13
|
+
hostId?: string;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
guid?: string;
|
|
16
|
+
workspacePath?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class WorkBuddyTransport implements WeChatTransport {
|
|
19
|
+
private config;
|
|
20
|
+
private state;
|
|
21
|
+
private centrifugeClient;
|
|
22
|
+
private oauth;
|
|
23
|
+
private messageHandler;
|
|
24
|
+
private stateChangeHandler;
|
|
25
|
+
constructor(config: WorkBuddyTransportConfig);
|
|
26
|
+
start(): Promise<void>;
|
|
27
|
+
stop(): void;
|
|
28
|
+
send(method: string, payload: unknown, replyTo?: string): void;
|
|
29
|
+
onMessage(handler: MessageHandler): void;
|
|
30
|
+
onStateChange(handler: StateChangeHandler): void;
|
|
31
|
+
getState(): WeChatChannelState;
|
|
32
|
+
private updateState;
|
|
33
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy Transport - 通过 Centrifuge WebSocket 连接微信
|
|
3
|
+
*
|
|
4
|
+
* 使用 CodeBuddy/WorkBuddy OAuth 获取 Centrifuge 凭证,
|
|
5
|
+
* 订阅频道接收消息,通过 HTTP POST 发送回复。
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { createLogger } from '../logger.js';
|
|
9
|
+
import { WorkBuddyCentrifugeClient } from '../workbuddy/centrifuge-client.js';
|
|
10
|
+
import { WorkBuddyOAuth } from '../workbuddy/oauth.js';
|
|
11
|
+
const log = createLogger('WeChat:WorkBuddy');
|
|
12
|
+
export class WorkBuddyTransport {
|
|
13
|
+
config;
|
|
14
|
+
state = 'disconnected';
|
|
15
|
+
centrifugeClient = null;
|
|
16
|
+
oauth = null;
|
|
17
|
+
messageHandler = null;
|
|
18
|
+
stateChangeHandler = null;
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
}
|
|
22
|
+
async start() {
|
|
23
|
+
this.updateState('connecting');
|
|
24
|
+
try {
|
|
25
|
+
// Initialize OAuth
|
|
26
|
+
this.oauth = new WorkBuddyOAuth(this.config.baseUrl ?? 'https://copilot.tencent.com');
|
|
27
|
+
this.oauth.accessToken = this.config.accessToken;
|
|
28
|
+
this.oauth.refreshToken = this.config.refreshToken;
|
|
29
|
+
this.oauth.userId = this.config.userId;
|
|
30
|
+
// Register workspace to get Centrifuge tokens
|
|
31
|
+
log.info('Registering workspace for Centrifuge tokens...');
|
|
32
|
+
const tokens = await this.oauth.registerWorkspace({
|
|
33
|
+
userId: this.config.userId,
|
|
34
|
+
hostId: this.config.hostId ?? randomUUID(),
|
|
35
|
+
workspaceId: randomUUID(),
|
|
36
|
+
workspaceName: 'open-im-wechat',
|
|
37
|
+
});
|
|
38
|
+
log.info(`Workspace registered: channel=${tokens.channel}`);
|
|
39
|
+
// Create Centrifuge client
|
|
40
|
+
const clientConfig = {
|
|
41
|
+
url: tokens.url,
|
|
42
|
+
connectionToken: tokens.connectionToken,
|
|
43
|
+
subscriptionToken: tokens.subscriptionToken,
|
|
44
|
+
channel: tokens.channel,
|
|
45
|
+
guid: this.config.guid ?? randomUUID(),
|
|
46
|
+
userId: this.config.userId,
|
|
47
|
+
httpBaseUrl: this.config.baseUrl ?? 'https://copilot.tencent.com',
|
|
48
|
+
httpAccessToken: this.config.accessToken,
|
|
49
|
+
};
|
|
50
|
+
const callbacks = {
|
|
51
|
+
onConnected: () => {
|
|
52
|
+
log.info('WorkBuddy Centrifuge connected');
|
|
53
|
+
this.updateState('connected');
|
|
54
|
+
},
|
|
55
|
+
onDisconnected: (reason) => {
|
|
56
|
+
log.info(`WorkBuddy Centrifuge disconnected: ${reason}`);
|
|
57
|
+
this.updateState('disconnected');
|
|
58
|
+
},
|
|
59
|
+
onError: (error) => {
|
|
60
|
+
log.error('WorkBuddy Centrifuge error:', error);
|
|
61
|
+
this.updateState('error');
|
|
62
|
+
},
|
|
63
|
+
onMessage: async (chatId, msgId, content) => {
|
|
64
|
+
// Normalize WeChat KF or AGP message to AGPEnvelope
|
|
65
|
+
const envelope = {
|
|
66
|
+
msg_id: msgId,
|
|
67
|
+
guid: this.config.guid,
|
|
68
|
+
user_id: this.config.userId,
|
|
69
|
+
method: 'session.prompt',
|
|
70
|
+
payload: {
|
|
71
|
+
session_id: chatId,
|
|
72
|
+
content,
|
|
73
|
+
options: { stream: true },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
if (this.messageHandler) {
|
|
77
|
+
await this.messageHandler(envelope);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
this.centrifugeClient = new WorkBuddyCentrifugeClient(clientConfig, callbacks);
|
|
82
|
+
this.centrifugeClient.start();
|
|
83
|
+
log.info('WorkBuddy transport initialized');
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
log.error('Failed to start WorkBuddy transport:', err);
|
|
87
|
+
this.updateState('error');
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
stop() {
|
|
92
|
+
if (this.centrifugeClient) {
|
|
93
|
+
this.centrifugeClient.stop();
|
|
94
|
+
this.centrifugeClient = null;
|
|
95
|
+
}
|
|
96
|
+
this.updateState('disconnected');
|
|
97
|
+
log.info('WorkBuddy transport stopped');
|
|
98
|
+
}
|
|
99
|
+
send(method, payload, replyTo) {
|
|
100
|
+
if (!this.centrifugeClient) {
|
|
101
|
+
log.warn('Cannot send message: Centrifuge client not initialized');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const msgId = replyTo ?? randomUUID();
|
|
105
|
+
// For prompt responses, use HTTP path (WorkBuddy's requirement)
|
|
106
|
+
if (method === 'session.promptResponse') {
|
|
107
|
+
const p = payload;
|
|
108
|
+
this.centrifugeClient.sendPromptResponse({
|
|
109
|
+
session_id: p.session_id,
|
|
110
|
+
prompt_id: p.prompt_id ?? msgId,
|
|
111
|
+
content: p.content,
|
|
112
|
+
error: p.error,
|
|
113
|
+
stop_reason: p.stop_reason ?? 'end_turn',
|
|
114
|
+
}, this.config.guid, this.config.userId);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Use sendMessageChunk for session.update, sendToolCall for tool calls
|
|
118
|
+
if (method === 'session.update') {
|
|
119
|
+
const p = payload;
|
|
120
|
+
const sessionId = p.session_id;
|
|
121
|
+
const promptId = p.prompt_id ?? msgId;
|
|
122
|
+
const content = p.content;
|
|
123
|
+
if (content?.[0]?.text) {
|
|
124
|
+
this.centrifugeClient.sendMessageChunk(sessionId, promptId, { type: 'text', text: content[0].text }, this.config.guid, this.config.userId);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Fallback: send via Centrifuge publish
|
|
129
|
+
log.debug(`WorkBuddy send: method=${method}, msg_id=${msgId}`);
|
|
130
|
+
}
|
|
131
|
+
onMessage(handler) {
|
|
132
|
+
this.messageHandler = handler;
|
|
133
|
+
}
|
|
134
|
+
onStateChange(handler) {
|
|
135
|
+
this.stateChangeHandler = handler;
|
|
136
|
+
}
|
|
137
|
+
getState() {
|
|
138
|
+
return this.state;
|
|
139
|
+
}
|
|
140
|
+
updateState(state) {
|
|
141
|
+
this.state = state;
|
|
142
|
+
this.stateChangeHandler?.(state);
|
|
143
|
+
log.debug('Channel state:', state);
|
|
144
|
+
}
|
|
145
|
+
}
|