@ganglion/weacpx-channel-feishu 0.2.1 → 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/inbound.d.ts CHANGED
@@ -7,6 +7,24 @@ export declare function parseFeishuConversationId(chatKey: string): {
7
7
  chatId: string;
8
8
  threadId?: string;
9
9
  } | null;
10
+ /**
11
+ * Builds the chat-route metadata weacpx records for the current coordinator
12
+ * session. The host requires `chatType` to be `"direct"` or `"group"`, but
13
+ * Feishu reports `chat_type` as `"p2p"` (direct) or `"group"`, so `"p2p"` and
14
+ * any unexpected value normalize to `"direct"`. Without this, interactive
15
+ * Feishu turns recorded a route with no `chatType`, which blocked the in-session
16
+ * scheduled_create/list/cancel tools and group-owner command authorization.
17
+ */
18
+ export declare function buildFeishuRouteMetadata(input: {
19
+ chatType: string | undefined;
20
+ senderOpenId?: string;
21
+ chatId: string;
22
+ }): {
23
+ channel: "feishu";
24
+ chatType: "direct" | "group";
25
+ senderId?: string;
26
+ groupId?: string;
27
+ };
10
28
  export declare function shouldHandleFeishuMessage(input: {
11
29
  event: FeishuMessageEvent;
12
30
  botOpenId?: 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 {
@@ -78312,6 +78305,15 @@ function parseFeishuConversationId(chatKey) {
78312
78305
  }
78313
78306
  return { accountId: parts[1], chatId: parts[2] };
78314
78307
  }
78308
+ function buildFeishuRouteMetadata(input) {
78309
+ const isGroup = input.chatType === "group";
78310
+ return {
78311
+ channel: "feishu",
78312
+ chatType: isGroup ? "group" : "direct",
78313
+ ...input.senderOpenId ? { senderId: input.senderOpenId } : {},
78314
+ ...isGroup ? { groupId: input.chatId } : {}
78315
+ };
78316
+ }
78315
78317
  function shouldHandleFeishuMessage(input) {
78316
78318
  const text = input.parsedText ?? parseFeishuText(input.event.message.content);
78317
78319
  if (input.event.message.message_type !== "text" && !input.allowMediaOnly && text.trim().length === 0) {
@@ -80276,6 +80278,9 @@ class FeishuChannel {
80276
80278
  agent = null;
80277
80279
  quota = null;
80278
80280
  logger = null;
80281
+ sessions = null;
80282
+ activeTurns = null;
80283
+ executor = createConversationExecutor();
80279
80284
  activeTasks = new Map;
80280
80285
  permissionNotifier;
80281
80286
  config;
@@ -80311,6 +80316,8 @@ class FeishuChannel {
80311
80316
  this.agent = input.agent;
80312
80317
  this.quota = input.quota;
80313
80318
  this.logger = input.logger;
80319
+ this.sessions = input.sessions ?? null;
80320
+ this.activeTurns = input.activeTurns ?? null;
80314
80321
  const eligible = this.config.accounts.filter((account) => account.enabled && account.configured);
80315
80322
  await input.logger.info("feishu.start", "starting feishu channel", {
80316
80323
  accountCount: eligible.length,
@@ -80543,33 +80550,34 @@ class FeishuChannel {
80543
80550
  initialSkipped: converted.skippedNotes
80544
80551
  });
80545
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);
80546
80556
  const { active, abortController } = this.registerActiveTask({
80547
80557
  accountId,
80548
80558
  chatId,
80549
80559
  messageId,
80550
80560
  queueKey,
80551
80561
  senderOpenId: event.sender?.sender_id?.open_id,
80552
- chatType: event.message.chat_type
80562
+ chatType: event.message.chat_type,
80563
+ boundAlias
80553
80564
  });
80554
- const run = enqueueFeishuChatTask({
80565
+ if (boundAlias)
80566
+ this.activeTurns?.markActive(chatKey, boundAlias);
80567
+ await this.executor.run(chatKey, lane, () => this.runTurn({
80568
+ runtime,
80555
80569
  accountId,
80556
80570
  chatId,
80557
- ...threadId ? { threadId } : {},
80558
- task: () => this.runTurn({
80559
- runtime,
80560
- accountId,
80561
- chatId,
80562
- chatType: event.message.chat_type,
80563
- chatKey,
80564
- queueKey,
80565
- messageId,
80566
- requestText,
80567
- media,
80568
- active,
80569
- abortController
80570
- })
80571
- });
80572
- 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);
80573
80581
  }
80574
80582
  async tryHandleAbortTrigger(input) {
80575
80583
  const { event, runtime, queueKey, accountId, chatId, messageId } = input;
@@ -80604,7 +80612,7 @@ class FeishuChannel {
80604
80612
  return false;
80605
80613
  }
80606
80614
  registerActiveTask(input) {
80607
- const { accountId, chatId, messageId, queueKey, senderOpenId, chatType } = input;
80615
+ const { accountId, chatId, messageId, queueKey, senderOpenId, chatType, boundAlias } = input;
80608
80616
  const abortController = new AbortController;
80609
80617
  const active = {
80610
80618
  accountId,
@@ -80612,6 +80620,7 @@ class FeishuChannel {
80612
80620
  messageId,
80613
80621
  senderOpenId,
80614
80622
  chatType,
80623
+ boundAlias,
80615
80624
  typingState: { messageId, reactionId: null },
80616
80625
  abortController,
80617
80626
  suppressed: false,
@@ -80622,8 +80631,26 @@ class FeishuChannel {
80622
80631
  this.activeTasks.set(queueKey, stack);
80623
80632
  return { active, abortController };
80624
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
+ }
80625
80651
  async runTurn(input) {
80626
- 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";
80627
80654
  try {
80628
80655
  if (!this.agent)
80629
80656
  return;
@@ -80670,6 +80697,10 @@ class FeishuChannel {
80670
80697
  text: requestText,
80671
80698
  ...media.length > 0 ? { media } : {},
80672
80699
  replyContextToken: messageId,
80700
+ metadata: {
80701
+ ...buildFeishuRouteMetadata({ chatType, senderOpenId: active.senderOpenId, chatId }),
80702
+ ...boundAlias ? { boundSessionAlias: boundAlias } : {}
80703
+ },
80673
80704
  reply: safeReply,
80674
80705
  ...active.cardController ? {
80675
80706
  onToolEvent: (event) => {
@@ -80688,13 +80719,26 @@ class FeishuChannel {
80688
80719
  if (active.suppressed)
80689
80720
  return;
80690
80721
  await this.deliverResponse({ runtime, accountId, chatId, messageId, active, response });
80722
+ turnStatus = "done";
80691
80723
  } catch (error) {
80692
80724
  if (active.cardController && !active.cardController.isTerminated()) {
80693
80725
  await active.cardController.fail(error instanceof Error ? error.message : String(error));
80694
80726
  }
80727
+ turnStatus = "error";
80695
80728
  throw error;
80696
80729
  }
80697
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
+ }
80698
80742
  const stack = this.activeTasks.get(queueKey);
80699
80743
  if (stack) {
80700
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.1",
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": {