@ganglion/xacpx-channel-feishu 0.4.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 +91 -0
- package/dist/abort-detect.d.ts +4 -0
- package/dist/card/card-builder.d.ts +23 -0
- package/dist/card/flush-controller.d.ts +48 -0
- package/dist/card/image-resolver.d.ts +70 -0
- package/dist/card/markdown-style.d.ts +14 -0
- package/dist/card/reasoning.d.ts +13 -0
- package/dist/card/shutdown-hooks.d.ts +15 -0
- package/dist/card/streaming-card-controller.d.ts +196 -0
- package/dist/card/tool-use-store.d.ts +19 -0
- package/dist/card/tool-use-types.d.ts +11 -0
- package/dist/channel.d.ts +65 -0
- package/dist/chat-queue.d.ts +12 -0
- package/dist/completion-notice.d.ts +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/content-converters.d.ts +11 -0
- package/dist/errors.d.ts +20 -0
- package/dist/feishu-provider.d.ts +2 -0
- package/dist/inbound.d.ts +50 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +81223 -0
- package/dist/lark-client.d.ts +25 -0
- package/dist/media-store.d.ts +29 -0
- package/dist/media-types.d.ts +28 -0
- package/dist/media.d.ts +87 -0
- package/dist/message-dedup.d.ts +13 -0
- package/dist/message-unavailable.d.ts +8 -0
- package/dist/outbound-media-safety.d.ts +7 -0
- package/dist/permission-error.d.ts +35 -0
- package/dist/provider.d.ts +53 -0
- package/dist/send.d.ts +58 -0
- package/dist/strings.d.ts +16 -0
- package/dist/tuning.d.ts +35 -0
- package/dist/types.d.ts +49 -0
- package/dist/typing.d.ts +40 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @ganglion/weacpx-channel-feishu
|
|
2
|
+
|
|
3
|
+
Feishu channel plugin for `weacpx`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
weacpx plugin add @ganglion/weacpx-channel-feishu
|
|
7
|
+
weacpx channel add feishu
|
|
8
|
+
weacpx restart
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The channel requires a Feishu self-built app `appId` and `appSecret`.
|
|
12
|
+
|
|
13
|
+
## Reply rendering: `replyMode`
|
|
14
|
+
|
|
15
|
+
| Mode | Behaviour |
|
|
16
|
+
|------|-----------|
|
|
17
|
+
| `"auto"` (default) | Streaming for direct (p2p) chats, static for groups. Groups already serialize visually around a thread, so the multi-message static path stays simpler there. |
|
|
18
|
+
| `"streaming"` | The channel creates one CardKit v2 interactive card per turn and updates it in place — thinking → streaming → complete (or aborted/error). User sees output appear progressively in one message slot. |
|
|
19
|
+
| `"static"` | Every `reply()` chunk + the final agent response are sent as separate text messages, replying to the user's incoming message. |
|
|
20
|
+
|
|
21
|
+
While streaming, the card uses two CardKit endpoints intelligently:
|
|
22
|
+
- `cardElement.content` for pure-text deltas — smaller payload, native typewriter animation.
|
|
23
|
+
- Full `card.update` on state transitions, image-key arrival, reasoning panel toggles, and the final state.
|
|
24
|
+
|
|
25
|
+
Final-state cards include the elapsed turn time in the footer (e.g. `已完成 · 3.4s`). Live streaming cards (thinking/streaming states) also show a ticking elapsed footer (`⏳ 处理中... 8.2s`) so long-running tasks give the user a continuous time signal. Models that emit `<think>...</think>` / `<thinking>...</thinking>` (or a `Reasoning:\n_…_` prefix) get the reasoning rendered above the answer in a separate notation-sized block, with a horizontal divider before the answer body. Markdown image URLs (``) are resolved to Feishu `image_key` references on the fly so the card renders the image inline; URLs that don't resolve within the configurable timeout are stripped.
|
|
26
|
+
|
|
27
|
+
When `channel.replyMode: "verbose"` (the default) is paired with streaming mode, tool calls are rendered as a collapsible **🔧 工具调用 (N)** panel above the answer body instead of inline text segments. Each step shows status (✅/⏳/❌), a kind icon (📖 read · 🔍 search · 💻 execute · ✏️ edit · 🧠 think · 🔧 other), the tool name, a one-line summary derived from the call's input (e.g. file path, command, search pattern), and the duration once finished. Static mode keeps the legacy inline behavior — each tool call lands as its own text bubble.
|
|
28
|
+
|
|
29
|
+
The streaming card consumes the structured tool-use side-channel by registering an `onToolEvent` callback. The transport defaults to `toolEventMode: "structured"` whenever a handler is provided, so events flow into the collapsible card panel instead of the legacy text bubbles.
|
|
30
|
+
|
|
31
|
+
The card terminates gracefully on daemon shutdown: SIGINT/SIGTERM/`beforeExit` drives every in-flight card to its "已停止" state before the process exits, so a killed `weacpx` daemon no longer leaves cards stuck at "处理中..." in the user's Feishu chat.
|
|
32
|
+
|
|
33
|
+
Streaming mode requires the bot to have **`cardkit:card:write`** plus **`im:message:send_as_bot`** scopes. If the initial `cardkit.v1.card.create` call fails (most commonly: missing scope), the channel logs `feishu.streaming.fallback` and falls back to the static path for that turn. When the failure is a Feishu permission error (code `99991672`) the grant URL is also sent to the user once per 5-minute cooldown.
|
|
34
|
+
|
|
35
|
+
Set globally:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"channels": [
|
|
40
|
+
{
|
|
41
|
+
"id": "feishu",
|
|
42
|
+
"type": "feishu",
|
|
43
|
+
"options": {
|
|
44
|
+
"appId": "cli_xxx",
|
|
45
|
+
"appSecret": "yyy",
|
|
46
|
+
"replyMode": "streaming"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or per account:
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
{
|
|
57
|
+
"options": {
|
|
58
|
+
"replyMode": "streaming",
|
|
59
|
+
"accounts": {
|
|
60
|
+
"main": { "appId": "...", "appSecret": "...", "replyMode": "streaming" },
|
|
61
|
+
"legacy": { "appId": "...", "appSecret": "...", "replyMode": "static" }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Cancelling the in-flight turn
|
|
68
|
+
|
|
69
|
+
While the agent is processing, the user can send `stop`, `/stop`, `abort`, `停止`, `取消`, etc. The channel:
|
|
70
|
+
|
|
71
|
+
1. Aborts the per-turn `AbortController` (which the router forwards to `transport.cancel()` so the underlying `acpx` process is interrupted).
|
|
72
|
+
2. Renders an "已停止" final state on the streaming card, or sends a "已停止当前任务。" reply in static mode.
|
|
73
|
+
3. Removes the typing reaction added to the user's original message.
|
|
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
|
+
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FeishuMessageEvent } from "./types.js";
|
|
2
|
+
export declare function isAbortTrigger(text: string): boolean;
|
|
3
|
+
export declare function isLikelyAbortText(text: string): boolean;
|
|
4
|
+
export declare function extractRawTextFromFeishuEvent(event: FeishuMessageEvent): string | undefined;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolUseStep } from "./tool-use-types.js";
|
|
2
|
+
export declare const STREAMING_ELEMENT_ID = "streaming_content";
|
|
3
|
+
export declare const REASONING_ELEMENT_ID = "reasoning_content";
|
|
4
|
+
export type CardState = "thinking" | "streaming" | "complete" | "aborted" | "error";
|
|
5
|
+
export declare const CARD_BODY_MAX_CHARS = 28000;
|
|
6
|
+
export declare function truncateForCardBody(text: string, maxChars?: number): string;
|
|
7
|
+
export interface BuildCardInput {
|
|
8
|
+
state: CardState;
|
|
9
|
+
text: string;
|
|
10
|
+
elapsedMs?: number;
|
|
11
|
+
reasoningText?: string;
|
|
12
|
+
reasoningElapsedMs?: number;
|
|
13
|
+
toolSteps?: ToolUseStep[];
|
|
14
|
+
/** Per-call override of {@link CARD_BODY_MAX_CHARS}. */
|
|
15
|
+
maxBodyChars?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function buildCard(input: BuildCardInput): Record<string, unknown>;
|
|
18
|
+
export declare function formatElapsedMs(ms: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Builds the `content` string for `im.message.create({ msg_type: "interactive" })`
|
|
21
|
+
* that references a CardKit card instance by id.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildCardMessageContent(cardId: string): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface FlushControllerOptions {
|
|
2
|
+
minIntervalMs: number;
|
|
3
|
+
now?: () => number;
|
|
4
|
+
setTimer?: (cb: () => void, delayMs: number) => unknown;
|
|
5
|
+
clearTimer?: (handle: unknown) => void;
|
|
6
|
+
/**
|
|
7
|
+
* Called when {@link consecutiveFailures} reaches `failureThreshold`. The
|
|
8
|
+
* controller continues to accept flushes (so a recovery still works), but
|
|
9
|
+
* the owner can use this signal to fall back to a non-card delivery path.
|
|
10
|
+
* Fires once per crossing of the threshold; resets after any successful
|
|
11
|
+
* flush.
|
|
12
|
+
*/
|
|
13
|
+
onFailureThreshold?: (consecutiveFailures: number) => void;
|
|
14
|
+
/** Default 3. */
|
|
15
|
+
failureThreshold?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Throttled flush primitive.
|
|
19
|
+
*
|
|
20
|
+
* - `requestFlush(work)` honours the min-interval. Rapid calls coalesce to a
|
|
21
|
+
* single trailing flush carrying the *latest* `work` callback.
|
|
22
|
+
* - `forceFlush(work)` runs ASAP, after any in-flight work completes,
|
|
23
|
+
* and supersedes any queued throttled flush.
|
|
24
|
+
* - `waitIdle()` resolves once the chain plus any scheduled flush has drained.
|
|
25
|
+
*/
|
|
26
|
+
export declare class FlushController {
|
|
27
|
+
private readonly minIntervalMs;
|
|
28
|
+
private readonly now;
|
|
29
|
+
private readonly setTimer;
|
|
30
|
+
private readonly clearTimer;
|
|
31
|
+
private chain;
|
|
32
|
+
private deferredWork;
|
|
33
|
+
private timer;
|
|
34
|
+
private lastFlushAtMs;
|
|
35
|
+
private timerWaiters;
|
|
36
|
+
private consecutiveFailures;
|
|
37
|
+
private failureCallbackFired;
|
|
38
|
+
private readonly onFailureThreshold;
|
|
39
|
+
private readonly failureThreshold;
|
|
40
|
+
constructor(options?: Partial<FlushControllerOptions>);
|
|
41
|
+
/** Snapshot of the current consecutive-failure counter (for diagnostics). */
|
|
42
|
+
getConsecutiveFailures(): number;
|
|
43
|
+
requestFlush(work: () => Promise<void>): void;
|
|
44
|
+
private notifyTimerWaiters;
|
|
45
|
+
forceFlush(work: () => Promise<void>): Promise<void>;
|
|
46
|
+
waitIdle(): Promise<void>;
|
|
47
|
+
private appendToChain;
|
|
48
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { extractBufferFromFeishuResponse } from "../media.js";
|
|
2
|
+
export interface ImageUploadClient {
|
|
3
|
+
im: {
|
|
4
|
+
image?: {
|
|
5
|
+
create(input: {
|
|
6
|
+
data: {
|
|
7
|
+
image_type: "message";
|
|
8
|
+
image: unknown;
|
|
9
|
+
};
|
|
10
|
+
}): Promise<unknown>;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export interface ImageResolverOptions {
|
|
15
|
+
client: ImageUploadClient;
|
|
16
|
+
/** Called whenever a previously-pending upload completes successfully. */
|
|
17
|
+
onImageResolved: () => void;
|
|
18
|
+
/**
|
|
19
|
+
* Optional override for fetching remote image bytes. Receives `maxBytes`
|
|
20
|
+
* so the implementation may stream-check the size *before* reading the
|
|
21
|
+
* full body. Defaults to global fetch which checks Content-Length.
|
|
22
|
+
*/
|
|
23
|
+
fetchUrl?: (url: string, options?: {
|
|
24
|
+
maxBytes?: number;
|
|
25
|
+
}) => Promise<Buffer>;
|
|
26
|
+
/** Optional logger; receives string events for observability. */
|
|
27
|
+
log?: (event: string, context?: Record<string, unknown>) => void;
|
|
28
|
+
/** Max bytes per image. Defaults to 5 MiB. */
|
|
29
|
+
maxBytes?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Max number of resolved/failed entries to retain. When exceeded, oldest
|
|
32
|
+
* entries are evicted in LRU order. Defaults to 256. Pending uploads are
|
|
33
|
+
* never evicted (they self-evict on completion).
|
|
34
|
+
*/
|
|
35
|
+
cacheCap?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Streams-friendly resolver that swaps `` references for
|
|
39
|
+
* `` Feishu image keys.
|
|
40
|
+
*
|
|
41
|
+
* - Synchronous `resolveImages` is safe to call on every flush; it never
|
|
42
|
+
* blocks. Unknown URLs are stripped and queued for async upload.
|
|
43
|
+
* - `onImageResolved` fires when a URL finishes uploading, letting the
|
|
44
|
+
* caller re-flush so the resolved image surfaces.
|
|
45
|
+
* - `resolveImagesAwait` is for terminal states (complete/abort) so the
|
|
46
|
+
* final card carries all known image keys.
|
|
47
|
+
*
|
|
48
|
+
* The `resolved` and `failed` maps are LRU-bounded so a long-running
|
|
49
|
+
* controller (or one fed thousands of URLs) can't leak memory.
|
|
50
|
+
*/
|
|
51
|
+
export declare class ImageResolver {
|
|
52
|
+
private readonly resolved;
|
|
53
|
+
private readonly pending;
|
|
54
|
+
private readonly failed;
|
|
55
|
+
private readonly client;
|
|
56
|
+
private readonly onImageResolved;
|
|
57
|
+
private readonly fetchUrl;
|
|
58
|
+
private readonly log?;
|
|
59
|
+
private readonly maxBytes;
|
|
60
|
+
private readonly cacheCap;
|
|
61
|
+
constructor(options: ImageResolverOptions);
|
|
62
|
+
resolveImages(text: string): string;
|
|
63
|
+
resolveImagesAwait(text: string, timeoutMs: number): Promise<string>;
|
|
64
|
+
hasPending(): boolean;
|
|
65
|
+
private startUpload;
|
|
66
|
+
private doUpload;
|
|
67
|
+
private markResolved;
|
|
68
|
+
private markFailed;
|
|
69
|
+
}
|
|
70
|
+
export { extractBufferFromFeishuResponse };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown style optimizer for Feishu rendering.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from openclaw-lark's `card/markdown-style.ts`. Goals:
|
|
5
|
+
* - Demote H1–H3 to H4–H5 so card headers don't dwarf the bot's reply
|
|
6
|
+
* - Pad tables and code blocks with `<br>` so they don't collide with surrounding paragraphs
|
|
7
|
+
* - Normalise list/cell spacing
|
|
8
|
+
* - Protect fenced code block content from any of the above edits
|
|
9
|
+
* - As a safety net, strip `` references that would
|
|
10
|
+
* trigger CardKit error 200570 if a stray URL escapes the image resolver.
|
|
11
|
+
*
|
|
12
|
+
* The function is fail-safe: if any rewrite throws, it returns the original text.
|
|
13
|
+
*/
|
|
14
|
+
export declare function optimizeMarkdownStyle(text: string, cardVersion?: number): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning text splitting for `<think>...</think>` / `<thinking>...</thinking>` /
|
|
3
|
+
* `<thought>...</thought>` blocks and the `Reasoning:\n_…_` prefix format.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors openclaw-lark's `splitReasoningText` / `stripReasoningTags` so cards
|
|
6
|
+
* can render a separate reasoning area above the answer body.
|
|
7
|
+
*/
|
|
8
|
+
export interface ReasoningSplit {
|
|
9
|
+
reasoningText?: string;
|
|
10
|
+
answerText?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function splitReasoningText(text?: string): ReasoningSplit;
|
|
13
|
+
export declare function stripReasoningTags(text: string): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type ShutdownHandler = () => Promise<void> | void;
|
|
2
|
+
/**
|
|
3
|
+
* Register a shutdown handler. Returns a dispose function.
|
|
4
|
+
*
|
|
5
|
+
* Restriction: handlers MUST NOT call `registerShutdownHook` from inside
|
|
6
|
+
* their own execution during shutdown. New registrations made while
|
|
7
|
+
* `runAll` is in flight are silently dropped because `firing` blocks
|
|
8
|
+
* subsequent fire requests.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerShutdownHook(name: string, handler: ShutdownHandler): () => void;
|
|
11
|
+
export declare function fireShutdownHooksForTests(opts?: {
|
|
12
|
+
perHandlerTimeoutMs?: number;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
export declare function __resetShutdownHooksForTests(): void;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ToolUseEvent } from "./tool-use-types.js";
|
|
2
|
+
export interface StreamingCardClient {
|
|
3
|
+
cardkit: {
|
|
4
|
+
v1: {
|
|
5
|
+
card: {
|
|
6
|
+
create(input: {
|
|
7
|
+
data: {
|
|
8
|
+
type: "card_json";
|
|
9
|
+
data: string;
|
|
10
|
+
};
|
|
11
|
+
}): Promise<{
|
|
12
|
+
data?: {
|
|
13
|
+
card_id?: string;
|
|
14
|
+
};
|
|
15
|
+
}>;
|
|
16
|
+
update(input: {
|
|
17
|
+
path: {
|
|
18
|
+
card_id: string;
|
|
19
|
+
};
|
|
20
|
+
data: {
|
|
21
|
+
card: {
|
|
22
|
+
type: "card_json";
|
|
23
|
+
data: string;
|
|
24
|
+
};
|
|
25
|
+
sequence: number;
|
|
26
|
+
};
|
|
27
|
+
}): Promise<unknown>;
|
|
28
|
+
};
|
|
29
|
+
cardElement?: {
|
|
30
|
+
content(input: {
|
|
31
|
+
path: {
|
|
32
|
+
card_id: string;
|
|
33
|
+
element_id: string;
|
|
34
|
+
};
|
|
35
|
+
data: {
|
|
36
|
+
content: string;
|
|
37
|
+
sequence: number;
|
|
38
|
+
};
|
|
39
|
+
}): Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
im: {
|
|
44
|
+
message: {
|
|
45
|
+
reply(input: {
|
|
46
|
+
path: {
|
|
47
|
+
message_id: string;
|
|
48
|
+
};
|
|
49
|
+
data: {
|
|
50
|
+
msg_type: "interactive";
|
|
51
|
+
content: string;
|
|
52
|
+
};
|
|
53
|
+
}): Promise<{
|
|
54
|
+
data?: {
|
|
55
|
+
message_id?: string;
|
|
56
|
+
chat_id?: string;
|
|
57
|
+
};
|
|
58
|
+
}>;
|
|
59
|
+
create(input: {
|
|
60
|
+
params: {
|
|
61
|
+
receive_id_type: "chat_id" | "open_id" | "user_id";
|
|
62
|
+
};
|
|
63
|
+
data: {
|
|
64
|
+
receive_id: string;
|
|
65
|
+
msg_type: "interactive";
|
|
66
|
+
content: string;
|
|
67
|
+
};
|
|
68
|
+
}): Promise<{
|
|
69
|
+
data?: {
|
|
70
|
+
message_id?: string;
|
|
71
|
+
chat_id?: string;
|
|
72
|
+
};
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
image?: {
|
|
76
|
+
create(input: {
|
|
77
|
+
data: {
|
|
78
|
+
image_type: "message";
|
|
79
|
+
image: unknown;
|
|
80
|
+
};
|
|
81
|
+
}): Promise<unknown>;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export interface StreamingCardSeedInput {
|
|
86
|
+
to: string;
|
|
87
|
+
replyToMessageId?: string;
|
|
88
|
+
}
|
|
89
|
+
export interface StreamingCardSeedResult {
|
|
90
|
+
cardId: string;
|
|
91
|
+
messageId: string;
|
|
92
|
+
}
|
|
93
|
+
export interface StreamingCardControllerOptions {
|
|
94
|
+
client: StreamingCardClient;
|
|
95
|
+
flushIntervalMs?: number;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
/** Set false to skip the markdown image-URL → image_key resolver. */
|
|
98
|
+
resolveImages?: boolean;
|
|
99
|
+
/** Max ms to wait for in-flight image uploads at terminal states. */
|
|
100
|
+
imageResolveTimeoutMs?: number;
|
|
101
|
+
/** Override HTTP fetch (used by the image resolver). Defaults to global fetch. */
|
|
102
|
+
fetchUrl?: (url: string, options?: {
|
|
103
|
+
maxBytes?: number;
|
|
104
|
+
}) => Promise<Buffer>;
|
|
105
|
+
/** Max bytes per uploaded image (forwarded to {@link ImageResolver}). */
|
|
106
|
+
imageMaxBytes?: number;
|
|
107
|
+
/** LRU cap for the image resolver's resolved/failed cache. */
|
|
108
|
+
imageCacheCap?: number;
|
|
109
|
+
/** Account scope for the message-unavailable cache. */
|
|
110
|
+
accountId?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Called once when card updates have failed N consecutive times (default 3).
|
|
113
|
+
* Receives the latest text buffer so the channel can deliver the answer via
|
|
114
|
+
* a non-card path (plain reply) without losing the user's response.
|
|
115
|
+
* After firing, the controller still attempts further updates so a
|
|
116
|
+
* recovering Feishu can resume the card — but the channel should treat the
|
|
117
|
+
* answer as delivered out-of-band.
|
|
118
|
+
*/
|
|
119
|
+
onCardDegraded?: (input: {
|
|
120
|
+
buffer: string;
|
|
121
|
+
consecutiveFailures: number;
|
|
122
|
+
}) => void;
|
|
123
|
+
/** Default 3. */
|
|
124
|
+
failureThreshold?: number;
|
|
125
|
+
/** Max chars for the card body before truncation; default 28000. */
|
|
126
|
+
cardBodyMaxChars?: number;
|
|
127
|
+
/** Interval for live elapsed-footer refresh while the card is non-terminal. Default 1000ms. */
|
|
128
|
+
liveFooterTickMs?: number;
|
|
129
|
+
setTimer?: (cb: () => void, delayMs: number) => unknown;
|
|
130
|
+
clearTimer?: (handle: unknown) => void;
|
|
131
|
+
}
|
|
132
|
+
export declare class StreamingCardController {
|
|
133
|
+
private readonly client;
|
|
134
|
+
private readonly flush;
|
|
135
|
+
private readonly now;
|
|
136
|
+
private readonly imageResolver;
|
|
137
|
+
private readonly imageResolveTimeoutMs;
|
|
138
|
+
private readonly accountId;
|
|
139
|
+
private readonly cardBodyMaxChars;
|
|
140
|
+
private cardId;
|
|
141
|
+
private messageId;
|
|
142
|
+
private streamedText;
|
|
143
|
+
private appendedFinal;
|
|
144
|
+
private sequence;
|
|
145
|
+
private state;
|
|
146
|
+
private lastPushedState;
|
|
147
|
+
private lastPushedReasoning;
|
|
148
|
+
private reasoningBuffer;
|
|
149
|
+
private reasoningStartedAt;
|
|
150
|
+
private reasoningLastAt;
|
|
151
|
+
private lastFooterText;
|
|
152
|
+
private readonly toolUseStore;
|
|
153
|
+
private lastPushedToolRevision;
|
|
154
|
+
private terminated;
|
|
155
|
+
private seededAtMs;
|
|
156
|
+
private degraded;
|
|
157
|
+
private disposeShutdownHook;
|
|
158
|
+
private terminalUpdateDelivered;
|
|
159
|
+
private readonly liveFooterTickMs;
|
|
160
|
+
private readonly setTimer;
|
|
161
|
+
private readonly clearTimer;
|
|
162
|
+
private liveFooterTimer;
|
|
163
|
+
private readonly onCardDegraded;
|
|
164
|
+
constructor(options: StreamingCardControllerOptions);
|
|
165
|
+
isTerminated(): boolean;
|
|
166
|
+
isDegraded(): boolean;
|
|
167
|
+
seed(input: StreamingCardSeedInput): Promise<StreamingCardSeedResult>;
|
|
168
|
+
appendStream(chunk: string): void;
|
|
169
|
+
recordToolEvent(event: ToolUseEvent): void;
|
|
170
|
+
appendReasoning(chunk: string): void;
|
|
171
|
+
complete(finalText?: string): Promise<void>;
|
|
172
|
+
abort(message?: string): Promise<void>;
|
|
173
|
+
private abortForShutdown;
|
|
174
|
+
fail(errorMessage: string): Promise<void>;
|
|
175
|
+
/**
|
|
176
|
+
* Compose the card body from the two-state text model. streamedText holds
|
|
177
|
+
* everything pushed during the turn; appendedFinal is the optional tail from
|
|
178
|
+
* complete(). When both are present we join with a blank line so the final
|
|
179
|
+
* tail visually separates from the progress.
|
|
180
|
+
*/
|
|
181
|
+
private buildDisplayText;
|
|
182
|
+
/**
|
|
183
|
+
* Atomically advance the state machine. Returns false (no-op) when the
|
|
184
|
+
* transition isn't legal — protects against complete/abort/fail racing each
|
|
185
|
+
* other, and prevents stray appendStream calls from resurrecting a closed
|
|
186
|
+
* card. Sets terminal state synchronously so concurrent callers can't both
|
|
187
|
+
* pass through; the actual `terminated` flag flips after async work.
|
|
188
|
+
*/
|
|
189
|
+
private transitionTo;
|
|
190
|
+
private markTerminalUpdateDelivered;
|
|
191
|
+
private scheduleLiveFooterTick;
|
|
192
|
+
private clearLiveFooterTick;
|
|
193
|
+
private awaitImageUploads;
|
|
194
|
+
waitIdle(): Promise<void>;
|
|
195
|
+
private pushUpdate;
|
|
196
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ToolUseEvent, ToolUseStep } from "./tool-use-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Per-controller accumulator for structured tool events. Each unique
|
|
4
|
+
* `toolCallId` gets a single ToolUseStep that is mutated as later events
|
|
5
|
+
* (start → update → end) for the same id arrive. Steps preserve
|
|
6
|
+
* insertion order so the rendered panel reads top-to-bottom in the
|
|
7
|
+
* order tools were invoked.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ToolUseStore {
|
|
10
|
+
private readonly stepsById;
|
|
11
|
+
private readonly order;
|
|
12
|
+
private readonly now;
|
|
13
|
+
private revision;
|
|
14
|
+
constructor(now?: () => number);
|
|
15
|
+
record(event: ToolUseEvent): void;
|
|
16
|
+
steps(): ToolUseStep[];
|
|
17
|
+
isEmpty(): boolean;
|
|
18
|
+
getRevision(): number;
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolUseEvent, ToolUseKind, ToolUseStatus } from "xacpx/plugin-api";
|
|
2
|
+
export type { ToolUseEvent, ToolUseKind, ToolUseStatus };
|
|
3
|
+
export interface ToolUseStep {
|
|
4
|
+
toolCallId: string;
|
|
5
|
+
toolName: string;
|
|
6
|
+
kind: ToolUseKind;
|
|
7
|
+
summary?: string;
|
|
8
|
+
status: ToolUseStatus;
|
|
9
|
+
startedAt: number;
|
|
10
|
+
durationMs?: number;
|
|
11
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ChannelStartInput, CoordinatorMessageInput, CreateChannelDeps, ScheduledChannelMessageInput, MessageChannelRuntime, OrchestrationDeliveryCallbacks } from "xacpx/plugin-api";
|
|
2
|
+
import type { FeishuResolvedAccountConfig } from "./config.js";
|
|
3
|
+
import { type FeishuLarkClient } from "./lark-client.js";
|
|
4
|
+
type OrchestrationTaskRecord = Parameters<MessageChannelRuntime["notifyTaskCompletion"]>[0];
|
|
5
|
+
interface FeishuChannelDeps extends CreateChannelDeps {
|
|
6
|
+
createClient?: (account: FeishuResolvedAccountConfig) => FeishuLarkClient;
|
|
7
|
+
}
|
|
8
|
+
export declare class FeishuChannel implements MessageChannelRuntime {
|
|
9
|
+
private readonly deps;
|
|
10
|
+
readonly id = "feishu";
|
|
11
|
+
private readonly accounts;
|
|
12
|
+
private dedup;
|
|
13
|
+
private markDelivered;
|
|
14
|
+
private markFailed;
|
|
15
|
+
private agent;
|
|
16
|
+
private quota;
|
|
17
|
+
private logger;
|
|
18
|
+
private sessions;
|
|
19
|
+
private activeTurns;
|
|
20
|
+
private readonly executor;
|
|
21
|
+
private readonly activeTasks;
|
|
22
|
+
private readonly permissionNotifier;
|
|
23
|
+
private readonly config;
|
|
24
|
+
constructor(options: Record<string, unknown> | undefined, deps?: FeishuChannelDeps);
|
|
25
|
+
isLoggedIn(): boolean;
|
|
26
|
+
login(): Promise<string>;
|
|
27
|
+
logout(): void;
|
|
28
|
+
configureOrchestration(callbacks: OrchestrationDeliveryCallbacks): void;
|
|
29
|
+
start(input: ChannelStartInput): Promise<void>;
|
|
30
|
+
notifyTaskCompletion(task: OrchestrationTaskRecord): Promise<void>;
|
|
31
|
+
notifyTaskProgress(task: OrchestrationTaskRecord, text: string): Promise<void>;
|
|
32
|
+
sendCoordinatorMessage(input: CoordinatorMessageInput): Promise<void>;
|
|
33
|
+
sendScheduledMessage(input: ScheduledChannelMessageInput): Promise<void>;
|
|
34
|
+
private sendRouteText;
|
|
35
|
+
private handleMessageEvent;
|
|
36
|
+
/**
|
|
37
|
+
* Detect a stop-word inbound and short-circuit to the abort fast-path.
|
|
38
|
+
* Returns true if the message was handled as an abort (caller should
|
|
39
|
+
* stop processing), false if the message should continue down the
|
|
40
|
+
* normal handling path.
|
|
41
|
+
*
|
|
42
|
+
* Authorization:
|
|
43
|
+
* - In a group with `requireMention`, the abort word must be addressed
|
|
44
|
+
* to the bot. Without this, anyone in the group can drop "stop" and
|
|
45
|
+
* cancel another user's turn.
|
|
46
|
+
* - The sender must own at least one live task in this chat/thread —
|
|
47
|
+
* you can only stop your own work.
|
|
48
|
+
*/
|
|
49
|
+
private tryHandleAbortTrigger;
|
|
50
|
+
/**
|
|
51
|
+
* Pre-register the active task BEFORE the chat-queue body runs so an
|
|
52
|
+
* abort message arriving while the turn is queued can still find it and
|
|
53
|
+
* mark it suppressed.
|
|
54
|
+
*/
|
|
55
|
+
private registerActiveTask;
|
|
56
|
+
private sendBackgroundCompletionNotice;
|
|
57
|
+
private runTurn;
|
|
58
|
+
private trySeedStreamingCard;
|
|
59
|
+
private deliverResponse;
|
|
60
|
+
private downloadInboundAttachments;
|
|
61
|
+
private handleAbortFastPath;
|
|
62
|
+
private sendReplyWithGuard;
|
|
63
|
+
private maybeNotifyPermissionError;
|
|
64
|
+
}
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function buildFeishuQueueKey(accountId: string, chatId: string, threadId?: string): string;
|
|
2
|
+
export declare function enqueueFeishuChatTask(input: {
|
|
3
|
+
accountId: string;
|
|
4
|
+
chatId: string;
|
|
5
|
+
threadId?: string;
|
|
6
|
+
task: () => Promise<void>;
|
|
7
|
+
}): {
|
|
8
|
+
status: "queued" | "immediate";
|
|
9
|
+
promise: Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function clearFeishuQueueForAccount(accountId: string): void;
|
|
12
|
+
export declare function resetFeishuChatQueueForTests(): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function buildFeishuCompletionNotice(displayAlias: string, status: "done" | "error"): string;
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type FeishuTuning } from "./tuning.js";
|
|
2
|
+
export type FeishuDmPolicy = "open" | "allowlist" | "disabled";
|
|
3
|
+
export type FeishuGroupPolicy = "open" | "allowlist" | "disabled";
|
|
4
|
+
export type FeishuReplyMode = "static" | "streaming" | "auto";
|
|
5
|
+
export interface FeishuAccountConfig {
|
|
6
|
+
name?: string;
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
appId?: string;
|
|
9
|
+
appSecret?: string;
|
|
10
|
+
domain?: string;
|
|
11
|
+
requireMention?: boolean;
|
|
12
|
+
dmPolicy?: FeishuDmPolicy;
|
|
13
|
+
groupPolicy?: FeishuGroupPolicy;
|
|
14
|
+
allowFrom?: string[];
|
|
15
|
+
replyMode?: FeishuReplyMode;
|
|
16
|
+
}
|
|
17
|
+
export interface FeishuResolvedAccountConfig {
|
|
18
|
+
accountId: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
configured: boolean;
|
|
22
|
+
appId: string;
|
|
23
|
+
appSecret: string;
|
|
24
|
+
domain: string;
|
|
25
|
+
requireMention: boolean;
|
|
26
|
+
dmPolicy: FeishuDmPolicy;
|
|
27
|
+
groupPolicy: FeishuGroupPolicy;
|
|
28
|
+
allowFrom: string[];
|
|
29
|
+
replyMode: FeishuReplyMode;
|
|
30
|
+
}
|
|
31
|
+
export interface FeishuChannelConfig extends FeishuAccountConfig {
|
|
32
|
+
defaultAccount: string;
|
|
33
|
+
textMessageFormat: "text";
|
|
34
|
+
dedupTtlMs: number;
|
|
35
|
+
dedupMaxEntries: number;
|
|
36
|
+
accounts: FeishuResolvedAccountConfig[];
|
|
37
|
+
tuning: FeishuTuning;
|
|
38
|
+
}
|
|
39
|
+
export declare function parseFeishuChannelConfig(raw: unknown): FeishuChannelConfig;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FeishuMessageEvent, FeishuContentConversionResult } from "./types";
|
|
2
|
+
interface ConvertInput {
|
|
3
|
+
messageType: string;
|
|
4
|
+
content: string;
|
|
5
|
+
messageId: string;
|
|
6
|
+
mentions?: FeishuMessageEvent["message"]["mentions"];
|
|
7
|
+
botOpenId?: string;
|
|
8
|
+
stripBotMentions?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function convertFeishuMessageContent(input: ConvertInput): Promise<FeishuContentConversionResult>;
|
|
11
|
+
export {};
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Feishu/Lark error helpers.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes the `{ code, msg, response: { data: { code, msg } } }` shape
|
|
5
|
+
* the @larksuiteoapi/node-sdk throws so domain modules (message-unavailable,
|
|
6
|
+
* permission-error, future rate-limit handling) all parse the envelope the
|
|
7
|
+
* same way. New error codes go in {@link FeishuErrorCode}.
|
|
8
|
+
*/
|
|
9
|
+
export declare const FeishuErrorCode: {
|
|
10
|
+
/** Message has been recalled. */
|
|
11
|
+
readonly MessageRecalled: 230011;
|
|
12
|
+
/** Message has been deleted. */
|
|
13
|
+
readonly MessageDeleted: 231003;
|
|
14
|
+
/** App is missing one or more required API scopes. */
|
|
15
|
+
readonly AppScopeMissing: 99991672;
|
|
16
|
+
};
|
|
17
|
+
export type FeishuErrorCodeValue = (typeof FeishuErrorCode)[keyof typeof FeishuErrorCode];
|
|
18
|
+
export declare function isTerminalMessageApiCode(code: unknown): code is number;
|
|
19
|
+
export declare function extractFeishuApiCode(error: unknown): number | undefined;
|
|
20
|
+
export declare function extractFeishuMessage(error: unknown): string;
|