@wu529778790/open-im 1.8.1-beta.21 → 1.8.1-beta.22

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 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
- // 微信启用条件:AGP 协议凭证 或 标准协议凭证
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 wechatEnabled = (hasWechatAGPCreds || hasWechatStandardCreds) && (wechatEnabledFlag !== false);
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
  ? {
@@ -1,8 +1,15 @@
1
1
  /**
2
- * WeChat Client - AGP WebSocket implementation for WeChat integration
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 with AGP WebSocket connection
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 WebSocket
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
  /**
@@ -1,33 +1,36 @@
1
1
  /**
2
- * WeChat Client - AGP WebSocket implementation for WeChat integration
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 ws = null;
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 lastServerResponseTime = 0; // 上次收到服务端消息的时间
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
- sendAGPMessage('ping', { timestamp: Date.now() }, envelope.msg_id);
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 with AGP WebSocket connection
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
- // Configure WebSocket
87
- const wsConfig = {
88
- url: config.wechatWsUrl ?? DEFAULT_WECHAT_WS_URL,
89
- reconnectInterval: 5000,
90
- maxReconnectAttempts: 10,
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
- updateState('connecting');
106
- return new Promise((resolve, reject) => {
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
- catch (err) {
188
- if (envelope.method === 'session.prompt' || envelope.method === 'session.update' || envelope.method === 'session.cancel') {
189
- log.error('Error in message handler:', err);
190
- return;
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
- throw err;
193
- }
94
+ });
95
+ await transport.start();
96
+ log.info(`WeChat client initialized (${loginMode} mode)`);
194
97
  }
195
98
  /**
196
- * Send AGP message through WebSocket
99
+ * Create QClaw transport from config
197
100
  */
198
- export function sendAGPMessage(method, payload, replyTo) {
199
- if (!ws || channelState !== 'connected') {
200
- log.warn('Cannot send message: WebSocket not connected');
201
- return;
202
- }
203
- const envelope = {
204
- msg_id: replyTo ?? generateMsgId(),
205
- method: method,
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
- try {
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
- * Start heartbeat to keep connection alive
234
- * 同时检测服务端是否响应,超时无响应则主动断开触发重连
113
+ * Create WorkBuddy transport from config
235
114
  */
236
- function startHeartbeat(interval) {
237
- stopHeartbeat();
238
- lastServerResponseTime = Date.now();
239
- heartbeatTimer = setInterval(() => {
240
- if (channelState === 'connected') {
241
- // 检测连接是否已死:长时间未收到任何服务端响应
242
- const elapsed = Date.now() - lastServerResponseTime;
243
- const pongTimeout = interval * PONG_TIMEOUT_FACTOR;
244
- if (lastServerResponseTime > 0 && elapsed > pongTimeout) {
245
- log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
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
- * Stop heartbeat
127
+ * Send AGP message through the transport
269
128
  */
270
- function stopHeartbeat() {
271
- if (heartbeatTimer) {
272
- clearInterval(heartbeatTimer);
273
- heartbeatTimer = null;
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
- * Schedule reconnection attempt
278
- * 超过 maxAttempts 后自动重置计数器继续重试,避免永久断连
137
+ * Stop WeChat client
279
138
  */
280
- function scheduleReconnect(config) {
281
- if (isStopping)
282
- return;
283
- const maxAttempts = config.maxReconnectAttempts ?? 10;
284
- if (reconnectTimer) {
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
- const baseInterval = config.reconnectInterval ?? 5000;
293
- // 超过一半次数后逐渐增加间隔,最大 60 秒
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,5 @@
1
+ /**
2
+ * WeChat Transport Interface
3
+ * 抽象 QClaw 和 WorkBuddy 两种 WebSocket 传输方式
4
+ */
5
+ export {};
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.8.1-beta.21",
3
+ "version": "1.8.1-beta.22",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",