@fastagent-sh/fastagent 0.29.0 → 0.30.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 +9 -4
- package/dist/atomic-write.d.ts +4 -5
- package/dist/atomic-write.js +4 -5
- package/dist/authoring.d.ts +32 -15
- package/dist/authoring.js +147 -127
- package/dist/channels/agentcore-protocol.d.ts +8 -3
- package/dist/channels/agentcore-protocol.js +3 -1
- package/dist/channels/agentcore-service.d.ts +1 -0
- package/dist/channels/agentcore-service.js +14 -4
- package/dist/channels/agentcore.d.ts +9 -0
- package/dist/channels/agentcore.js +29 -4
- package/dist/channels/context.d.ts +2 -0
- package/dist/channels/context.js +5 -1
- package/dist/channels/feishu/feishu-api.d.ts +2 -1
- package/dist/channels/feishu/feishu.d.ts +0 -3
- package/dist/channels/feishu/feishu.js +10 -10
- package/dist/channels/feishu/register-app.js +3 -5
- package/dist/channels/feishu/scaffold/channel.ts +32 -34
- package/dist/channels/feishu/scaffold/feishu-send.ts +5 -5
- package/dist/channels/feishu/scaffold/feishu-threads.ts +3 -3
- package/dist/channels/feishu/setup-mode.d.ts +2 -12
- package/dist/channels/feishu/setup-mode.js +2 -22
- package/dist/channels/feishu/setup.d.ts +12 -0
- package/dist/channels/feishu/setup.js +197 -0
- package/dist/channels/kit/abortable.d.ts +8 -0
- package/dist/channels/kit/abortable.js +22 -0
- package/dist/channels/lark/lark.d.ts +0 -3
- package/dist/channels/lark/lark.js +0 -5
- package/dist/channels/lark/scaffold/channel.ts +33 -35
- package/dist/channels/lark/scaffold/lark-send.ts +5 -5
- package/dist/channels/lark/scaffold/lark-threads.ts +3 -3
- package/dist/channels/lark/setup.d.ts +9 -0
- package/dist/channels/lark/setup.js +57 -0
- package/dist/channels/setup.d.ts +112 -0
- package/dist/channels/setup.js +31 -0
- package/dist/channels/slack/history.js +1 -1
- package/dist/channels/slack/manifest.d.ts +17 -7
- package/dist/channels/slack/manifest.js +12 -16
- package/dist/channels/slack/scaffold/channel.ts +44 -30
- package/dist/channels/slack/scaffold/slack-send.ts +8 -8
- package/dist/channels/slack/scaffold/slack-threads.ts +4 -4
- package/dist/channels/slack/setup.d.ts +7 -0
- package/dist/channels/slack/setup.js +119 -0
- package/dist/channels/slack/slack-api.d.ts +2 -0
- package/dist/channels/slack/slack-api.js +4 -0
- package/dist/channels/slack/slack.d.ts +21 -2
- package/dist/channels/slack/slack.js +398 -338
- package/dist/channels/slack/socket.d.ts +57 -0
- package/dist/channels/slack/socket.js +305 -0
- package/dist/channels/telegram/polling.d.ts +27 -0
- package/dist/channels/telegram/polling.js +105 -0
- package/dist/channels/telegram/scaffold/channel.ts +52 -32
- package/dist/channels/telegram/scaffold/telegram-send.ts +9 -9
- package/dist/channels/telegram/setup.d.ts +2 -0
- package/dist/channels/telegram/setup.js +42 -0
- package/dist/channels/telegram/telegram-api.d.ts +27 -1
- package/dist/channels/telegram/telegram-api.js +7 -6
- package/dist/channels/telegram/telegram.d.ts +11 -3
- package/dist/channels/telegram/telegram.js +184 -157
- package/dist/channels/webhook-evidence.d.ts +11 -0
- package/dist/channels/webhook-evidence.js +71 -0
- package/dist/channels/webhooks.d.ts +37 -0
- package/dist/channels/webhooks.js +70 -0
- package/dist/child-env.d.ts +16 -0
- package/dist/child-env.js +30 -0
- package/dist/cli/box-login.js +4 -1
- package/dist/cli/channel-app.d.ts +3 -0
- package/dist/cli/channel-app.js +35 -0
- package/dist/cli/channel-setup.d.ts +38 -0
- package/dist/cli/channel-setup.js +195 -0
- package/dist/cli/commands/add.d.ts +6 -14
- package/dist/cli/commands/add.js +45 -303
- package/dist/cli/commands/auth.js +8 -2
- package/dist/cli/commands/build.d.ts +5 -0
- package/dist/cli/commands/build.js +16 -0
- package/dist/cli/commands/channels.d.ts +19 -0
- package/dist/cli/commands/channels.js +230 -0
- package/dist/cli/commands/content.d.ts +7 -3
- package/dist/cli/commands/content.js +16 -10
- package/dist/cli/commands/deploy/agentcore.js +55 -23
- package/dist/cli/commands/deploy/channel-apps.d.ts +17 -0
- package/dist/cli/commands/deploy/channel-apps.js +30 -0
- package/dist/cli/commands/deploy/docker.js +32 -65
- package/dist/cli/commands/deploy/fly.js +28 -12
- package/dist/cli/commands/deploy/railway.js +38 -13
- package/dist/cli/commands/deploy/secrets-step.d.ts +19 -0
- package/dist/cli/commands/deploy/secrets-step.js +66 -0
- package/dist/cli/commands/deploy/shared.d.ts +16 -5
- package/dist/cli/commands/deploy/shared.js +25 -8
- package/dist/cli/commands/deploy.js +53 -60
- package/dist/cli/commands/dev.d.ts +2 -0
- package/dist/cli/commands/dev.js +15 -3
- package/dist/cli/commands/env.js +1 -1
- package/dist/cli/commands/info.d.ts +2 -0
- package/dist/cli/commands/info.js +60 -25
- package/dist/cli/commands/init.js +3 -1
- package/dist/cli/commands/invoke.d.ts +2 -0
- package/dist/cli/commands/invoke.js +20 -7
- package/dist/cli/commands/login.d.ts +0 -7
- package/dist/cli/commands/login.js +22 -10
- package/dist/cli/commands/logout.js +1 -1
- package/dist/cli/commands/mcp.js +14 -6
- package/dist/cli/commands/models.js +3 -2
- package/dist/cli/commands/schedules.d.ts +4 -2
- package/dist/cli/commands/schedules.js +14 -10
- package/dist/cli/commands/secrets.d.ts +14 -0
- package/dist/cli/commands/secrets.js +213 -0
- package/dist/cli/commands/start.d.ts +14 -14
- package/dist/cli/commands/start.js +46 -60
- package/dist/cli/content-view.d.ts +4 -6
- package/dist/cli/content-view.js +5 -11
- package/dist/cli/first-run-channels.d.ts +3 -0
- package/dist/cli/first-run-channels.js +63 -0
- package/dist/cli/instance.d.ts +41 -0
- package/dist/cli/instance.js +155 -0
- package/dist/cli/invoke-stream.d.ts +5 -2
- package/dist/cli/invoke-stream.js +7 -2
- package/dist/cli/program.js +282 -110
- package/dist/cli/serve.d.ts +4 -5
- package/dist/cli/serve.js +4 -7
- package/dist/cli/shared.d.ts +11 -2
- package/dist/cli/shared.js +32 -19
- package/dist/cli/ticket.js +50 -6
- package/dist/content/declare.d.ts +31 -37
- package/dist/content/declare.js +59 -60
- package/dist/content/file.d.ts +23 -12
- package/dist/content/file.js +48 -35
- package/dist/content/git.d.ts +0 -10
- package/dist/content/git.js +5 -39
- package/dist/content/resolve.d.ts +18 -22
- package/dist/content/resolve.js +32 -66
- package/dist/content/source.d.ts +7 -8
- package/dist/content/source.js +10 -28
- package/dist/credentials-page.d.ts +26 -0
- package/dist/credentials-page.js +185 -0
- package/dist/declared-secrets.d.ts +10 -3
- package/dist/declared-secrets.js +8 -4
- package/dist/deploy/agentcore/destroy.d.ts +4 -1
- package/dist/deploy/agentcore/destroy.js +25 -1
- package/dist/deploy/agentcore/forwarder.js +1 -1
- package/dist/deploy/agentcore/plan.d.ts +7 -21
- package/dist/deploy/agentcore/plan.js +47 -49
- package/dist/deploy/agentcore/run.d.ts +18 -10
- package/dist/deploy/agentcore/run.js +93 -70
- package/dist/deploy/agentcore/runtime-secrets.d.ts +7 -0
- package/dist/deploy/agentcore/runtime-secrets.js +108 -0
- package/dist/deploy/agentcore/shell.js +12 -19
- package/dist/deploy/agentcore/sigv4.d.ts +7 -0
- package/dist/deploy/agentcore/sigv4.js +15 -0
- package/dist/deploy/agentcore/slr.d.ts +18 -0
- package/dist/deploy/agentcore/slr.js +27 -0
- package/dist/deploy/agentcore/store.d.ts +7 -0
- package/dist/deploy/agentcore/store.js +115 -0
- package/dist/deploy/api.d.ts +14 -14
- package/dist/deploy/api.js +26 -24
- package/dist/deploy/box-shell.d.ts +3 -1
- package/dist/deploy/build-context.d.ts +4 -4
- package/dist/deploy/build-context.js +11 -15
- package/dist/deploy/channel-ingress.d.ts +35 -9
- package/dist/deploy/channel-ingress.js +119 -22
- package/dist/deploy/container.d.ts +35 -10
- package/dist/deploy/container.js +79 -37
- package/dist/deploy/delivered-text.d.ts +13 -0
- package/dist/deploy/delivered-text.js +86 -0
- package/dist/deploy/docker/plan.d.ts +7 -8
- package/dist/deploy/docker/plan.js +31 -52
- package/dist/deploy/docker/run.d.ts +5 -43
- package/dist/deploy/docker/run.js +9 -84
- package/dist/deploy/file-store.d.ts +2 -0
- package/dist/deploy/file-store.js +17 -0
- package/dist/deploy/fly/plan.d.ts +3 -2
- package/dist/deploy/fly/plan.js +20 -9
- package/dist/deploy/fly/run.d.ts +18 -8
- package/dist/deploy/fly/run.js +24 -42
- package/dist/deploy/fly/store.d.ts +12 -0
- package/dist/deploy/fly/store.js +83 -0
- package/dist/deploy/hosts.d.ts +1 -2
- package/dist/deploy/hosts.js +1 -2
- package/dist/deploy/preflight.d.ts +18 -5
- package/dist/deploy/preflight.js +135 -63
- package/dist/deploy/railway/ignore.d.ts +8 -0
- package/dist/deploy/railway/ignore.js +57 -0
- package/dist/deploy/railway/plan.d.ts +1 -2
- package/dist/deploy/railway/plan.js +8 -6
- package/dist/deploy/railway/run.d.ts +21 -15
- package/dist/deploy/railway/run.js +32 -78
- package/dist/deploy/railway/store.d.ts +29 -0
- package/dist/deploy/railway/store.js +143 -0
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +4 -3
- package/dist/deploy/secret-store.d.ts +63 -0
- package/dist/deploy/secret-store.js +98 -0
- package/dist/deploy/secrets.d.ts +29 -46
- package/dist/deploy/secrets.js +49 -122
- package/dist/deploy/workspace.d.ts +6 -12
- package/dist/deploy/workspace.js +18 -94
- package/dist/dev-supervisor.js +6 -8
- package/dist/environment/declare.js +3 -2
- package/dist/environment/mise.d.ts +3 -1
- package/dist/environment/mise.js +4 -2
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/harnesses/models/catalog.d.ts +1 -0
- package/dist/harnesses/models/catalog.js +1 -2
- package/dist/harnesses/models/environment.d.ts +7 -2
- package/dist/harnesses/models/environment.js +9 -5
- package/dist/harnesses/models/host.d.ts +0 -7
- package/dist/harnesses/models/host.js +10 -1
- package/dist/harnesses/models/login.d.ts +0 -8
- package/dist/harnesses/models/login.js +5 -4
- package/dist/harnesses/models/models.d.ts +7 -2
- package/dist/harnesses/models/models.js +28 -4
- package/dist/harnesses/pi-durable/agent.js +59 -38
- package/dist/harnesses/pi-durable/build.d.ts +12 -0
- package/dist/harnesses/pi-durable/build.js +270 -0
- package/dist/harnesses/pi-durable/built.d.ts +24 -0
- package/dist/harnesses/pi-durable/built.js +136 -0
- package/dist/harnesses/pi-durable/config.d.ts +81 -0
- package/dist/harnesses/pi-durable/config.js +266 -0
- package/dist/harnesses/pi-durable/define.d.ts +63 -13
- package/dist/harnesses/pi-durable/define.js +23 -2
- package/dist/harnesses/pi-durable/definition.d.ts +72 -0
- package/dist/harnesses/pi-durable/definition.js +143 -0
- package/dist/harnesses/pi-durable/events.d.ts +4 -3
- package/dist/harnesses/pi-durable/events.js +179 -136
- package/dist/harnesses/pi-durable/execution-env.d.ts +17 -0
- package/dist/harnesses/pi-durable/execution-env.js +11 -0
- package/dist/harnesses/pi-durable/mcp.js +1 -3
- package/dist/harnesses/pi-durable/open.d.ts +35 -37
- package/dist/harnesses/pi-durable/open.js +91 -153
- package/dist/harnesses/pi-durable/reads.d.ts +0 -1
- package/dist/harnesses/pi-durable/reads.js +1 -3
- package/dist/harnesses/pi-durable/registry.d.ts +4 -6
- package/dist/harnesses/pi-durable/registry.js +29 -26
- package/dist/harnesses/pi-durable/release.d.ts +2 -2
- package/dist/harnesses/pi-durable/release.js +10 -7
- package/dist/harnesses/pi-durable/serve.d.ts +91 -15
- package/dist/harnesses/pi-durable/serve.js +109 -47
- package/dist/harnesses/pi-durable/sessions.js +15 -12
- package/dist/harnesses/pi-durable/text-sources.d.ts +30 -0
- package/dist/harnesses/pi-durable/text-sources.js +55 -0
- package/dist/harnesses/pi-durable/text.d.ts +16 -4
- package/dist/harnesses/pi-durable/text.js +115 -59
- package/dist/harnesses/pi-durable/wake.js +8 -8
- package/dist/http/client.js +9 -3
- package/dist/http/contract.d.ts +3 -3
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +4 -1
- package/dist/instance-name.d.ts +7 -0
- package/dist/instance-name.js +26 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/mcp/client.js +11 -2
- package/dist/mcp/config.d.ts +70 -18
- package/dist/mcp/config.js +136 -53
- package/dist/mcp/host.d.ts +5 -2
- package/dist/mcp/host.js +14 -6
- package/dist/mcp/sign-in.js +2 -2
- package/dist/mcp-api.d.ts +14 -0
- package/dist/paths.d.ts +22 -8
- package/dist/paths.js +44 -11
- package/dist/runtime-websocket.d.ts +14 -0
- package/dist/runtime-websocket.js +39 -0
- package/dist/scaffold/add-channel.d.ts +9 -16
- package/dist/scaffold/add-channel.js +13 -83
- package/dist/scaffold/templates/env.example +4 -5
- package/dist/scaffold/templates/fastagent.config.ts +20 -14
- package/dist/scaffold/templates/gitignore +9 -6
- package/dist/scaffold/templates.d.ts +6 -2
- package/dist/scaffold/templates.js +7 -2
- package/dist/scaffold/vendor-skill.js +4 -2
- package/dist/service.d.ts +44 -6
- package/dist/service.js +110 -10
- package/dist/serving-command.d.ts +10 -0
- package/dist/serving-command.js +1 -0
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +1 -1
- package/dist/strip-edges.d.ts +10 -0
- package/dist/strip-edges.js +22 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +0 -1
- package/dist/tunnel.js +2 -8
- package/dist/value-file.d.ts +11 -6
- package/dist/value-file.js +21 -9
- package/dist/version.d.ts +7 -1
- package/dist/version.js +9 -3
- package/package.json +6 -5
- package/dist/channels/lark/credentials-page.d.ts +0 -25
- package/dist/channels/lark/credentials-page.js +0 -206
- package/dist/channels/lark/onboard.d.ts +0 -52
- package/dist/channels/lark/onboard.js +0 -86
- package/dist/channels/slack/config-api.d.ts +0 -69
- package/dist/channels/slack/config-api.js +0 -162
- package/dist/channels/slack/onboard.d.ts +0 -40
- package/dist/channels/slack/onboard.js +0 -136
- package/dist/channels/slack/onboarding-state.d.ts +0 -31
- package/dist/channels/slack/onboarding-state.js +0 -64
- package/dist/channels/slack/register-webhook.d.ts +0 -12
- package/dist/channels/slack/register-webhook.js +0 -67
- package/dist/channels/slack/setup-server.d.ts +0 -32
- package/dist/channels/slack/setup-server.js +0 -246
- package/dist/cli/add-feishu.d.ts +0 -41
- package/dist/cli/add-feishu.js +0 -391
- package/dist/cli/add-slack.d.ts +0 -18
- package/dist/cli/add-slack.js +0 -216
- package/dist/harnesses/pi-durable/parked.d.ts +0 -18
- package/dist/harnesses/pi-durable/parked.js +0 -27
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack Socket Mode: how an app receives with no public URL. `apps.connections.open` (the app-level token) gives a
|
|
3
|
+
* short-lived `wss://` address; Slack sends `hello`, then every event as an envelope that must be acknowledged by its
|
|
4
|
+
* `envelope_id` within three seconds or Slack sends it again. Connections are refreshed every few hours: Slack says so
|
|
5
|
+
* with a `disconnect` (a `warning` about ten seconds ahead, then `refresh_requested`), and the app opens the next
|
|
6
|
+
* connection BEFORE the old one goes, so no event falls between them.
|
|
7
|
+
*
|
|
8
|
+
* What ends the connection and what does not follows what can be done about it. Before the first `hello`, any failure
|
|
9
|
+
* ends it: a start that cannot reach Slack, or is refused, is the operator's to see. After it, a lost socket, a
|
|
10
|
+
* refused `apps.connections.open` that can pass (rate limit, an outage) or a connection that stops answering pings is
|
|
11
|
+
* retried with a growing wait; a refused token and Socket Mode turned off for the app (`link_disabled`) end it with their
|
|
12
|
+
* reason, because retrying cannot fix them.
|
|
13
|
+
*/
|
|
14
|
+
import type { LongConnection } from "../../channel.ts";
|
|
15
|
+
import type { SlackEventEnvelope } from "./parse.ts";
|
|
16
|
+
/** The socket as this module uses it, so a test can stand in a fake and a runtime without pings still serves. */
|
|
17
|
+
interface SlackSocket {
|
|
18
|
+
send(data: string): void;
|
|
19
|
+
close(): void;
|
|
20
|
+
/** Sends a ping frame, when the runtime can; its pong arrives as `handlers.pong`. */
|
|
21
|
+
ping?(): void;
|
|
22
|
+
}
|
|
23
|
+
export interface SlackSocketHandlers {
|
|
24
|
+
message(data: string): void;
|
|
25
|
+
pong(): void;
|
|
26
|
+
/** The socket ended, or could not be opened; `reason` says how. */
|
|
27
|
+
close(reason: string): void;
|
|
28
|
+
}
|
|
29
|
+
export type OpenSlackSocket = (url: string, handlers: SlackSocketHandlers) => SlackSocket;
|
|
30
|
+
export interface SlackSocketOptions {
|
|
31
|
+
apiBaseUrl: string;
|
|
32
|
+
/** The app-level token (`xapp-…`). */
|
|
33
|
+
appToken: string;
|
|
34
|
+
/** Take an `event_callback` durably; a rejection leaves its envelope unacknowledged, so Slack sends it again. */
|
|
35
|
+
ingest(envelope: SlackEventEnvelope): Promise<void>;
|
|
36
|
+
/** Replaces the WebSocket and the waits, for tests. */
|
|
37
|
+
openSocket?: OpenSlackSocket;
|
|
38
|
+
timing?: {
|
|
39
|
+
retryFirstMs?: number;
|
|
40
|
+
retryCapMs?: number;
|
|
41
|
+
helloMs?: number;
|
|
42
|
+
pingEveryMs?: number;
|
|
43
|
+
pongWithinMs?: number;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** A failure no retry can fix: the connection ends with it. */
|
|
47
|
+
export declare class TerminalSocketError extends Error {
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `apps.connections.open`: the address a connection is dialled at. A refusal that names the token (see
|
|
51
|
+
* {@link TERMINAL_OPEN_ERRORS}) is a {@link TerminalSocketError}; one that can pass (a rate limit, an outage) and an
|
|
52
|
+
* unreachable Slack are plain errors.
|
|
53
|
+
*/
|
|
54
|
+
export declare function openSlackConnection(apiBaseUrl: string, appToken: string, signal?: AbortSignal): Promise<string>;
|
|
55
|
+
/** Hold a Socket Mode connection until `signal` aborts; `ready` settles at the first `hello`. */
|
|
56
|
+
export declare function connectSlackSocket(options: SlackSocketOptions, signal: AbortSignal): LongConnection;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { RuntimeWebSocket, pinger, watchPongs } from "../../runtime-websocket.js";
|
|
2
|
+
import { log } from "../../log.js";
|
|
3
|
+
import { pause, whenAborted } from "../kit/abortable.js";
|
|
4
|
+
const RETRY_FIRST_MS = 1_000;
|
|
5
|
+
const RETRY_CAP_MS = 30_000;
|
|
6
|
+
/** How long a new connection may take to say `hello`. */
|
|
7
|
+
const HELLO_MS = 15_000;
|
|
8
|
+
/** A socket that stays silent is asked, with a ping, whether it is there: how often, and how long to wait for the pong. */
|
|
9
|
+
const PING_EVERY_MS = 30_000;
|
|
10
|
+
const PONG_WITHIN_MS = 10_000;
|
|
11
|
+
/** What `apps.connections.open` says when the token is the problem: no retry will fix these. */
|
|
12
|
+
const TERMINAL_OPEN_ERRORS = new Set([
|
|
13
|
+
"invalid_auth",
|
|
14
|
+
"not_authed",
|
|
15
|
+
"token_revoked",
|
|
16
|
+
"token_expired",
|
|
17
|
+
"account_inactive",
|
|
18
|
+
"not_allowed_token_type",
|
|
19
|
+
"missing_scope",
|
|
20
|
+
"no_permission",
|
|
21
|
+
]);
|
|
22
|
+
/** The runtime's own WebSocket, with the pings it can send; a runtime that offers none serves without them. */
|
|
23
|
+
const openRuntimeSocket = (url, handlers) => {
|
|
24
|
+
const socket = new RuntimeWebSocket(url);
|
|
25
|
+
const stopPongs = watchPongs(socket, () => handlers.pong());
|
|
26
|
+
const ping = pinger(socket);
|
|
27
|
+
let closed = false;
|
|
28
|
+
const end = (reason) => {
|
|
29
|
+
if (closed)
|
|
30
|
+
return;
|
|
31
|
+
closed = true;
|
|
32
|
+
stopPongs();
|
|
33
|
+
handlers.close(reason);
|
|
34
|
+
};
|
|
35
|
+
socket.addEventListener("message", (event) => handlers.message(String(event.data)));
|
|
36
|
+
socket.addEventListener("error", (event) => end(`socket error${event.message ? `: ${event.message}` : ""}`));
|
|
37
|
+
socket.addEventListener("close", (event) => end(`closed (${event.code}${event.reason ? ` ${event.reason}` : ""})`));
|
|
38
|
+
return {
|
|
39
|
+
send: (data) => socket.send(data),
|
|
40
|
+
close: () => socket.close(),
|
|
41
|
+
...(ping ? { ping } : {}),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/** A failure no retry can fix: the connection ends with it. */
|
|
45
|
+
export class TerminalSocketError extends Error {
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `apps.connections.open`: the address a connection is dialled at. A refusal that names the token (see
|
|
49
|
+
* {@link TERMINAL_OPEN_ERRORS}) is a {@link TerminalSocketError}; one that can pass (a rate limit, an outage) and an
|
|
50
|
+
* unreachable Slack are plain errors.
|
|
51
|
+
*/
|
|
52
|
+
export async function openSlackConnection(apiBaseUrl, appToken, signal) {
|
|
53
|
+
let response;
|
|
54
|
+
try {
|
|
55
|
+
response = await fetch(`${apiBaseUrl}/apps.connections.open`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { authorization: `Bearer ${appToken}`, "content-type": "application/x-www-form-urlencoded" },
|
|
58
|
+
signal: AbortSignal.any([AbortSignal.timeout(15_000), ...(signal ? [signal] : [])]),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new Error(`apps.connections.open could not be reached: ${String(error)}`, { cause: error });
|
|
63
|
+
}
|
|
64
|
+
const body = (await response.json().catch(() => ({})));
|
|
65
|
+
if (body.ok === true && typeof body.url === "string")
|
|
66
|
+
return body.url;
|
|
67
|
+
const reason = body.error ?? `HTTP ${response.status}`;
|
|
68
|
+
const message = `apps.connections.open failed: ${reason}`;
|
|
69
|
+
throw TERMINAL_OPEN_ERRORS.has(reason) ? new TerminalSocketError(message) : new Error(message);
|
|
70
|
+
}
|
|
71
|
+
/** Hold a Socket Mode connection until `signal` aborts; `ready` settles at the first `hello`. */
|
|
72
|
+
export function connectSlackSocket(options, signal) {
|
|
73
|
+
const { apiBaseUrl, appToken, ingest } = options;
|
|
74
|
+
const openSocket = options.openSocket ?? openRuntimeSocket;
|
|
75
|
+
const retryFirstMs = options.timing?.retryFirstMs ?? RETRY_FIRST_MS;
|
|
76
|
+
const retryCapMs = options.timing?.retryCapMs ?? RETRY_CAP_MS;
|
|
77
|
+
const helloMs = options.timing?.helloMs ?? HELLO_MS;
|
|
78
|
+
const pingEveryMs = options.timing?.pingEveryMs ?? PING_EVERY_MS;
|
|
79
|
+
const pongWithinMs = options.timing?.pongWithinMs ?? PONG_WITHIN_MS;
|
|
80
|
+
const ready = Promise.withResolvers();
|
|
81
|
+
const closed = Promise.withResolvers();
|
|
82
|
+
let isReady = false;
|
|
83
|
+
const markReady = () => {
|
|
84
|
+
if (isReady)
|
|
85
|
+
return;
|
|
86
|
+
isReady = true;
|
|
87
|
+
ready.resolve();
|
|
88
|
+
};
|
|
89
|
+
const openUrl = () => openSlackConnection(apiBaseUrl, appToken, signal);
|
|
90
|
+
/** Acknowledge each event once it is taken; an event that cannot be taken is left for Slack to send again. */
|
|
91
|
+
const handleEnvelope = async (socket, envelope) => {
|
|
92
|
+
const id = envelope.envelope_id;
|
|
93
|
+
if (typeof id !== "string")
|
|
94
|
+
return;
|
|
95
|
+
if (envelope.type === "events_api") {
|
|
96
|
+
const payload = envelope.payload;
|
|
97
|
+
if (payload?.type === "event_callback") {
|
|
98
|
+
try {
|
|
99
|
+
await ingest(payload);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
log.error(`[slack] an event was not taken, so it is not acknowledged and Slack will send it again: ${String(error)}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// Interactivity and slash commands are not what this channel serves: acknowledged, so Slack does not resend them.
|
|
109
|
+
log.debug(`[slack] ignoring a Socket Mode "${String(envelope.type)}" envelope`);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
socket.send(JSON.stringify({ envelope_id: id }));
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// The connection went while the event was taken (a refresh closes the old one): Slack sends it again, and the
|
|
116
|
+
// delivery ring in the channel recognises it.
|
|
117
|
+
log.debug(`[slack] could not acknowledge an event on a closed connection: ${String(error)}`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/** Dial `url` and resolve at its `hello`; the connection then answers events until it ends. */
|
|
121
|
+
const dial = (url) => new Promise((resolve, reject) => {
|
|
122
|
+
const ended = Promise.withResolvers();
|
|
123
|
+
let outcome;
|
|
124
|
+
let helloed = false;
|
|
125
|
+
let pingTimer;
|
|
126
|
+
let pongTimer;
|
|
127
|
+
const stop = () => {
|
|
128
|
+
clearTimeout(pingTimer);
|
|
129
|
+
clearTimeout(pongTimer);
|
|
130
|
+
};
|
|
131
|
+
const finish = (value) => {
|
|
132
|
+
if (outcome !== undefined)
|
|
133
|
+
return;
|
|
134
|
+
outcome = value;
|
|
135
|
+
stop();
|
|
136
|
+
ended.resolve(value);
|
|
137
|
+
};
|
|
138
|
+
const fail = (error) => {
|
|
139
|
+
stop();
|
|
140
|
+
stopWatching();
|
|
141
|
+
socket.close();
|
|
142
|
+
if (!helloed)
|
|
143
|
+
reject(error);
|
|
144
|
+
else if (error instanceof TerminalSocketError) {
|
|
145
|
+
// Only the connection still in service ends the channel with it: one already let go has nothing to tell.
|
|
146
|
+
if (outcome === undefined) {
|
|
147
|
+
outcome = "lost";
|
|
148
|
+
ended.reject(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
finish("lost");
|
|
153
|
+
};
|
|
154
|
+
const helloTimer = setTimeout(() => fail(new Error(`no hello from Slack within ${helloMs / 1000}s`)), helloMs);
|
|
155
|
+
// Until it says hello, the connection is only a socket being opened: an abort ends it at once, and `run` reads
|
|
156
|
+
// the rejection as the cancellation it is.
|
|
157
|
+
const stopWatching = whenAborted(signal, () => {
|
|
158
|
+
clearTimeout(helloTimer);
|
|
159
|
+
fail(new Error("cancelled"));
|
|
160
|
+
});
|
|
161
|
+
const ping = () => {
|
|
162
|
+
if (socket.ping === undefined)
|
|
163
|
+
return;
|
|
164
|
+
pingTimer = setTimeout(() => {
|
|
165
|
+
socket.ping?.();
|
|
166
|
+
pongTimer = setTimeout(() => {
|
|
167
|
+
log.warn(`[slack] Socket Mode connection stopped answering pings; reconnecting`);
|
|
168
|
+
fail(new Error("no pong"));
|
|
169
|
+
}, pongWithinMs);
|
|
170
|
+
}, pingEveryMs);
|
|
171
|
+
};
|
|
172
|
+
const socket = openSocket(url, {
|
|
173
|
+
message: (data) => {
|
|
174
|
+
let envelope;
|
|
175
|
+
try {
|
|
176
|
+
envelope = JSON.parse(data);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
log.warn("[slack] ignoring a Socket Mode message that is not JSON");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (envelope.type === "hello") {
|
|
183
|
+
clearTimeout(helloTimer);
|
|
184
|
+
stopWatching();
|
|
185
|
+
helloed = true;
|
|
186
|
+
ping();
|
|
187
|
+
const info = envelope.connection_info;
|
|
188
|
+
resolve({
|
|
189
|
+
socket,
|
|
190
|
+
hello: {
|
|
191
|
+
...(typeof info?.app_id === "string" ? { appId: info.app_id } : {}),
|
|
192
|
+
...(typeof envelope.num_connections === "number" ? { connections: envelope.num_connections } : {}),
|
|
193
|
+
},
|
|
194
|
+
ended: ended.promise,
|
|
195
|
+
close: () => {
|
|
196
|
+
finish("lost");
|
|
197
|
+
socket.close();
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else if (envelope.type === "disconnect") {
|
|
202
|
+
const reason = String(envelope.reason);
|
|
203
|
+
if (reason === "link_disabled") {
|
|
204
|
+
fail(new TerminalSocketError("Slack Socket Mode is turned off for this app (link_disabled)"));
|
|
205
|
+
}
|
|
206
|
+
else if (reason === "warning" || reason === "refresh_requested") {
|
|
207
|
+
finish("refresh");
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
log.warn(`[slack] Slack asked this Socket Mode connection to go (${reason})`);
|
|
211
|
+
finish("lost");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
void handleEnvelope(socket, envelope);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
pong: () => {
|
|
219
|
+
clearTimeout(pongTimer);
|
|
220
|
+
clearTimeout(pingTimer);
|
|
221
|
+
ping();
|
|
222
|
+
},
|
|
223
|
+
close: (reason) => {
|
|
224
|
+
clearTimeout(helloTimer);
|
|
225
|
+
stopWatching();
|
|
226
|
+
fail(new Error(`Socket Mode connection ${reason}`));
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
/**
|
|
231
|
+
* Slack answers `apps.connections.open` and says `hello` whether or not Socket Mode is on for the app, and sends
|
|
232
|
+
* events only while it is, so a connection that is "ready" can be deaf with nothing to say so. The first `hello` is
|
|
233
|
+
* where to say which app this is and what it depends on. Slack also splits an app's events among its connections, so
|
|
234
|
+
* another one open at the start (another process, another developer) takes some of them.
|
|
235
|
+
*/
|
|
236
|
+
const announce = ({ appId, connections }) => {
|
|
237
|
+
log.info(`[slack] Socket Mode connected${appId ? ` to app ${appId}` : ""}; events arrive only while Socket Mode is on in ` +
|
|
238
|
+
"the app's settings");
|
|
239
|
+
if (connections !== undefined && connections > 1) {
|
|
240
|
+
log.warn(`[slack] ${connections} connections are open for this app, and Slack splits its events between them: ` +
|
|
241
|
+
"another process uses the same app-level token, so this one will miss some messages");
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const run = async () => {
|
|
245
|
+
let current;
|
|
246
|
+
let waitMs = retryFirstMs;
|
|
247
|
+
try {
|
|
248
|
+
while (!signal.aborted) {
|
|
249
|
+
let next;
|
|
250
|
+
try {
|
|
251
|
+
next = await dial(await openUrl());
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (signal.aborted)
|
|
255
|
+
break;
|
|
256
|
+
if (error instanceof TerminalSocketError || !isReady)
|
|
257
|
+
throw error;
|
|
258
|
+
log.warn(`[slack] Socket Mode connection failed, retrying in ${waitMs / 1000}s: ${String(error)}`);
|
|
259
|
+
await pause(waitMs, signal);
|
|
260
|
+
waitMs = Math.min(waitMs * 2, retryCapMs);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Both receive until the old one is let go, so an event sent in between is taken by one of them.
|
|
264
|
+
current?.close();
|
|
265
|
+
current = next;
|
|
266
|
+
waitMs = retryFirstMs;
|
|
267
|
+
if (!isReady)
|
|
268
|
+
announce(next.hello);
|
|
269
|
+
markReady();
|
|
270
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
271
|
+
const stopWatching = whenAborted(signal, () => resolve("lost"));
|
|
272
|
+
next.ended.then((value) => {
|
|
273
|
+
stopWatching();
|
|
274
|
+
resolve(value);
|
|
275
|
+
}, (error) => {
|
|
276
|
+
stopWatching();
|
|
277
|
+
reject(error);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
if (signal.aborted)
|
|
281
|
+
break;
|
|
282
|
+
if (outcome === "lost") {
|
|
283
|
+
current = undefined;
|
|
284
|
+
await pause(retryFirstMs, signal);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
// However it ends, including with a failure no retry can fix: a channel that has ended takes no more events.
|
|
290
|
+
current?.close();
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
run().then(() => {
|
|
294
|
+
markReady();
|
|
295
|
+
closed.resolve();
|
|
296
|
+
}, (error) => {
|
|
297
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
298
|
+
if (!isReady) {
|
|
299
|
+
isReady = true;
|
|
300
|
+
ready.reject(failure);
|
|
301
|
+
}
|
|
302
|
+
closed.reject(failure);
|
|
303
|
+
});
|
|
304
|
+
return { ready: ready.promise, closed: closed.promise };
|
|
305
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram long polling: how a bot receives with no public URL. The process asks `getUpdates` for what is new, hands
|
|
3
|
+
* each update to the channel, and confirms it by asking again past its id (Telegram holds what is not confirmed).
|
|
4
|
+
*
|
|
5
|
+
* Failures are told apart by what can be done about them. Before the first answer, anything but a conflict ends the
|
|
6
|
+
* connection: a start that cannot reach Telegram or is refused by it is the operator's to see. After it, a lost network
|
|
7
|
+
* or a 5xx is weather and is retried with a growing wait, while a refused token, a webhook someone set under the poll,
|
|
8
|
+
* and a second process polling the same bot end the connection with their reason, because retrying cannot fix them (and
|
|
9
|
+
* the last would only take turns with the other process).
|
|
10
|
+
*/
|
|
11
|
+
import type { LongConnection } from "../../channel.ts";
|
|
12
|
+
import type { TelegramUpdate } from "./parse.ts";
|
|
13
|
+
export interface TelegramPollingOptions {
|
|
14
|
+
apiBaseUrl: string;
|
|
15
|
+
botToken: string;
|
|
16
|
+
/** The channel's `accept`: durable before it returns, a throw means the update was not taken. */
|
|
17
|
+
accept(update: TelegramUpdate): void;
|
|
18
|
+
/** Waits in milliseconds, for tests. */
|
|
19
|
+
timing?: {
|
|
20
|
+
longPollS?: number;
|
|
21
|
+
retryFirstMs?: number;
|
|
22
|
+
retryCapMs?: number;
|
|
23
|
+
conflictWaitMs?: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Poll until `signal` aborts; `ready` settles after the first answer, `closed` when the poll ends or cannot go on. */
|
|
27
|
+
export declare function pollTelegramUpdates(options: TelegramPollingOptions, signal: AbortSignal): LongConnection;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { log } from "../../log.js";
|
|
2
|
+
import { pause } from "../kit/abortable.js";
|
|
3
|
+
import { TelegramApiError, callApi } from "./telegram-api.js";
|
|
4
|
+
/** How long Telegram may hold a poll open with nothing to say. */
|
|
5
|
+
const LONG_POLL_S = 30;
|
|
6
|
+
/** Retry waits for a failure that can pass: doubling from the first, never past the last. */
|
|
7
|
+
const RETRY_FIRST_MS = 1_000;
|
|
8
|
+
const RETRY_CAP_MS = 30_000;
|
|
9
|
+
/** A 409 from another process polling this bot: how often to ask again, and how many answers before giving up. */
|
|
10
|
+
const CONFLICT_WAIT_MS = 5_000;
|
|
11
|
+
const CONFLICT_RETRIES = 3;
|
|
12
|
+
const isApiError = (error) => error instanceof TelegramApiError;
|
|
13
|
+
/** Poll until `signal` aborts; `ready` settles after the first answer, `closed` when the poll ends or cannot go on. */
|
|
14
|
+
export function pollTelegramUpdates(options, signal) {
|
|
15
|
+
const { apiBaseUrl, botToken, accept } = options;
|
|
16
|
+
const longPollS = options.timing?.longPollS ?? LONG_POLL_S;
|
|
17
|
+
const retryFirstMs = options.timing?.retryFirstMs ?? RETRY_FIRST_MS;
|
|
18
|
+
const retryCapMs = options.timing?.retryCapMs ?? RETRY_CAP_MS;
|
|
19
|
+
const conflictWaitMs = options.timing?.conflictWaitMs ?? CONFLICT_WAIT_MS;
|
|
20
|
+
const ready = Promise.withResolvers();
|
|
21
|
+
const closed = Promise.withResolvers();
|
|
22
|
+
let isReady = false;
|
|
23
|
+
const markReady = () => {
|
|
24
|
+
if (isReady)
|
|
25
|
+
return;
|
|
26
|
+
isReady = true;
|
|
27
|
+
ready.resolve();
|
|
28
|
+
};
|
|
29
|
+
/** `getUpdates` does not work while a webhook is set, so a bot that polls is a bot with none. */
|
|
30
|
+
const takeWebhookAway = async () => {
|
|
31
|
+
const info = await callApi(apiBaseUrl, botToken, "getWebhookInfo", {}, { signal });
|
|
32
|
+
if (!info.url)
|
|
33
|
+
return;
|
|
34
|
+
await callApi(apiBaseUrl, botToken, "deleteWebhook", {}, { signal });
|
|
35
|
+
log.info(`[telegram] deleted the bot's webhook (${info.url}) so it can receive by long polling`);
|
|
36
|
+
};
|
|
37
|
+
const run = async () => {
|
|
38
|
+
await takeWebhookAway();
|
|
39
|
+
let offset;
|
|
40
|
+
let waitMs = retryFirstMs;
|
|
41
|
+
let conflicts = 0;
|
|
42
|
+
while (!signal.aborted) {
|
|
43
|
+
let updates;
|
|
44
|
+
try {
|
|
45
|
+
updates = await callApi(apiBaseUrl, botToken, "getUpdates",
|
|
46
|
+
// The first call answers at once: readiness is "Telegram accepts this token and nobody else polls", which a
|
|
47
|
+
// poll held open for a quiet bot could not say for half a minute.
|
|
48
|
+
{ ...(offset === undefined ? {} : { offset }), timeout: isReady ? longPollS : 0 }, { retries: 0, timeoutMs: (longPollS + 15) * 1000, signal });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (signal.aborted)
|
|
52
|
+
break;
|
|
53
|
+
if (isApiError(error) && error.status === 409) {
|
|
54
|
+
if (/webhook/i.test(error.description)) {
|
|
55
|
+
throw new Error(`a webhook was set for this bot while it polls, so another instance receives by webhook (${error.description})`);
|
|
56
|
+
}
|
|
57
|
+
if (++conflicts > CONFLICT_RETRIES) {
|
|
58
|
+
throw new Error(`another process polls this bot (${error.description})`);
|
|
59
|
+
}
|
|
60
|
+
log.warn(`[telegram] another process is polling this bot; asking again in ${conflictWaitMs / 1000}s (${conflicts}/${CONFLICT_RETRIES})`);
|
|
61
|
+
await pause(conflictWaitMs, signal);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!isReady || (isApiError(error) && (error.status === 401 || error.status === 403 || error.status === 404))) {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
log.warn(`[telegram] getUpdates failed, retrying in ${waitMs / 1000}s: ${String(error)}`);
|
|
68
|
+
await pause(waitMs, signal);
|
|
69
|
+
waitMs = Math.min(waitMs * 2, retryCapMs);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
conflicts = 0;
|
|
73
|
+
waitMs = retryFirstMs;
|
|
74
|
+
markReady();
|
|
75
|
+
for (const update of updates) {
|
|
76
|
+
try {
|
|
77
|
+
accept(update);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// Left unconfirmed it would come back for ever, and every update behind it with it: said, and passed.
|
|
81
|
+
log.error(`[telegram] update ${update.update_id} was not taken, and is skipped: ${String(error)}`);
|
|
82
|
+
}
|
|
83
|
+
offset = update.update_id + 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
run().then(() => {
|
|
88
|
+
markReady(); // an abort before the first answer: ready settles, and resolving it means cancelled
|
|
89
|
+
closed.resolve();
|
|
90
|
+
}, (error) => {
|
|
91
|
+
if (signal.aborted) {
|
|
92
|
+
// A call cut short by the abort is the cancellation, not a failure of the connection.
|
|
93
|
+
markReady();
|
|
94
|
+
closed.resolve();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
98
|
+
if (!isReady) {
|
|
99
|
+
isReady = true;
|
|
100
|
+
ready.reject(failure);
|
|
101
|
+
}
|
|
102
|
+
closed.reject(failure);
|
|
103
|
+
});
|
|
104
|
+
return { ready: ready.promise, closed: closed.promise };
|
|
105
|
+
}
|
|
@@ -1,35 +1,55 @@
|
|
|
1
|
-
import { needs } from "@fastagent-sh/fastagent";
|
|
2
|
-
import {
|
|
1
|
+
import { byIngress, needs } from "@fastagent-sh/fastagent";
|
|
2
|
+
import {
|
|
3
|
+
type TelegramChannelOptions,
|
|
4
|
+
telegramChannel,
|
|
5
|
+
telegramPollingChannel,
|
|
6
|
+
} from "@fastagent-sh/fastagent/telegram";
|
|
3
7
|
|
|
4
8
|
// A channel = a third-party ADAPTER (telegramChannel: verify + run + reply) configured with YOUR policy.
|
|
5
|
-
// fastagent registers this file from src/channels
|
|
6
|
-
//
|
|
7
|
-
// 1. @BotFather → /newbot → put the bot token in TELEGRAM_BOT_TOKEN
|
|
8
|
-
// 2. pick a random TELEGRAM_SECRET_TOKEN (verifies that inbound updates really come from Telegram)
|
|
9
|
-
// 3. register the webhook once, pointing Telegram at POST /telegram with that secret:
|
|
10
|
-
// curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
|
|
11
|
-
// -d url=https://your.host/telegram -d secret_token=$TELEGRAM_SECRET_TOKEN
|
|
9
|
+
// fastagent registers this file from src/channels/ and hands the adapter the agent, its sessions and a directory of
|
|
10
|
+
// its own; this file holds only policy.
|
|
12
11
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
12
|
+
// How it receives is the instance's choice, `channels.telegram.ingress` in its fastagent.json (webhook | connection).
|
|
13
|
+
// Unset, `fastagent dev` polls Telegram (no public URL, no tunnel), and everything else — `fastagent start`, every
|
|
14
|
+
// deployment — receives by webhook at POST /telegram, the way a host that wakes on a request is reached. A bot is
|
|
15
|
+
// polled by ONE process or reached by one webhook: polling takes the bot's webhook away first.
|
|
16
|
+
//
|
|
17
|
+
// Setup: @BotFather → /newbot → put the bot token in TELEGRAM_BOT_TOKEN. For webhook, also pick a random
|
|
18
|
+
// TELEGRAM_SECRET_TOKEN (verifies that inbound updates really come from Telegram); the instance sets the webhook
|
|
19
|
+
// itself when it serves at a public URL (`http.publicUrl`), or by hand:
|
|
20
|
+
// curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
|
|
21
|
+
// -d url=https://your.host/telegram -d secret_token=$TELEGRAM_SECRET_TOKEN
|
|
22
|
+
|
|
23
|
+
// Your policy, the same whichever way the channel receives.
|
|
24
|
+
const policy: Pick<TelegramChannelOptions, "onError" | "route" | "botUsername"> = {
|
|
25
|
+
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
26
|
+
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
27
|
+
// full details always go to the server log regardless.
|
|
28
|
+
onError: (failed) => `⚠️ ${failed.details}`,
|
|
29
|
+
// Your bot's @username. Optional — resolved via getMe when omitted — but set it if you pass a `route`
|
|
30
|
+
// below: the channel needs its own copy to recognise a `/stop` addressed to it.
|
|
31
|
+
// botUsername: "your_bot",
|
|
32
|
+
// The channel owns transport + format (HTML) + attachments (photo→vision, file→disk) + streaming.
|
|
33
|
+
// `route` (POLICY) is OPTIONAL — omitted, it uses defaultTelegramRoute: private chats always answer,
|
|
34
|
+
// groups only on a reply to THIS bot, an @mention of it, or a `/stop` addressed to it. Override to
|
|
35
|
+
// customise, reusing the export — but pass your bot's identity BOTH here and as `botUsername` above
|
|
36
|
+
// (the omitted default gets it from the channel; a bare `defaultTelegramRoute(u)` answers only
|
|
37
|
+
// private chats):
|
|
38
|
+
// route: (u) => defaultTelegramRoute(u, { botUsername: "my_bot" }) && { conversation: `user:${u.message?.from?.id}` },
|
|
39
|
+
// route: (u) => defaultTelegramRoute(u, { botUsername: "my_bot" }) && { text: `${telegramEnvelope(u.message!)}\n[extra]` },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Each way's `needs` declares the env vars it reads: an instance is asked only for its way's, and refused to serve
|
|
43
|
+
// while a required one is unset.
|
|
44
|
+
export default byIngress({
|
|
45
|
+
webhook: needs(["TELEGRAM_BOT_TOKEN", "TELEGRAM_SECRET_TOKEN"], (secrets) =>
|
|
46
|
+
telegramChannel({
|
|
47
|
+
botToken: secrets.TELEGRAM_BOT_TOKEN, // used to send the agent's reply back to the chat
|
|
48
|
+
secretToken: secrets.TELEGRAM_SECRET_TOKEN, // verifies inbound updates really come from Telegram
|
|
49
|
+
...policy,
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
connection: needs(["TELEGRAM_BOT_TOKEN"], (secrets) =>
|
|
53
|
+
telegramPollingChannel({ botToken: secrets.TELEGRAM_BOT_TOKEN, ...policy }),
|
|
54
|
+
),
|
|
55
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { bool, num, obj, opt, str, union } from "@earendil-works/pi-schema";
|
|
3
3
|
import { defineTool } from "@earendil-works/pi-durable";
|
|
4
4
|
import { needs } from "@fastagent-sh/fastagent";
|
|
5
5
|
import { telegramTransport } from "@fastagent-sh/fastagent/telegram";
|
|
@@ -26,14 +26,14 @@ export default needs(["TELEGRAM_BOT_TOKEN"], ({ TELEGRAM_BOT_TOKEN }) =>
|
|
|
26
26
|
"from the [telegram: chat …] context line in a chat turn; a scheduled or woken message has no context " +
|
|
27
27
|
"line, so name the destination in your instruction. What you send is shown to you in that chat's " +
|
|
28
28
|
"next turn.",
|
|
29
|
-
parameters:
|
|
30
|
-
chatId:
|
|
31
|
-
text:
|
|
32
|
-
path:
|
|
33
|
-
caption:
|
|
34
|
-
asPhoto:
|
|
35
|
-
messageThreadId:
|
|
36
|
-
|
|
29
|
+
parameters: obj({
|
|
30
|
+
chatId: union([str(), num()], { description: "target chat id" }),
|
|
31
|
+
text: opt(str({ description: "message text to send" })),
|
|
32
|
+
path: opt(str({ description: "absolute path of the local file to send" })),
|
|
33
|
+
caption: opt(str({ description: "file caption (file mode only)" })),
|
|
34
|
+
asPhoto: opt(bool({ description: "send the file as a photo (inline) instead of a document" })),
|
|
35
|
+
messageThreadId: opt(
|
|
36
|
+
num({ description: "thread to reply into (from the context line), if any" }),
|
|
37
37
|
),
|
|
38
38
|
}),
|
|
39
39
|
execute: async ({ chatId, text, path, caption, asPhoto, messageThreadId }) => {
|