@fastagent-sh/fastagent 0.13.0 → 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.
- package/README.md +2 -2
- package/dist/channels/feishu/context-buffer.d.ts +46 -0
- package/dist/channels/feishu/context-buffer.js +133 -0
- package/dist/channels/feishu/crypto.d.ts +4 -2
- package/dist/channels/feishu/crypto.js +4 -2
- package/dist/channels/feishu/feishu-api.d.ts +4 -4
- package/dist/channels/feishu/feishu-api.js +2 -2
- package/dist/channels/feishu/feishu.d.ts +11 -1
- package/dist/channels/feishu/feishu.js +143 -30
- package/dist/channels/feishu/invoke-turn.d.ts +19 -13
- package/dist/channels/feishu/invoke-turn.js +70 -19
- package/dist/channels/feishu/model.d.ts +97 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +22 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +11 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +20 -102
- package/dist/channels/feishu/parse.js +35 -145
- package/dist/channels/feishu/preview.js +2 -2
- package/dist/channels/feishu/scaffold/channel.ts +10 -3
- package/dist/channels/feishu/seen.js +12 -12
- package/dist/channels/lark/scaffold/channel.ts +10 -3
- package/dist/cli/commands/add.d.ts +9 -0
- package/dist/cli/commands/add.js +142 -0
- package/dist/cli/commands/chat.d.ts +3 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +338 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +76 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +29 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +63 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +90 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +37 -0
- package/dist/cli/fail.d.ts +19 -0
- package/dist/cli/fail.js +32 -0
- package/dist/cli/kernel.d.ts +89 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +421 -0
- package/dist/cli/serve.d.ts +28 -0
- package/dist/cli/serve.js +90 -0
- package/dist/cli/shared.d.ts +24 -0
- package/dist/cli/shared.js +116 -0
- package/dist/cli.js +8 -1329
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +139 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +77 -4
- package/dist/engines/pi/config.d.ts +12 -3
- package/dist/engines/pi/config.js +16 -1
- package/dist/engines/pi/create.d.ts +14 -5
- package/dist/engines/pi/create.js +44 -9
- package/dist/engines/pi/harness.d.ts +16 -1
- package/dist/engines/pi/harness.js +77 -1
- package/dist/engines/pi/invoke.d.ts +1 -1
- package/dist/engines/pi/invoke.js +37 -2
- package/dist/engines/pi/login.js +1 -1
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/tool-context.d.ts +28 -0
- package/dist/engines/pi/tool-context.js +8 -0
- package/dist/engines/pi/tool.d.ts +32 -1
- package/dist/engines/pi/tool.js +42 -1
- package/dist/engines/pi/workspace.d.ts +4 -1
- package/dist/engines/pi/workspace.js +3 -1
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.js +3 -1
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/package.json +7 -4
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
* half touches the Open API + disk; split from feishu.ts so the factory keeps only wiring and the
|
|
7
7
|
* per-turn lifecycle.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* with the buffer itself, which needs the sensitive all-group-messages scope.)
|
|
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.
|
|
14
13
|
*/
|
|
15
14
|
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
16
15
|
import { log } from "../../log.js";
|
|
@@ -21,16 +20,17 @@ import { codePointPrefix } from "./text.js";
|
|
|
21
20
|
const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card.)";
|
|
22
21
|
/**
|
|
23
22
|
* Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
|
|
24
|
-
* (vision) and file (disk).
|
|
23
|
+
* (vision) and file (disk). Primary failures throw; buffered resources degrade independently.
|
|
25
24
|
*/
|
|
26
25
|
async function resolveTurnInputs(t, attachments) {
|
|
27
|
-
const images = [...attachments.images];
|
|
28
|
-
const files = [...attachments.files];
|
|
26
|
+
const images = [...attachments.primary.images];
|
|
27
|
+
const files = [...attachments.primary.files];
|
|
29
28
|
let referentBlock = "";
|
|
30
|
-
if (attachments.parentId !== undefined) {
|
|
31
|
-
const
|
|
29
|
+
if (attachments.primary.parentId !== undefined) {
|
|
30
|
+
const parentId = attachments.primary.parentId;
|
|
31
|
+
const parent = await t.api.getMessage(parentId);
|
|
32
32
|
if (!parent)
|
|
33
|
-
throw new Error(`replied-to message ${
|
|
33
|
+
throw new Error(`replied-to message ${parentId} is not readable`);
|
|
34
34
|
const parsed = parseContent({
|
|
35
35
|
message_type: parent.msg_type ?? "unknown",
|
|
36
36
|
content: parent.body?.content ?? "",
|
|
@@ -38,33 +38,84 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
38
38
|
});
|
|
39
39
|
// The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
|
|
40
40
|
for (const key of parsed.imageKeys)
|
|
41
|
-
images.push({ msg:
|
|
41
|
+
images.push({ msg: parentId, key });
|
|
42
42
|
for (const ref of parsed.fileRefs)
|
|
43
|
-
files.push({ msg:
|
|
43
|
+
files.push({ msg: parentId, key: ref.key, name: ref.name });
|
|
44
44
|
// getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
|
|
45
45
|
// sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
|
|
46
46
|
const senderId = parent.sender?.id;
|
|
47
47
|
const from = senderId ? `user ${senderId}` : undefined;
|
|
48
|
-
referentBlock = `\n\n[replied-to message (msg ${
|
|
48
|
+
referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${codePointPrefix(parsed.text, 560) || "(empty)"}]`;
|
|
49
49
|
}
|
|
50
|
+
// Primary first and fail-fast: these are resources the current user explicitly pointed at.
|
|
50
51
|
const imageRefs = [];
|
|
51
52
|
for (const ref of images)
|
|
52
53
|
imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
|
|
53
54
|
const downloaded = [];
|
|
54
55
|
for (const ref of files)
|
|
55
56
|
downloaded.push(await t.api.fetchFile(ref.msg, ref.key, ref.name ?? ref.key, t.chatId, t.filesDir));
|
|
56
|
-
|
|
57
|
-
|
|
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]`
|
|
58
105
|
: "";
|
|
59
|
-
|
|
106
|
+
const allImages = [...imageRefs, ...backgroundImages.map(({ image }) => image)];
|
|
107
|
+
return {
|
|
108
|
+
images: allImages.length ? allImages : undefined,
|
|
109
|
+
promptSuffix: `${referentBlock}${bufferedNote}${backgroundImageManifest}${manifest}`,
|
|
110
|
+
};
|
|
60
111
|
}
|
|
61
112
|
// Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
|
|
62
113
|
// free, and the loop exits within one delay of the holder finishing. The cap is sized to outlast a real
|
|
63
114
|
// tool-using wake turn (minutes); a holder that runs longer still surfaces the busy error to the user.
|
|
64
115
|
const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
|
|
65
116
|
/**
|
|
66
|
-
* Run one turn: resolve its inputs, then stream agent.invoke.
|
|
67
|
-
* event (never a silent drop). `onCompleted` (if given) fires on the
|
|
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
|
|
68
119
|
* durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
|
|
69
120
|
* moment the turn provably lives in the session.
|
|
70
121
|
*
|
|
@@ -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
|
+
}
|
|
@@ -1,124 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical Feishu protocol
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* yields the readable text (mention placeholders restored), the vision image keys, and the downloadable
|
|
7
|
-
* file refs — the envelope, the turn record, and the parent-message resolution all read through it.
|
|
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.
|
|
8
6
|
*/
|
|
9
7
|
import type { FeishuCloudKind } from "./cloud.ts";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
id?: {
|
|
15
|
-
open_id?: string;
|
|
16
|
-
user_id?: string;
|
|
17
|
-
union_id?: string;
|
|
18
|
-
};
|
|
19
|
-
name?: string;
|
|
20
|
-
[k: string]: unknown;
|
|
21
|
-
}
|
|
22
|
-
/** A received message (the `im.message.receive_v1` event's `message`, common subset; `[k]` keeps the
|
|
23
|
-
* rest reachable without a types dependency). */
|
|
24
|
-
export interface FeishuMessage {
|
|
25
|
-
message_id: string;
|
|
26
|
-
/** The replied-to message, when this message is a reply. Content is NOT included — the channel
|
|
27
|
-
* fetches it via the API (the IO half, invoke-turn.ts). */
|
|
28
|
-
parent_id?: string;
|
|
29
|
-
root_id?: string;
|
|
30
|
-
chat_id: string;
|
|
31
|
-
/** Present in topic groups — reply into the same topic to stay threaded. */
|
|
32
|
-
thread_id?: string;
|
|
33
|
-
chat_type: string;
|
|
34
|
-
message_type: string;
|
|
35
|
-
/** JSON-encoded string, shaped by message_type — decode with {@link parseContent}. */
|
|
36
|
-
content: string;
|
|
37
|
-
mentions?: FeishuMention[];
|
|
38
|
-
[k: string]: unknown;
|
|
39
|
-
}
|
|
40
|
-
export interface FeishuSender {
|
|
41
|
-
sender_id?: {
|
|
42
|
-
open_id?: string;
|
|
43
|
-
user_id?: string;
|
|
44
|
-
union_id?: string;
|
|
45
|
-
};
|
|
46
|
-
/** "user" for humans; apps/bots have other values — the default route ignores those (anti-loop). */
|
|
47
|
-
sender_type?: string;
|
|
48
|
-
[k: string]: unknown;
|
|
49
|
-
}
|
|
50
|
-
/** The `im.message.receive_v1` event body (`event` of the v2.0 envelope). */
|
|
51
|
-
export interface FeishuMessageEvent {
|
|
52
|
-
sender?: FeishuSender;
|
|
53
|
-
message?: FeishuMessage;
|
|
54
|
-
[k: string]: unknown;
|
|
55
|
-
}
|
|
56
|
-
/** What `route` returns: act with these (every field optional — omitted ones default from the message),
|
|
57
|
-
* or null to ignore. */
|
|
58
|
-
export interface FeishuRoute {
|
|
59
|
-
/** Conversation identity (default: `chat` or `chat:thread`). */
|
|
60
|
-
session?: string;
|
|
61
|
-
/** Reply target chat (default: the message's chat). */
|
|
62
|
-
chatId?: string;
|
|
63
|
-
/** Base prompt (default: {@link feishuEnvelope}); the channel still appends attachments. */
|
|
64
|
-
text?: string;
|
|
65
|
-
}
|
|
66
|
-
/** A downloadable attachment reference: the resource key inside its carrying message (the resource API
|
|
67
|
-
* addresses a file by message_id + key). */
|
|
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. */
|
|
68
12
|
export interface FeishuAttachmentRef {
|
|
69
13
|
key: string;
|
|
70
14
|
name?: string;
|
|
71
15
|
}
|
|
72
|
-
/** A message's decoded payload — see the module header. */
|
|
73
16
|
export interface ParsedFeishuContent {
|
|
74
|
-
/** Readable text: the text/post body with mention placeholders restored to `@Name`; marker lines
|
|
75
|
-
* (`[image]`, `[file: …]`) for non-text payloads, so a media-only message is never blank. */
|
|
76
17
|
text: string;
|
|
77
|
-
/** Vision images: `image_key`s from an image message or a post's inline images. */
|
|
78
18
|
imageKeys: string[];
|
|
79
|
-
/** Files to download to disk (file / audio / media). */
|
|
80
19
|
fileRefs: FeishuAttachmentRef[];
|
|
81
20
|
}
|
|
82
21
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* external input, and a message the agent cannot read should still say WHAT it couldn't read.
|
|
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.
|
|
86
24
|
*/
|
|
87
|
-
export declare function parseContent(
|
|
88
|
-
/** A stable sender label for attribution.
|
|
89
|
-
* contacts-API scope), so the label is the open_id — stable across turns, which is what a shared
|
|
90
|
-
* multi-user session needs to tell participants apart. */
|
|
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. */
|
|
91
27
|
export declare function senderLabel(sender: FeishuSender | undefined): string | undefined;
|
|
92
|
-
/** The place a message lives (chat, or chat:topic in a topic group) — the default session key. */
|
|
93
|
-
export declare function placeKey(
|
|
94
|
-
/**
|
|
95
|
-
* The default base prompt: a context envelope (chat/thread/sender + a group note + a reply marker),
|
|
96
|
-
* then the message's decoded body. The sender is named on every message and a group chat is flagged —
|
|
97
|
-
* in a shared multi-user session that is how the model tells participants apart and knows it is not a
|
|
98
|
-
* 1:1. A reply carries only `[in reply to msg …]` here: the referent's CONTENT is not in the event, so
|
|
99
|
-
* the channel fetches and appends it in the IO half (invoke-turn.ts), keeping this layer pure. Exported
|
|
100
|
-
* so a custom Feishu `route` can reuse it, e.g. `text: `${feishuEnvelope(event)}\n\n[extra]``. The
|
|
101
|
-
* internal compatibility seam binds the same shape to `[lark: …]`; each kind's send tool reads the
|
|
102
|
-
* chat id from its own branded line.
|
|
103
|
-
*/
|
|
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. */
|
|
104
31
|
export declare function feishuEnvelope(event: FeishuMessageEvent): string;
|
|
105
32
|
/** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
|
|
106
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;
|
|
107
36
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* mention me?" with "I don't know who I am, so yes" would mis-summon in every multi-bot group.
|
|
112
|
-
*/
|
|
113
|
-
export declare function mentionsBot(m: Pick<FeishuMessage, "mentions">, botOpenId: string | undefined): boolean;
|
|
114
|
-
/**
|
|
115
|
-
* The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
|
|
116
|
-
* answer humans only (a non-`user` sender is another bot/app — two bots answering each other loop
|
|
117
|
-
* forever), p2p chats always, a group only on an @mention of THIS bot (matched by open_id, which
|
|
118
|
-
* feishuChannel resolves via bot/v3/info). NOTE the platform side of the same coin: with the default
|
|
119
|
-
* `im:message.group_at_msg` scope, un-mentioned group messages are never even delivered — receiving
|
|
120
|
-
* everything needs the sensitive `im:message.group_msg` scope. Returns `{}` (act; the channel fills
|
|
121
|
-
* session/target/prompt from the message) or `null` (ignore).
|
|
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.
|
|
122
40
|
*/
|
|
123
41
|
export declare function defaultFeishuRoute(event: FeishuMessageEvent, options?: {
|
|
124
42
|
botOpenId?: string;
|