@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.
- package/README.md +6 -4
- package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
- package/dist/channels/feishu/bootstrap-token.js +94 -0
- package/dist/channels/feishu/card.d.ts +32 -0
- package/dist/channels/feishu/card.js +66 -0
- package/dist/channels/feishu/cloud.d.ts +17 -0
- package/dist/channels/feishu/cloud.js +19 -0
- 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 +15 -0
- package/dist/channels/feishu/crypto.js +43 -0
- package/dist/channels/feishu/feishu-api.d.ts +108 -0
- package/dist/channels/feishu/feishu-api.js +325 -0
- package/dist/channels/feishu/feishu.d.ts +46 -0
- package/dist/channels/feishu/feishu.js +472 -0
- package/dist/channels/feishu/invoke-turn.d.ts +65 -0
- package/dist/channels/feishu/invoke-turn.js +157 -0
- 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 +43 -0
- package/dist/channels/feishu/parse.js +65 -0
- package/dist/channels/feishu/preview.d.ts +36 -0
- package/dist/channels/feishu/preview.js +387 -0
- package/dist/channels/feishu/register-app.d.ts +70 -0
- package/dist/channels/feishu/register-app.js +141 -0
- package/dist/channels/feishu/register-webhook.d.ts +22 -0
- package/dist/channels/feishu/register-webhook.js +106 -0
- package/dist/channels/feishu/scaffold/channel.ts +41 -0
- package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
- package/dist/channels/feishu/seen.d.ts +5 -0
- package/dist/channels/feishu/seen.js +47 -0
- package/dist/channels/feishu/text.d.ts +13 -0
- package/dist/channels/feishu/text.js +63 -0
- package/dist/channels/lark/lark.d.ts +15 -0
- package/dist/channels/lark/lark.js +10 -0
- package/dist/channels/lark/onboard.d.ts +39 -0
- package/dist/channels/lark/onboard.js +58 -0
- package/dist/channels/lark/scaffold/channel.ts +39 -0
- package/dist/channels/lark/scaffold/lark-send.ts +87 -0
- package/dist/channels/registration.d.ts +15 -0
- package/dist/channels/registration.js +1 -0
- package/dist/channels/{telegram/state.js → state.js} +6 -4
- package/dist/channels/telegram/context-buffer.js +1 -1
- package/dist/channels/telegram/register-webhook.d.ts +4 -1
- package/dist/channels/telegram/register-webhook.js +17 -26
- package/dist/channels/telegram/telegram.js +2 -2
- package/dist/channels/telegram/turn-store.d.ts +8 -21
- package/dist/channels/telegram/turn-store.js +11 -130
- package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
- package/dist/channels/turn-store.d.ts +42 -0
- package/dist/channels/turn-store.js +139 -0
- package/dist/channels/wait-health.d.ts +6 -0
- package/dist/channels/wait-health.js +27 -0
- 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-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +8 -1284
- package/dist/deploy/container.js +10 -6
- 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/fly/plan.d.ts +1 -1
- package/dist/deploy/fly/plan.js +15 -4
- package/dist/deploy/fly/run.d.ts +7 -4
- package/dist/deploy/fly/run.js +26 -5
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/railway/plan.d.ts +1 -1
- package/dist/deploy/railway/plan.js +17 -5
- package/dist/deploy/railway/run.d.ts +6 -3
- package/dist/deploy/railway/run.js +26 -4
- package/dist/deploy/registration-gate.d.ts +20 -0
- package/dist/deploy/registration-gate.js +20 -0
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/deploy/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +80 -7
- 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 -6
- package/dist/engines/pi/create.js +52 -16
- 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/feishu.d.ts +2 -0
- package/dist/feishu.js +2 -0
- package/dist/lark.d.ts +3 -0
- package/dist/lark.js +3 -0
- package/dist/open-url.d.ts +2 -0
- package/dist/open-url.js +6 -0
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +75 -7
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +18 -4
- /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
- /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu/Lark event-URL registration — the platform-domain step both `--tunnel` (dev, tunnel.ts) and
|
|
3
|
+
* `deploy … --run` (the host runners' post-deploy step) invoke, once per mounted kind. What
|
|
4
|
+
* "registering feishu/lark" means lives here, beside the engine it serves; it reads the same .env
|
|
5
|
+
* credentials the channel of that kind uses (`FEISHU_*` / `LARK_*`).
|
|
6
|
+
*
|
|
7
|
+
* Mechanism: the application-v7 config PATCH (`updateEventSubscription`) flips the app's event
|
|
8
|
+
* subscription to webhook mode and points it at `<baseUrl>/<kind>`. Two properties make this the full
|
|
9
|
+
* telegram-setWebhook parity: the platform applies a request-URL change IMMEDIATELY (no version
|
|
10
|
+
* publish), and it VERIFIES the URL with a url_verification challenge during the PATCH — which is why
|
|
11
|
+
* this waits for `<baseUrl>/health` to serve first (the same readiness race the telegram registrar
|
|
12
|
+
* fixes). Requires the `application:application:patch` scope (field-tested: `self_manage` does NOT
|
|
13
|
+
* cover this PATCH) — `add feishu` requests it at creation via addons; without it the PATCH fails
|
|
14
|
+
* visibly and the manual console instruction is printed.
|
|
15
|
+
*
|
|
16
|
+
* CLOUD LAG: the application-v7 config API exists on open.feishu.cn but (as of 2026-07) is NOT
|
|
17
|
+
* deployed on open.larksuite.com — the route 404s there. The registrar still attempts it (the day the
|
|
18
|
+
* platform ships it, registration starts working with no change here) and names the real cause in the
|
|
19
|
+
* fallback instead of blaming the app's scopes.
|
|
20
|
+
*/
|
|
21
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
22
|
+
import { log } from "../../log.js";
|
|
23
|
+
import { waitForHealth } from "../wait-health.js";
|
|
24
|
+
import { cloudFor } from "./cloud.js";
|
|
25
|
+
import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError } from "./feishu-api.js";
|
|
26
|
+
export async function registerFeishuWebhook(baseUrl, kind, opts = {}) {
|
|
27
|
+
const profile = cloudFor(kind);
|
|
28
|
+
const envPrefix = profile.envPrefix;
|
|
29
|
+
const appId = process.env[`${envPrefix}_APP_ID`];
|
|
30
|
+
const appSecret = process.env[`${envPrefix}_APP_SECRET`];
|
|
31
|
+
const apiBase = opts.apiBase ?? profile.apiBase;
|
|
32
|
+
const requestUrl = `${baseUrl}/${kind}`;
|
|
33
|
+
const manual = `switch Subscription mode to webhook and set the event Request URL in the developer console (Events & Callbacks) to ${requestUrl} — keep the server running while you save (the console verifies the URL with a challenge)`;
|
|
34
|
+
if (!appId || !appSecret) {
|
|
35
|
+
log.info(`[fastagent] ${kind}: set ${envPrefix}_APP_ID + ${envPrefix}_APP_SECRET in .env, then re-run to auto-register. Or ${manual}`);
|
|
36
|
+
return "manual";
|
|
37
|
+
}
|
|
38
|
+
// Align registration with the server actually serving: the PATCH triggers the platform's
|
|
39
|
+
// url_verification challenge against requestUrl — registering before /health serves would fail.
|
|
40
|
+
log.info(`[fastagent] ${kind}: waiting for ${baseUrl} to be reachable before registering the event URL…`);
|
|
41
|
+
const ready = await waitForHealth(`${baseUrl}/health`, opts.readyTimeoutMs ?? 120_000, opts.readyIntervalMs ?? 3_000);
|
|
42
|
+
if (!ready) {
|
|
43
|
+
// Terminal for this run (registration will not be retried) — error, not warn: the event URL is NOT
|
|
44
|
+
// registered and the operator must act. Same taxonomy as the permanent PATCH failure below.
|
|
45
|
+
log.error(`[fastagent] ${kind}: ${baseUrl}/health did not come up in time — the app may still be starting. ${manual}`);
|
|
46
|
+
return "failed";
|
|
47
|
+
}
|
|
48
|
+
const api = createFeishuApi({ kind, baseUrl: apiBase, appId, appSecret });
|
|
49
|
+
const consoleUrl = `${apiBase}/app/${encodeURIComponent(appId)}/event`;
|
|
50
|
+
const versionUrl = `${apiBase}/app/${encodeURIComponent(appId)}/version`;
|
|
51
|
+
const manualRegistration = () => {
|
|
52
|
+
log.info(`[fastagent] ${kind}: Events & Callbacks:\n ${consoleUrl}`);
|
|
53
|
+
log.info(`[fastagent] ${kind}: switch Subscription mode to webhook and copy this Request URL:\n ${requestUrl}\n` +
|
|
54
|
+
` Keep fastagent running while saving — the console verifies the URL immediately.`);
|
|
55
|
+
log.info(`[fastagent] ${kind}: if this app's webhook mode has not been published yet, ` +
|
|
56
|
+
`create + publish a version before testing messages:\n ${versionUrl}`);
|
|
57
|
+
try {
|
|
58
|
+
opts.onManualRegistration?.({ consoleUrl, requestUrl });
|
|
59
|
+
}
|
|
60
|
+
catch (callbackError) {
|
|
61
|
+
log.warn(`[fastagent] ${kind}: could not open Events & Callbacks: ${String(callbackError)}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// Reachable → register. The PATCH is the real probe (same lesson as the token bootstrap): the
|
|
65
|
+
// platform verifies request_url with a challenge DURING the call, and a fresh tunnel's edge can be
|
|
66
|
+
// reachable from here while the platform's own path still lags — its 210042 "request_url validation
|
|
67
|
+
// failed" is therefore retried with backoff, alongside transient network errors. Only a permanent
|
|
68
|
+
// config error (missing scope, app under review, the intl 404) is reported once with the manual path.
|
|
69
|
+
const attempts = 8;
|
|
70
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
71
|
+
if (attempt > 0)
|
|
72
|
+
await sleep(opts.retryMs ?? 10_000);
|
|
73
|
+
try {
|
|
74
|
+
await api.updateEventSubscription(appId, { subscriptionType: "webhook", requestUrl });
|
|
75
|
+
log.info(`[fastagent] ${kind}: event Request URL registered → ${requestUrl}`);
|
|
76
|
+
// Field-tested: a URL change applies immediately, but the MODE flip (the template's long
|
|
77
|
+
// connection → webhook) only takes effect when a version is published — the dispatcher serves
|
|
78
|
+
// the published snapshot, and version publishing has no open API. One console click, once.
|
|
79
|
+
log.info(`[fastagent] ${kind}: if messages do not arrive, publish a version (one click, prompted) — the switch to webhook mode takes effect on publish: ${versionUrl}`);
|
|
80
|
+
return "registered";
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
// A 404 on the config route is the CLOUD lagging, not this app's configuration: the v7 API is
|
|
84
|
+
// live on open.feishu.cn but not yet on open.larksuite.com. Name that — "check your scopes"
|
|
85
|
+
// would send the operator hunting for a problem they cannot fix.
|
|
86
|
+
if (isFeishuConfigApiMissing(e)) {
|
|
87
|
+
log.warn(`[fastagent] ${kind}: this cloud (${apiBase}) returned HTTP 404 for the app-config API — ` +
|
|
88
|
+
`manual registration is required`);
|
|
89
|
+
manualRegistration();
|
|
90
|
+
return "manual"; // that cloud has no config API — the manual path is the norm there
|
|
91
|
+
}
|
|
92
|
+
if (!isTransientFeishuRegistrationError(e)) {
|
|
93
|
+
log.error(`[fastagent] ${kind}: could not register the event URL (${String(e)}). ` +
|
|
94
|
+
`The app may lack the "application:application:patch" scope (console → Permissions) or be under review; manual registration is available below.`);
|
|
95
|
+
manualRegistration();
|
|
96
|
+
return "failed";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Exhausted retries end in the same state as a permanent error (event URL not registered, manual
|
|
101
|
+
// action required) — report at the same level. The cloud-lag 404 above stays warn: on that cloud the
|
|
102
|
+
// manual path is the known norm, not an exceptional failure.
|
|
103
|
+
log.error(`[fastagent] ${kind}: registration still failing after retries — manual registration is required`);
|
|
104
|
+
manualRegistration();
|
|
105
|
+
return "failed";
|
|
106
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { feishuChannel } from "@fastagent-sh/fastagent/feishu";
|
|
2
|
+
|
|
3
|
+
// feishuChannel is fastagent's canonical Feishu adapter (verify + run + reply), configured with YOUR
|
|
4
|
+
// policy. fastagent discovers this file, mounts POST /feishu, and pipes the agent + state home to it.
|
|
5
|
+
// Feishu (open.feishu.cn) only; a Lark-international
|
|
6
|
+
// tenant uses `fastagent add lark` instead. Setup (developer console):
|
|
7
|
+
// 1. create a custom app → enable the BOT capability → copy App ID / App Secret into .env
|
|
8
|
+
// 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
|
|
9
|
+
// (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
|
|
10
|
+
// card scope ("Create and update card" — the live preview streams through a card). To answer bare
|
|
11
|
+
// messages in Agent-managed threads and buffer other unsummoned group/thread context, also add the
|
|
12
|
+
// sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
|
|
13
|
+
// 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
|
|
14
|
+
// .env; RECOMMENDED: set an Encrypt Key there and mirror it in FEISHU_ENCRYPT_KEY
|
|
15
|
+
// 4. the event Request URL (https://your.host/feishu) is registered AUTOMATICALLY by
|
|
16
|
+
// `fastagent dev --tunnel` and `fastagent deploy … --run`; to set it by hand in the console,
|
|
17
|
+
// keep the server running while you save (the platform verifies the URL with a challenge)
|
|
18
|
+
// 5. create a version and publish the app (a Feishu admin approves it), then add the bot to a chat
|
|
19
|
+
// `fastagent add feishu` already did steps 1-3 for you (scan-to-create); this walkthrough is for a
|
|
20
|
+
// hand-made app or for auditing what the scan configured.
|
|
21
|
+
export default feishuChannel({
|
|
22
|
+
appId: process.env.FEISHU_APP_ID ?? "", // missing → fails at startup (no replies could be sent)
|
|
23
|
+
appSecret: process.env.FEISHU_APP_SECRET ?? "",
|
|
24
|
+
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN ?? "", // authenticates inbound events
|
|
25
|
+
encryptKey: process.env.FEISHU_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
|
|
26
|
+
// Direct and group chats default to one Agent session/thread per top-level ask. Opt out independently:
|
|
27
|
+
// directMessageSession: "continuous",
|
|
28
|
+
// groupMessageSession: "continuous",
|
|
29
|
+
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
30
|
+
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
31
|
+
// full details always go to the server log regardless.
|
|
32
|
+
onError: (failed) => `⚠️ ${failed.details}`,
|
|
33
|
+
// The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
|
|
34
|
+
// the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultFeishuRoute:
|
|
35
|
+
// p2p chats always answer; groups answer on @this-bot, plus bare continuations in Agent-managed
|
|
36
|
+
// threads. Other human group/thread discussion buffers until that place's next answered turn;
|
|
37
|
+
// @other-only messages in managed threads buffer rather than triggering the Agent.
|
|
38
|
+
// Override to customise explicit routing, reusing the export:
|
|
39
|
+
// route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
|
|
40
|
+
// route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { text: `${feishuEnvelope(e)}\n[extra]` },
|
|
41
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
2
|
+
|
|
3
|
+
// Send a message to a Feishu chat. In a CHAT turn the channel delivers the reply itself — this
|
|
4
|
+
// tool is for turns NO channel is carrying: a scheduled turn (schedules/<name>.ts) or a self-scheduled
|
|
5
|
+
// wake-up, whose plain reply is not delivered anywhere. The chatId comes from the [feishu: chat …]
|
|
6
|
+
// context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
|
|
7
|
+
// the target chat id. tools/ is auto-discovered.
|
|
8
|
+
|
|
9
|
+
// Standalone copy of the channel transport's discipline: a timeout so a wedged connection can't hang
|
|
10
|
+
// the tool call (and the turn), named errors, and success gated on the body's own code===0.
|
|
11
|
+
// Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
|
|
12
|
+
// fail-fast beats a silently sleeping tool.
|
|
13
|
+
const BASE = "https://open.feishu.cn";
|
|
14
|
+
|
|
15
|
+
async function callApi(path: string, body: unknown, token?: string): Promise<Record<string, unknown>> {
|
|
16
|
+
let res: Response;
|
|
17
|
+
let raw: string;
|
|
18
|
+
try {
|
|
19
|
+
res = await fetch(`${BASE}${path}`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json; charset=utf-8",
|
|
23
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: AbortSignal.timeout(30_000),
|
|
27
|
+
});
|
|
28
|
+
raw = await res.text();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new Error(`feishu ${path}: ${String(e)}`, { cause: e });
|
|
31
|
+
}
|
|
32
|
+
let data: { code?: number; msg?: string; [k: string]: unknown };
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(raw) as typeof data;
|
|
35
|
+
} catch {
|
|
36
|
+
data = {};
|
|
37
|
+
}
|
|
38
|
+
if (!res.ok || data.code !== 0) {
|
|
39
|
+
throw new Error(`feishu ${path} failed: ${res.status} ${data.msg ?? "response was not the expected JSON"}`);
|
|
40
|
+
}
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function tenantToken(): Promise<string> {
|
|
45
|
+
const appId = process.env.FEISHU_APP_ID;
|
|
46
|
+
const appSecret = process.env.FEISHU_APP_SECRET;
|
|
47
|
+
if (!appId || !appSecret) throw new Error("FEISHU_APP_ID / FEISHU_APP_SECRET are not set");
|
|
48
|
+
const data = await callApi("/open-apis/auth/v3/tenant_access_token/internal", {
|
|
49
|
+
app_id: appId,
|
|
50
|
+
app_secret: appSecret,
|
|
51
|
+
});
|
|
52
|
+
const token = data.tenant_access_token;
|
|
53
|
+
if (typeof token !== "string") throw new Error("feishu tenant_access_token: response carried no token");
|
|
54
|
+
return token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default defineTool({
|
|
58
|
+
description:
|
|
59
|
+
"Send a message to a Feishu chat: plain `text`, or `markdown` (rendered as a card — headings, " +
|
|
60
|
+
"bold, code blocks, links). Exactly one of the two. In a chat turn take chatId from the " +
|
|
61
|
+
"[feishu: chat …] context line; in a scheduled/woken turn (no context line) the chat id must come " +
|
|
62
|
+
"from your instruction.",
|
|
63
|
+
input: z.object({
|
|
64
|
+
chatId: z.string().describe("target chat id (oc_…)"),
|
|
65
|
+
text: z.string().optional().describe("plain text message to send"),
|
|
66
|
+
markdown: z.string().optional().describe("markdown to send as a card"),
|
|
67
|
+
}),
|
|
68
|
+
async execute({ chatId, text, markdown }) {
|
|
69
|
+
if ((text === undefined) === (markdown === undefined)) {
|
|
70
|
+
throw new Error("pass exactly one of `text` (plain) or `markdown` (a card)");
|
|
71
|
+
}
|
|
72
|
+
const token = await tenantToken();
|
|
73
|
+
const msg =
|
|
74
|
+
text !== undefined
|
|
75
|
+
? { msg_type: "text", content: JSON.stringify({ text }) }
|
|
76
|
+
: {
|
|
77
|
+
msg_type: "interactive",
|
|
78
|
+
// An inline static card: one markdown element, no entity/streaming machinery needed here.
|
|
79
|
+
content: JSON.stringify({
|
|
80
|
+
schema: "2.0",
|
|
81
|
+
body: { elements: [{ tag: "markdown", content: markdown }] },
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
await callApi(`/open-apis/im/v1/messages?receive_id_type=chat_id`, { receive_id: chatId, ...msg }, token);
|
|
85
|
+
return `sent ${text !== undefined ? "message" : "card"} to chat ${chatId}`;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded delivery dedup for message_ids whose webhook handling produced a durable side effect: either
|
|
3
|
+
* an accepted turn intent or a buffered group-context entry. Feishu/Lark document duplicate pushes even
|
|
4
|
+
* after a successful 200 and recommend idempotency on message_id (not event_id). The unfinished-turn
|
|
5
|
+
* store cannot cover a duplicate after completion, and a duplicated background event would otherwise be
|
|
6
|
+
* folded twice.
|
|
7
|
+
*
|
|
8
|
+
* Record only AFTER the pre-ACK side effect is durable: recording first could turn a later state-write
|
|
9
|
+
* failure into silent loss when the platform redelivers. The ring write is best-effort post-persist
|
|
10
|
+
* insurance. A crash between the two writes, a ring-write failure, or an id older than the bounded cap
|
|
11
|
+
* retains L1's at-least-once tail; this is dedup, not exactly-once execution.
|
|
12
|
+
*/
|
|
13
|
+
import { log } from "../../log.js";
|
|
14
|
+
import { loadStateFile, saveStateFile } from "../state.js";
|
|
15
|
+
export function createSeenRing(path, label = "[feishu]", cap = 2000) {
|
|
16
|
+
const load = () => {
|
|
17
|
+
const raw = loadStateFile(path);
|
|
18
|
+
if (raw === undefined)
|
|
19
|
+
return [];
|
|
20
|
+
if (Array.isArray(raw) && raw.every((id) => typeof id === "string"))
|
|
21
|
+
return raw.slice(-cap);
|
|
22
|
+
log.warn(`${label} unexpected shape in ${path} — starting with no seen ids`);
|
|
23
|
+
return [];
|
|
24
|
+
};
|
|
25
|
+
const order = load();
|
|
26
|
+
const ids = new Set(order);
|
|
27
|
+
return {
|
|
28
|
+
has: (id) => ids.has(id),
|
|
29
|
+
add(id) {
|
|
30
|
+
if (ids.has(id))
|
|
31
|
+
return;
|
|
32
|
+
ids.add(id);
|
|
33
|
+
order.push(id);
|
|
34
|
+
while (order.length > cap) {
|
|
35
|
+
const evicted = order.shift();
|
|
36
|
+
if (evicted !== undefined)
|
|
37
|
+
ids.delete(evicted);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
saveStateFile(path, order);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
log.warn(`${label} seen-ring write failed (delivery dedup is in-memory until restart): ${String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Pure Unicode-safe text slicing helpers shared by Feishu/Lark rendering paths. JavaScript string
|
|
2
|
+
* indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
|
|
3
|
+
* characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
|
|
4
|
+
/** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
|
|
5
|
+
export declare function codePointPrefix(text: string, maxPoints: number): string;
|
|
6
|
+
/** Ellipsize from the right while keeping the result within `maxPoints` Unicode code points. */
|
|
7
|
+
export declare function truncateCodePointPrefix(text: string, maxPoints: number, marker?: string): string;
|
|
8
|
+
/** Ellipsize from the left while keeping the result within `maxPoints` Unicode code points. */
|
|
9
|
+
export declare function truncateCodePointSuffix(text: string, maxPoints: number, marker?: string): string;
|
|
10
|
+
/** Largest code-point-aligned prefix whose UTF-8 encoding fits `maxBytes`. */
|
|
11
|
+
export declare function utf8Prefix(text: string, maxBytes: number): string;
|
|
12
|
+
/** Ellipsize a UTF-8 string without exceeding `maxBytes` or tearing a code point. */
|
|
13
|
+
export declare function truncateUtf8(text: string, maxBytes: number, marker?: string): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Pure Unicode-safe text slicing helpers shared by Feishu/Lark rendering paths. JavaScript string
|
|
2
|
+
* indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
|
|
3
|
+
* characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
|
|
4
|
+
/** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
|
|
5
|
+
export function codePointPrefix(text, maxPoints) {
|
|
6
|
+
if (maxPoints <= 0)
|
|
7
|
+
return "";
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const point of text) {
|
|
10
|
+
if (out.length >= maxPoints)
|
|
11
|
+
break;
|
|
12
|
+
out.push(point);
|
|
13
|
+
}
|
|
14
|
+
return out.join("");
|
|
15
|
+
}
|
|
16
|
+
/** Ellipsize from the right while keeping the result within `maxPoints` Unicode code points. */
|
|
17
|
+
export function truncateCodePointPrefix(text, maxPoints, marker = "…") {
|
|
18
|
+
const points = Array.from(text);
|
|
19
|
+
if (points.length <= maxPoints)
|
|
20
|
+
return text;
|
|
21
|
+
if (maxPoints <= 0)
|
|
22
|
+
return "";
|
|
23
|
+
const markerPoints = Array.from(marker);
|
|
24
|
+
if (markerPoints.length >= maxPoints)
|
|
25
|
+
return markerPoints.slice(0, maxPoints).join("");
|
|
26
|
+
return `${points.slice(0, maxPoints - markerPoints.length).join("")}${marker}`;
|
|
27
|
+
}
|
|
28
|
+
/** Ellipsize from the left while keeping the result within `maxPoints` Unicode code points. */
|
|
29
|
+
export function truncateCodePointSuffix(text, maxPoints, marker = "…") {
|
|
30
|
+
const points = Array.from(text);
|
|
31
|
+
if (points.length <= maxPoints)
|
|
32
|
+
return text;
|
|
33
|
+
if (maxPoints <= 0)
|
|
34
|
+
return "";
|
|
35
|
+
const markerPoints = Array.from(marker);
|
|
36
|
+
if (markerPoints.length >= maxPoints)
|
|
37
|
+
return markerPoints.slice(0, maxPoints).join("");
|
|
38
|
+
return `${marker}${points.slice(points.length - (maxPoints - markerPoints.length)).join("")}`;
|
|
39
|
+
}
|
|
40
|
+
/** Largest code-point-aligned prefix whose UTF-8 encoding fits `maxBytes`. */
|
|
41
|
+
export function utf8Prefix(text, maxBytes) {
|
|
42
|
+
if (maxBytes <= 0)
|
|
43
|
+
return "";
|
|
44
|
+
const out = [];
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
for (const point of text) {
|
|
47
|
+
const pointBytes = Buffer.byteLength(point, "utf8");
|
|
48
|
+
if (bytes + pointBytes > maxBytes)
|
|
49
|
+
break;
|
|
50
|
+
out.push(point);
|
|
51
|
+
bytes += pointBytes;
|
|
52
|
+
}
|
|
53
|
+
return out.join("");
|
|
54
|
+
}
|
|
55
|
+
/** Ellipsize a UTF-8 string without exceeding `maxBytes` or tearing a code point. */
|
|
56
|
+
export function truncateUtf8(text, maxBytes, marker = "…") {
|
|
57
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes)
|
|
58
|
+
return text;
|
|
59
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
60
|
+
if (markerBytes > maxBytes)
|
|
61
|
+
return utf8Prefix(marker, maxBytes);
|
|
62
|
+
return `${utf8Prefix(text, maxBytes - markerBytes)}${marker}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lark-international compatibility adapter. Feishu is the canonical protocol/runtime implementation;
|
|
3
|
+
* this module binds it to Lark's cloud profile and exposes natural Lark-branded public names. Lark's
|
|
4
|
+
* weaker control-plane capabilities live in onboarding/registration, not in a fork of the turn engine.
|
|
5
|
+
*/
|
|
6
|
+
import type { ChannelModule } from "../../host/node.ts";
|
|
7
|
+
import { type FeishuChannelOptions, type FeishuFailure, type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute } from "../feishu/feishu.ts";
|
|
8
|
+
export type LarkChannelOptions = FeishuChannelOptions;
|
|
9
|
+
export type LarkFailure = FeishuFailure;
|
|
10
|
+
export type LarkMessage = FeishuMessage;
|
|
11
|
+
export type LarkMessageEvent = FeishuMessageEvent;
|
|
12
|
+
export type LarkRoute = FeishuRoute;
|
|
13
|
+
export declare const defaultLarkRoute: typeof defaultFeishuRoute;
|
|
14
|
+
export declare function larkEnvelope(event: LarkMessageEvent): string;
|
|
15
|
+
export declare function larkChannel(opts: LarkChannelOptions): ChannelModule;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { LARK_COMPAT_CLOUD } from "../feishu/cloud.js";
|
|
2
|
+
import { buildFeishuChannel, defaultFeishuRoute, } from "../feishu/feishu.js";
|
|
3
|
+
import { cloudEnvelope } from "../feishu/parse.js";
|
|
4
|
+
export const defaultLarkRoute = defaultFeishuRoute;
|
|
5
|
+
export function larkEnvelope(event) {
|
|
6
|
+
return cloudEnvelope(event, "lark");
|
|
7
|
+
}
|
|
8
|
+
export function larkChannel(opts) {
|
|
9
|
+
return buildFeishuChannel(LARK_COMPAT_CLOUD, opts, larkChannel.name);
|
|
10
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guided Lark-international onboarding. The intl cloud cannot complete the BOUND scan-to-create flow,
|
|
3
|
+
* so a new/partial setup opens its unbound one-click launcher and collects one App-scoped credential
|
|
4
|
+
* set; a complete existing ID/Secret pair resumes that App directly. Then optimistically run Feishu's
|
|
5
|
+
* webhook-mode + Verification-Token bootstrap against THIS app: a successful PATCH captures the token
|
|
6
|
+
* and flips Subscription mode; only a definitive config-route 404 falls back to the token the console
|
|
7
|
+
* displays + a manual mode switch. IO is injected so the workflow is testable without a terminal or
|
|
8
|
+
* browser.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LARK_CONSOLE_URL = "https://open.larksuite.com/page/launcher?from=backend_oneclick";
|
|
11
|
+
/** Directly where the fallback token lives (and where the successful automatic mode change is visible). */
|
|
12
|
+
export declare function larkEventSecurityUrl(appId: string): string;
|
|
13
|
+
export interface LarkOnboardIO {
|
|
14
|
+
openUrl(url: string): void;
|
|
15
|
+
note(message: string): void;
|
|
16
|
+
prompt(message: string, opts?: {
|
|
17
|
+
hidden?: boolean;
|
|
18
|
+
}): Promise<string | undefined>;
|
|
19
|
+
}
|
|
20
|
+
export interface LarkBootstrapResult {
|
|
21
|
+
/** Challenge-captured token: the PATCH also switched Subscription mode to webhook. */
|
|
22
|
+
token?: string;
|
|
23
|
+
/** Present only for a definitive config-route 404; tells the user why the manual path is active. */
|
|
24
|
+
manualReason?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface LarkOnboardOptions {
|
|
27
|
+
/** Existing active .env values. A complete credential pair is reused (and still validated). */
|
|
28
|
+
existing?: Readonly<Record<string, string | undefined>>;
|
|
29
|
+
verifyCredentials(appId: string, appSecret: string): Promise<void>;
|
|
30
|
+
bootstrapWebhook(appId: string, appSecret: string): Promise<LarkBootstrapResult>;
|
|
31
|
+
}
|
|
32
|
+
export interface LarkOnboardCredentials extends Record<string, string> {
|
|
33
|
+
LARK_APP_ID: string;
|
|
34
|
+
LARK_APP_SECRET: string;
|
|
35
|
+
LARK_VERIFICATION_TOKEN: string;
|
|
36
|
+
}
|
|
37
|
+
/** Open the stable app console and collect everything the runtime needs. Cancellation is a visible
|
|
38
|
+
* failure: the scaffold remains and `add lark` is deliberately re-runnable to resume onboarding. */
|
|
39
|
+
export declare function onboardLarkApp(io: LarkOnboardIO, opts: LarkOnboardOptions): Promise<LarkOnboardCredentials>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guided Lark-international onboarding. The intl cloud cannot complete the BOUND scan-to-create flow,
|
|
3
|
+
* so a new/partial setup opens its unbound one-click launcher and collects one App-scoped credential
|
|
4
|
+
* set; a complete existing ID/Secret pair resumes that App directly. Then optimistically run Feishu's
|
|
5
|
+
* webhook-mode + Verification-Token bootstrap against THIS app: a successful PATCH captures the token
|
|
6
|
+
* and flips Subscription mode; only a definitive config-route 404 falls back to the token the console
|
|
7
|
+
* displays + a manual mode switch. IO is injected so the workflow is testable without a terminal or
|
|
8
|
+
* browser.
|
|
9
|
+
*/
|
|
10
|
+
export const LARK_CONSOLE_URL = "https://open.larksuite.com/page/launcher?from=backend_oneclick";
|
|
11
|
+
/** Directly where the fallback token lives (and where the successful automatic mode change is visible). */
|
|
12
|
+
export function larkEventSecurityUrl(appId) {
|
|
13
|
+
return `https://open.larksuite.com/app/${encodeURIComponent(appId)}/event?tab=safe`;
|
|
14
|
+
}
|
|
15
|
+
function required(value, name) {
|
|
16
|
+
const trimmed = value?.trim();
|
|
17
|
+
if (!trimmed)
|
|
18
|
+
throw new Error(`${name} is required — re-run \`fastagent add lark\` to continue setup`);
|
|
19
|
+
return trimmed;
|
|
20
|
+
}
|
|
21
|
+
/** Open the stable app console and collect everything the runtime needs. Cancellation is a visible
|
|
22
|
+
* failure: the scaffold remains and `add lark` is deliberately re-runnable to resume onboarding. */
|
|
23
|
+
export async function onboardLarkApp(io, opts) {
|
|
24
|
+
const existingId = opts.existing?.LARK_APP_ID?.trim();
|
|
25
|
+
const existingSecret = opts.existing?.LARK_APP_SECRET?.trim();
|
|
26
|
+
const reuseExistingApp = Boolean(existingId && existingSecret);
|
|
27
|
+
// Credentials are one App-scoped set. Reuse only a COMPLETE ID/Secret pair; a partial pair starts a
|
|
28
|
+
// fresh launcher/input path, and its unrelated old Token must not be attached to the newly-entered App.
|
|
29
|
+
if (reuseExistingApp) {
|
|
30
|
+
io.note(`Reusing Lark app ${existingId}; opening its Events & Callbacks configuration directly.`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
io.note(`Create the app on Lark's one-click launcher. Opening ${LARK_CONSOLE_URL}`);
|
|
34
|
+
io.openUrl(LARK_CONSOLE_URL);
|
|
35
|
+
}
|
|
36
|
+
const appId = required(reuseExistingApp ? existingId : await io.prompt("LARK_APP_ID (Credentials & Basic Info)", { hidden: false }), "LARK_APP_ID");
|
|
37
|
+
const appSecret = required(reuseExistingApp ? existingSecret : await io.prompt("LARK_APP_SECRET (Credentials & Basic Info)", { hidden: true }), "LARK_APP_SECRET");
|
|
38
|
+
await opts.verifyCredentials(appId, appSecret);
|
|
39
|
+
const eventSecurityUrl = larkEventSecurityUrl(appId);
|
|
40
|
+
io.note(`App ID / Secret verified. Opening Events & Callbacks → Security: ${eventSecurityUrl}`);
|
|
41
|
+
io.openUrl(eventSecurityUrl);
|
|
42
|
+
io.note("Trying automatic webhook-mode + Verification-Token bootstrap…");
|
|
43
|
+
const bootstrap = await opts.bootstrapWebhook(appId, appSecret);
|
|
44
|
+
if (bootstrap.token) {
|
|
45
|
+
io.note("Verification Token captured; Subscription mode changed to webhook in the app draft.");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
io.note(`${bootstrap.manualReason ?? "Automatic bootstrap unavailable."} Open Events & Callbacks → Encryption Strategy and copy the Verification Token. You must also switch Subscription mode to webhook when setting the Request URL.`);
|
|
49
|
+
}
|
|
50
|
+
const verificationToken = required(bootstrap.token ||
|
|
51
|
+
(reuseExistingApp ? opts.existing?.LARK_VERIFICATION_TOKEN?.trim() : undefined) ||
|
|
52
|
+
(await io.prompt("LARK_VERIFICATION_TOKEN (Events & Callbacks → Encryption Strategy)", { hidden: true })), "LARK_VERIFICATION_TOKEN");
|
|
53
|
+
return {
|
|
54
|
+
LARK_APP_ID: appId,
|
|
55
|
+
LARK_APP_SECRET: appSecret,
|
|
56
|
+
LARK_VERIFICATION_TOKEN: verificationToken,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { larkChannel } from "@fastagent-sh/fastagent/lark";
|
|
2
|
+
|
|
3
|
+
// larkChannel is the branded compatibility adapter over fastagent's canonical Feishu engine, configured
|
|
4
|
+
// with YOUR policy. fastagent discovers this file, mounts POST /lark, and pipes the agent + state home
|
|
5
|
+
// to the adapter. Lark international (open.larksuite.com) only; a
|
|
6
|
+
// Feishu tenant uses `fastagent add feishu` instead. Setup (developer console):
|
|
7
|
+
// 1. create a custom app → enable the BOT capability → copy App ID / App Secret into .env
|
|
8
|
+
// 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
|
|
9
|
+
// (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
|
|
10
|
+
// card scope ("Create and update card" — the live preview streams through a card). To answer bare
|
|
11
|
+
// messages in Agent-managed threads and buffer other unsummoned group/thread context, also add the
|
|
12
|
+
// sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
|
|
13
|
+
// 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
|
|
14
|
+
// .env; RECOMMENDED: set an Encrypt Key there and mirror it in LARK_ENCRYPT_KEY
|
|
15
|
+
// 4. run `fastagent dev --tunnel`: it attempts to switch Subscription mode to webhook + register
|
|
16
|
+
// the URL automatically. If this app returns a config-API 404, do both BY HAND in the console
|
|
17
|
+
// with the server running (the platform verifies https://your.host/lark with a challenge).
|
|
18
|
+
// 5. create a version and publish the app (a tenant admin approves it), then add the bot to a chat
|
|
19
|
+
export default larkChannel({
|
|
20
|
+
appId: process.env.LARK_APP_ID ?? "", // missing → fails at startup (no replies could be sent)
|
|
21
|
+
appSecret: process.env.LARK_APP_SECRET ?? "",
|
|
22
|
+
verificationToken: process.env.LARK_VERIFICATION_TOKEN ?? "", // authenticates inbound events
|
|
23
|
+
encryptKey: process.env.LARK_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
|
|
24
|
+
// Direct and group chats default to one Agent session/thread per top-level ask. Opt out independently:
|
|
25
|
+
// directMessageSession: "continuous",
|
|
26
|
+
// groupMessageSession: "continuous",
|
|
27
|
+
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
28
|
+
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
29
|
+
// full details always go to the server log regardless.
|
|
30
|
+
onError: (failed) => `⚠️ ${failed.details}`,
|
|
31
|
+
// The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
|
|
32
|
+
// the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultLarkRoute:
|
|
33
|
+
// p2p chats always answer; groups answer on @this-bot, plus bare continuations in Agent-managed
|
|
34
|
+
// threads. Other human group/thread discussion buffers until that place's next answered turn;
|
|
35
|
+
// @other-only messages in managed threads buffer rather than triggering the Agent.
|
|
36
|
+
// Override to customise explicit routing, reusing the export:
|
|
37
|
+
// route: (e) => defaultLarkRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
|
|
38
|
+
// route: (e) => defaultLarkRoute(e, { botOpenId: "ou_xxx" }) && { text: `${larkEnvelope(e)}\n[extra]` },
|
|
39
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
2
|
+
|
|
3
|
+
// Send a message to a Lark chat. In a CHAT turn the channel delivers the reply itself — this
|
|
4
|
+
// tool is for turns NO channel is carrying: a scheduled turn (schedules/<name>.ts) or a self-scheduled
|
|
5
|
+
// wake-up, whose plain reply is not delivered anywhere. The chatId comes from the [lark: chat …]
|
|
6
|
+
// context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
|
|
7
|
+
// the target chat id. tools/ is auto-discovered.
|
|
8
|
+
|
|
9
|
+
// Standalone copy of the channel transport's discipline: a timeout so a wedged connection can't hang
|
|
10
|
+
// the tool call (and the turn), named errors, and success gated on the body's own code===0.
|
|
11
|
+
// Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
|
|
12
|
+
// fail-fast beats a silently sleeping tool.
|
|
13
|
+
const BASE = "https://open.larksuite.com";
|
|
14
|
+
|
|
15
|
+
async function callApi(path: string, body: unknown, token?: string): Promise<Record<string, unknown>> {
|
|
16
|
+
let res: Response;
|
|
17
|
+
let raw: string;
|
|
18
|
+
try {
|
|
19
|
+
res = await fetch(`${BASE}${path}`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json; charset=utf-8",
|
|
23
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: AbortSignal.timeout(30_000),
|
|
27
|
+
});
|
|
28
|
+
raw = await res.text();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new Error(`lark ${path}: ${String(e)}`, { cause: e });
|
|
31
|
+
}
|
|
32
|
+
let data: { code?: number; msg?: string; [k: string]: unknown };
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(raw) as typeof data;
|
|
35
|
+
} catch {
|
|
36
|
+
data = {};
|
|
37
|
+
}
|
|
38
|
+
if (!res.ok || data.code !== 0) {
|
|
39
|
+
throw new Error(`lark ${path} failed: ${res.status} ${data.msg ?? "response was not the expected JSON"}`);
|
|
40
|
+
}
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function tenantToken(): Promise<string> {
|
|
45
|
+
const appId = process.env.LARK_APP_ID;
|
|
46
|
+
const appSecret = process.env.LARK_APP_SECRET;
|
|
47
|
+
if (!appId || !appSecret) throw new Error("LARK_APP_ID / LARK_APP_SECRET are not set");
|
|
48
|
+
const data = await callApi("/open-apis/auth/v3/tenant_access_token/internal", {
|
|
49
|
+
app_id: appId,
|
|
50
|
+
app_secret: appSecret,
|
|
51
|
+
});
|
|
52
|
+
const token = data.tenant_access_token;
|
|
53
|
+
if (typeof token !== "string") throw new Error("lark tenant_access_token: response carried no token");
|
|
54
|
+
return token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default defineTool({
|
|
58
|
+
description:
|
|
59
|
+
"Send a message to a Lark chat: plain `text`, or `markdown` (rendered as a card — headings, " +
|
|
60
|
+
"bold, code blocks, links). Exactly one of the two. In a chat turn take chatId from the " +
|
|
61
|
+
"[lark: chat …] context line; in a scheduled/woken turn (no context line) the chat id must come " +
|
|
62
|
+
"from your instruction.",
|
|
63
|
+
input: z.object({
|
|
64
|
+
chatId: z.string().describe("target chat id (oc_…)"),
|
|
65
|
+
text: z.string().optional().describe("plain text message to send"),
|
|
66
|
+
markdown: z.string().optional().describe("markdown to send as a card"),
|
|
67
|
+
}),
|
|
68
|
+
async execute({ chatId, text, markdown }) {
|
|
69
|
+
if ((text === undefined) === (markdown === undefined)) {
|
|
70
|
+
throw new Error("pass exactly one of `text` (plain) or `markdown` (a card)");
|
|
71
|
+
}
|
|
72
|
+
const token = await tenantToken();
|
|
73
|
+
const msg =
|
|
74
|
+
text !== undefined
|
|
75
|
+
? { msg_type: "text", content: JSON.stringify({ text }) }
|
|
76
|
+
: {
|
|
77
|
+
msg_type: "interactive",
|
|
78
|
+
// An inline static card: one markdown element, no entity/streaming machinery needed here.
|
|
79
|
+
content: JSON.stringify({
|
|
80
|
+
schema: "2.0",
|
|
81
|
+
body: { elements: [{ tag: "markdown", content: markdown }] },
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
await callApi(`/open-apis/im/v1/messages?receive_id_type=chat_id`, { receive_id: chatId, ...msg }, token);
|
|
85
|
+
return `sent ${text !== undefined ? "message" : "card"} to chat ${chatId}`;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHARED: the webhook registrars' outcome. A registrar reports its own FACT; what to do about it (gate
|
|
3
|
+
* the deploy or not, and with what remediation) is the CALLER's policy — `deploy --run` gates on
|
|
4
|
+
* "failed", the tunnel (a long-running dev process) ignores the result entirely.
|
|
5
|
+
*
|
|
6
|
+
* - "registered": the platform accepted the webhook / event URL.
|
|
7
|
+
* - "manual": this run did not fail, but an operator-facing step remains (the registrar printed the
|
|
8
|
+
* instructions). Two sub-states differ on re-runnability: credentials not configured (re-run after
|
|
9
|
+
* setting .env DOES auto-register; on the deploy path this is pre-gated by missingSecrets and
|
|
10
|
+
* unreachable) and a cloud without the config API (the Lark cloud-lag 404 — no re-run can ever
|
|
11
|
+
* register it; the console is the only path).
|
|
12
|
+
* - "failed": this run ends with the webhook NOT registered, and acting + re-running can fix it
|
|
13
|
+
* (health timeout, a permanent config error, exhausted retries).
|
|
14
|
+
*/
|
|
15
|
+
export type RegistrationOutcome = "registered" | "manual" | "failed";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|