@fastagent-sh/fastagent 0.12.1 → 0.14.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.
Files changed (151) hide show
  1. package/README.md +6 -4
  2. package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
  3. package/dist/channels/feishu/bootstrap-token.js +94 -0
  4. package/dist/channels/feishu/card.d.ts +32 -0
  5. package/dist/channels/feishu/card.js +66 -0
  6. package/dist/channels/feishu/cloud.d.ts +17 -0
  7. package/dist/channels/feishu/cloud.js +19 -0
  8. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  9. package/dist/channels/feishu/context-buffer.js +133 -0
  10. package/dist/channels/feishu/crypto.d.ts +15 -0
  11. package/dist/channels/feishu/crypto.js +43 -0
  12. package/dist/channels/feishu/feishu-api.d.ts +108 -0
  13. package/dist/channels/feishu/feishu-api.js +325 -0
  14. package/dist/channels/feishu/feishu.d.ts +46 -0
  15. package/dist/channels/feishu/feishu.js +472 -0
  16. package/dist/channels/feishu/invoke-turn.d.ts +65 -0
  17. package/dist/channels/feishu/invoke-turn.js +157 -0
  18. package/dist/channels/feishu/model.d.ts +97 -0
  19. package/dist/channels/feishu/model.js +9 -0
  20. package/dist/channels/feishu/normalize.d.ts +22 -0
  21. package/dist/channels/feishu/normalize.js +132 -0
  22. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  23. package/dist/channels/feishu/owned-threads.js +47 -0
  24. package/dist/channels/feishu/parse.d.ts +43 -0
  25. package/dist/channels/feishu/parse.js +65 -0
  26. package/dist/channels/feishu/preview.d.ts +36 -0
  27. package/dist/channels/feishu/preview.js +387 -0
  28. package/dist/channels/feishu/register-app.d.ts +70 -0
  29. package/dist/channels/feishu/register-app.js +141 -0
  30. package/dist/channels/feishu/register-webhook.d.ts +22 -0
  31. package/dist/channels/feishu/register-webhook.js +106 -0
  32. package/dist/channels/feishu/scaffold/channel.ts +41 -0
  33. package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
  34. package/dist/channels/feishu/seen.d.ts +5 -0
  35. package/dist/channels/feishu/seen.js +47 -0
  36. package/dist/channels/feishu/text.d.ts +13 -0
  37. package/dist/channels/feishu/text.js +63 -0
  38. package/dist/channels/lark/lark.d.ts +15 -0
  39. package/dist/channels/lark/lark.js +10 -0
  40. package/dist/channels/lark/onboard.d.ts +39 -0
  41. package/dist/channels/lark/onboard.js +58 -0
  42. package/dist/channels/lark/scaffold/channel.ts +39 -0
  43. package/dist/channels/lark/scaffold/lark-send.ts +87 -0
  44. package/dist/channels/registration.d.ts +15 -0
  45. package/dist/channels/registration.js +1 -0
  46. package/dist/channels/{telegram/state.js → state.js} +6 -4
  47. package/dist/channels/telegram/context-buffer.js +1 -1
  48. package/dist/channels/telegram/register-webhook.d.ts +4 -1
  49. package/dist/channels/telegram/register-webhook.js +17 -26
  50. package/dist/channels/telegram/telegram.js +2 -2
  51. package/dist/channels/telegram/turn-store.d.ts +8 -21
  52. package/dist/channels/telegram/turn-store.js +11 -130
  53. package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
  54. package/dist/channels/turn-store.d.ts +42 -0
  55. package/dist/channels/turn-store.js +139 -0
  56. package/dist/channels/wait-health.d.ts +6 -0
  57. package/dist/channels/wait-health.js +27 -0
  58. package/dist/cli/commands/add.d.ts +9 -0
  59. package/dist/cli/commands/add.js +142 -0
  60. package/dist/cli/commands/chat.d.ts +3 -0
  61. package/dist/cli/commands/chat.js +16 -0
  62. package/dist/cli/commands/deploy.d.ts +13 -0
  63. package/dist/cli/commands/deploy.js +338 -0
  64. package/dist/cli/commands/dev.d.ts +11 -0
  65. package/dist/cli/commands/dev.js +76 -0
  66. package/dist/cli/commands/fire.d.ts +7 -0
  67. package/dist/cli/commands/fire.js +45 -0
  68. package/dist/cli/commands/info.d.ts +7 -0
  69. package/dist/cli/commands/info.js +108 -0
  70. package/dist/cli/commands/init.d.ts +8 -0
  71. package/dist/cli/commands/init.js +81 -0
  72. package/dist/cli/commands/invoke.d.ts +7 -0
  73. package/dist/cli/commands/invoke.js +29 -0
  74. package/dist/cli/commands/login.d.ts +6 -0
  75. package/dist/cli/commands/login.js +63 -0
  76. package/dist/cli/commands/models.d.ts +1 -0
  77. package/dist/cli/commands/models.js +15 -0
  78. package/dist/cli/commands/schedule.d.ts +12 -0
  79. package/dist/cli/commands/schedule.js +89 -0
  80. package/dist/cli/commands/start.d.ts +10 -0
  81. package/dist/cli/commands/start.js +90 -0
  82. package/dist/cli/commands/tool.d.ts +1 -0
  83. package/dist/cli/commands/tool.js +37 -0
  84. package/dist/cli/fail.d.ts +19 -0
  85. package/dist/cli/fail.js +32 -0
  86. package/dist/cli/kernel.d.ts +89 -0
  87. package/dist/cli/kernel.js +190 -0
  88. package/dist/cli/program.d.ts +11 -0
  89. package/dist/cli/program.js +421 -0
  90. package/dist/cli/serve.d.ts +28 -0
  91. package/dist/cli/serve.js +90 -0
  92. package/dist/cli/shared.d.ts +24 -0
  93. package/dist/cli/shared.js +116 -0
  94. package/dist/cli-add-feishu.d.ts +8 -0
  95. package/dist/cli-add-feishu.js +223 -0
  96. package/dist/cli.js +8 -1284
  97. package/dist/deploy/container.js +10 -6
  98. package/dist/deploy/docker/plan.d.ts +45 -0
  99. package/dist/deploy/docker/plan.js +139 -0
  100. package/dist/deploy/docker/run.d.ts +40 -0
  101. package/dist/deploy/docker/run.js +126 -0
  102. package/dist/deploy/fly/plan.d.ts +1 -1
  103. package/dist/deploy/fly/plan.js +15 -4
  104. package/dist/deploy/fly/run.d.ts +7 -4
  105. package/dist/deploy/fly/run.js +26 -5
  106. package/dist/deploy/preflight.js +4 -3
  107. package/dist/deploy/railway/plan.d.ts +1 -1
  108. package/dist/deploy/railway/plan.js +17 -5
  109. package/dist/deploy/railway/run.d.ts +6 -3
  110. package/dist/deploy/railway/run.js +26 -4
  111. package/dist/deploy/registration-gate.d.ts +20 -0
  112. package/dist/deploy/registration-gate.js +20 -0
  113. package/dist/deploy/runner.d.ts +4 -1
  114. package/dist/deploy/runner.js +1 -0
  115. package/dist/deploy/secrets.d.ts +10 -9
  116. package/dist/deploy/secrets.js +15 -14
  117. package/dist/dev-supervisor.js +2 -1
  118. package/dist/engines/pi/auth.js +160 -46
  119. package/dist/engines/pi/chat.js +80 -7
  120. package/dist/engines/pi/config.d.ts +12 -3
  121. package/dist/engines/pi/config.js +16 -1
  122. package/dist/engines/pi/create.d.ts +14 -6
  123. package/dist/engines/pi/create.js +52 -16
  124. package/dist/engines/pi/harness.d.ts +16 -1
  125. package/dist/engines/pi/harness.js +77 -1
  126. package/dist/engines/pi/invoke.d.ts +1 -1
  127. package/dist/engines/pi/invoke.js +37 -2
  128. package/dist/engines/pi/login.js +1 -1
  129. package/dist/engines/pi/search-tools.d.ts +10 -0
  130. package/dist/engines/pi/search-tools.js +138 -0
  131. package/dist/engines/pi/tool-context.d.ts +28 -0
  132. package/dist/engines/pi/tool-context.js +8 -0
  133. package/dist/engines/pi/tool.d.ts +32 -1
  134. package/dist/engines/pi/tool.js +42 -1
  135. package/dist/engines/pi/workspace.d.ts +4 -1
  136. package/dist/engines/pi/workspace.js +3 -1
  137. package/dist/feishu.d.ts +2 -0
  138. package/dist/feishu.js +2 -0
  139. package/dist/lark.d.ts +3 -0
  140. package/dist/lark.js +3 -0
  141. package/dist/open-url.d.ts +2 -0
  142. package/dist/open-url.js +6 -0
  143. package/dist/pi.d.ts +2 -1
  144. package/dist/scaffold/add-channel.d.ts +9 -5
  145. package/dist/scaffold/add-channel.js +75 -7
  146. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  147. package/dist/tunnel.d.ts +9 -6
  148. package/dist/tunnel.js +48 -31
  149. package/package.json +18 -4
  150. /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
  151. /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Run one turn (the IO half of canonical Feishu→Agent translation): assemble its inputs — resolve the reply
3
+ * referent (a summon that replies to an earlier message names it only by `parent_id`; the content is
4
+ * NOT in the event, so it is fetched here) and the attachments (vision images inline, files to disk) —
5
+ * and stream `agent.invoke` with the assembled prompt. Split from parse.ts (which is pure) because this
6
+ * half touches the Open API + disk; split from feishu.ts so the factory keeps only wiring and the
7
+ * per-turn lifecycle.
8
+ *
9
+ * Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
10
+ * any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
11
+ * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
+ * one expired background file must not block the current ask or hide its still-readable siblings.
13
+ */
14
+ import { SESSION_BUSY_CODE } from "../../agent.js";
15
+ import { log } from "../../log.js";
16
+ import { parseContent } from "./parse.js";
17
+ import { codePointPrefix } from "./text.js";
18
+ /** Appended to the prompt (not the system prompt): the channel renders the reply in a card, and the
19
+ * card's markdown element is the natural fit for LLM output — steer away from HTML/plain. */
20
+ const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card.)";
21
+ /**
22
+ * Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
23
+ * (vision) and file (disk). Primary failures throw; buffered resources degrade independently.
24
+ */
25
+ async function resolveTurnInputs(t, attachments) {
26
+ const images = [...attachments.primary.images];
27
+ const files = [...attachments.primary.files];
28
+ let referentBlock = "";
29
+ if (attachments.primary.parentId !== undefined) {
30
+ const parentId = attachments.primary.parentId;
31
+ const parent = await t.api.getMessage(parentId);
32
+ if (!parent)
33
+ throw new Error(`replied-to message ${parentId} is not readable`);
34
+ const parsed = parseContent({
35
+ message_type: parent.msg_type ?? "unknown",
36
+ content: parent.body?.content ?? "",
37
+ mentions: parent.mentions,
38
+ });
39
+ // The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
40
+ for (const key of parsed.imageKeys)
41
+ images.push({ msg: parentId, key });
42
+ for (const ref of parsed.fileRefs)
43
+ files.push({ msg: parentId, key: ref.key, name: ref.name });
44
+ // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
45
+ // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
46
+ const senderId = parent.sender?.id;
47
+ const from = senderId ? `user ${senderId}` : undefined;
48
+ referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${codePointPrefix(parsed.text, 560) || "(empty)"}]`;
49
+ }
50
+ // Primary first and fail-fast: these are resources the current user explicitly pointed at.
51
+ const imageRefs = [];
52
+ for (const ref of images)
53
+ imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
54
+ const downloaded = [];
55
+ for (const ref of files)
56
+ downloaded.push(await t.api.fetchFile(ref.msg, ref.key, ref.name ?? ref.key, t.chatId, t.filesDir));
57
+ // A replied-to buffered message is now primary. Filter by message-scoped identity so it is not
58
+ // downloaded twice or rendered twice in the manifest.
59
+ const primaryImages = new Set(images.map((ref) => `${ref.msg}\u0000${ref.key}`));
60
+ const primaryFiles = new Set(files.map((ref) => `${ref.msg}\u0000${ref.key}`));
61
+ const bufferedImages = attachments.buffered.images.filter((ref) => !primaryImages.has(`${ref.messageId}\u0000${ref.key}`));
62
+ const bufferedFiles = attachments.buffered.files.filter((ref) => !primaryFiles.has(`${ref.messageId}\u0000${ref.key}`));
63
+ const backgroundImages = [];
64
+ const backgroundFiles = [];
65
+ let lost = 0;
66
+ const imageResults = await Promise.allSettled(bufferedImages.map(async (ref) => ({ ref, image: await t.api.fetchImage(ref.messageId, ref.key) })));
67
+ for (const result of imageResults) {
68
+ if (result.status === "fulfilled")
69
+ backgroundImages.push(result.value);
70
+ else {
71
+ lost++;
72
+ log.warn(`${t.label} could not load an earlier (buffered) image: ${String(result.reason)}`);
73
+ }
74
+ }
75
+ const fileResults = await Promise.allSettled(bufferedFiles.map(async (ref) => ({
76
+ ref,
77
+ file: await t.api.fetchFile(ref.messageId, ref.key, ref.name ?? ref.key, t.chatId, t.filesDir),
78
+ })));
79
+ for (const result of fileResults) {
80
+ if (result.status === "fulfilled")
81
+ backgroundFiles.push(result.value);
82
+ else {
83
+ lost++;
84
+ log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
85
+ }
86
+ }
87
+ const missing = lost + attachments.buffered.skipped;
88
+ const bufferedNote = missing > 0
89
+ ? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (expired, or older than the most recent few)]`
90
+ : "";
91
+ const backgroundImageManifest = backgroundImages.length
92
+ ? `\n\n[background vision images from earlier discussion — appended after ${imageRefs.length} primary image(s):\n${backgroundImages
93
+ .map(({ ref }, index) => `- vision image ${imageRefs.length + index + 1}: from ${ref.from}, msg ${ref.messageId}`)
94
+ .join("\n")}\n]`
95
+ : "";
96
+ const allFiles = [
97
+ ...downloaded,
98
+ ...backgroundFiles.map(({ file, ref }) => ({
99
+ ...file,
100
+ name: `${file.name} (from ${ref.from}, msg ${ref.messageId}, earlier discussion)`,
101
+ })),
102
+ ];
103
+ const manifest = allFiles.length
104
+ ? `\n\n[attached files — read them with your tools:\n${allFiles.map((file) => `- ${file.name} (${file.size} bytes) → ${file.path}`).join("\n")}\n]`
105
+ : "";
106
+ const allImages = [...imageRefs, ...backgroundImages.map(({ image }) => image)];
107
+ return {
108
+ images: allImages.length ? allImages : undefined,
109
+ promptSuffix: `${referentBlock}${bufferedNote}${backgroundImageManifest}${manifest}`,
110
+ };
111
+ }
112
+ // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
113
+ // free, and the loop exits within one delay of the holder finishing. The cap is sized to outlast a real
114
+ // tool-using wake turn (minutes); a holder that runs longer still surfaces the busy error to the user.
115
+ const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
116
+ /**
117
+ * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
118
+ * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
119
+ * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
120
+ * moment the turn provably lives in the session.
121
+ *
122
+ * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
123
+ * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
124
+ * the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
125
+ * FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
126
+ * engine emits it in, so nothing that started is ever re-run.
127
+ */
128
+ export async function* invokeFeishuTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
129
+ let resolved;
130
+ try {
131
+ resolved = await resolveTurnInputs(transport, attachments);
132
+ }
133
+ catch (e) {
134
+ yield { type: "failed", details: `could not load attachment: ${String(e)}`, retryable: true };
135
+ return;
136
+ }
137
+ const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
138
+ const deadline = Date.now() + busyRetry.maxWaitMs;
139
+ for (;;) {
140
+ let retryBusy = false;
141
+ let first = true;
142
+ for await (const e of agent.invoke({ session }, prompt)) {
143
+ if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
144
+ retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
145
+ break;
146
+ }
147
+ first = false;
148
+ if (e.type === "completed")
149
+ onCompleted?.(); // the turn is durably in the session — commit point
150
+ yield e;
151
+ }
152
+ if (!retryBusy)
153
+ return;
154
+ log.info(`${transport.label} session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
155
+ await new Promise((r) => setTimeout(r, busyRetry.delayMs));
156
+ }
157
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Canonical Feishu/Lark protocol and normalized-message models.
3
+ *
4
+ * Raw wire types stay at the webhook/policy boundary: authors' existing `route(event)` callbacks keep
5
+ * receiving the platform event unchanged. The narrow normalized shape below owns only what currently
6
+ * benefits from normalization: conversation-place identity, decoded content, mention presence, and
7
+ * message-scoped resource locators. Do not pre-model unused transport metadata for a future ingress.
8
+ */
9
+ /** The v2 event envelope header. Feishu and Lark use the same wire shape. */
10
+ export interface FeishuEventHeader {
11
+ event_id?: string;
12
+ event_type?: string;
13
+ create_time?: string;
14
+ token?: string;
15
+ app_id?: string;
16
+ tenant_key?: string;
17
+ [k: string]: unknown;
18
+ }
19
+ /** One entry of a message's `mentions` array. `key` is the placeholder carried in text content. */
20
+ export interface FeishuMention {
21
+ key: string;
22
+ id?: {
23
+ open_id?: string;
24
+ user_id?: string;
25
+ union_id?: string;
26
+ };
27
+ name?: string;
28
+ mentioned_type?: string;
29
+ tenant_key?: string;
30
+ [k: string]: unknown;
31
+ }
32
+ /** A received `im.message.receive_v1` message. `content` is JSON encoded according to `message_type`. */
33
+ export interface FeishuMessage {
34
+ message_id: string;
35
+ root_id?: string;
36
+ parent_id?: string;
37
+ create_time?: string;
38
+ update_time?: string;
39
+ chat_id: string;
40
+ thread_id?: string;
41
+ chat_type: string;
42
+ message_type: string;
43
+ content: string;
44
+ mentions?: FeishuMention[];
45
+ user_agent?: string;
46
+ lark_agent_context?: {
47
+ active_chat_id?: string;
48
+ [k: string]: unknown;
49
+ };
50
+ [k: string]: unknown;
51
+ }
52
+ export interface FeishuSender {
53
+ sender_id?: {
54
+ open_id?: string;
55
+ user_id?: string;
56
+ union_id?: string;
57
+ };
58
+ sender_type?: string;
59
+ tenant_key?: string;
60
+ [k: string]: unknown;
61
+ }
62
+ /** The `event` member of a v2 `im.message.receive_v1` envelope. */
63
+ export interface FeishuMessageEvent {
64
+ sender?: FeishuSender;
65
+ message?: FeishuMessage;
66
+ [k: string]: unknown;
67
+ }
68
+ /** Existing public route result: act with these overrides, or return null to ignore the message. */
69
+ export interface FeishuRoute {
70
+ session?: string;
71
+ chatId?: string;
72
+ text?: string;
73
+ }
74
+ export type FeishuResourceKind = "image" | "file" | "audio" | "video";
75
+ /**
76
+ * A resource locator. User-sent resources are scoped by BOTH their carrying message id and resource
77
+ * key; a bare file_key/image_key is insufficient for the message-resource download API.
78
+ */
79
+ export interface FeishuResourceRef {
80
+ kind: FeishuResourceKind;
81
+ messageId: string;
82
+ key: string;
83
+ name?: string;
84
+ }
85
+ /** Narrow internal representation shared by the Feishu reference and Lark compatibility clouds. */
86
+ export interface NormalizedFeishuMessage {
87
+ conversation: {
88
+ chatId: string;
89
+ threadId?: string;
90
+ rootId?: string;
91
+ };
92
+ content: {
93
+ text: string;
94
+ hasMentions: boolean;
95
+ resources: FeishuResourceRef[];
96
+ };
97
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Canonical Feishu/Lark protocol and normalized-message models.
3
+ *
4
+ * Raw wire types stay at the webhook/policy boundary: authors' existing `route(event)` callbacks keep
5
+ * receiving the platform event unchanged. The narrow normalized shape below owns only what currently
6
+ * benefits from normalization: conversation-place identity, decoded content, mention presence, and
7
+ * message-scoped resource locators. Do not pre-model unused transport metadata for a future ingress.
8
+ */
9
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pure Feishu/Lark message normalization. The platform sends a stable event shell but encodes the
3
+ * actual message body as a JSON string selected by `message_type`; this module is the one decoder and
4
+ * converts resource keys into message-scoped locators before the turn engine sees them.
5
+ */
6
+ import type { FeishuMessage, FeishuMessageEvent, FeishuResourceKind, NormalizedFeishuMessage } from "./model.ts";
7
+ export interface DecodedFeishuResource {
8
+ kind: FeishuResourceKind;
9
+ key: string;
10
+ name?: string;
11
+ }
12
+ export interface DecodedFeishuContent {
13
+ text: string;
14
+ resources: DecodedFeishuResource[];
15
+ }
16
+ /**
17
+ * Decode one JSON-string message body. Unknown or malformed external input degrades to a visible
18
+ * marker rather than throwing, preserving the channel's existing fail-visible prompt behavior.
19
+ */
20
+ export declare function decodeFeishuContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): DecodedFeishuContent;
21
+ /** Normalize one verified message event. Returns null only when its required identity is absent. */
22
+ export declare function normalizeFeishuMessage(event: FeishuMessageEvent): NormalizedFeishuMessage | null;
@@ -0,0 +1,132 @@
1
+ function nonEmptyString(value) {
2
+ return typeof value === "string" && value !== "" ? value : undefined;
3
+ }
4
+ /** Restore text-message mention placeholders to readable names. */
5
+ function restoreMentions(text, mentions) {
6
+ let out = text;
7
+ for (const mention of mentions ?? []) {
8
+ if (!mention.key)
9
+ continue;
10
+ out = out.split(mention.key).join(`@${mention.name ?? "user"}`);
11
+ }
12
+ return out;
13
+ }
14
+ /**
15
+ * Decode one JSON-string message body. Unknown or malformed external input degrades to a visible
16
+ * marker rather than throwing, preserving the channel's existing fail-visible prompt behavior.
17
+ */
18
+ export function decodeFeishuContent(message) {
19
+ const rawType = typeof message.message_type === "string" ? message.message_type : "unknown";
20
+ let content;
21
+ try {
22
+ content = JSON.parse(message.content);
23
+ if (typeof content !== "object" || content === null)
24
+ throw new Error("not an object");
25
+ }
26
+ catch {
27
+ return { text: `[unreadable ${rawType} message]`, resources: [] };
28
+ }
29
+ const resources = [];
30
+ switch (rawType) {
31
+ case "text":
32
+ return { text: restoreMentions(nonEmptyString(content.text) ?? "", message.mentions), resources };
33
+ case "post": {
34
+ const lines = [];
35
+ const title = nonEmptyString(content.title);
36
+ if (title)
37
+ lines.push(title);
38
+ const paragraphs = Array.isArray(content.content) ? content.content : [];
39
+ for (const paragraph of paragraphs) {
40
+ if (!Array.isArray(paragraph))
41
+ continue;
42
+ const parts = [];
43
+ for (const node of paragraph) {
44
+ if (typeof node !== "object" || node === null)
45
+ continue;
46
+ if (node.tag === "at") {
47
+ parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
48
+ }
49
+ else if (node.tag === "a") {
50
+ parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
51
+ }
52
+ else if (node.tag === "img") {
53
+ const key = nonEmptyString(node.image_key);
54
+ if (key)
55
+ resources.push({ kind: "image", key });
56
+ parts.push("[image]");
57
+ }
58
+ else if (node.tag === "media") {
59
+ const key = nonEmptyString(node.file_key);
60
+ if (key)
61
+ resources.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
62
+ parts.push("[video]");
63
+ }
64
+ else if (node.tag === "code_block") {
65
+ parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
66
+ }
67
+ else if (nonEmptyString(node.text)) {
68
+ parts.push(node.text);
69
+ }
70
+ }
71
+ const line = parts.join("").trim();
72
+ if (line)
73
+ lines.push(line);
74
+ }
75
+ return { text: lines.join("\n"), resources };
76
+ }
77
+ case "image": {
78
+ const key = nonEmptyString(content.image_key);
79
+ if (key)
80
+ resources.push({ kind: "image", key });
81
+ return { text: "[image]", resources };
82
+ }
83
+ case "file": {
84
+ const key = nonEmptyString(content.file_key);
85
+ const name = nonEmptyString(content.file_name);
86
+ if (key)
87
+ resources.push({ kind: "file", key, name });
88
+ return { text: `[file: ${name ?? "file"}]`, resources };
89
+ }
90
+ case "audio": {
91
+ const key = nonEmptyString(content.file_key);
92
+ if (key)
93
+ resources.push({ kind: "audio", key, name: "voice-message" });
94
+ return { text: "[voice message]", resources };
95
+ }
96
+ case "media": {
97
+ const key = nonEmptyString(content.file_key);
98
+ const name = nonEmptyString(content.file_name);
99
+ if (key)
100
+ resources.push({ kind: "video", key, name });
101
+ return { text: `[video: ${name ?? "video"}]`, resources };
102
+ }
103
+ case "location": {
104
+ const name = nonEmptyString(content.name);
105
+ return {
106
+ text: `[location: ${name ? `${name} — ` : ""}${nonEmptyString(content.latitude) ?? "?"},${nonEmptyString(content.longitude) ?? "?"}]`,
107
+ resources,
108
+ };
109
+ }
110
+ default:
111
+ return { text: `[${rawType} message]`, resources };
112
+ }
113
+ }
114
+ /** Normalize one verified message event. Returns null only when its required identity is absent. */
115
+ export function normalizeFeishuMessage(event) {
116
+ const message = event.message;
117
+ if (!message || typeof message.message_id !== "string" || typeof message.chat_id !== "string")
118
+ return null;
119
+ const decoded = decodeFeishuContent(message);
120
+ return {
121
+ conversation: {
122
+ chatId: message.chat_id,
123
+ threadId: message.thread_id,
124
+ rootId: message.root_id,
125
+ },
126
+ content: {
127
+ text: decoded.text,
128
+ hasMentions: (message.mentions?.length ?? 0) > 0,
129
+ resources: decoded.resources.map((resource) => ({ ...resource, messageId: message.message_id })),
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,11 @@
1
+ export interface OwnedFeishuThread {
2
+ rootId: string;
3
+ chatId: string;
4
+ createdAt: number;
5
+ }
6
+ export interface OwnedFeishuThreads {
7
+ has(chatId: string, rootId: string): boolean;
8
+ /** Idempotent, synchronous, pre-ACK persistence. A failed write throws so the platform redelivers. */
9
+ add(chatId: string, rootId: string): void;
10
+ }
11
+ export declare function createOwnedFeishuThreads(path: string, label: string, now?: () => number): OwnedFeishuThreads;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Durable ownership index for group threads the channel created from a top-level summon. Feishu/Lark
3
+ * deliver an ordinary thread continuation as `thread_id + root_id`; the root is the original user
4
+ * message the channel reply-threaded under. Keeping that root across restarts lets the default route
5
+ * admit unmentioned continuations only inside Agent-managed threads, never across the whole group.
6
+ */
7
+ import { log } from "../../log.js";
8
+ import { loadStateFile, saveStateFile } from "../state.js";
9
+ function isRecord(value) {
10
+ const record = value;
11
+ return (typeof record?.rootId === "string" &&
12
+ typeof record.chatId === "string" &&
13
+ typeof record.createdAt === "number" &&
14
+ Number.isFinite(record.createdAt));
15
+ }
16
+ export function createOwnedFeishuThreads(path, label, now = Date.now) {
17
+ const raw = loadStateFile(path);
18
+ let records = new Map();
19
+ if (raw !== undefined) {
20
+ if (typeof raw === "object" &&
21
+ raw !== null &&
22
+ !Array.isArray(raw) &&
23
+ Object.entries(raw).every(([rootId, record]) => isRecord(record) && record.rootId === rootId)) {
24
+ records = new Map(Object.entries(raw));
25
+ }
26
+ else {
27
+ log.warn(`${label} unexpected shape in ${path} — starting with no managed group threads`);
28
+ }
29
+ }
30
+ return {
31
+ has(chatId, rootId) {
32
+ const record = records.get(rootId);
33
+ return record?.chatId === chatId;
34
+ },
35
+ add(chatId, rootId) {
36
+ const existing = records.get(rootId);
37
+ if (existing?.chatId === chatId)
38
+ return;
39
+ const next = new Map(records);
40
+ next.set(rootId, { rootId, chatId, createdAt: now() });
41
+ // Persist the staged copy first: an IO failure must not leave memory claiming durability the file
42
+ // does not have. The webhook remains un-ACKed and the platform can redeliver after recovery.
43
+ saveStateFile(path, Object.fromEntries(next));
44
+ records = next;
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Canonical Feishu protocol policy and prompt-envelope helpers — PURE. Raw event types remain public
3
+ * for the existing `route(event)` authoring surface; JSON-string content decoding is delegated to the
4
+ * normalized-message boundary in normalize.ts so the turn engine and compatibility wrappers share one
5
+ * decoder.
6
+ */
7
+ import type { FeishuCloudKind } from "./cloud.ts";
8
+ import type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender } from "./model.ts";
9
+ export type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender };
10
+ /** Legacy compatibility shape returned by {@link parseContent}. New internal code consumes normalized
11
+ * resource refs, which retain resource kind + carrying message id. */
12
+ export interface FeishuAttachmentRef {
13
+ key: string;
14
+ name?: string;
15
+ }
16
+ export interface ParsedFeishuContent {
17
+ text: string;
18
+ imageKeys: string[];
19
+ fileRefs: FeishuAttachmentRef[];
20
+ }
21
+ /**
22
+ * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
23
+ * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
24
+ */
25
+ export declare function parseContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): ParsedFeishuContent;
26
+ /** A stable sender label for attribution. Display names require an additional contacts permission. */
27
+ export declare function senderLabel(sender: FeishuSender | undefined): string | undefined;
28
+ /** The place a message lives (chat, or chat:topic in a topic group) — the legacy default session key. */
29
+ export declare function placeKey(message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
30
+ /** The canonical Feishu-branded prompt envelope. */
31
+ export declare function feishuEnvelope(event: FeishuMessageEvent): string;
32
+ /** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
33
+ export declare function cloudEnvelope(event: FeishuMessageEvent, tag: FeishuCloudKind): string;
34
+ /** Whether the parsed mention list contains this bot's app-scoped open_id. */
35
+ export declare function mentionsBot(message: Pick<FeishuMessage, "mentions">, botOpenId: string | undefined): boolean;
36
+ /**
37
+ * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
38
+ * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
39
+ * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
40
+ */
41
+ export declare function defaultFeishuRoute(event: FeishuMessageEvent, options?: {
42
+ botOpenId?: string;
43
+ }): FeishuRoute | null;
@@ -0,0 +1,65 @@
1
+ import { decodeFeishuContent } from "./normalize.js";
2
+ /**
3
+ * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
4
+ * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
5
+ */
6
+ export function parseContent(message) {
7
+ const decoded = decodeFeishuContent(message);
8
+ return {
9
+ text: decoded.text,
10
+ imageKeys: decoded.resources.filter((resource) => resource.kind === "image").map((resource) => resource.key),
11
+ fileRefs: decoded.resources
12
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
13
+ .map((resource) => ({ key: resource.key, name: resource.name })),
14
+ };
15
+ }
16
+ /** A stable sender label for attribution. Display names require an additional contacts permission. */
17
+ export function senderLabel(sender) {
18
+ const id = sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
19
+ return id ? `user ${id}` : undefined;
20
+ }
21
+ /** The place a message lives (chat, or chat:topic in a topic group) — the legacy default session key. */
22
+ export function placeKey(message) {
23
+ return message.thread_id ? `${message.chat_id}:${message.thread_id}` : message.chat_id;
24
+ }
25
+ /** The canonical Feishu-branded prompt envelope. */
26
+ export function feishuEnvelope(event) {
27
+ return cloudEnvelope(event, "feishu");
28
+ }
29
+ /** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
30
+ export function cloudEnvelope(event, tag) {
31
+ const message = event.message;
32
+ if (!message)
33
+ return "";
34
+ const from = senderLabel(event.sender);
35
+ const meta = [
36
+ `chat ${message.chat_id} (${message.chat_type})`,
37
+ message.thread_id ? `topic ${message.thread_id}` : undefined,
38
+ from ? `from ${from}` : undefined,
39
+ ]
40
+ .filter(Boolean)
41
+ .join(", ");
42
+ const scope = message.chat_type === "group" ? "\n[group chat — multiple people; each message is prefixed with its sender]" : "";
43
+ const replyTo = message.parent_id ? `\n[in reply to msg ${message.parent_id}]` : "";
44
+ return `[${tag}: ${meta}]${scope}${replyTo}\n${parseContent(message).text}`;
45
+ }
46
+ /** Whether the parsed mention list contains this bot's app-scoped open_id. */
47
+ export function mentionsBot(message, botOpenId) {
48
+ if (!botOpenId)
49
+ return false;
50
+ return (message.mentions ?? []).some((mention) => mention.id?.open_id === botOpenId);
51
+ }
52
+ /**
53
+ * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
54
+ * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
55
+ * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
56
+ */
57
+ export function defaultFeishuRoute(event, options) {
58
+ const message = event.message;
59
+ if (!message)
60
+ return null;
61
+ if (event.sender?.sender_type !== "user")
62
+ return null;
63
+ const summoned = message.chat_type === "p2p" || mentionsBot(message, options?.botOpenId);
64
+ return summoned ? {} : null;
65
+ }
@@ -0,0 +1,36 @@
1
+ import type { AgentEvent } from "../../agent.ts";
2
+ import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
3
+ /** A terminal failure, as the channel hands it to `onError`. */
4
+ export interface FeishuFailure {
5
+ details: string;
6
+ retryable: boolean;
7
+ }
8
+ /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
9
+ export declare function defaultErrorMessage(failed: FeishuFailure): string;
10
+ /** A visible preview mounted into the chat. Exported only for the channel wiring: a queued turn mounts
11
+ * one before execution, then hands the exact entity/message to {@link streamFeishuReply} for takeover. */
12
+ export type MountedFeishuPreview = {
13
+ kind: "card";
14
+ cardId: string;
15
+ messageId: string;
16
+ } | {
17
+ kind: "text";
18
+ messageId: string;
19
+ };
20
+ /**
21
+ * Mount one preview message: preferably a streaming card entity, with a static text message as the
22
+ * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
23
+ * has exactly the same shape the stream pump expects to take over later.
24
+ */
25
+ export declare function mountFeishuPreview(api: FeishuApi, target: FeishuTarget, initial: string, label?: string): Promise<MountedFeishuPreview>;
26
+ /** Settle an already-mounted queue preview without starting an Agent stream (the poison/defer paths).
27
+ * Card and text tiers both change in place; only a missing/failed preview sends a fresh message. */
28
+ export declare function settleFeishuPreview(api: FeishuApi, target: FeishuTarget, preview: MountedFeishuPreview | undefined, text: string): Promise<void>;
29
+ /**
30
+ * Consume one turn's event stream into a Feishu-compatible chat, live (see the module header for the preview
31
+ * model). Preview updates are best-effort (logged once if they fail); the final write is authoritative
32
+ * and surfaces a real failure (bad credentials, etc.). `initialPreview`, when present, is the queued
33
+ * turn's already-mounted card/text message: the pump and terminal write mutate that same message rather
34
+ * than recalling it and posting another reply.
35
+ */
36
+ export declare function streamFeishuReply(events: AsyncIterable<AgentEvent>, api: FeishuApi, target: FeishuTarget, formatError: (failed: FeishuFailure) => string | undefined, initialPreview?: MountedFeishuPreview, label?: string): Promise<void>;