@nettee/beacon 0.1.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 (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +163 -0
  3. package/deploy/io.nettee.beacon.plist.example +32 -0
  4. package/dist/cli-app.js +71 -0
  5. package/dist/cli.js +21 -0
  6. package/dist/config/global.js +84 -0
  7. package/dist/config/profile.js +126 -0
  8. package/dist/config/registry.js +12 -0
  9. package/dist/config/secrets.js +68 -0
  10. package/dist/config/yaml.js +33 -0
  11. package/dist/doctor.js +29 -0
  12. package/dist/domain/types.js +18 -0
  13. package/dist/feishu/doctor.js +21 -0
  14. package/dist/feishu/gateway.js +129 -0
  15. package/dist/feishu/intake.js +67 -0
  16. package/dist/feishu/message-gateway.js +1 -0
  17. package/dist/feishu/message-pipeline.js +14 -0
  18. package/dist/feishu/reply-experiment.js +111 -0
  19. package/dist/feishu/trigger-input.js +46 -0
  20. package/dist/manual-trigger.js +59 -0
  21. package/dist/message/gateway.js +1 -0
  22. package/dist/outcome/doctor.js +29 -0
  23. package/dist/outcome/server.js +108 -0
  24. package/dist/outcome/submit.js +47 -0
  25. package/dist/run/create-pi-orchestrator.js +21 -0
  26. package/dist/run/orchestrator.js +269 -0
  27. package/dist/run/profile-runner.js +39 -0
  28. package/dist/run/queue.js +46 -0
  29. package/dist/runtime/pi-doctor.js +14 -0
  30. package/dist/runtime/pi-outcome-extension.js +53 -0
  31. package/dist/runtime/pi-rpc.js +247 -0
  32. package/dist/schedule/cron.js +48 -0
  33. package/dist/schedule/cursor-store.js +105 -0
  34. package/dist/schedule/loop.js +45 -0
  35. package/dist/schedule/reconciler.js +41 -0
  36. package/dist/service.js +97 -0
  37. package/dist/state/trigger-store.js +234 -0
  38. package/dist/version.js +9 -0
  39. package/examples/config.yaml +12 -0
  40. package/examples/profiles/example/profile.yaml +13 -0
  41. package/examples/profiles/example/prompt.md +1 -0
  42. package/examples/secrets.json.example +11 -0
  43. package/package.json +61 -0
@@ -0,0 +1,126 @@
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { z } from "zod";
5
+ import { nextOccurrence } from "../schedule/cron.js";
6
+ import { parseStrictYaml } from "./yaml.js";
7
+ const profileIdPattern = /^[a-z0-9](?:[a-z0-9_-]{0,62})$/;
8
+ const scheduleSchema = z
9
+ .object({
10
+ id: z.string().regex(profileIdPattern),
11
+ cron: z
12
+ .string()
13
+ .trim()
14
+ .refine((value) => value.split(/\s+/).length === 5, {
15
+ message: "Schedule cron must contain exactly five fields",
16
+ }),
17
+ timezone: z
18
+ .string()
19
+ .min(1)
20
+ .refine((value) => {
21
+ try {
22
+ new Intl.DateTimeFormat("en", { timeZone: value }).format();
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }, "Schedule timezone must be a valid IANA timezone"),
29
+ input: z.string().trim().min(1),
30
+ delivery: z.object({ chat_id: z.string().trim().min(1) }).strict(),
31
+ })
32
+ .strict();
33
+ const profileDocumentSchema = z
34
+ .object({
35
+ prompt: z.string().min(1),
36
+ workspace: z.string().min(1),
37
+ runtime: z.literal("pi"),
38
+ model: z
39
+ .object({
40
+ provider: z.string().min(1),
41
+ id: z.string().min(1),
42
+ })
43
+ .strict(),
44
+ schedules: z.array(scheduleSchema).default([]),
45
+ })
46
+ .strict();
47
+ function assertProfileId(profileId) {
48
+ if (!profileIdPattern.test(profileId)) {
49
+ throw new Error(`Invalid Profile ID: ${JSON.stringify(profileId)}`);
50
+ }
51
+ }
52
+ function isWithin(parent, child) {
53
+ const path = relative(parent, child);
54
+ return path === "" || (!path.startsWith("..") && !isAbsolute(path));
55
+ }
56
+ export async function loadProfile(profileId, profilesDirectory = join(homedir(), ".beacon", "profiles")) {
57
+ assertProfileId(profileId);
58
+ const profileDirectory = join(profilesDirectory, profileId);
59
+ const configPath = join(profileDirectory, "profile.yaml");
60
+ const config = profileDocumentSchema.parse(await parseStrictYaml(configPath));
61
+ const scheduleIds = new Set();
62
+ for (const schedule of config.schedules) {
63
+ if (scheduleIds.has(schedule.id)) {
64
+ throw new Error(`Duplicate Schedule ID in Profile ${profileId}: ${schedule.id}`);
65
+ }
66
+ scheduleIds.add(schedule.id);
67
+ try {
68
+ nextOccurrence(schedule.cron, schedule.timezone, new Date(0));
69
+ }
70
+ catch (error) {
71
+ throw new Error(`Invalid Schedule cron in Profile ${profileId}: ${schedule.id}`, {
72
+ cause: error,
73
+ });
74
+ }
75
+ }
76
+ if (isAbsolute(config.prompt)) {
77
+ throw new Error(`Profile prompt path must be relative to ${profileDirectory}`);
78
+ }
79
+ const promptPath = resolve(profileDirectory, config.prompt);
80
+ const canonicalProfileDirectory = await realpath(profileDirectory);
81
+ let canonicalPromptPath;
82
+ try {
83
+ canonicalPromptPath = await realpath(promptPath);
84
+ }
85
+ catch (error) {
86
+ throw new Error(`Cannot read Profile prompt at ${promptPath}`, {
87
+ cause: error,
88
+ });
89
+ }
90
+ if (!isWithin(canonicalProfileDirectory, canonicalPromptPath)) {
91
+ throw new Error(`Profile prompt must stay inside ${canonicalProfileDirectory}`);
92
+ }
93
+ const prompt = (await readFile(canonicalPromptPath, "utf8")).trim();
94
+ if (!prompt)
95
+ throw new Error(`Profile prompt must not be empty: ${canonicalPromptPath}`);
96
+ const workspace = isAbsolute(config.workspace)
97
+ ? config.workspace
98
+ : resolve(dirname(configPath), config.workspace);
99
+ let workspaceStat;
100
+ try {
101
+ workspaceStat = await stat(workspace);
102
+ }
103
+ catch (error) {
104
+ throw new Error(`Cannot access Profile workspace at ${workspace}`, {
105
+ cause: error,
106
+ });
107
+ }
108
+ if (!workspaceStat.isDirectory()) {
109
+ throw new Error(`Profile workspace is not a directory: ${workspace}`);
110
+ }
111
+ return {
112
+ id: profileId,
113
+ directory: canonicalProfileDirectory,
114
+ prompt,
115
+ workspace,
116
+ runtime: config.runtime,
117
+ model: config.model,
118
+ schedules: config.schedules.map((schedule) => ({
119
+ id: schedule.id,
120
+ cron: schedule.cron,
121
+ timezone: schedule.timezone,
122
+ input: schedule.input,
123
+ delivery: { chatId: schedule.delivery.chat_id },
124
+ })),
125
+ };
126
+ }
@@ -0,0 +1,12 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { loadProfile } from "./profile.js";
3
+ export async function loadProfileRegistry(profilesDirectory) {
4
+ const entries = await readdir(profilesDirectory, { withFileTypes: true });
5
+ const ids = entries
6
+ .filter((entry) => entry.isDirectory())
7
+ .map((entry) => entry.name)
8
+ .sort((left, right) => left.localeCompare(right));
9
+ if (ids.length === 0)
10
+ throw new Error(`No Profiles found in ${profilesDirectory}`);
11
+ return Promise.all(ids.map((id) => loadProfile(id, profilesDirectory)));
12
+ }
@@ -0,0 +1,68 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { z } from "zod";
5
+ const secretsSchema = z
6
+ .object({
7
+ version: z.literal(1),
8
+ profiles: z.record(z.string(), z
9
+ .object({
10
+ feishu: z
11
+ .object({
12
+ app_id: z
13
+ .string()
14
+ .regex(/^cli_[0-9a-fA-F]{16}$/, "Invalid Feishu app_id"),
15
+ app_secret: z.string().min(1),
16
+ })
17
+ .strict(),
18
+ })
19
+ .strict()),
20
+ })
21
+ .strict();
22
+ export async function loadFeishuCredentials(profileId, path = join(homedir(), ".beacon", "secrets.json")) {
23
+ const metadata = await stat(path).catch((error) => {
24
+ throw new Error(`Cannot inspect Beacon secrets file at ${path}`, {
25
+ cause: error,
26
+ });
27
+ });
28
+ if (!metadata.isFile())
29
+ throw new Error(`Beacon secrets path is not a file: ${path}`);
30
+ if ((metadata.mode & 0o777) !== 0o600) {
31
+ throw new Error(`Beacon secrets file must have mode 0600: ${path}`);
32
+ }
33
+ if (typeof process.getuid === "function" &&
34
+ metadata.uid !== process.getuid()) {
35
+ throw new Error(`Beacon secrets file must be owned by the current user: ${path}`);
36
+ }
37
+ const parent = await stat(dirname(path));
38
+ if ((parent.mode & 0o077) !== 0) {
39
+ throw new Error(`Beacon secrets parent directory must not grant group/world access: ${dirname(path)}`);
40
+ }
41
+ let raw;
42
+ try {
43
+ raw = await readFile(path, "utf8");
44
+ }
45
+ catch (error) {
46
+ throw new Error(`Cannot read Beacon secrets file at ${path}`, {
47
+ cause: error,
48
+ });
49
+ }
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(raw);
53
+ }
54
+ catch (error) {
55
+ throw new Error(`Beacon secrets file is not valid JSON: ${path}`, {
56
+ cause: error,
57
+ });
58
+ }
59
+ const secrets = secretsSchema.parse(parsed);
60
+ const profile = secrets.profiles[profileId];
61
+ if (!profile) {
62
+ throw new Error(`No credentials configured for profile "${profileId}" in ${path}`);
63
+ }
64
+ return {
65
+ appId: profile.feishu.app_id,
66
+ appSecret: profile.feishu.app_secret,
67
+ };
68
+ }
@@ -0,0 +1,33 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseDocument } from "yaml";
3
+ export async function parseStrictYaml(path) {
4
+ let source;
5
+ try {
6
+ source = await readFile(path, "utf8");
7
+ }
8
+ catch (error) {
9
+ throw new Error(`Cannot read YAML configuration at ${path}`, {
10
+ cause: error,
11
+ });
12
+ }
13
+ const document = parseDocument(source, {
14
+ schema: "core",
15
+ strict: true,
16
+ uniqueKeys: true,
17
+ version: "1.2",
18
+ });
19
+ if (document.errors.length > 0) {
20
+ throw new Error(`Invalid YAML configuration at ${path}: ${document.errors[0]?.message}`);
21
+ }
22
+ if (document.warnings.length > 0) {
23
+ throw new Error(`YAML warning at ${path}: ${document.warnings[0]?.message}`);
24
+ }
25
+ try {
26
+ return document.toJS({ maxAliasCount: 0 });
27
+ }
28
+ catch (error) {
29
+ throw new Error(`YAML aliases are not allowed at ${path}`, {
30
+ cause: error,
31
+ });
32
+ }
33
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,29 @@
1
+ import { loadGlobalConfig } from "./config/global.js";
2
+ import { loadProfileRegistry } from "./config/registry.js";
3
+ import { loadFeishuCredentials } from "./config/secrets.js";
4
+ import { createFeishuGateway } from "./feishu/gateway.js";
5
+ import { runPiAgent } from "./runtime/pi-rpc.js";
6
+ export async function runDoctor(configPath) {
7
+ const global = await loadGlobalConfig(configPath);
8
+ const profiles = await loadProfileRegistry(global.profilesDirectory);
9
+ for (const profile of profiles) {
10
+ const credentials = await loadFeishuCredentials(profile.id, global.secretsPath);
11
+ await createFeishuGateway(credentials).checkReady();
12
+ const result = await runPiAgent({
13
+ prompt: "Reply with exactly: BEACON_PI_RPC_OK",
14
+ workspace: profile.workspace,
15
+ provider: profile.model.provider,
16
+ model: profile.model.id,
17
+ systemPrompt: "Follow the user's instruction exactly. Do not use tools.",
18
+ }, {
19
+ executable: global.pi.executable,
20
+ timeoutMs: global.runs.timeoutSeconds * 1_000,
21
+ terminateGraceMs: global.runs.terminateGraceSeconds * 1_000,
22
+ environment: { PI_CODING_AGENT_DIR: global.pi.codingAgentDirectory },
23
+ });
24
+ if (result.text !== "BEACON_PI_RPC_OK") {
25
+ throw new Error(`Pi readiness returned unexpected text for Profile ${profile.id}: ${JSON.stringify(result.text)}`);
26
+ }
27
+ console.log(`[beacon] doctor Profile ready id=${profile.id}`);
28
+ }
29
+ }
@@ -0,0 +1,18 @@
1
+ export const failureCodes = [
2
+ "config_invalid",
3
+ "secret_invalid",
4
+ "gateway_terminal",
5
+ "trigger_persist_failed",
6
+ "trigger_normalization_failed",
7
+ "schedule_state_invalid",
8
+ "capacity_exceeded",
9
+ "runtime_spawn_failed",
10
+ "runtime_protocol_error",
11
+ "runtime_timeout",
12
+ "runtime_exit_failed",
13
+ "outcome_missing",
14
+ "outcome_invalid",
15
+ "service_interrupted",
16
+ "delivery_api_failed",
17
+ "delivery_interrupted",
18
+ ];
@@ -0,0 +1,21 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ export async function runFeishuDoctor(credentials) {
3
+ const dispatcher = new Lark.EventDispatcher({
4
+ loggerLevel: Lark.LoggerLevel.info,
5
+ }).register({
6
+ "im.message.receive_v1": async (event) => {
7
+ const messageId = event.message.message_id;
8
+ const chatType = event.message.chat_type;
9
+ const content = JSON.parse(event.message.content);
10
+ console.log(`[beacon] received message event message_id=${messageId} chat_type=${chatType}`);
11
+ console.log(`[beacon] message content=${JSON.stringify(content)}`);
12
+ },
13
+ });
14
+ const client = new Lark.WSClient({
15
+ appId: credentials.appId,
16
+ appSecret: credentials.appSecret,
17
+ loggerLevel: Lark.LoggerLevel.info,
18
+ });
19
+ console.log("[beacon] starting Feishu long connection; press Ctrl-C to stop");
20
+ await client.start({ eventDispatcher: dispatcher });
21
+ }
@@ -0,0 +1,129 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ export function waitForShutdown(signals = process) {
3
+ return new Promise((resolve) => {
4
+ const shutdown = () => {
5
+ signals.off("SIGINT", shutdown);
6
+ signals.off("SIGTERM", shutdown);
7
+ resolve();
8
+ };
9
+ signals.once("SIGINT", shutdown);
10
+ signals.once("SIGTERM", shutdown);
11
+ });
12
+ }
13
+ export function shouldAcceptFeishuMessage(chatType, senderType, messageId, processedMessageIds) {
14
+ return ((chatType === "p2p" || chatType === "group") &&
15
+ senderType === "user" &&
16
+ !processedMessageIds.has(messageId));
17
+ }
18
+ function assertSucceeded(operation, result) {
19
+ if (result.code !== undefined && result.code !== 0) {
20
+ throw new Error(`${operation} failed: code=${result.code} msg=${result.msg ?? "unknown"}`);
21
+ }
22
+ }
23
+ function textContent(text) {
24
+ return JSON.stringify({ text });
25
+ }
26
+ export class FeishuGateway {
27
+ credentials;
28
+ client;
29
+ constructor(credentials) {
30
+ this.credentials = credentials;
31
+ this.client = new Lark.Client(credentials);
32
+ }
33
+ async checkReady() {
34
+ const result = (await this.client.auth.v3.tenantAccessToken.internal({
35
+ data: {
36
+ app_id: this.credentials.appId,
37
+ app_secret: this.credentials.appSecret,
38
+ },
39
+ }));
40
+ assertSucceeded("obtain tenant access token", result);
41
+ if (!result.tenant_access_token) {
42
+ throw new Error("Feishu readiness returned no tenant_access_token");
43
+ }
44
+ }
45
+ async run(handleEvent, shutdown = waitForShutdown()) {
46
+ const dispatcher = new Lark.EventDispatcher({
47
+ loggerLevel: Lark.LoggerLevel.info,
48
+ }).register({
49
+ "im.message.receive_v1": async (event) => {
50
+ await handleEvent(event);
51
+ },
52
+ });
53
+ let rejectTerminalFailure = () => undefined;
54
+ const terminalFailure = new Promise((_resolve, reject) => {
55
+ rejectTerminalFailure = reject;
56
+ });
57
+ const ws = new Lark.WSClient({
58
+ ...this.credentials,
59
+ loggerLevel: Lark.LoggerLevel.info,
60
+ onError: (error) => rejectTerminalFailure(error),
61
+ });
62
+ console.log("[beacon] Feishu Gateway ready");
63
+ try {
64
+ await ws.start({ eventDispatcher: dispatcher });
65
+ await Promise.race([shutdown, terminalFailure]);
66
+ }
67
+ finally {
68
+ ws.close({ force: true });
69
+ }
70
+ }
71
+ async acknowledge(messageId) {
72
+ const result = await this.client.im.v1.messageReaction.create({
73
+ path: { message_id: messageId },
74
+ data: { reaction_type: { emoji_type: "OnIt" } },
75
+ });
76
+ assertSucceeded("add OnIt reaction", result);
77
+ }
78
+ async fetchMessage(messageId) {
79
+ const result = await this.client.im.v1.message.get({
80
+ path: { message_id: messageId },
81
+ });
82
+ assertSucceeded("fetch quoted message", result);
83
+ const message = result.data?.items?.[0];
84
+ if (!message?.message_id ||
85
+ !message.msg_type ||
86
+ !message.sender?.sender_type ||
87
+ message.body?.content === undefined) {
88
+ throw new Error(`Feishu returned no readable quoted message for message_id=${messageId}`);
89
+ }
90
+ return {
91
+ messageId: message.message_id,
92
+ messageType: message.msg_type,
93
+ senderType: message.sender.sender_type,
94
+ senderId: message.sender.id,
95
+ content: message.body.content,
96
+ parentMessageId: message.parent_id,
97
+ };
98
+ }
99
+ async deliver(target, text) {
100
+ if (target.kind === "reply") {
101
+ const result = await this.client.im.v1.message.reply({
102
+ path: { message_id: target.messageId },
103
+ data: {
104
+ msg_type: "text",
105
+ content: textContent(text),
106
+ reply_in_thread: false,
107
+ },
108
+ });
109
+ assertSucceeded("deliver quoted reply", result);
110
+ return {};
111
+ }
112
+ if (target.kind === "chat") {
113
+ const result = await this.client.im.v1.message.create({
114
+ params: { receive_id_type: "chat_id" },
115
+ data: {
116
+ receive_id: target.chatId,
117
+ msg_type: "text",
118
+ content: textContent(text),
119
+ },
120
+ });
121
+ assertSucceeded("deliver chat message", result);
122
+ return {};
123
+ }
124
+ throw new Error("Feishu Gateway cannot deliver to local stdout");
125
+ }
126
+ }
127
+ export function createFeishuGateway(credentials) {
128
+ return new FeishuGateway(credentials);
129
+ }
@@ -0,0 +1,67 @@
1
+ import { buildFeishuTriggerInput, } from "./trigger-input.js";
2
+ export class FeishuIntake {
3
+ options;
4
+ active = new Set();
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ async handle(event) {
9
+ if (event.sender.sender_type !== "user" ||
10
+ (event.message.chat_type !== "p2p" && event.message.chat_type !== "group")) {
11
+ return "ignored";
12
+ }
13
+ if (!event.event_id)
14
+ throw new Error("Feishu event_id is required");
15
+ const claim = await this.options.store.claim({
16
+ sourceKey: ["feishu", event.event_id],
17
+ target: { kind: "reply", messageId: event.message.message_id },
18
+ ingress: {
19
+ eventId: event.event_id,
20
+ messageId: event.message.message_id,
21
+ chatId: event.message.chat_id,
22
+ chatType: event.message.chat_type,
23
+ senderId: event.sender.sender_id?.open_id,
24
+ senderType: event.sender.sender_type,
25
+ messageType: event.message.message_type,
26
+ content: event.message.content,
27
+ ...(event.message.parent_id
28
+ ? { parentMessageId: event.message.parent_id }
29
+ : {}),
30
+ },
31
+ });
32
+ if (!claim.created)
33
+ return "duplicate";
34
+ void this.options
35
+ .acknowledge(event.message.message_id)
36
+ .catch((error) => {
37
+ console.error(`[beacon] non-critical acknowledgement failure message_id=${event.message.message_id}: ${error instanceof Error ? error.message : String(error)}`);
38
+ });
39
+ const task = this.options.process(claim.record.triggerKey, async () => {
40
+ const normalized = await buildFeishuTriggerInput({
41
+ messageId: event.message.message_id,
42
+ chatId: event.message.chat_id,
43
+ chatType: event.message.chat_type,
44
+ senderId: event.sender.sender_id?.open_id,
45
+ senderType: event.sender.sender_type,
46
+ messageType: event.message.message_type,
47
+ content: event.message.content,
48
+ parentMessageId: event.message.parent_id,
49
+ }, this.options.fetchMessage);
50
+ return {
51
+ kind: "feishu_message",
52
+ eventId: event.event_id,
53
+ chatType: normalized.source.chatType,
54
+ quotedMessages: normalized.quotedMessages,
55
+ currentMessage: normalized.message,
56
+ };
57
+ });
58
+ this.active.add(task);
59
+ void task
60
+ .catch((error) => this.options.onFatal(error instanceof Error ? error : new Error(String(error))))
61
+ .finally(() => this.active.delete(task));
62
+ return "accepted";
63
+ }
64
+ async drain() {
65
+ await Promise.all([...this.active]);
66
+ }
67
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { FeishuIntake } from "./intake.js";
2
+ export function createFeishuMessagePipeline(options) {
3
+ const intake = new FeishuIntake({
4
+ store: options.store,
5
+ process: options.process,
6
+ fetchMessage: (messageId) => options.gateway.fetchMessage(messageId),
7
+ acknowledge: (messageId) => options.gateway.acknowledge(messageId),
8
+ onFatal: options.onFatal,
9
+ });
10
+ return {
11
+ run: (shutdown) => options.gateway.run((event) => intake.handle(event), shutdown),
12
+ drain: () => intake.drain(),
13
+ };
14
+ }
@@ -0,0 +1,111 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { buildFeishuTriggerInput, } from "./trigger-input.js";
3
+ function assertSucceeded(operation, result) {
4
+ if (result.code !== undefined && result.code !== 0) {
5
+ throw new Error(`${operation} failed: code=${result.code} msg=${result.msg ?? "unknown"}`);
6
+ }
7
+ }
8
+ function textContent(text) {
9
+ return JSON.stringify({ text });
10
+ }
11
+ export function shouldExerciseReplyExperiment(chatType, messageId, processedMessageIds) {
12
+ return ((chatType === "p2p" || chatType === "group") &&
13
+ !processedMessageIds.has(messageId));
14
+ }
15
+ export async function runFeishuReplyExperiment(credentials) {
16
+ const api = new Lark.Client(credentials);
17
+ const processedMessageIds = new Set();
18
+ async function attempt(label, operation) {
19
+ try {
20
+ const result = await operation();
21
+ assertSucceeded(label, result);
22
+ console.log(`[beacon] succeeded: ${label}`);
23
+ }
24
+ catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ console.error(`[beacon] failed: ${label}: ${message}`);
27
+ }
28
+ }
29
+ async function fetchMessage(messageId) {
30
+ const result = await api.im.v1.message.get({
31
+ path: { message_id: messageId },
32
+ });
33
+ assertSucceeded("fetch quoted message", result);
34
+ const message = result.data?.items?.[0];
35
+ if (!message?.message_id ||
36
+ !message.msg_type ||
37
+ !message.sender?.sender_type ||
38
+ message.body?.content === undefined) {
39
+ throw new Error(`Feishu returned no readable message for quoted message_id=${messageId}`);
40
+ }
41
+ return {
42
+ messageId: message.message_id,
43
+ messageType: message.msg_type,
44
+ senderType: message.sender.sender_type,
45
+ senderId: message.sender.id,
46
+ content: message.body.content,
47
+ parentMessageId: message.parent_id,
48
+ };
49
+ }
50
+ async function exercise(event) {
51
+ const { message, sender } = event;
52
+ const messageId = message.message_id;
53
+ await attempt("add OnIt reaction", () => api.im.v1.messageReaction.create({
54
+ path: { message_id: messageId },
55
+ data: { reaction_type: { emoji_type: "OnIt" } },
56
+ }));
57
+ const triggerInput = await buildFeishuTriggerInput({
58
+ messageId,
59
+ chatId: message.chat_id,
60
+ chatType: message.chat_type,
61
+ senderId: sender.sender_id?.open_id,
62
+ senderType: sender.sender_type,
63
+ messageType: message.message_type,
64
+ content: message.content,
65
+ parentMessageId: message.parent_id,
66
+ }, fetchMessage);
67
+ console.log(`[beacon] trigger input=${JSON.stringify(triggerInput)}`);
68
+ await attempt("quoted final reply", () => api.im.v1.message.reply({
69
+ path: { message_id: messageId },
70
+ data: {
71
+ msg_type: "text",
72
+ content: textContent(triggerInput.quotedMessages.length > 0
73
+ ? `已记录当前消息及完整引用链(${triggerInput.quotedMessages.length} 条);后续会一起交给 Agent Runtime。`
74
+ : "已记录当前消息;本条消息没有引用上下文。"),
75
+ reply_in_thread: false,
76
+ },
77
+ }));
78
+ }
79
+ const dispatcher = new Lark.EventDispatcher({
80
+ loggerLevel: Lark.LoggerLevel.info,
81
+ }).register({
82
+ "im.message.receive_v1": async (event) => {
83
+ const { chat_type: chatType, content, message_id: messageId, } = event.message;
84
+ const parsed = JSON.parse(content);
85
+ console.log(`[beacon] received message event message_id=${messageId} chat_type=${chatType} content=${JSON.stringify(parsed)}`);
86
+ if (!shouldExerciseReplyExperiment(chatType, messageId, processedMessageIds)) {
87
+ console.log(`[beacon] ignored duplicate or unsupported event message_id=${messageId}`);
88
+ return;
89
+ }
90
+ processedMessageIds.add(messageId);
91
+ void exercise(event).catch(async (error) => {
92
+ const message = error instanceof Error ? error.message : String(error);
93
+ console.error(`[beacon] reply experiment failed: ${message}`);
94
+ await attempt("quoted failure reply", () => api.im.v1.message.reply({
95
+ path: { message_id: messageId },
96
+ data: {
97
+ msg_type: "text",
98
+ content: textContent(`处理失败:${message}`),
99
+ reply_in_thread: false,
100
+ },
101
+ }));
102
+ });
103
+ },
104
+ });
105
+ const ws = new Lark.WSClient({
106
+ ...credentials,
107
+ loggerLevel: Lark.LoggerLevel.info,
108
+ });
109
+ console.log("[beacon] quoted-reply experiment ready; every new DM and group mention triggers one run");
110
+ await ws.start({ eventDispatcher: dispatcher });
111
+ }