opencode-chat-channel 1.2.8 → 1.2.10

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/feishu/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAe,cAAc,EAAiC,MAAM,gBAAgB,CAAC;AA0KjG;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,cAuBlC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/feishu/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAe,cAAc,EAAiC,MAAM,gBAAgB,CAAC;AAmOjG;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,cAuBlC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAwHlD,eAAO,MAAM,iBAAiB,EAAE,MAyF/B,CAAC;AAEF,eAAe,iBAAiB,CAAC;AAMjC,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAyTlD,eAAO,MAAM,iBAAiB,EAAE,MAyF/B,CAAC;AAEF,eAAe,iBAAiB,CAAC;AAMjC,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -149,15 +149,37 @@ class FeishuChannel {
149
149
  });
150
150
  }
151
151
  }
152
- async sendThinking(chatId) {
153
- await this.larkClient.im.message.create({
154
- params: { receive_id_type: "chat_id" },
155
- data: {
156
- receive_id: chatId,
157
- content: JSON.stringify({ text: "⏳ 正在思考..." }),
158
- msg_type: "text"
159
- }
160
- });
152
+ async sendThinkingCard(chatId) {
153
+ const card = buildThinkingCard("⏳ 正在思考...");
154
+ try {
155
+ const res = await this.larkClient.im.message.create({
156
+ params: { receive_id_type: "chat_id" },
157
+ data: {
158
+ receive_id: chatId,
159
+ content: JSON.stringify(card),
160
+ msg_type: "interactive"
161
+ }
162
+ });
163
+ return res.data?.message_id ?? null;
164
+ } catch (err) {
165
+ this.client.app.log({
166
+ body: {
167
+ service: "chat-channel",
168
+ level: "warn",
169
+ message: `[feishu] 发送思考卡片失败: ${String(err)}`
170
+ }
171
+ });
172
+ return null;
173
+ }
174
+ }
175
+ async updateThinkingCard(messageId, statusText) {
176
+ const card = buildThinkingCard(statusText);
177
+ try {
178
+ await this.larkClient.im.message.patch({
179
+ data: { content: JSON.stringify(card) },
180
+ path: { message_id: messageId }
181
+ });
182
+ } catch {}
161
183
  }
162
184
  parseEvent(data) {
163
185
  const { message, sender } = data ?? {};
@@ -196,6 +218,17 @@ class FeishuChannel {
196
218
  };
197
219
  }
198
220
  }
221
+ function buildThinkingCard(text) {
222
+ return {
223
+ config: { update_multi: true },
224
+ elements: [
225
+ {
226
+ tag: "markdown",
227
+ content: text
228
+ }
229
+ ]
230
+ };
231
+ }
199
232
  var feishuChannelFactory = async (client) => {
200
233
  const appId = process.env["FEISHU_APP_ID"];
201
234
  const appSecret = readAppSecret();
@@ -287,6 +320,20 @@ function resolveEnabledChannels(client) {
287
320
  }
288
321
  return enabled;
289
322
  }
323
+ function stripMarkdownTables(text) {
324
+ return text.split(`
325
+ `).map((line) => {
326
+ const trimmed = line.trim();
327
+ if (/^\|[-:\s|]+\|$/.test(trimmed))
328
+ return "";
329
+ if (trimmed.startsWith("|"))
330
+ return "[表格内容]";
331
+ return line;
332
+ }).filter((line, i, arr) => !(line === "[表格内容]" && arr[i - 1] === "[表格内容]")).join(`
333
+ `).trim();
334
+ }
335
+ var REASONING_PREVIEW_LEN = 200;
336
+ var PATCH_THROTTLE_MS = 3000;
290
337
  function createMessageHandler(channel, sessionManager, client) {
291
338
  return async (msg) => {
292
339
  const { userId, replyTarget, text } = msg;
@@ -298,32 +345,141 @@ function createMessageHandler(channel, sessionManager, client) {
298
345
  extra: { userId, replyTarget }
299
346
  }
300
347
  });
301
- let responseText = null;
348
+ let thinkingMsgId = null;
349
+ if (channel.sendThinkingCard) {
350
+ thinkingMsgId = await channel.sendThinkingCard(replyTarget);
351
+ }
352
+ let sessionId;
302
353
  try {
303
- const sessionId = await sessionManager.getOrCreate(userId);
304
- const result = await client.session.prompt({
354
+ sessionId = await sessionManager.getOrCreate(userId);
355
+ } catch (err) {
356
+ const errorMsg = err?.message ?? String(err);
357
+ await client.app.log({
358
+ body: { service: "chat-channel", level: "error", message: `[${channel.name}] 获取 session 失败: ${errorMsg}`, extra: { userId } }
359
+ });
360
+ await channel.send(replyTarget, `⚠️ 出错了:${errorMsg}`);
361
+ return;
362
+ }
363
+ let eventStream = null;
364
+ try {
365
+ eventStream = await client.event.subscribe();
366
+ } catch (err) {
367
+ await client.app.log({
368
+ body: { service: "chat-channel", level: "warn", message: `[${channel.name}] SSE 订阅失败,降级为轮询: ${String(err)}` }
369
+ });
370
+ }
371
+ try {
372
+ await client.session.promptAsync({
305
373
  path: { id: sessionId },
306
374
  body: {
307
375
  parts: [{ type: "text", text }]
308
376
  }
309
377
  });
310
- responseText = extractResponseText(result.data?.parts ?? []);
311
378
  } catch (err) {
312
379
  const errorMsg = err?.data?.message ?? err?.message ?? String(err);
313
380
  await client.app.log({
314
- body: {
315
- service: "chat-channel",
316
- level: "error",
317
- message: `[${channel.name}] 处理消息失败: ${errorMsg}`,
318
- extra: { userId }
319
- }
381
+ body: { service: "chat-channel", level: "error", message: `[${channel.name}] promptAsync 失败: ${errorMsg}`, extra: { userId } }
320
382
  });
321
- await channel.send(replyTarget, `⚠️ 出错了:${errorMsg}`);
383
+ if (thinkingMsgId && channel.updateThinkingCard) {
384
+ await channel.updateThinkingCard(thinkingMsgId, `⚠️ 出错了:${errorMsg}`);
385
+ } else {
386
+ await channel.send(replyTarget, `⚠️ 出错了:${errorMsg}`);
387
+ }
322
388
  return;
323
389
  }
324
- await channel.send(replyTarget, responseText || "(AI 没有返回文字回复)");
390
+ await consumeSessionEvents(client, channel, sessionId, replyTarget, thinkingMsgId, eventStream);
325
391
  };
326
392
  }
393
+ async function consumeSessionEvents(client, channel, sessionId, replyTarget, thinkingMsgId, eventStream) {
394
+ let lastPatchAt = 0;
395
+ let reasoningAccum = "";
396
+ const log = (level, message) => void client.app.log({ body: { service: "chat-channel", level, message } });
397
+ async function throttledPatch(text, force = false) {
398
+ if (!thinkingMsgId || !channel.updateThinkingCard)
399
+ return;
400
+ const now = Date.now();
401
+ if (!force && now - lastPatchAt < PATCH_THROTTLE_MS)
402
+ return;
403
+ lastPatchAt = now;
404
+ await channel.updateThinkingCard(thinkingMsgId, text);
405
+ }
406
+ if (eventStream) {
407
+ try {
408
+ for await (const event of eventStream.stream) {
409
+ if (!isSessionEvent(event, sessionId))
410
+ continue;
411
+ if (event.type === "message.part.updated") {
412
+ const part = event.properties?.part;
413
+ if (!part)
414
+ continue;
415
+ if (part.type === "reasoning" && part.text) {
416
+ reasoningAccum = part.text;
417
+ const preview = stripMarkdownTables(reasoningAccum.slice(0, REASONING_PREVIEW_LEN));
418
+ const suffix = reasoningAccum.length > REASONING_PREVIEW_LEN ? "..." : "";
419
+ await throttledPatch(`\uD83D\uDCAD **正在思考...**
420
+
421
+ ${preview}${suffix}`);
422
+ } else if (part.type === "tool" && part.state?.status === "running") {
423
+ const toolLabel = (part.tool ?? "") || "工具";
424
+ await throttledPatch(`\uD83D\uDD27 **正在使用工具:${toolLabel}**`);
425
+ }
426
+ }
427
+ if (event.type === "session.idle" || event.type === "session.error") {
428
+ break;
429
+ }
430
+ }
431
+ } catch (err) {
432
+ log("warn", `[${channel.name}] SSE 事件流中断: ${String(err)}`);
433
+ }
434
+ } else {
435
+ await pollForSessionCompletion(client, channel.name, sessionId);
436
+ }
437
+ let responseText = null;
438
+ try {
439
+ const messagesRes = await client.session.messages({ path: { id: sessionId } });
440
+ const messages = messagesRes.data ?? [];
441
+ const lastAssistant = [...messages].reverse().find((m) => m.info?.role === "assistant");
442
+ if (lastAssistant) {
443
+ responseText = extractResponseText(lastAssistant.parts ?? []);
444
+ }
445
+ } catch (err) {
446
+ log("error", `[${channel.name}] 获取最终回复失败: ${String(err)}`);
447
+ }
448
+ if (!responseText) {
449
+ responseText = "(AI 没有返回文字回复)";
450
+ }
451
+ await channel.send(replyTarget, responseText);
452
+ }
453
+ async function pollForSessionCompletion(client, channelName, sessionId) {
454
+ const POLL_INTERVAL_MS = 1000;
455
+ const MAX_WAIT_MS = 5 * 60 * 1000;
456
+ const started = Date.now();
457
+ while (Date.now() - started < MAX_WAIT_MS) {
458
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
459
+ try {
460
+ const res = await client.session.status();
461
+ const allStatuses = res.data ?? {};
462
+ const sessionStatus = allStatuses[sessionId];
463
+ if (!sessionStatus || sessionStatus.type === "idle")
464
+ break;
465
+ } catch {
466
+ client.app.log({
467
+ body: { service: "chat-channel", level: "warn", message: `[${channelName}] 轮询 session 状态失败,继续等待...` }
468
+ });
469
+ }
470
+ }
471
+ }
472
+ function isSessionEvent(event, sessionId) {
473
+ if (!event || !event.type)
474
+ return false;
475
+ const props = event.properties;
476
+ if (!props)
477
+ return false;
478
+ if (event.type === "message.part.updated") {
479
+ return props.part?.sessionID === sessionId;
480
+ }
481
+ return props.sessionID === sessionId || props.id === sessionId;
482
+ }
327
483
  var ChatChannelPlugin = async ({ client }) => {
328
484
  const configDir = join(process.env["HOME"] ?? `/Users/${process.env["USER"] ?? "unknown"}`, ".config", "opencode");
329
485
  loadDotEnv(join(configDir, ".env"));
package/dist/types.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface IncomingMessage {
24
24
  * - name: 渠道标识符(用于日志、配置 key)
25
25
  * - start(): 启动监听,收到消息时调用 onMessage 回调
26
26
  * - send(): 向指定 target 发送文本回复
27
+ * - sendThinkingCard(): 发送占位卡片,返回可更新的 ID(可选)
28
+ * - updateThinkingCard(): 更新占位卡片内容(可选)
27
29
  * - stop(): 优雅关闭(可选)
28
30
  */
29
31
  export interface ChatChannel {
@@ -39,6 +41,18 @@ export interface ChatChannel {
39
41
  * replyTarget 为 IncomingMessage.replyTarget。
40
42
  */
41
43
  send(replyTarget: string, text: string): Promise<void>;
44
+ /**
45
+ * 发送"正在思考"占位卡片,返回可用于后续更新的占位消息 ID。
46
+ * 返回 null 表示该渠道不支持更新式占位(降级为无占位)。
47
+ * 可选——未实现的渠道会跳过思考展示。
48
+ */
49
+ sendThinkingCard?(replyTarget: string): Promise<string | null>;
50
+ /**
51
+ * 更新占位消息的内容。
52
+ * @param placeholderId sendThinkingCard 返回的 ID
53
+ * @param statusText 新状态文本
54
+ */
55
+ updateThinkingCard?(placeholderId: string, statusText: string): Promise<void>;
42
56
  /** 优雅停止渠道(可选)。 */
43
57
  stop?(): Promise<void>;
44
58
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,8CAA8C;AAC9C,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AAI3D,kBAAkB;AAClB,MAAM,WAAW,eAAe;IAC9B,oBAAoB;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC1B,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,KAAK,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEzE;;;OAGG;IACH,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvD,kBAAkB;IAClB,IAAI,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAID;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,CAC3B,MAAM,EAAE,YAAY,KACjB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;AAIjC;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,8CAA8C;AAC9C,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AAI3D,kBAAkB;AAClB,MAAM,WAAW,eAAe;IAC9B,oBAAoB;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,KAAK,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEzE;;;OAGG;IACH,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvD;;;;OAIG;IACH,gBAAgB,CAAC,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAE/D;;;;OAIG;IACH,kBAAkB,CAAC,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9E,kBAAkB;IAClB,IAAI,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAID;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,CAC3B,MAAM,EAAE,YAAY,KACjB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;AAIjC;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-chat-channel",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "opencode plugin — multi-channel bot (Feishu/Lark, WeCom) with extensible ChatChannel interface",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",