@wu529778790/open-im 1.8.1-beta.2 → 1.8.1-beta.21
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/access/access-control.js +1 -1
- package/dist/adapters/claude-sdk-adapter.js +94 -36
- package/dist/channels/capabilities.js +5 -0
- package/dist/cli.js +5 -2
- package/dist/commands/handler.d.ts +1 -2
- package/dist/commands/handler.js +6 -18
- package/dist/config-web-page-i18n.d.ts +12 -0
- package/dist/config-web-page-i18n.js +12 -0
- package/dist/config-web-page-script.js +1 -0
- package/dist/config-web-page-template.js +48 -1
- package/dist/config-web.js +110 -7
- package/dist/config.d.ts +25 -1
- package/dist/config.js +46 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/dingtalk/client.js +11 -3
- package/dist/dingtalk/event-handler.js +18 -3
- package/dist/dingtalk/message-sender.js +13 -0
- package/dist/feishu/event-handler.js +144 -10
- package/dist/index.js +26 -2
- package/dist/manager-control.js +7 -0
- package/dist/qq/client.js +111 -88
- package/dist/qq/event-handler.js +16 -2
- package/dist/qq/message-sender.js +11 -0
- package/dist/service-control.js +4 -0
- package/dist/session/session-manager.js +11 -1
- package/dist/setup.js +2 -1
- package/dist/shared/active-chats.d.ts +2 -2
- package/dist/shared/ai-task.js +13 -1
- package/dist/shared/chat-user-map.js +11 -0
- package/dist/shared/media-storage.js +27 -0
- package/dist/telegram/client.js +25 -3
- package/dist/telegram/event-handler.js +44 -8
- package/dist/telegram/message-sender.js +13 -0
- package/dist/wechat/auth/qclaw-api.js +1 -1
- package/dist/wechat/client.js +81 -4
- package/dist/wechat/event-handler.js +10 -3
- package/dist/wework/client.js +36 -14
- package/dist/wework/event-handler.js +39 -4
- package/dist/wework/message-sender.js +53 -21
- package/dist/workbuddy/centrifuge-client.d.ts +74 -0
- package/dist/workbuddy/centrifuge-client.js +272 -0
- package/dist/workbuddy/client.d.ts +27 -0
- package/dist/workbuddy/client.js +162 -0
- package/dist/workbuddy/event-handler.d.ts +11 -0
- package/dist/workbuddy/event-handler.js +118 -0
- package/dist/workbuddy/index.d.ts +8 -0
- package/dist/workbuddy/index.js +8 -0
- package/dist/workbuddy/message-sender.d.ts +16 -0
- package/dist/workbuddy/message-sender.js +51 -0
- package/dist/workbuddy/oauth.d.ts +114 -0
- package/dist/workbuddy/oauth.js +310 -0
- package/dist/workbuddy/types.d.ts +86 -0
- package/dist/workbuddy/types.js +4 -0
- package/package.json +4 -2
|
@@ -101,6 +101,18 @@ function formatWeWorkMessage(title, content, status, note) {
|
|
|
101
101
|
return message;
|
|
102
102
|
}
|
|
103
103
|
const streamStates = new Map();
|
|
104
|
+
// Periodic cleanup of expired/orphaned stream states
|
|
105
|
+
const STREAM_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
106
|
+
setInterval(() => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
for (const [id, state] of streamStates) {
|
|
109
|
+
if (now - state.createdAt >= STREAM_SAFE_TTL_MS) {
|
|
110
|
+
state.closed = true;
|
|
111
|
+
streamStates.delete(id);
|
|
112
|
+
log.info(`Cleaned up expired stream state: ${id}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}, STREAM_CLEANUP_INTERVAL_MS);
|
|
104
116
|
function sleep(ms) {
|
|
105
117
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
118
|
}
|
|
@@ -187,6 +199,12 @@ export async function updateMessage(chatId, streamId, content, status, note, too
|
|
|
187
199
|
return;
|
|
188
200
|
if (Date.now() - state.createdAt >= STREAM_SAFE_TTL_MS) {
|
|
189
201
|
markExpired(state, streamId);
|
|
202
|
+
// Stream expired - fall back to text delivery for errors and final states
|
|
203
|
+
if (status === 'error' || status === 'done') {
|
|
204
|
+
const reqIdUsed = getReqId(reqId);
|
|
205
|
+
sendText(reqIdUsed, message);
|
|
206
|
+
log.info(`Stream expired, sent ${status} via text fallback: streamId=${streamId}`);
|
|
207
|
+
}
|
|
190
208
|
return;
|
|
191
209
|
}
|
|
192
210
|
state.pendingUpdate = { message, status, reqId };
|
|
@@ -207,17 +225,24 @@ export async function sendFinalMessages(chatId, streamId, fullContent, note, too
|
|
|
207
225
|
const title = getToolTitle(toolId, 'done');
|
|
208
226
|
const parts = splitLongContent(contentToSend, MAX_WEWORK_MESSAGE_LENGTH);
|
|
209
227
|
const finalMessage = formatWeWorkMessage(title, parts[0], 'done', parts.length > 1 ? `内容较长,已分段发送 (1/${parts.length})` : note);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
const state = streamStates.get(streamId);
|
|
229
|
+
const shouldFallbackToText = !!state && (state.expired || Date.now() - state.createdAt >= STREAM_SAFE_TTL_MS);
|
|
230
|
+
// 先发一条「输出中」带正文,再发 finish 的最终条,避免企微端一直停在「思考中」不刷新
|
|
231
|
+
// 独立 try-catch:即使此步失败,仍须发送 finish=true,否则企微永远卡在「正在思考」
|
|
232
|
+
if (!shouldFallbackToText && state && contentToSend.length > 0) {
|
|
233
|
+
try {
|
|
215
234
|
await updateMessage(chatId, streamId, contentToSend, 'streaming', note, toolId, reqId);
|
|
216
235
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
state.pendingUpdate = undefined;
|
|
236
|
+
catch (err) {
|
|
237
|
+
log.warn('Pre-finish streaming update failed, will still send finish=true:', err);
|
|
220
238
|
}
|
|
239
|
+
}
|
|
240
|
+
if (state) {
|
|
241
|
+
state.closed = true;
|
|
242
|
+
state.pendingUpdate = undefined;
|
|
243
|
+
}
|
|
244
|
+
// finish=true 是关键:必须保证发出,否则企微 UI 永远停留在「正在思考」
|
|
245
|
+
try {
|
|
221
246
|
if (!shouldFallbackToText) {
|
|
222
247
|
if (state) {
|
|
223
248
|
const elapsed = Date.now() - state.lastSentAt;
|
|
@@ -232,21 +257,28 @@ export async function sendFinalMessages(chatId, streamId, fullContent, note, too
|
|
|
232
257
|
sendText(getReqId(reqId), finalMessage);
|
|
233
258
|
log.info(`Final stream expired, sent text fallback instead: streamId=${streamId}`);
|
|
234
259
|
}
|
|
235
|
-
streamStates.delete(streamId);
|
|
236
|
-
for (let i = 1; i < parts.length; i++) {
|
|
237
|
-
try {
|
|
238
|
-
const partContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
|
|
239
|
-
const partMessage = formatWeWorkMessage(title, partContent, 'done', i === parts.length - 1 ? note : undefined);
|
|
240
|
-
sendText(getReqId(reqId), partMessage);
|
|
241
|
-
log.info(`Final message part ${i + 1}/${parts.length} sent`);
|
|
242
|
-
}
|
|
243
|
-
catch (err) {
|
|
244
|
-
log.error(`Failed to send part ${i + 1}:`, err);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
260
|
}
|
|
248
261
|
catch (err) {
|
|
249
|
-
log.
|
|
262
|
+
log.warn('Primary finish send failed, trying text fallback:', err);
|
|
263
|
+
try {
|
|
264
|
+
sendText(getReqId(reqId), finalMessage);
|
|
265
|
+
log.info(`Fallback text sent after primary finish failure, streamId=${streamId}`);
|
|
266
|
+
}
|
|
267
|
+
catch (fallbackErr) {
|
|
268
|
+
log.error('Both primary and fallback finish sends failed:', fallbackErr);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
streamStates.delete(streamId);
|
|
272
|
+
for (let i = 1; i < parts.length; i++) {
|
|
273
|
+
try {
|
|
274
|
+
const partContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
|
|
275
|
+
const partMessage = formatWeWorkMessage(title, partContent, 'done', i === parts.length - 1 ? note : undefined);
|
|
276
|
+
sendText(getReqId(reqId), partMessage);
|
|
277
|
+
log.info(`Final message part ${i + 1}/${parts.length} sent`);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
log.error(`Failed to send part ${i + 1}:`, err);
|
|
281
|
+
}
|
|
250
282
|
}
|
|
251
283
|
}
|
|
252
284
|
/**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy Centrifuge Client - WebSocket connection for WeChat KF messages
|
|
3
|
+
*/
|
|
4
|
+
import type { WorkBuddyState, PromptResponsePayload } from './types.js';
|
|
5
|
+
/** Centrifuge client configuration */
|
|
6
|
+
export interface CentrifugeClientConfig {
|
|
7
|
+
url: string;
|
|
8
|
+
connectionToken: string;
|
|
9
|
+
subscriptionToken: string;
|
|
10
|
+
channel: string;
|
|
11
|
+
guid: string;
|
|
12
|
+
userId: string;
|
|
13
|
+
httpBaseUrl?: string;
|
|
14
|
+
httpAccessToken?: string;
|
|
15
|
+
workspaceSessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Client callbacks */
|
|
18
|
+
export interface CentrifugeCallbacks {
|
|
19
|
+
onConnected?: () => void;
|
|
20
|
+
onDisconnected?: (reason?: string) => void;
|
|
21
|
+
onError?: (error: Error) => void;
|
|
22
|
+
onMessage?: (chatId: string, msgId: string, content: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare class WorkBuddyCentrifugeClient {
|
|
25
|
+
private config;
|
|
26
|
+
private callbacks;
|
|
27
|
+
private client;
|
|
28
|
+
private sub;
|
|
29
|
+
private extraSubs;
|
|
30
|
+
private state;
|
|
31
|
+
private processedMsgIds;
|
|
32
|
+
private static readonly MAX_MSG_ID_CACHE;
|
|
33
|
+
constructor(config: CentrifugeClientConfig, callbacks?: CentrifugeCallbacks);
|
|
34
|
+
get logPrefix(): string;
|
|
35
|
+
getState(): WorkBuddyState;
|
|
36
|
+
start(): void;
|
|
37
|
+
stop(): void;
|
|
38
|
+
setCallbacks(callbacks: Partial<CentrifugeCallbacks>): void;
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe to additional channel
|
|
41
|
+
*/
|
|
42
|
+
subscribeChannel(channel: string, subscriptionToken: string): void;
|
|
43
|
+
/**
|
|
44
|
+
* Send message chunk through Centrifuge
|
|
45
|
+
*/
|
|
46
|
+
sendMessageChunk(sessionId: string, promptId: string, content: {
|
|
47
|
+
type: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
}, guid?: string, userId?: string): void;
|
|
50
|
+
/**
|
|
51
|
+
* Send tool call through Centrifuge
|
|
52
|
+
*/
|
|
53
|
+
sendToolCall(sessionId: string, promptId: string, toolCall: {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
input?: Record<string, unknown>;
|
|
57
|
+
}, guid?: string, userId?: string): void;
|
|
58
|
+
/**
|
|
59
|
+
* Send prompt response (for WeChat KF, use HTTP instead)
|
|
60
|
+
*/
|
|
61
|
+
sendPromptResponse(payload: PromptResponsePayload, _guid?: string, _userId?: string): void;
|
|
62
|
+
/**
|
|
63
|
+
* Handle incoming publication from Centrifuge
|
|
64
|
+
*/
|
|
65
|
+
private handlePublication;
|
|
66
|
+
/**
|
|
67
|
+
* Send AGP envelope through Centrifuge
|
|
68
|
+
*/
|
|
69
|
+
private sendEnvelope;
|
|
70
|
+
/**
|
|
71
|
+
* Clean up old message IDs from cache
|
|
72
|
+
*/
|
|
73
|
+
private cleanMsgIdCache;
|
|
74
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy Centrifuge Client - WebSocket connection for WeChat KF messages
|
|
3
|
+
*/
|
|
4
|
+
import { Centrifuge } from 'centrifuge';
|
|
5
|
+
import { WebSocket } from 'ws';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const log = createLogger('WorkBuddyCentrifuge');
|
|
9
|
+
export class WorkBuddyCentrifugeClient {
|
|
10
|
+
config;
|
|
11
|
+
callbacks;
|
|
12
|
+
client = null;
|
|
13
|
+
sub = null;
|
|
14
|
+
extraSubs = [];
|
|
15
|
+
state = 'disconnected';
|
|
16
|
+
processedMsgIds = new Set();
|
|
17
|
+
static MAX_MSG_ID_CACHE = 1000;
|
|
18
|
+
constructor(config, callbacks = {}) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.callbacks = callbacks;
|
|
21
|
+
}
|
|
22
|
+
get logPrefix() {
|
|
23
|
+
return `[workbuddy:${this.config.userId}]`;
|
|
24
|
+
}
|
|
25
|
+
getState() {
|
|
26
|
+
return this.state;
|
|
27
|
+
}
|
|
28
|
+
start() {
|
|
29
|
+
if (this.state === 'connected' || this.state === 'connecting') {
|
|
30
|
+
log.info(`${this.logPrefix} Already connected or connecting`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.state = 'connecting';
|
|
34
|
+
log.info(`${this.logPrefix} Connecting to: ${this.config.url}, channel=${this.config.channel}`);
|
|
35
|
+
this.client = new Centrifuge(this.config.url, {
|
|
36
|
+
token: this.config.connectionToken,
|
|
37
|
+
websocket: WebSocket,
|
|
38
|
+
});
|
|
39
|
+
this.client.on('connected', (ctx) => {
|
|
40
|
+
log.info(`${this.logPrefix} Connected (transport=${ctx.transport})`);
|
|
41
|
+
this.state = 'connected';
|
|
42
|
+
this.callbacks.onConnected?.();
|
|
43
|
+
});
|
|
44
|
+
this.client.on('disconnected', (ctx) => {
|
|
45
|
+
log.info(`${this.logPrefix} Disconnected: code=${ctx.code}, reason=${ctx.reason}`);
|
|
46
|
+
if (this.state !== 'disconnected') {
|
|
47
|
+
this.state = 'disconnected';
|
|
48
|
+
this.callbacks.onDisconnected?.(ctx.reason || `code=${ctx.code}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
this.client.on('connecting', (ctx) => {
|
|
52
|
+
log.info(`${this.logPrefix} Reconnecting: code=${ctx.code}, reason=${ctx.reason}`);
|
|
53
|
+
if (this.state === 'connected') {
|
|
54
|
+
this.state = 'reconnecting';
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
this.client.on('error', (ctx) => {
|
|
58
|
+
log.error(`${this.logPrefix} Error: ${ctx.error.message}`);
|
|
59
|
+
this.callbacks.onError?.(new Error(ctx.error.message));
|
|
60
|
+
});
|
|
61
|
+
// Create channel subscription
|
|
62
|
+
this.sub = this.client.newSubscription(this.config.channel, {
|
|
63
|
+
token: this.config.subscriptionToken,
|
|
64
|
+
});
|
|
65
|
+
this.sub.on('publication', (ctx) => {
|
|
66
|
+
this.handlePublication(ctx.data);
|
|
67
|
+
});
|
|
68
|
+
this.sub.on('error', (ctx) => {
|
|
69
|
+
log.error(`${this.logPrefix} Subscription error: ${ctx.error.message}`);
|
|
70
|
+
});
|
|
71
|
+
this.sub.subscribe();
|
|
72
|
+
this.client.connect();
|
|
73
|
+
}
|
|
74
|
+
stop() {
|
|
75
|
+
log.info(`${this.logPrefix} Stopping...`);
|
|
76
|
+
this.state = 'disconnected';
|
|
77
|
+
this.processedMsgIds.clear();
|
|
78
|
+
for (const sub of this.extraSubs) {
|
|
79
|
+
sub.unsubscribe();
|
|
80
|
+
}
|
|
81
|
+
this.extraSubs = [];
|
|
82
|
+
if (this.sub) {
|
|
83
|
+
this.sub.unsubscribe();
|
|
84
|
+
this.sub = null;
|
|
85
|
+
}
|
|
86
|
+
if (this.client) {
|
|
87
|
+
this.client.disconnect();
|
|
88
|
+
this.client = null;
|
|
89
|
+
}
|
|
90
|
+
log.info(`${this.logPrefix} Stopped`);
|
|
91
|
+
}
|
|
92
|
+
setCallbacks(callbacks) {
|
|
93
|
+
this.callbacks = { ...this.callbacks, ...callbacks };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Subscribe to additional channel
|
|
97
|
+
*/
|
|
98
|
+
subscribeChannel(channel, subscriptionToken) {
|
|
99
|
+
if (!this.client) {
|
|
100
|
+
log.warn(`${this.logPrefix} Cannot subscribe: client not initialized`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
log.info(`${this.logPrefix} Subscribing to additional channel: ${channel}`);
|
|
104
|
+
const sub = this.client.newSubscription(channel, { token: subscriptionToken });
|
|
105
|
+
sub.on('publication', (ctx) => {
|
|
106
|
+
this.handlePublication(ctx.data);
|
|
107
|
+
});
|
|
108
|
+
sub.on('error', (ctx) => {
|
|
109
|
+
log.error(`${this.logPrefix} Extra subscription error (${channel}): ${ctx.error.message}`);
|
|
110
|
+
});
|
|
111
|
+
sub.on('subscribed', () => {
|
|
112
|
+
log.info(`${this.logPrefix} Extra channel subscribed: ${channel}`);
|
|
113
|
+
});
|
|
114
|
+
this.extraSubs.push(sub);
|
|
115
|
+
sub.subscribe();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Send message chunk through Centrifuge
|
|
119
|
+
*/
|
|
120
|
+
sendMessageChunk(sessionId, promptId, content, guid, userId) {
|
|
121
|
+
const payload = {
|
|
122
|
+
session_id: sessionId,
|
|
123
|
+
prompt_id: promptId,
|
|
124
|
+
update_type: 'message_chunk',
|
|
125
|
+
content: [content],
|
|
126
|
+
};
|
|
127
|
+
this.sendEnvelope('session.update', payload, guid, userId);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Send tool call through Centrifuge
|
|
131
|
+
*/
|
|
132
|
+
sendToolCall(sessionId, promptId, toolCall, guid, userId) {
|
|
133
|
+
const payload = {
|
|
134
|
+
session_id: sessionId,
|
|
135
|
+
prompt_id: promptId,
|
|
136
|
+
update_type: 'tool_call',
|
|
137
|
+
tool_call: toolCall,
|
|
138
|
+
};
|
|
139
|
+
this.sendEnvelope('session.update', payload, guid, userId);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Send prompt response (for WeChat KF, use HTTP instead)
|
|
143
|
+
*/
|
|
144
|
+
sendPromptResponse(payload, _guid, _userId) {
|
|
145
|
+
// WeChat KF messages: send via HTTP COPILOT_RESPONSE
|
|
146
|
+
if (this.config.httpBaseUrl && this.config.httpAccessToken) {
|
|
147
|
+
const message = payload.content?.map((c) => c.text).join('') || payload.error || '';
|
|
148
|
+
const httpPayload = {
|
|
149
|
+
type: 'COPILOT_RESPONSE',
|
|
150
|
+
msgId: payload.prompt_id,
|
|
151
|
+
chatId: payload.session_id,
|
|
152
|
+
success: payload.stop_reason === 'end_turn',
|
|
153
|
+
message,
|
|
154
|
+
metadata: {
|
|
155
|
+
sessionId: this.config.workspaceSessionId || payload.session_id,
|
|
156
|
+
requestId: payload.prompt_id,
|
|
157
|
+
state: payload.stop_reason === 'end_turn' ? 'completed' : payload.stop_reason,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
const url = `${this.config.httpBaseUrl}/v2/backgroundagent/wecom/local-proxy/receive`;
|
|
161
|
+
fetch(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
Authorization: `Bearer ${this.config.httpAccessToken}`,
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(httpPayload),
|
|
168
|
+
signal: AbortSignal.timeout(30_000),
|
|
169
|
+
})
|
|
170
|
+
.then(async (res) => {
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const body = await res.text().catch(() => '');
|
|
173
|
+
log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE failed: ${res.status} ${body.substring(0, 200)}`);
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
.catch((err) => {
|
|
177
|
+
log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE error:`, err);
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.sendEnvelope('session.promptResponse', payload, _guid, _userId);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Handle incoming publication from Centrifuge
|
|
185
|
+
*/
|
|
186
|
+
handlePublication(data) {
|
|
187
|
+
try {
|
|
188
|
+
const raw = data;
|
|
189
|
+
// AGP format message (from QClaw gateway)
|
|
190
|
+
if (raw?.method && raw?.msg_id) {
|
|
191
|
+
const envelope = raw;
|
|
192
|
+
if (this.processedMsgIds.has(envelope.msg_id)) {
|
|
193
|
+
log.debug(`${this.logPrefix} Duplicate message, skipping: ${envelope.msg_id}`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.processedMsgIds.add(envelope.msg_id);
|
|
197
|
+
this.cleanMsgIdCache();
|
|
198
|
+
log.debug(`${this.logPrefix} Received AGP message: method=${envelope.method}, msg_id=${envelope.msg_id}`);
|
|
199
|
+
if (envelope.method === 'session.prompt') {
|
|
200
|
+
const payload = envelope.payload;
|
|
201
|
+
const content = payload.content?.find((c) => c.type === 'text')?.text || '';
|
|
202
|
+
this.callbacks.onMessage?.(payload.session_id, envelope.msg_id, content);
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// WeChat KF format message (from WorkBuddy Centrifuge)
|
|
207
|
+
if (raw?.chatId && raw?.msgId) {
|
|
208
|
+
const msgId = String(raw.msgId);
|
|
209
|
+
if (this.processedMsgIds.has(msgId)) {
|
|
210
|
+
log.debug(`${this.logPrefix} Duplicate message, skipping: ${msgId}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.processedMsgIds.add(msgId);
|
|
214
|
+
this.cleanMsgIdCache();
|
|
215
|
+
const content = String(raw.content ?? '');
|
|
216
|
+
const chatId = String(raw.chatId);
|
|
217
|
+
log.info(`${this.logPrefix} Received WeChat KF message: msgId=${msgId}, chatId=${chatId}, content=${content.substring(0, 50)}`);
|
|
218
|
+
this.callbacks.onMessage?.(chatId, msgId, content);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const preview = JSON.stringify(data).substring(0, 500);
|
|
222
|
+
log.warn(`${this.logPrefix} Unknown message format: ${preview}`);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
log.error(`${this.logPrefix} Message handling failed:`, error);
|
|
226
|
+
this.callbacks.onError?.(error instanceof Error ? error : new Error(`Message handling failed: ${String(error)}`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Send AGP envelope through Centrifuge
|
|
231
|
+
*/
|
|
232
|
+
sendEnvelope(method, payload, guid, userId) {
|
|
233
|
+
if (!this.client || this.state !== 'connected') {
|
|
234
|
+
log.warn(`${this.logPrefix} Cannot send message, state: ${this.state}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const envelope = {
|
|
238
|
+
msg_id: randomUUID(),
|
|
239
|
+
guid: guid ?? this.config.guid,
|
|
240
|
+
user_id: userId ?? this.config.userId,
|
|
241
|
+
method: method,
|
|
242
|
+
payload,
|
|
243
|
+
};
|
|
244
|
+
try {
|
|
245
|
+
this.client.publish(this.config.channel, envelope).catch((err) => {
|
|
246
|
+
log.error(`${this.logPrefix} Message send failed:`, err);
|
|
247
|
+
this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
248
|
+
});
|
|
249
|
+
const json = JSON.stringify(envelope);
|
|
250
|
+
const preview = json.length > 500 ? json.substring(0, 500) + `...(truncated)` : json;
|
|
251
|
+
log.debug(`${this.logPrefix} Sent message: method=${method}, msg_id=${envelope.msg_id}`);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
log.error(`${this.logPrefix} Message send failed:`, error);
|
|
255
|
+
this.callbacks.onError?.(error instanceof Error ? error : new Error(`Message send failed: ${String(error)}`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Clean up old message IDs from cache
|
|
260
|
+
*/
|
|
261
|
+
cleanMsgIdCache() {
|
|
262
|
+
if (this.processedMsgIds.size > WorkBuddyCentrifugeClient.MAX_MSG_ID_CACHE) {
|
|
263
|
+
const entries = [...this.processedMsgIds];
|
|
264
|
+
this.processedMsgIds.clear();
|
|
265
|
+
entries
|
|
266
|
+
.slice(-WorkBuddyCentrifugeClient.MAX_MSG_ID_CACHE / 2)
|
|
267
|
+
.forEach((id) => {
|
|
268
|
+
this.processedMsgIds.add(id);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy Client - Main client for WorkBuddy WeChat integration
|
|
3
|
+
*/
|
|
4
|
+
import type { Config } from '../config.js';
|
|
5
|
+
import { WorkBuddyOAuth } from './oauth.js';
|
|
6
|
+
import { WorkBuddyCentrifugeClient } from './centrifuge-client.js';
|
|
7
|
+
import type { WorkBuddyState } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Get current channel state
|
|
10
|
+
*/
|
|
11
|
+
export declare function getChannelState(): WorkBuddyState;
|
|
12
|
+
/**
|
|
13
|
+
* Initialize WorkBuddy client with CodeBuddy OAuth and Centrifuge WebSocket
|
|
14
|
+
*/
|
|
15
|
+
export declare function initWorkBuddy(config: Config, eventHandler: (chatId: string, msgId: string, content: string) => Promise<void>, onStateChange?: (state: WorkBuddyState) => void): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Get Centrifuge client for sending messages
|
|
18
|
+
*/
|
|
19
|
+
export declare function getCentrifugeClient(): WorkBuddyCentrifugeClient | null;
|
|
20
|
+
/**
|
|
21
|
+
* Get OAuth client
|
|
22
|
+
*/
|
|
23
|
+
export declare function getOAuth(): WorkBuddyOAuth | null;
|
|
24
|
+
/**
|
|
25
|
+
* Stop WorkBuddy client
|
|
26
|
+
*/
|
|
27
|
+
export declare function stopWorkBuddy(): void;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy Client - Main client for WorkBuddy WeChat integration
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
import { WorkBuddyOAuth } from './oauth.js';
|
|
9
|
+
import { WorkBuddyCentrifugeClient } from './centrifuge-client.js';
|
|
10
|
+
const log = createLogger('WorkBuddy');
|
|
11
|
+
const CREDENTIALS_FILE = 'workbuddy-credentials.json';
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://copilot.tencent.com';
|
|
13
|
+
const DEFAULT_WORKSPACE_ID = 'open-im-workspace';
|
|
14
|
+
const DEFAULT_WORKSPACE_NAME = 'OpenIM Workspace';
|
|
15
|
+
// Global state
|
|
16
|
+
let oauth = null;
|
|
17
|
+
let centrifugeClient = null;
|
|
18
|
+
let channelState = 'disconnected';
|
|
19
|
+
let credentialsPath = null;
|
|
20
|
+
let currentSessionId = null;
|
|
21
|
+
// Event handlers
|
|
22
|
+
let messageHandler = null;
|
|
23
|
+
let stateChangeHandler = null;
|
|
24
|
+
/**
|
|
25
|
+
* Get current channel state
|
|
26
|
+
*/
|
|
27
|
+
export function getChannelState() {
|
|
28
|
+
return channelState;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Initialize WorkBuddy client with CodeBuddy OAuth and Centrifuge WebSocket
|
|
32
|
+
*/
|
|
33
|
+
export async function initWorkBuddy(config, eventHandler, onStateChange) {
|
|
34
|
+
const platformConfig = config.platforms?.workbuddy;
|
|
35
|
+
if (!platformConfig?.enabled) {
|
|
36
|
+
throw new Error('WorkBuddy platform not enabled');
|
|
37
|
+
}
|
|
38
|
+
// Check credentials
|
|
39
|
+
const hasCredentials = platformConfig.accessToken && platformConfig.refreshToken && platformConfig.userId;
|
|
40
|
+
if (!hasCredentials) {
|
|
41
|
+
throw new Error('WorkBuddy credentials required: accessToken, refreshToken, userId');
|
|
42
|
+
}
|
|
43
|
+
log.info('Initializing WorkBuddy client...');
|
|
44
|
+
messageHandler = eventHandler;
|
|
45
|
+
stateChangeHandler = onStateChange ?? null;
|
|
46
|
+
// Set up credentials storage path
|
|
47
|
+
const baseDir = config.logDir ?? join(process.env.HOME ?? '', '.open-im');
|
|
48
|
+
credentialsPath = join(baseDir, 'data');
|
|
49
|
+
if (!existsSync(credentialsPath)) {
|
|
50
|
+
mkdirSync(credentialsPath, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
// Initialize OAuth client
|
|
53
|
+
const baseUrl = platformConfig.baseUrl || DEFAULT_BASE_URL;
|
|
54
|
+
oauth = new WorkBuddyOAuth(baseUrl);
|
|
55
|
+
oauth.loadCredentials({
|
|
56
|
+
accessToken: platformConfig.accessToken,
|
|
57
|
+
refreshToken: platformConfig.refreshToken,
|
|
58
|
+
userId: platformConfig.userId,
|
|
59
|
+
});
|
|
60
|
+
// Build session ID
|
|
61
|
+
currentSessionId = oauth.buildSessionId(platformConfig.workspacePath);
|
|
62
|
+
log.info(`WorkBuddy sessionId: ${currentSessionId ?? ''}`);
|
|
63
|
+
// Register workspace to get Centrifuge tokens
|
|
64
|
+
let centrifugeTokens;
|
|
65
|
+
try {
|
|
66
|
+
centrifugeTokens = await oauth.registerWorkspace({
|
|
67
|
+
userId: platformConfig.userId || '',
|
|
68
|
+
hostId: hostname(),
|
|
69
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
70
|
+
workspaceName: DEFAULT_WORKSPACE_NAME,
|
|
71
|
+
});
|
|
72
|
+
log.info(`Registered workspace: channel=${centrifugeTokens.channel}`);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
log.error('Failed to register workspace:', err);
|
|
76
|
+
throw new Error(`WorkBuddy workspace registration failed: ${err}`);
|
|
77
|
+
}
|
|
78
|
+
// Generate GUID for this instance
|
|
79
|
+
const guid = platformConfig.guid || randomUUID();
|
|
80
|
+
const workspaceSessionId = currentSessionId || '';
|
|
81
|
+
// Create Centrifuge client
|
|
82
|
+
const callbacks = {
|
|
83
|
+
onConnected: () => {
|
|
84
|
+
log.info('WorkBuddy Centrifuge connected');
|
|
85
|
+
updateState('connected');
|
|
86
|
+
},
|
|
87
|
+
onDisconnected: (reason) => {
|
|
88
|
+
log.info(`WorkBuddy Centrifuge disconnected: ${reason}`);
|
|
89
|
+
updateState('disconnected');
|
|
90
|
+
},
|
|
91
|
+
onError: (error) => {
|
|
92
|
+
log.error('WorkBuddy Centrifuge error:', error);
|
|
93
|
+
updateState('error');
|
|
94
|
+
},
|
|
95
|
+
onMessage: async (chatId, msgId, content) => {
|
|
96
|
+
if (messageHandler) {
|
|
97
|
+
try {
|
|
98
|
+
await messageHandler(chatId, msgId, content);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
log.error('Error in message handler:', err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
centrifugeClient = new WorkBuddyCentrifugeClient({
|
|
107
|
+
url: centrifugeTokens.url,
|
|
108
|
+
connectionToken: centrifugeTokens.connectionToken,
|
|
109
|
+
subscriptionToken: centrifugeTokens.subscriptionToken,
|
|
110
|
+
channel: centrifugeTokens.channel,
|
|
111
|
+
guid,
|
|
112
|
+
userId: platformConfig.userId || '',
|
|
113
|
+
httpBaseUrl: baseUrl,
|
|
114
|
+
httpAccessToken: platformConfig.accessToken || '',
|
|
115
|
+
workspaceSessionId,
|
|
116
|
+
}, callbacks);
|
|
117
|
+
// Start Centrifuge client
|
|
118
|
+
centrifugeClient.start();
|
|
119
|
+
log.info('WorkBuddy client initialized');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get Centrifuge client for sending messages
|
|
123
|
+
*/
|
|
124
|
+
export function getCentrifugeClient() {
|
|
125
|
+
return centrifugeClient;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Get OAuth client
|
|
129
|
+
*/
|
|
130
|
+
export function getOAuth() {
|
|
131
|
+
return oauth;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Update channel state and notify listeners
|
|
135
|
+
*/
|
|
136
|
+
function updateState(state) {
|
|
137
|
+
channelState = state;
|
|
138
|
+
if (stateChangeHandler) {
|
|
139
|
+
stateChangeHandler(state);
|
|
140
|
+
}
|
|
141
|
+
log.debug(`Channel state: ${state}`);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Stop WorkBuddy client
|
|
145
|
+
*/
|
|
146
|
+
export function stopWorkBuddy() {
|
|
147
|
+
log.info('Stopping WorkBuddy client...');
|
|
148
|
+
if (centrifugeClient) {
|
|
149
|
+
centrifugeClient.stop();
|
|
150
|
+
centrifugeClient = null;
|
|
151
|
+
}
|
|
152
|
+
oauth = null;
|
|
153
|
+
currentSessionId = null;
|
|
154
|
+
updateState('disconnected');
|
|
155
|
+
log.info('WorkBuddy client stopped');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Helper to get hostname
|
|
159
|
+
*/
|
|
160
|
+
function hostname() {
|
|
161
|
+
return process.env.HOSTNAME || process.env.COMPUTERNAME || 'unknown';
|
|
162
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy Event Handler - Handle WeChat KF message events from Centrifuge
|
|
3
|
+
*/
|
|
4
|
+
import { type Config } from '../config.js';
|
|
5
|
+
import type { SessionManager } from '../session/session-manager.js';
|
|
6
|
+
export interface WorkBuddyEventHandlerHandle {
|
|
7
|
+
stop: () => void;
|
|
8
|
+
getRunningTaskCount: () => number;
|
|
9
|
+
handleEvent: (chatId: string, msgId: string, content: string) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function setupWorkBuddyHandlers(config: Config, sessionManager: SessionManager): WorkBuddyEventHandlerHandle;
|