@ganglion/weacpx-channel-feishu 0.2.2 → 0.3.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/README.md CHANGED
@@ -72,3 +72,20 @@ While the agent is processing, the user can send `stop`, `/stop`, `abort`, `停
72
72
  2. Renders an "已停止" final state on the streaming card, or sends a "已停止当前任务。" reply in static mode.
73
73
  3. Removes the typing reaction added to the user's original message.
74
74
 
75
+ ## Real-time session switching & background execution
76
+
77
+ Each inbound prompt is **bound at dispatch time** to whatever session the chat is currently on, then runs on a **per-session lane**:
78
+
79
+ - **Different sessions run concurrently.** Switching to another session (`/use`/`/ss`) while a task is in flight lets you use the new session immediately — turns on different sessions don't block each other.
80
+ - **Same-session turns serialize**, preserving order within a session.
81
+ - **Switch and cancel commands preempt.** `/use`, `/ss`, `/cancel`, `/stop` run on a **control lane**, so they take effect right away even while a prompt is running (the running prompt keeps going in the background — see below).
82
+
83
+ When you switch away from a running session, its turn keeps executing in the background. Feishu uses **"B-semantics"**, which differs from the WeChat channel:
84
+
85
+ - The backgrounded session has its **own streaming card** that keeps refreshing **to completion in the chat timeline** — it is *not* gated/suppressed. The result simply stays on that card.
86
+ - On completion, a short ping is sent to the chat: `✅ <alias> 已完成` (or `⚠️ <alias> 失败`). Unlike WeChat, there is **no `/use 查看结果`** suffix — there is nothing to replay, because the card already holds the result.
87
+ - Switching **back** to that session does **not** re-send the result.
88
+ - `/sessions` marks sessions with an unfinished/unread background completion using `●`.
89
+
90
+ `/cancel <alias>` (and `/stop <alias>`) target a specific session's in-flight turn by alias — fuzzy alias resolution applies, the same as `/use`.
91
+
package/dist/channel.d.ts CHANGED
@@ -15,6 +15,9 @@ export declare class FeishuChannel implements MessageChannelRuntime {
15
15
  private agent;
16
16
  private quota;
17
17
  private logger;
18
+ private sessions;
19
+ private activeTurns;
20
+ private readonly executor;
18
21
  private readonly activeTasks;
19
22
  private readonly permissionNotifier;
20
23
  private readonly config;
@@ -50,6 +53,7 @@ export declare class FeishuChannel implements MessageChannelRuntime {
50
53
  * mark it suppressed.
51
54
  */
52
55
  private registerActiveTask;
56
+ private sendBackgroundCompletionNotice;
53
57
  private runTurn;
54
58
  private trySeedStreamingCard;
55
59
  private deliverResponse;
@@ -0,0 +1 @@
1
+ export declare function buildFeishuCompletionNotice(displayAlias: string, status: "done" | "error"): string;
package/dist/index.js CHANGED
@@ -77967,6 +77967,7 @@ var require_lib2 = __commonJS((exports) => {
77967
77967
 
77968
77968
  // packages/channel-feishu/src/channel.ts
77969
77969
  import path5 from "node:path";
77970
+ import { createConversationExecutor, resolveTurnLane, toDisplaySessionAlias } from "weacpx/plugin-api";
77970
77971
 
77971
77972
  // packages/channel-feishu/src/tuning.ts
77972
77973
  var DEFAULT_FEISHU_TUNING = {
@@ -78269,19 +78270,6 @@ var queues = new Map;
78269
78270
  function buildFeishuQueueKey(accountId, chatId, threadId) {
78270
78271
  return threadId ? `${accountId}:${chatId}:thread:${threadId}` : `${accountId}:${chatId}`;
78271
78272
  }
78272
- function enqueueFeishuChatTask(input) {
78273
- const key = buildFeishuQueueKey(input.accountId, input.chatId, input.threadId);
78274
- const previous = queues.get(key) ?? Promise.resolve();
78275
- const status = queues.has(key) ? "queued" : "immediate";
78276
- const promise = previous.then(input.task, input.task);
78277
- queues.set(key, promise);
78278
- const cleanup = () => {
78279
- if (queues.get(key) === promise)
78280
- queues.delete(key);
78281
- };
78282
- promise.then(cleanup, cleanup);
78283
- return { status, promise };
78284
- }
78285
78273
  function clearFeishuQueueForAccount(accountId) {
78286
78274
  const prefix = `${accountId}:`;
78287
78275
  for (const key of queues.keys()) {
@@ -78290,6 +78278,11 @@ function clearFeishuQueueForAccount(accountId) {
78290
78278
  }
78291
78279
  }
78292
78280
 
78281
+ // packages/channel-feishu/src/completion-notice.ts
78282
+ function buildFeishuCompletionNotice(displayAlias, status) {
78283
+ return status === "done" ? `✅ ${displayAlias} 已完成` : `⚠️ ${displayAlias} 失败`;
78284
+ }
78285
+
78293
78286
  // packages/channel-feishu/src/inbound.ts
78294
78287
  function parseFeishuText(raw) {
78295
78288
  try {
@@ -80285,6 +80278,9 @@ class FeishuChannel {
80285
80278
  agent = null;
80286
80279
  quota = null;
80287
80280
  logger = null;
80281
+ sessions = null;
80282
+ activeTurns = null;
80283
+ executor = createConversationExecutor();
80288
80284
  activeTasks = new Map;
80289
80285
  permissionNotifier;
80290
80286
  config;
@@ -80320,6 +80316,8 @@ class FeishuChannel {
80320
80316
  this.agent = input.agent;
80321
80317
  this.quota = input.quota;
80322
80318
  this.logger = input.logger;
80319
+ this.sessions = input.sessions ?? null;
80320
+ this.activeTurns = input.activeTurns ?? null;
80323
80321
  const eligible = this.config.accounts.filter((account) => account.enabled && account.configured);
80324
80322
  await input.logger.info("feishu.start", "starting feishu channel", {
80325
80323
  accountCount: eligible.length,
@@ -80552,33 +80550,34 @@ class FeishuChannel {
80552
80550
  initialSkipped: converted.skippedNotes
80553
80551
  });
80554
80552
  const requestText = appendSkippedAttachmentNotes(decision.text, skipped);
80553
+ const isSlash = requestText.trim().startsWith("/");
80554
+ const boundAlias = isSlash ? undefined : this.sessions?.peekCurrentSessionAlias(chatKey) ?? undefined;
80555
+ const lane = resolveTurnLane(requestText);
80555
80556
  const { active, abortController } = this.registerActiveTask({
80556
80557
  accountId,
80557
80558
  chatId,
80558
80559
  messageId,
80559
80560
  queueKey,
80560
80561
  senderOpenId: event.sender?.sender_id?.open_id,
80561
- chatType: event.message.chat_type
80562
+ chatType: event.message.chat_type,
80563
+ boundAlias
80562
80564
  });
80563
- const run = enqueueFeishuChatTask({
80565
+ if (boundAlias)
80566
+ this.activeTurns?.markActive(chatKey, boundAlias);
80567
+ await this.executor.run(chatKey, lane, () => this.runTurn({
80568
+ runtime,
80564
80569
  accountId,
80565
80570
  chatId,
80566
- ...threadId ? { threadId } : {},
80567
- task: () => this.runTurn({
80568
- runtime,
80569
- accountId,
80570
- chatId,
80571
- chatType: event.message.chat_type,
80572
- chatKey,
80573
- queueKey,
80574
- messageId,
80575
- requestText,
80576
- media,
80577
- active,
80578
- abortController
80579
- })
80580
- });
80581
- await run.promise;
80571
+ chatType: event.message.chat_type,
80572
+ chatKey,
80573
+ queueKey,
80574
+ messageId,
80575
+ requestText,
80576
+ media,
80577
+ active,
80578
+ abortController,
80579
+ boundAlias
80580
+ }), boundAlias);
80582
80581
  }
80583
80582
  async tryHandleAbortTrigger(input) {
80584
80583
  const { event, runtime, queueKey, accountId, chatId, messageId } = input;
@@ -80613,7 +80612,7 @@ class FeishuChannel {
80613
80612
  return false;
80614
80613
  }
80615
80614
  registerActiveTask(input) {
80616
- const { accountId, chatId, messageId, queueKey, senderOpenId, chatType } = input;
80615
+ const { accountId, chatId, messageId, queueKey, senderOpenId, chatType, boundAlias } = input;
80617
80616
  const abortController = new AbortController;
80618
80617
  const active = {
80619
80618
  accountId,
@@ -80621,6 +80620,7 @@ class FeishuChannel {
80621
80620
  messageId,
80622
80621
  senderOpenId,
80623
80622
  chatType,
80623
+ boundAlias,
80624
80624
  typingState: { messageId, reactionId: null },
80625
80625
  abortController,
80626
80626
  suppressed: false,
@@ -80631,8 +80631,26 @@ class FeishuChannel {
80631
80631
  this.activeTasks.set(queueKey, stack);
80632
80632
  return { active, abortController };
80633
80633
  }
80634
+ async sendBackgroundCompletionNotice(input) {
80635
+ const text = buildFeishuCompletionNotice(toDisplaySessionAlias(input.boundAlias), input.status);
80636
+ try {
80637
+ await this.sendReplyWithGuard({
80638
+ runtime: input.runtime,
80639
+ chatId: input.chatId,
80640
+ replyToMessageId: input.messageId,
80641
+ text
80642
+ });
80643
+ } catch (error) {
80644
+ await this.logger?.error("feishu.bg_notice.failed", "failed to send background completion notice", {
80645
+ chatId: input.chatId,
80646
+ boundAlias: input.boundAlias,
80647
+ message: error instanceof Error ? error.message : String(error)
80648
+ });
80649
+ }
80650
+ }
80634
80651
  async runTurn(input) {
80635
- const { runtime, accountId, chatId, chatType, chatKey, queueKey, messageId, requestText, media, active, abortController } = input;
80652
+ const { runtime, accountId, chatId, chatType, chatKey, queueKey, messageId, requestText, media, active, abortController, boundAlias } = input;
80653
+ let turnStatus = "skipped";
80636
80654
  try {
80637
80655
  if (!this.agent)
80638
80656
  return;
@@ -80679,7 +80697,10 @@ class FeishuChannel {
80679
80697
  text: requestText,
80680
80698
  ...media.length > 0 ? { media } : {},
80681
80699
  replyContextToken: messageId,
80682
- metadata: buildFeishuRouteMetadata({ chatType, senderOpenId: active.senderOpenId, chatId }),
80700
+ metadata: {
80701
+ ...buildFeishuRouteMetadata({ chatType, senderOpenId: active.senderOpenId, chatId }),
80702
+ ...boundAlias ? { boundSessionAlias: boundAlias } : {}
80703
+ },
80683
80704
  reply: safeReply,
80684
80705
  ...active.cardController ? {
80685
80706
  onToolEvent: (event) => {
@@ -80698,13 +80719,26 @@ class FeishuChannel {
80698
80719
  if (active.suppressed)
80699
80720
  return;
80700
80721
  await this.deliverResponse({ runtime, accountId, chatId, messageId, active, response });
80722
+ turnStatus = "done";
80701
80723
  } catch (error) {
80702
80724
  if (active.cardController && !active.cardController.isTerminated()) {
80703
80725
  await active.cardController.fail(error instanceof Error ? error.message : String(error));
80704
80726
  }
80727
+ turnStatus = "error";
80705
80728
  throw error;
80706
80729
  }
80707
80730
  } finally {
80731
+ if (boundAlias) {
80732
+ this.activeTurns?.markInactive(chatKey, boundAlias);
80733
+ if (turnStatus !== "skipped" && this.sessions && this.sessions.peekCurrentSessionAlias(chatKey) !== boundAlias) {
80734
+ await this.sessions.setBackgroundResult(chatKey, boundAlias, {
80735
+ text: "",
80736
+ status: turnStatus,
80737
+ finished_at: new Date().toISOString()
80738
+ });
80739
+ await this.sendBackgroundCompletionNotice({ runtime, chatId, messageId, boundAlias, status: turnStatus });
80740
+ }
80741
+ }
80708
80742
  const stack = this.activeTasks.get(queueKey);
80709
80743
  if (stack) {
80710
80744
  const i = stack.indexOf(active);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganglion/weacpx-channel-feishu",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Feishu channel plugin for weacpx.",
5
5
  "license": "MIT",
6
6
  "keywords": ["weacpx", "feishu", "channel", "plugin"],
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "files": ["dist", "README.md"],
22
22
  "peerDependencies": {
23
- "weacpx": ">=0.5.0-0"
23
+ "weacpx": ">=0.7.0-0"
24
24
  },
25
25
  "peerDependenciesMeta": {
26
26
  "weacpx": {