@fastagent-sh/fastagent 0.20.0 → 0.21.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 +5 -1
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +0 -4
- package/dist/channels/agentcore-service.js +1 -6
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +1 -67
- package/dist/channels/agentcore.js +90 -89
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +13 -62
- package/dist/channels/discover.d.ts +0 -1
- package/dist/channels/discover.js +1 -3
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +2 -4
- package/dist/channels/feishu/feishu.js +56 -115
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +41 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +34 -14
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -126
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +81 -22
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +60 -99
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/telegram.js +47 -142
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +7 -20
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -31
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +17 -49
- package/dist/cli/commands/deploy.js +39 -618
- package/dist/cli/commands/dev.js +17 -46
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +17 -46
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +36 -19
- package/dist/cli/serve.js +91 -7
- package/dist/cli/shared.d.ts +13 -18
- package/dist/cli/shared.js +20 -4
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -2
- package/dist/deploy/agentcore/plan.js +29 -244
- package/dist/deploy/agentcore/run.d.ts +4 -20
- package/dist/deploy/agentcore/run.js +22 -7
- package/dist/deploy/channel-ingress.js +1 -1
- package/dist/deploy/docker/run.d.ts +17 -3
- package/dist/deploy/docker/run.js +30 -8
- package/dist/deploy/fly/plan.js +7 -0
- package/dist/deploy/fly/run.d.ts +27 -0
- package/dist/deploy/fly/run.js +102 -20
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.js +3 -3
- package/dist/deploy/railway/plan.d.ts +5 -0
- package/dist/deploy/railway/plan.js +7 -0
- package/dist/deploy/railway/run.d.ts +2 -1
- package/dist/deploy/railway/run.js +5 -4
- package/dist/deploy/secrets.js +0 -17
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +109 -85
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +22 -39
- package/dist/engines/pi/create.js +58 -73
- package/dist/engines/pi/definition.js +8 -8
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +31 -41
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/session-builder.js +26 -113
- package/dist/engines/pi/session-control.d.ts +10 -18
- package/dist/engines/pi/session-control.js +47 -81
- package/dist/engines/pi/session-settings.d.ts +1 -1
- package/dist/engines/pi/session-settings.js +1 -1
- package/dist/engines/pi/session-store.d.ts +12 -17
- package/dist/engines/pi/session-store.js +18 -40
- package/dist/engines/pi/tool-context.d.ts +3 -3
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +1 -0
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +5 -0
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +17 -4
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +48 -65
- package/dist/schedule/wake-alarm.d.ts +1 -12
- package/dist/schedule/wake-alarm.js +2 -3
- package/dist/service.d.ts +23 -22
- package/dist/service.js +7 -73
- package/dist/session-remote.d.ts +5 -5
- package/dist/session-remote.js +25 -28
- package/dist/session.d.ts +1 -1
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/tunnel.d.ts +16 -6
- package/dist/tunnel.js +53 -12
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
package/dist/channels/control.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { INVALID_COMMAND_CODE, SESSIONS_UNAVAILABLE_CODE, UNSUPPORTED_CAPABILITY_CODE, UPDATE_FIELDS, } from "../session.js";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
3
2
|
import { parseRouteKey, withoutBody } from "./serve.js";
|
|
4
3
|
import { log } from "../log.js";
|
|
5
4
|
import { readBodyCapped } from "./body.js";
|
|
6
|
-
import { MAX_BODY_BYTES, createInvokeHandler
|
|
5
|
+
import { MAX_BODY_BYTES, createInvokeHandler } from "./http.js";
|
|
6
|
+
import { sseResponse } from "./sse.js";
|
|
7
7
|
import { text } from "./respond.js";
|
|
8
|
+
import { secretEquals } from "./secret.js";
|
|
8
9
|
/** The prefix this plane OWNS: everything under it is the plane's to answer. */
|
|
9
10
|
const CONTROL_PREFIX = "/control";
|
|
10
11
|
/** The one variable segment in this plane's paths: a percent-encoded session id. Written into route
|
|
@@ -266,13 +267,10 @@ export function controlPlaneRoutes(control, options) {
|
|
|
266
267
|
if (!token)
|
|
267
268
|
throw new Error("createControlPlane: a bearer token is required (empty tokens are not a mode)");
|
|
268
269
|
const epoch = crypto.randomUUID();
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
const expected =
|
|
272
|
-
const authed = (req) =>
|
|
273
|
-
const header = Buffer.from(req.headers.get("authorization") ?? "");
|
|
274
|
-
return header.length === expected.length && timingSafeEqual(header, expected);
|
|
275
|
-
};
|
|
270
|
+
// The bearer token is this surface's ONLY auth (and the --tunnel warning names it as the sole
|
|
271
|
+
// protection on a public URL).
|
|
272
|
+
const expected = `Bearer ${token}`;
|
|
273
|
+
const authed = (req) => secretEquals(req.headers.get("authorization"), expected);
|
|
276
274
|
const invokeHandler = options.agent ? createInvokeHandler(options.agent) : undefined;
|
|
277
275
|
/** Authenticate, then hand the handler the pieces every route wants: the request, the URL (for
|
|
278
276
|
* query parameters), and the session the PATH named — `""` on the routes that have no id in
|
|
@@ -404,60 +402,13 @@ export function controlPlaneRoutes(control, options) {
|
|
|
404
402
|
}
|
|
405
403
|
}),
|
|
406
404
|
[`GET /control/sessions/${SESSION_SEGMENT}/events`]: guard((_req, _url, session) => {
|
|
407
|
-
const iterator = control.sessions.get(session).events()[Symbol.asyncIterator]();
|
|
408
|
-
// EAGER registration: issue the first pull NOW, before the Response (and thus the client's
|
|
409
|
-
// fetch resolution) exists — hub subscription is registered synchronously inside next(), so
|
|
410
|
-
// "the client saw response headers" implies "events from that moment on will be delivered".
|
|
411
|
-
// Shrinks the subscribe/backfill race to network reordering instead of a full pull cycle.
|
|
412
|
-
let pending = iterator.next();
|
|
413
|
-
// Observed here so a client that disconnects BEFORE the first pull cannot turn a rejecting
|
|
414
|
-
// events iterator (this is the neutral contract face — any implementation may reject) into a
|
|
415
|
-
// process-killing unhandledRejection; awaiting `pending` at pull still surfaces the error.
|
|
416
|
-
pending.catch(() => { });
|
|
417
405
|
let seq = 0;
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
async pull(controller) {
|
|
425
|
-
let next;
|
|
426
|
-
try {
|
|
427
|
-
next = await (pending ?? iterator.next());
|
|
428
|
-
}
|
|
429
|
-
catch (error) {
|
|
430
|
-
// A rejecting implementation (the neutral contract permits it) must not leak its
|
|
431
|
-
// subscription: an errored stream never gets cancel(), so the unsubscribe and the
|
|
432
|
-
// heartbeat teardown happen HERE.
|
|
433
|
-
stopHeartbeat();
|
|
434
|
-
void iterator.return?.(undefined)?.catch?.(() => { });
|
|
435
|
-
controller.error(error);
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
pending = undefined;
|
|
439
|
-
if (next.done) {
|
|
440
|
-
stopHeartbeat();
|
|
441
|
-
controller.close();
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
const wire = { sessionId: session, epoch, seq: seq++, event: next.value };
|
|
445
|
-
controller.enqueue(encoder.encode(`data: ${JSON.stringify(wire)}\n\n`));
|
|
446
|
-
},
|
|
447
|
-
cancel() {
|
|
448
|
-
stopHeartbeat();
|
|
449
|
-
// Same neutral-contract defense as the pull error path: a rejecting return() on client
|
|
450
|
-
// disconnect must not become a process-level unhandledRejection.
|
|
451
|
-
void iterator.return?.(undefined)?.catch?.(() => { });
|
|
452
|
-
},
|
|
453
|
-
});
|
|
454
|
-
return new Response(stream, {
|
|
455
|
-
headers: {
|
|
456
|
-
"content-type": "text/event-stream",
|
|
457
|
-
"cache-control": "no-cache",
|
|
458
|
-
connection: "keep-alive",
|
|
459
|
-
},
|
|
460
|
-
});
|
|
406
|
+
return sseResponse(control.sessions.get(session).events(), (event) => ({
|
|
407
|
+
sessionId: session,
|
|
408
|
+
epoch,
|
|
409
|
+
seq: seq++,
|
|
410
|
+
event,
|
|
411
|
+
}));
|
|
461
412
|
}),
|
|
462
413
|
};
|
|
463
414
|
}
|
|
@@ -53,7 +53,6 @@ export declare function loadChannels(dir: string, ctx: ChannelContext): Promise<
|
|
|
53
53
|
routes: Routes;
|
|
54
54
|
longConnections: LoadedLongConnectionChannel[];
|
|
55
55
|
routeChannels: string[];
|
|
56
|
-
longConnectionChannels: string[];
|
|
57
56
|
collisions: ChannelCollision[];
|
|
58
57
|
failures: ModuleLoadFailure[];
|
|
59
58
|
}>;
|
|
@@ -85,7 +85,6 @@ export async function loadChannels(dir, ctx) {
|
|
|
85
85
|
const routes = {};
|
|
86
86
|
const longConnections = [];
|
|
87
87
|
const routeChannels = [];
|
|
88
|
-
const longConnectionChannels = [];
|
|
89
88
|
const collisions = [];
|
|
90
89
|
for (const { name, label, file, mod } of modules) {
|
|
91
90
|
try {
|
|
@@ -96,7 +95,6 @@ export async function loadChannels(dir, ctx) {
|
|
|
96
95
|
name: channel.name,
|
|
97
96
|
connect: (signal) => channel.connect(ctx, signal),
|
|
98
97
|
});
|
|
99
|
-
longConnectionChannels.push(name);
|
|
100
98
|
continue;
|
|
101
99
|
}
|
|
102
100
|
if (typeof mod.default !== "function") {
|
|
@@ -124,5 +122,5 @@ export async function loadChannels(dir, ctx) {
|
|
|
124
122
|
failures.push({ label, file, message: error.message });
|
|
125
123
|
}
|
|
126
124
|
}
|
|
127
|
-
return { routes, longConnections, routeChannels,
|
|
125
|
+
return { routes, longConnections, routeChannels, collisions, failures };
|
|
128
126
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
|
|
3
|
+
* ../kit/context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
|
|
4
|
+
* buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
|
|
5
|
+
* concrete thread root) and folded into the next answered turn in that place.
|
|
6
|
+
*/
|
|
1
7
|
import { type ContextBuffer } from "../kit/context-buffer.ts";
|
|
2
8
|
import type { NormalizedFeishuMessage } from "./model.ts";
|
|
3
9
|
export interface FeishuBufferedResource {
|
|
@@ -4,9 +4,7 @@
|
|
|
4
4
|
* buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
|
|
5
5
|
* concrete thread root) and folded into the next answered turn in that place.
|
|
6
6
|
*/
|
|
7
|
-
import { log } from "../../log.js";
|
|
8
7
|
import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
|
|
9
|
-
import { loadStateFile, saveStateFile } from "../kit/state.js";
|
|
10
8
|
import { truncateCodePointPrefix } from "../kit/text.js";
|
|
11
9
|
function bufferLine(entry) {
|
|
12
10
|
const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
|
|
@@ -80,43 +78,7 @@ function isEntry(value) {
|
|
|
80
78
|
resources(entry.files) &&
|
|
81
79
|
resources(entry.images));
|
|
82
80
|
}
|
|
83
|
-
/**
|
|
84
|
-
* Buckets from the pre-participant-model keying (`<chat>:root:<root_id>`) can never be produced again —
|
|
85
|
-
* a place is `<chat>` or `<chat>:thread:<thread_id>` — so nothing could ever fold or clear them, and
|
|
86
|
-
* they would hold chat content on disk forever. Dropped here, before the buffer loads, so the shared
|
|
87
|
-
* kernel never learns about a key shape one channel retired.
|
|
88
|
-
*
|
|
89
|
-
* TWO one-time losses, both accepted and both logged by count. (1) The retired shape covered every
|
|
90
|
-
* thread bucket and every main-chat quoted-reply bucket, so buffered discussion in threads does not
|
|
91
|
-
* survive the upgrade — it becomes unreachable BECAUSE of the re-keying, not before it. (2)
|
|
92
|
-
* `turns.json` persists each in-flight turn's `bufferKey` verbatim and this runs before turn recovery,
|
|
93
|
-
* so a turn spanning the upgrade finds its bucket already gone. Sparing referenced keys would couple
|
|
94
|
-
* the buffer to the turn store to protect a single upgrade, and would not help (1) at all.
|
|
95
|
-
*
|
|
96
|
-
* PERMANENT, unlike the `owned-threads.json` cleanup it otherwise resembles. That one leaves an inert
|
|
97
|
-
* orphan file, so deleting it a release later is free; this one is what stops user chat content
|
|
98
|
-
* lingering, and a deployment that skips from before the model to well after it would never run an
|
|
99
|
-
* expired version of this code. The standing cost is one key scan at load, and nothing when no retired
|
|
100
|
-
* key is present.
|
|
101
|
-
*/
|
|
102
|
-
function dropRetiredBuckets(path, label) {
|
|
103
|
-
const raw = loadStateFile(path);
|
|
104
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
105
|
-
return;
|
|
106
|
-
const live = Object.entries(raw).filter(([placeKey]) => !placeKey.includes(":root:"));
|
|
107
|
-
const dropped = Object.keys(raw).length - live.length;
|
|
108
|
-
if (dropped === 0)
|
|
109
|
-
return;
|
|
110
|
-
log.info(`${label} dropped ${dropped} context bucket(s) with a retired key shape`);
|
|
111
|
-
try {
|
|
112
|
-
saveStateFile(path, Object.fromEntries(live));
|
|
113
|
-
}
|
|
114
|
-
catch (error) {
|
|
115
|
-
log.warn(`${label} could not rewrite ${path} after dropping retired buckets: ${String(error)}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
81
|
export function createFeishuContextBuffer(path, label) {
|
|
119
|
-
dropRetiredBuckets(path, label);
|
|
120
82
|
return createGenericContextBuffer({
|
|
121
83
|
path,
|
|
122
84
|
label,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
|
|
2
|
-
export declare function timingSafeEqualStr(a: string, b: string): boolean;
|
|
3
1
|
/** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
|
|
4
2
|
* input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
|
|
5
3
|
* guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
|
|
@@ -14,13 +14,8 @@
|
|
|
14
14
|
* Comparisons are constant-time (timingSafeEqual) so neither the signature check nor the verification-
|
|
15
15
|
* token check leaks a timing signal.
|
|
16
16
|
*/
|
|
17
|
-
import { createDecipheriv, createHash
|
|
18
|
-
|
|
19
|
-
export function timingSafeEqualStr(a, b) {
|
|
20
|
-
const ab = Buffer.from(a);
|
|
21
|
-
const bb = Buffer.from(b);
|
|
22
|
-
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
23
|
-
}
|
|
17
|
+
import { createDecipheriv, createHash } from "node:crypto";
|
|
18
|
+
import { secretEquals } from "../secret.js";
|
|
24
19
|
/** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
|
|
25
20
|
* input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
|
|
26
21
|
* guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
|
|
@@ -39,5 +34,5 @@ export function eventSignature(encryptKey, timestamp, nonce, rawBody) {
|
|
|
39
34
|
}
|
|
40
35
|
/** Whether a request's signature headers verify against the raw body (constant-time). */
|
|
41
36
|
export function verifySignature(encryptKey, headers, rawBody) {
|
|
42
|
-
return
|
|
37
|
+
return secretEquals(headers.signature, eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody));
|
|
43
38
|
}
|
|
@@ -14,10 +14,8 @@
|
|
|
14
14
|
* 5. Every failure is a {@link FeishuApiError} naming the call; self-description is a property of the
|
|
15
15
|
* error type, not per-call-site string assembly.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* or grows past ~a dozen methods, adopt @larksuiteoapi/node-sdk instead of growing it — the methods
|
|
20
|
-
* here are shape-compatible with the SDK's `client.im.*` style, so the policy layer survives that swap.
|
|
17
|
+
* Typed methods cover messages, cards, resources and app configuration. Proactive tools share this
|
|
18
|
+
* transport through shared-api.ts. The official SDK handles WebSocket ingress in ws-ingress.ts.
|
|
21
19
|
*/
|
|
22
20
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
23
21
|
import { attachmentPath } from "../kit/attachment-path.js";
|
|
@@ -12,17 +12,18 @@ import { isAbsolute, join } from "node:path";
|
|
|
12
12
|
import { log } from "../../log.js";
|
|
13
13
|
import { readBodyCapped } from "../body.js";
|
|
14
14
|
import { text } from "../respond.js";
|
|
15
|
+
import { secretEquals } from "../secret.js";
|
|
15
16
|
import { createSeenRing } from "../kit/seen.js";
|
|
16
17
|
import { createTaskTracker } from "../kit/tasks.js";
|
|
17
18
|
import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
|
|
18
19
|
import { signatureIsFresh } from "../kit/signature.js";
|
|
19
20
|
import { dispatchStop, isStopText } from "../kit/stop-command.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
21
|
+
import { createTurnRunner } from "../kit/turn-runner.js";
|
|
22
|
+
import { createTurnStore } from "../kit/turn-store.js";
|
|
22
23
|
import { discussionBlock } from "../kit/context-buffer.js";
|
|
23
24
|
import { FEISHU_CLOUD } from "./cloud.js";
|
|
24
25
|
import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
|
|
25
|
-
import { decryptEvent,
|
|
26
|
+
import { decryptEvent, verifySignature } from "./crypto.js";
|
|
26
27
|
import { invokeFeishuTurn } from "./invoke-turn.js";
|
|
27
28
|
import { createFeishuApi } from "./feishu-api.js";
|
|
28
29
|
import { normalizeFeishuMessage } from "./normalize.js";
|
|
@@ -31,6 +32,7 @@ import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE
|
|
|
31
32
|
import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
|
|
32
33
|
import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
|
|
33
34
|
import { connectFeishuWs } from "./ws-ingress.js";
|
|
35
|
+
import { registerFeishuApi } from "./shared-api.js";
|
|
34
36
|
// Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
|
|
35
37
|
export { defaultFeishuRoute, feishuEnvelope };
|
|
36
38
|
/** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
|
|
@@ -79,16 +81,6 @@ export function feishuChannel(opts) {
|
|
|
79
81
|
export function feishuWebSocketChannel(opts) {
|
|
80
82
|
return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
|
|
81
83
|
}
|
|
82
|
-
/** The participant model removed the session modes (docs/design/participant-model.md §12). An upgraded
|
|
83
|
-
* workspace still passing one would otherwise start fine and silently get different placement AND a
|
|
84
|
-
* different memory boundary — the one breaking change most likely to be hit, and invisible. */
|
|
85
|
-
function rejectRemovedSessionOptions(opts, factoryName) {
|
|
86
|
-
const removed = ["directMessageSession", "groupMessageSession"].filter((name) => opts[name] !== undefined);
|
|
87
|
-
if (removed.length > 0) {
|
|
88
|
-
throw new Error(`${factoryName} no longer accepts ${removed.join(" / ")}: a chat is one session and a thread is another, ` +
|
|
89
|
-
"and the summon rule no longer depends on the mode — remove the option (see docs/design/participant-model.md)");
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
84
|
function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
93
85
|
const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
|
|
94
86
|
const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
|
|
@@ -228,10 +220,6 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
228
220
|
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
229
221
|
// Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
|
|
230
222
|
const sideTasks = createTaskTracker(label);
|
|
231
|
-
const toStored = (r) => {
|
|
232
|
-
const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
|
|
233
|
-
return { ...intent, attempts: 0 };
|
|
234
|
-
};
|
|
235
223
|
const targetOf = (r) => ({
|
|
236
224
|
chatId: r.chatId,
|
|
237
225
|
replyTo: r.replyTo,
|
|
@@ -242,18 +230,28 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
242
230
|
replyTo: r.queueReplyTo,
|
|
243
231
|
replyInThread: r.replyInThread,
|
|
244
232
|
});
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
233
|
+
// Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
|
|
234
|
+
// not just an operator log line. Take over its queue preview in place if present (else send fresh) —
|
|
235
|
+
// leaving it pinned at "Queued" while sending a separate failure would double-post.
|
|
236
|
+
const notifyDropped = (r) => {
|
|
237
|
+
const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
|
|
238
|
+
void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
|
|
239
|
+
};
|
|
240
|
+
const runner = createTurnRunner({
|
|
251
241
|
label,
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
242
|
+
store,
|
|
243
|
+
buffer,
|
|
244
|
+
seen,
|
|
245
|
+
toStored: ({ preview: _live, ...intent }) => ({ ...intent, attempts: 0 }),
|
|
246
|
+
fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, preview: undefined }),
|
|
247
|
+
bufferKey: (rec) => rec.bufferKey,
|
|
248
|
+
where: (rec) => `chat=${rec.chatId}`,
|
|
249
|
+
// Queue feedback: mount that turn's preview early with a queue status. It reply-quotes the exact
|
|
250
|
+
// source message (including p2p), then the runner mutates the SAME card/text into Thinking →
|
|
251
|
+
// final answer. Immediate by default; with an explicit delay it mounts only if the turn is still
|
|
252
|
+
// waiting when the timer fires and is cancelled unsent otherwise. Best-effort and post-ACK: a
|
|
253
|
+
// failed mount is a log line, never a failed event delivery; the turn later mounts its normal
|
|
254
|
+
// preview.
|
|
257
255
|
onQueuedBehind: (rec) => {
|
|
258
256
|
let fired = false;
|
|
259
257
|
let settle = () => { };
|
|
@@ -271,8 +269,9 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
271
269
|
const timer = queueNoticeDelayMs > 0 ? setTimeout(mount, queueNoticeDelayMs) : undefined;
|
|
272
270
|
if (timer === undefined)
|
|
273
271
|
mount();
|
|
274
|
-
|
|
275
|
-
|
|
272
|
+
return {
|
|
273
|
+
done,
|
|
274
|
+
// A no-op once mounting started — the send is in flight and `done` settles with it.
|
|
276
275
|
cancel: () => {
|
|
277
276
|
if (!fired) {
|
|
278
277
|
if (timer !== undefined)
|
|
@@ -280,103 +279,47 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
280
279
|
settle();
|
|
281
280
|
}
|
|
282
281
|
},
|
|
283
|
-
|
|
284
|
-
});
|
|
282
|
+
};
|
|
285
283
|
},
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
notice?.cancel();
|
|
292
|
-
await notice?.done;
|
|
293
|
-
notices.delete(rec.id);
|
|
294
|
-
// Count this execution against the durable record (poison-turn ceiling) before running it again.
|
|
295
|
-
const decision = store.startAttempt(rec.id);
|
|
296
|
-
if (decision === "exceeded") {
|
|
297
|
-
notifyDropped(rec);
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
if (decision === "defer") {
|
|
301
|
-
// Couldn't record the attempt (disk failure): skip this cycle; a restart replays it. Do not
|
|
302
|
-
// recall an existing queue preview (the client exposes a confusing tombstone): settle it in
|
|
303
|
-
// place to an honest delayed status. The eventual replay mounts a fresh preview.
|
|
304
|
-
if (rec.preview !== undefined) {
|
|
305
|
-
void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
|
|
306
|
-
}
|
|
307
|
-
return;
|
|
284
|
+
// Do not recall an existing queue preview (the client exposes a confusing tombstone): settle it in
|
|
285
|
+
// place to an honest delayed status. The eventual replay mounts a fresh preview.
|
|
286
|
+
onDeferred: (rec) => {
|
|
287
|
+
if (rec.preview !== undefined) {
|
|
288
|
+
void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
|
|
308
289
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// message arriving while the turn runs remains buffered for the next answered turn.
|
|
313
|
-
// ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
|
|
314
|
-
// this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
|
|
315
|
-
// would instead couple otherwise-independent root sessions and require failure rollback.
|
|
316
|
-
const { text: recent, consumed } = buffer.peek(rec.bufferKey);
|
|
290
|
+
},
|
|
291
|
+
notifyDropped,
|
|
292
|
+
execute: (rec, discussion, onCompleted) => {
|
|
317
293
|
// PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
|
|
294
|
+
// ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
|
|
295
|
+
// the thread's snapshot before either commits it. That fan-out loses nothing; claiming by buffer
|
|
296
|
+
// key would instead couple otherwise-independent root sessions and require failure rollback.
|
|
318
297
|
const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
|
|
319
298
|
const roomBlock = room?.text
|
|
320
299
|
? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
|
|
321
300
|
: "";
|
|
322
|
-
const prompt = `${roomBlock}${discussionBlock(
|
|
301
|
+
const prompt = `${roomBlock}${discussionBlock(discussion.text)}${rec.baseText}`;
|
|
323
302
|
// Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
|
|
324
303
|
// attachments win the slots.
|
|
325
|
-
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
|
|
304
|
+
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...discussion.consumed], {
|
|
326
305
|
images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
|
|
327
306
|
files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
|
|
328
307
|
});
|
|
329
308
|
// Recorded at ingress (see submit) — never re-derived from the session key, which may be a
|
|
330
309
|
// routed OPAQUE id that only looks like a place key.
|
|
331
310
|
const parentSession = rec.parentSession;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.bufferKey, consumed })), api, targetOf(rec), formatError, rec.preview, label);
|
|
341
|
-
log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
342
|
-
}
|
|
343
|
-
catch (error) {
|
|
344
|
-
log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
|
|
345
|
-
}
|
|
346
|
-
finally {
|
|
347
|
-
// Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
|
|
348
|
-
// never reach the completed hook above. Idempotent — a second remove is a no-op. Only an
|
|
349
|
-
// INTERRUPTED run (this finally never runs — a crash or SIGTERM deploy) leaves the record for
|
|
350
|
-
// replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
|
|
351
|
-
store.remove(rec.id);
|
|
352
|
-
}
|
|
311
|
+
return streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
|
|
312
|
+
api,
|
|
313
|
+
chatId: rec.chatId,
|
|
314
|
+
filesDir: join(stateHome, "files"),
|
|
315
|
+
label,
|
|
316
|
+
appId,
|
|
317
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
318
|
+
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, onCompleted), api, targetOf(rec), formatError, rec.preview, label);
|
|
353
319
|
},
|
|
354
320
|
});
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
|
|
358
|
-
const submit = (rec, persist) => {
|
|
359
|
-
if (persist) {
|
|
360
|
-
store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
|
|
361
|
-
seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
|
|
362
|
-
}
|
|
363
|
-
queue.accept(rec);
|
|
364
|
-
};
|
|
365
|
-
// Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
|
|
366
|
-
// not just an operator log line. Take over its queue preview in place if present (else send fresh) —
|
|
367
|
-
// leaving it pinned at "Queued" while sending a separate failure would double-post.
|
|
368
|
-
const notifyDropped = (r) => {
|
|
369
|
-
const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
|
|
370
|
-
void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
|
|
371
|
-
};
|
|
372
|
-
// Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
|
|
373
|
-
// the queue runs them on the next tick. The execution ceiling is enforced per turn at dequeue.
|
|
374
|
-
const recovered = store.recover();
|
|
375
|
-
if (recovered.length > 0)
|
|
376
|
-
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
377
|
-
let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
|
|
378
|
-
for (const { attempts: _a, ...intent } of recovered)
|
|
379
|
-
submit({ ...intent, preview: undefined }, false);
|
|
321
|
+
registerFeishuApi(stateRoot, kind, api);
|
|
322
|
+
let seqCounter = runner.recover().reduce((max, r) => Math.max(max, r.seq), 0);
|
|
380
323
|
// Who the agent has heard in a thread decides whether a bare message addresses it (participant
|
|
381
324
|
// model §3), and it comes from what this channel observed — see thread-participants.ts.
|
|
382
325
|
let warnedUnidentified = false;
|
|
@@ -524,7 +467,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
524
467
|
const baseText = r.text ?? cloudEnvelope(event, kind);
|
|
525
468
|
if (baseText.trim() === "" && images.length === 0 && files.length === 0)
|
|
526
469
|
return;
|
|
527
|
-
submit({
|
|
470
|
+
runner.submit({
|
|
528
471
|
id: m.message_id,
|
|
529
472
|
seq: ++seqCounter, // arrival order; the turn store replays by it
|
|
530
473
|
session,
|
|
@@ -577,7 +520,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
577
520
|
threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
|
|
578
521
|
}
|
|
579
522
|
};
|
|
580
|
-
return { acceptEvent, turnsIdle: () => Promise.all([
|
|
523
|
+
return { acceptEvent, turnsIdle: () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined) };
|
|
581
524
|
};
|
|
582
525
|
}
|
|
583
526
|
function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
@@ -656,7 +599,7 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
656
599
|
(typeof envelope.header?.token === "string"
|
|
657
600
|
? envelope.header.token
|
|
658
601
|
: undefined);
|
|
659
|
-
if (!
|
|
602
|
+
if (!secretEquals(token, verificationToken)) {
|
|
660
603
|
log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
|
|
661
604
|
return text("invalid token\n", 401);
|
|
662
605
|
}
|
|
@@ -676,7 +619,6 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
676
619
|
return { [`POST /${kind}`]: handler };
|
|
677
620
|
}
|
|
678
621
|
export function buildFeishuChannel(profile, opts, factoryName) {
|
|
679
|
-
rejectRemovedSessionOptions(opts, factoryName);
|
|
680
622
|
const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
|
|
681
623
|
return (ctx) => {
|
|
682
624
|
if (!opts.verificationToken) {
|
|
@@ -686,7 +628,6 @@ export function buildFeishuChannel(profile, opts, factoryName) {
|
|
|
686
628
|
};
|
|
687
629
|
}
|
|
688
630
|
export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
|
|
689
|
-
rejectRemovedSessionOptions(opts, factoryName);
|
|
690
631
|
const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
|
|
691
632
|
return {
|
|
692
633
|
name: `${profile.kind} websocket`,
|
|
@@ -35,9 +35,10 @@ export declare function senderId(sender: FeishuSender | undefined): string | und
|
|
|
35
35
|
*
|
|
36
36
|
* Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
|
|
37
37
|
* every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
|
|
38
|
-
* would answer into the same memory. The length bound is the FILENAME the id becomes (
|
|
39
|
-
*
|
|
40
|
-
* a 36-char thread id — encodes to well
|
|
38
|
+
* would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
|
|
39
|
+
* `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
|
|
40
|
+
* three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
|
|
41
|
+
* under 100 bytes against the filesystem's 255.
|
|
41
42
|
*/
|
|
42
43
|
export declare function placeKey(kind: string, message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
|
|
43
44
|
/** The canonical Feishu-branded prompt envelope. */
|
|
@@ -30,9 +30,10 @@ export function senderId(sender) {
|
|
|
30
30
|
*
|
|
31
31
|
* Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
|
|
32
32
|
* every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
|
|
33
|
-
* would answer into the same memory. The length bound is the FILENAME the id becomes (
|
|
34
|
-
*
|
|
35
|
-
* a 36-char thread id — encodes to well
|
|
33
|
+
* would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
|
|
34
|
+
* `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
|
|
35
|
+
* three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
|
|
36
|
+
* under 100 bytes against the filesystem's 255.
|
|
36
37
|
*/
|
|
37
38
|
export function placeKey(kind, message) {
|
|
38
39
|
const chat = `${kind}:${message.chat_id}`;
|
|
@@ -54,7 +55,7 @@ export function cloudEnvelope(event, tag) {
|
|
|
54
55
|
from ? `from ${from}` : undefined,
|
|
55
56
|
// The message's own id is LOAD-BEARING, not decoration: it is the only way this message's id
|
|
56
57
|
// enters the session transcript, and session inheritance locates a thread's branch point by
|
|
57
|
-
// searching the parent transcript for exactly these ids (scope.branchHints —
|
|
58
|
+
// searching the parent transcript for exactly these ids (scope.branchHints — session-inheritance.ts).
|
|
58
59
|
// Remove it and every thread quietly inherits from the room's present instead of the branch
|
|
59
60
|
// point. It also lets the model name what it is answering in a busy chat.
|
|
60
61
|
`msg ${message.message_id}`,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type RegistrationOutcome } from "../registration.ts";
|
|
2
2
|
import { type FeishuCloudKind } from "./cloud.ts";
|
|
3
3
|
/**
|
|
4
4
|
* Register `<baseUrl>/<kind>` as the app's event Request URL (webhook mode). Missing credentials print
|
|
5
|
-
* the manual instruction instead of failing. `opts`
|
|
6
|
-
*
|
|
5
|
+
* the manual instruction instead of failing. `opts` carries the attempt budget — `--tunnel` takes the
|
|
6
|
+
* default, `deploy --run` passes `DEPLOY_REGISTRATION_ATTEMPTS` (a host starts slower than a tunnel) —
|
|
7
|
+
* plus `apiBase`, which is a test's fake platform (production derives it from the kind).
|
|
7
8
|
*
|
|
8
9
|
* Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
|
|
9
10
|
*/
|
|
@@ -12,8 +13,7 @@ interface FeishuManualRegistration {
|
|
|
12
13
|
requestUrl: string;
|
|
13
14
|
}
|
|
14
15
|
export interface RegisterFeishuWebhookOptions {
|
|
15
|
-
|
|
16
|
-
readyIntervalMs?: number;
|
|
16
|
+
attempts?: number;
|
|
17
17
|
retryMs?: number;
|
|
18
18
|
apiBase?: string;
|
|
19
19
|
/** Manual fallback after a definitive config error or exhausted retries. Local dev opens this App. */
|