@fastagent-sh/fastagent 0.12.0 → 0.13.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 +42 -36
- 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/crypto.d.ts +13 -0
- package/dist/channels/feishu/crypto.js +41 -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 +36 -0
- package/dist/channels/feishu/feishu.js +359 -0
- package/dist/channels/feishu/invoke-turn.d.ts +59 -0
- package/dist/channels/feishu/invoke-turn.js +106 -0
- package/dist/channels/feishu/parse.d.ts +125 -0
- package/dist/channels/feishu/parse.js +175 -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 +34 -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 +32 -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-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +68 -23
- package/dist/deploy/container.js +10 -6
- 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/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/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/chat.js +3 -3
- package/dist/engines/pi/create.d.ts +0 -1
- package/dist/engines/pi/create.js +8 -7
- 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/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +73 -7
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +19 -8
- /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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-click app creation ("scan to create") — the OAuth 2.0 Device Authorization Grant (RFC 8628)
|
|
3
|
+
* flow the platform provides for agent apps: `begin` returns a one-time verification URL the user
|
|
4
|
+
* opens in Feishu/Lark and confirms (the platform pre-configures the agent app template: bot
|
|
5
|
+
* capability, messaging scopes, event subscriptions); polling returns the new app's credentials.
|
|
6
|
+
*
|
|
7
|
+
* Hand-rolled on fetch, no SDK: the wire protocol is two form-encoded POSTs to the accounts endpoint
|
|
8
|
+
* plus RFC 8628's polling error dance — shared verbatim by all four official SDKs (node/python/java/go),
|
|
9
|
+
* which makes it a de-facto stable surface even though only the SDKs document it. Provenance:
|
|
10
|
+
* larksuite/node-sdk `scene/registration` (registerApp). If the platform ever moves this behind
|
|
11
|
+
* something non-trivial (signed payloads, websockets), adopt the official SDK instead of chasing it —
|
|
12
|
+
* the same tripwire as feishu-api.ts.
|
|
13
|
+
*
|
|
14
|
+
* The scanning user's tenant decides the brand: a Lark-tenant user flips polling to the Lark accounts
|
|
15
|
+
* domain mid-flow (`tenant_brand: "lark"`), and the result carries the brand so the caller can point
|
|
16
|
+
* everything else (API origin) at the right cloud.
|
|
17
|
+
*/
|
|
18
|
+
import { gzipSync } from "node:zlib";
|
|
19
|
+
/** Feishu accounts endpoint (the flow starts here for every user; a Lark-tenant scan switches over). */
|
|
20
|
+
const FEISHU_ACCOUNTS = "https://accounts.feishu.cn";
|
|
21
|
+
const LARK_ACCOUNTS = "https://accounts.larksuite.com";
|
|
22
|
+
const ENDPOINT = "/oauth/v1/app/registration";
|
|
23
|
+
/** Per-attempt timeout for one registration POST — small form/JSON round-trips. */
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
25
|
+
/** JSON → gzip → base64url — the platform's fixed addons encoding. */
|
|
26
|
+
function encodeAddons(addons) {
|
|
27
|
+
return gzipSync(Buffer.from(JSON.stringify(addons), "utf8"))
|
|
28
|
+
.toString("base64")
|
|
29
|
+
.replace(/\+/g, "-")
|
|
30
|
+
.replace(/\//g, "_")
|
|
31
|
+
.replace(/=+$/, "");
|
|
32
|
+
}
|
|
33
|
+
/** RFC 8628 device-flow states whose non-2xx JSON bodies belong to the polling state machine. */
|
|
34
|
+
const DEVICE_FLOW_ERRORS = new Set(["authorization_pending", "slow_down", "access_denied", "expired_token"]);
|
|
35
|
+
/** One registration POST (form-encoded). RFC 8628 delivers polling states (authorization_pending,
|
|
36
|
+
* slow_down, …) as HTTP 400 with a JSON body — those parse as data, not as transport failures. */
|
|
37
|
+
async function post(baseUrl, params) {
|
|
38
|
+
let res;
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
res = await fetch(`${baseUrl}${ENDPOINT}`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
44
|
+
body: new URLSearchParams(params).toString(),
|
|
45
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
46
|
+
});
|
|
47
|
+
raw = await res.text();
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
throw new Error(`feishu app registration: ${String(e)}`, { cause: e });
|
|
51
|
+
}
|
|
52
|
+
let data;
|
|
53
|
+
try {
|
|
54
|
+
data = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`feishu app registration failed: ${res.status} — response was not the expected JSON`);
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok && !(data.error && DEVICE_FLOW_ERRORS.has(data.error))) {
|
|
60
|
+
const diagnostic = data.error
|
|
61
|
+
? `${data.error}${data.error_description ? ` — ${data.error_description}` : ""}`
|
|
62
|
+
: raw.trim().slice(0, 500) || "empty response";
|
|
63
|
+
throw new Error(`feishu app registration failed: HTTP ${res.status} — ${diagnostic}`);
|
|
64
|
+
}
|
|
65
|
+
return data;
|
|
66
|
+
}
|
|
67
|
+
/** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers. */
|
|
68
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
/**
|
|
70
|
+
* Run the scan-to-create flow (module header): begin → hand the verification URL to the caller →
|
|
71
|
+
* poll until the user confirms. Resolves with the new app's credentials; rejects on denial, expiry,
|
|
72
|
+
* abort, or a transport failure — every rejection is a plain Error whose message says what to do.
|
|
73
|
+
*/
|
|
74
|
+
export async function registerFeishuApp(options) {
|
|
75
|
+
const feishuBase = options.accountsBaseUrl ?? FEISHU_ACCOUNTS;
|
|
76
|
+
const larkBase = options.larkAccountsBaseUrl ?? LARK_ACCOUNTS;
|
|
77
|
+
const begin = await post(feishuBase, {
|
|
78
|
+
action: "begin",
|
|
79
|
+
archetype: "PersonalAgent", // the platform's agent-app template: bot + messaging scopes + events pre-configured
|
|
80
|
+
auth_method: "client_secret",
|
|
81
|
+
request_user_info: "open_id",
|
|
82
|
+
});
|
|
83
|
+
if (!begin.device_code || !begin.verification_uri_complete) {
|
|
84
|
+
throw new Error(`feishu app registration: begin returned no device code (${begin.error ?? "unexpected response"}${begin.error_description ? `: ${begin.error_description}` : ""})`);
|
|
85
|
+
}
|
|
86
|
+
const url = new URL(begin.verification_uri_complete);
|
|
87
|
+
url.searchParams.set("from", "sdk");
|
|
88
|
+
url.searchParams.set("tp", "sdk");
|
|
89
|
+
url.searchParams.set("source", "fastagent");
|
|
90
|
+
if (options.name !== undefined)
|
|
91
|
+
url.searchParams.set("name", options.name);
|
|
92
|
+
if (options.desc !== undefined)
|
|
93
|
+
url.searchParams.set("desc", options.desc);
|
|
94
|
+
if (options.addons !== undefined)
|
|
95
|
+
url.searchParams.set("addons", encodeAddons(options.addons));
|
|
96
|
+
const expiresInS = begin.expires_in ?? 600;
|
|
97
|
+
options.onVerificationUrl({ url: url.toString(), expiresInS });
|
|
98
|
+
let pollBase = feishuBase;
|
|
99
|
+
let switched = false;
|
|
100
|
+
let intervalMs = (begin.interval ?? 5) * 1000;
|
|
101
|
+
const deadline = Date.now() + expiresInS * 1000;
|
|
102
|
+
for (;;) {
|
|
103
|
+
if (options.signal?.aborted)
|
|
104
|
+
throw new Error("feishu app registration was aborted");
|
|
105
|
+
if (Date.now() >= deadline) {
|
|
106
|
+
throw new Error("feishu app registration: the verification link expired before anyone confirmed — re-run to get a fresh one");
|
|
107
|
+
}
|
|
108
|
+
const poll = await post(pollBase, { action: "poll", device_code: begin.device_code });
|
|
109
|
+
// A Lark-tenant user: the flow continues on the Lark accounts domain (once), same device code.
|
|
110
|
+
if (poll.user_info?.tenant_brand === "lark" && !switched) {
|
|
111
|
+
switched = true;
|
|
112
|
+
pollBase = larkBase;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (poll.client_id && poll.client_secret) {
|
|
116
|
+
return {
|
|
117
|
+
appId: poll.client_id,
|
|
118
|
+
appSecret: poll.client_secret,
|
|
119
|
+
tenantBrand: poll.user_info?.tenant_brand,
|
|
120
|
+
openId: poll.user_info?.open_id,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
switch (poll.error) {
|
|
124
|
+
case "authorization_pending":
|
|
125
|
+
break; // the user has not confirmed yet — keep polling
|
|
126
|
+
case "slow_down":
|
|
127
|
+
intervalMs += 5000; // RFC 8628: back off and keep polling
|
|
128
|
+
break;
|
|
129
|
+
case "access_denied":
|
|
130
|
+
throw new Error("feishu app registration: the user declined the authorization");
|
|
131
|
+
case "expired_token":
|
|
132
|
+
throw new Error("feishu app registration: the verification link expired — re-run to get a fresh one");
|
|
133
|
+
default:
|
|
134
|
+
if (poll.error) {
|
|
135
|
+
throw new Error(`feishu app registration failed: ${poll.error}${poll.error_description ? ` — ${poll.error_description}` : ""}`);
|
|
136
|
+
}
|
|
137
|
+
break; // no error, no credentials — treat as pending
|
|
138
|
+
}
|
|
139
|
+
await wait(intervalMs);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RegistrationOutcome } from "../registration.ts";
|
|
2
|
+
import { type FeishuCloudKind } from "./cloud.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Register `<baseUrl>/<kind>` as the app's event Request URL (webhook mode). Missing credentials print
|
|
5
|
+
* the manual instruction instead of failing. `opts` exist for tests: timeouts + `apiBase` (a fake
|
|
6
|
+
* platform — production derives it from the kind).
|
|
7
|
+
*
|
|
8
|
+
* Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
|
|
9
|
+
*/
|
|
10
|
+
export interface FeishuManualRegistration {
|
|
11
|
+
consoleUrl: string;
|
|
12
|
+
requestUrl: string;
|
|
13
|
+
}
|
|
14
|
+
export interface RegisterFeishuWebhookOptions {
|
|
15
|
+
readyTimeoutMs?: number;
|
|
16
|
+
readyIntervalMs?: number;
|
|
17
|
+
retryMs?: number;
|
|
18
|
+
apiBase?: string;
|
|
19
|
+
/** Manual fallback after a definitive config error or exhausted retries. Local dev opens this App. */
|
|
20
|
+
onManualRegistration?: (info: FeishuManualRegistration) => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function registerFeishuWebhook(baseUrl: string, kind: FeishuCloudKind, opts?: RegisterFeishuWebhookOptions): Promise<RegistrationOutcome>;
|
|
@@ -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,34 @@
|
|
|
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)
|
|
11
|
+
// 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
|
|
12
|
+
// .env; RECOMMENDED: set an Encrypt Key there and mirror it in FEISHU_ENCRYPT_KEY
|
|
13
|
+
// 4. the event Request URL (https://your.host/feishu) is registered AUTOMATICALLY by
|
|
14
|
+
// `fastagent dev --tunnel` and `fastagent deploy … --run`; to set it by hand in the console,
|
|
15
|
+
// keep the server running while you save (the platform verifies the URL with a challenge)
|
|
16
|
+
// 5. create a version and publish the app (a Feishu admin approves it), then add the bot to a chat
|
|
17
|
+
// `fastagent add feishu` already did steps 1-3 for you (scan-to-create); this walkthrough is for a
|
|
18
|
+
// hand-made app or for auditing what the scan configured.
|
|
19
|
+
export default feishuChannel({
|
|
20
|
+
appId: process.env.FEISHU_APP_ID ?? "", // missing → fails at startup (no replies could be sent)
|
|
21
|
+
appSecret: process.env.FEISHU_APP_SECRET ?? "",
|
|
22
|
+
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN ?? "", // authenticates inbound events
|
|
23
|
+
encryptKey: process.env.FEISHU_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
|
|
24
|
+
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
25
|
+
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
26
|
+
// full details always go to the server log regardless.
|
|
27
|
+
onError: (failed) => `⚠️ ${failed.details}`,
|
|
28
|
+
// The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
|
|
29
|
+
// the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultFeishuRoute:
|
|
30
|
+
// p2p chats always answer, groups only on an @mention of this bot (matched by open_id, resolved at
|
|
31
|
+
// startup). Override to customise, reusing the export:
|
|
32
|
+
// route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
|
|
33
|
+
// route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { text: `${feishuEnvelope(e)}\n[extra]` },
|
|
34
|
+
});
|
|
@@ -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
|
+
* Accepted-turn dedup: a bounded, persisted ring of message_ids the channel has ACCEPTED. The platform
|
|
3
|
+
* redelivers events it thinks undelivered AND documents duplicate pushes in "special scenarios", keying
|
|
4
|
+
* idempotency on message_id (its own guidance: dedup on message_id, not event_id). The turn-store alone
|
|
5
|
+
* cannot cover the tail: once a completed turn's intent is removed, a late redelivery would re-run it —
|
|
6
|
+
* this ring is what refuses that. Only ACCEPTED (routed) messages enter: ignored events are side-effect-
|
|
7
|
+
* free, so recording them would just churn the ring.
|
|
8
|
+
*
|
|
9
|
+
* Best-effort durability (post-decision insurance, not the pre-ACK intent): a failed write logs and
|
|
10
|
+
* moves on — the exposure is a re-run after BOTH a completed turn and a redelivery straddle a crash,
|
|
11
|
+
* the same at-least-once tail the turn-store documents.
|
|
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((x) => typeof x === "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 (e) {
|
|
43
|
+
log.warn(`${label} seen-ring write failed (dedup degrades to the turn-store window): ${String(e)}`);
|
|
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>;
|