@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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** First-party Slack HTTP Events API channel: signed ingress, durable turns/context, files, and edited previews. */
|
|
2
|
-
import { createHmac
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
4
|
import { log } from "../../log.js";
|
|
5
5
|
import { readBodyCapped } from "../body.js";
|
|
6
6
|
import { text } from "../respond.js";
|
|
7
|
+
import { secretEquals } from "../secret.js";
|
|
7
8
|
import { createSeenRing } from "../kit/seen.js";
|
|
8
9
|
import { signatureIsFresh } from "../kit/signature.js";
|
|
9
10
|
import { createThreadParticipants } from "../kit/thread-participants.js";
|
|
@@ -11,15 +12,15 @@ import { createTaskTracker } from "../kit/tasks.js";
|
|
|
11
12
|
import { ensureStateHome } from "../kit/state.js";
|
|
12
13
|
import { dispatchStop, isStopText } from "../kit/stop-command.js";
|
|
13
14
|
import { codePointPrefix } from "../kit/text.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
15
|
+
import { createTurnRunner } from "../kit/turn-runner.js";
|
|
16
|
+
import { createTurnStore } from "../kit/turn-store.js";
|
|
16
17
|
import { discussionBlock } from "../kit/context-buffer.js";
|
|
17
|
-
import { createSlackBotTokenProvider } from "./bot-auth.js";
|
|
18
18
|
import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
|
|
19
19
|
import { invokeSlackTurn } from "./invoke-turn.js";
|
|
20
20
|
import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, hasSlackUserMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
|
|
21
21
|
import { defaultErrorMessage, settleSlackPreview, streamSlackReply, } from "./preview.js";
|
|
22
22
|
import { resolveReactionEmojis, startSlackReaction } from "./reaction.js";
|
|
23
|
+
import { registerSlackApi } from "./shared-api.js";
|
|
23
24
|
import { createSlackApi } from "./slack-api.js";
|
|
24
25
|
import { createWelcomedUsers } from "./welcomed.js";
|
|
25
26
|
export { defaultSlackRoute, slackEnvelope };
|
|
@@ -53,9 +54,7 @@ export function verifySlackSignature(signingSecret, timestamp, signature, rawBod
|
|
|
53
54
|
if (!signatureIsFresh(timestamp, MAX_SIGNATURE_AGE_S, nowMs))
|
|
54
55
|
return false;
|
|
55
56
|
const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`;
|
|
56
|
-
|
|
57
|
-
const expectedBytes = Buffer.from(expected);
|
|
58
|
-
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
57
|
+
return secretEquals(signature, expected);
|
|
59
58
|
}
|
|
60
59
|
/** The (channel, ts) of the user's triggering message, encoded in the logical turn id `team:channel:ts`
|
|
61
60
|
* (Slack ids never contain a colon, so a 3-part split is exact). Used to place the reaction ack. */
|
|
@@ -64,19 +63,7 @@ function messageRefOf(turnId) {
|
|
|
64
63
|
return parts.length === 3 && parts[1] && parts[2] ? { channelId: parts[1], ts: parts[2] } : undefined;
|
|
65
64
|
}
|
|
66
65
|
export function slackChannel(options) {
|
|
67
|
-
const { botToken, signingSecret,
|
|
68
|
-
// The participant model derives placement instead of selecting it: Slack has no quote primitive, so
|
|
69
|
-
// answering in place means answering in a thread on the ask, whichever renderer draws it. An
|
|
70
|
-
// upgraded workspace still passing one of the removed modes would otherwise start fine and silently
|
|
71
|
-
// get a different placement AND a different memory boundary.
|
|
72
|
-
const removedModes = ["directMessageSession", "groupMessageSession"].filter((name) => options[name] !== undefined);
|
|
73
|
-
if (removedModes.length > 0) {
|
|
74
|
-
throw new Error(`slackChannel no longer accepts ${removedModes.join(" / ")}: an answer goes in a thread on the ask, and ` +
|
|
75
|
-
"that thread is the session — see docs/design/participant-model.md");
|
|
76
|
-
}
|
|
77
|
-
if (!["context", "mentions"].includes(groupBehavior)) {
|
|
78
|
-
throw new Error('slackChannel groupBehavior must be "context" or "mentions"');
|
|
79
|
-
}
|
|
66
|
+
const { botToken, signingSecret, rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
|
|
80
67
|
if (!["native", "classic"].includes(rendering)) {
|
|
81
68
|
throw new Error('slackChannel rendering must be "native" or "classic"');
|
|
82
69
|
}
|
|
@@ -95,16 +82,8 @@ export function slackChannel(options) {
|
|
|
95
82
|
const formatError = onError ?? defaultErrorMessage;
|
|
96
83
|
const stateHome = join(stateRoot, "channels", "slack");
|
|
97
84
|
ensureStateHome(stateHome);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
botToken,
|
|
101
|
-
botRefreshToken,
|
|
102
|
-
clientId,
|
|
103
|
-
clientSecret,
|
|
104
|
-
botTokenExpiresAt,
|
|
105
|
-
apiBaseUrl,
|
|
106
|
-
});
|
|
107
|
-
const api = createSlackApi({ botToken: currentBotToken, baseUrl: apiBaseUrl });
|
|
85
|
+
const api = createSlackApi({ botToken, baseUrl: apiBaseUrl });
|
|
86
|
+
registerSlackApi(stateRoot, api); // the send tool delivers through this one (shared-api.ts)
|
|
108
87
|
let authenticatedTeamId;
|
|
109
88
|
let botUserId;
|
|
110
89
|
let authenticationState = "pending";
|
|
@@ -154,64 +133,66 @@ export function slackChannel(options) {
|
|
|
154
133
|
order: (a, b) => a.seq - b.seq,
|
|
155
134
|
});
|
|
156
135
|
const decide = route ?? defaultSlackRoute;
|
|
157
|
-
const toStored = (turn) => {
|
|
158
|
-
const { previewTs: _preview, nativeQueueStatus: _status, ...intent } = turn;
|
|
159
|
-
return { ...intent, attempts: 0 };
|
|
160
|
-
};
|
|
161
136
|
const targetOf = (turn) => ({
|
|
162
137
|
channelId: turn.channelId,
|
|
163
138
|
threadTs: turn.threadTs,
|
|
164
139
|
recipientUserId: turn.requesterUserId,
|
|
165
140
|
recipientTeamId: turn.teamId,
|
|
166
141
|
});
|
|
167
|
-
const
|
|
168
|
-
|
|
142
|
+
const notifyDropped = (turn) => {
|
|
143
|
+
const target = targetOf(turn);
|
|
144
|
+
if (turn.nativeQueueStatus)
|
|
145
|
+
void api.setThreadStatus(target, "").catch(() => { });
|
|
146
|
+
void settleSlackPreview(api, target, turn.previewTs, "⚠️ I couldn’t complete an earlier request — please ask again.").catch((error) => log.warn(`${label} could not notify a dropped turn: ${String(error)}`));
|
|
147
|
+
};
|
|
148
|
+
const runner = createTurnRunner({
|
|
169
149
|
label,
|
|
150
|
+
store,
|
|
151
|
+
buffer,
|
|
152
|
+
seen,
|
|
153
|
+
toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
|
|
154
|
+
fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
|
|
155
|
+
bufferKey: (turn) => turn.bufferKey,
|
|
156
|
+
where: (turn) => `channel=${turn.channelId}`,
|
|
170
157
|
onQueuedBehind(turn) {
|
|
171
158
|
const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
|
|
172
159
|
if (nativeDmStatus)
|
|
173
160
|
turn.nativeQueueStatus = true;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
turn.
|
|
179
|
-
|
|
180
|
-
|
|
161
|
+
return {
|
|
162
|
+
done: authentication
|
|
163
|
+
.then(() => nativeDmStatus
|
|
164
|
+
? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
|
|
165
|
+
: api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
|
|
166
|
+
turn.previewTs = ts;
|
|
167
|
+
}))
|
|
168
|
+
.catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
|
|
169
|
+
};
|
|
181
170
|
},
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
171
|
+
// Leave the intent untouched when Slack auth failed. A fixed token + restart replays it;
|
|
172
|
+
// running now would execute an Agent turn whose only customer-facing transport is known to be
|
|
173
|
+
// unavailable.
|
|
174
|
+
beforeRun: async (turn) => {
|
|
185
175
|
try {
|
|
186
176
|
await authentication;
|
|
177
|
+
return true;
|
|
187
178
|
}
|
|
188
179
|
catch (error) {
|
|
189
|
-
// Leave the intent untouched. A fixed token + restart replays it; running now would execute an
|
|
190
|
-
// Agent turn whose only customer-facing transport is known to be unavailable.
|
|
191
180
|
log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
|
|
192
|
-
return;
|
|
181
|
+
return false;
|
|
193
182
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
183
|
+
},
|
|
184
|
+
onDeferred: (turn) => {
|
|
185
|
+
if (turn.nativeQueueStatus) {
|
|
186
|
+
void api
|
|
187
|
+
.setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
|
|
188
|
+
.catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
|
|
198
189
|
}
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
void api
|
|
202
|
-
.setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
|
|
203
|
-
.catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
|
|
204
|
-
}
|
|
205
|
-
else if (turn.previewTs) {
|
|
206
|
-
void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
|
|
207
|
-
}
|
|
208
|
-
return;
|
|
190
|
+
else if (turn.previewTs) {
|
|
191
|
+
void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
|
|
209
192
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const prompt = `${discussionBlock(recent)}${turn.baseText}`;
|
|
214
|
-
const buffered = collectSlackBufferedFiles(consumed, new Set(turn.fileIds));
|
|
193
|
+
},
|
|
194
|
+
notifyDropped,
|
|
195
|
+
execute: async (turn, discussion, onCompleted) => {
|
|
215
196
|
const messageRef = messageRefOf(turn.id);
|
|
216
197
|
const reaction = reactionEmojis && messageRef
|
|
217
198
|
? await startSlackReaction({
|
|
@@ -223,44 +204,25 @@ export function slackChannel(options) {
|
|
|
223
204
|
})
|
|
224
205
|
: undefined;
|
|
225
206
|
try {
|
|
226
|
-
await streamSlackReply(invokeSlackTurn(agent, turn.session,
|
|
207
|
+
await streamSlackReply(invokeSlackTurn(agent, turn.session, `${discussionBlock(discussion.text)}${turn.baseText}`, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, {
|
|
208
|
+
primaryFileIds: turn.fileIds,
|
|
209
|
+
buffered: collectSlackBufferedFiles(discussion.consumed, new Set(turn.fileIds)),
|
|
210
|
+
}, onCompleted), api, targetOf(turn), formatError, {
|
|
227
211
|
rendering,
|
|
228
212
|
initialPreviewTs: turn.previewTs,
|
|
229
213
|
threadTitle: turn.threadTitle,
|
|
230
214
|
disclaimer: aiDisclaimer,
|
|
231
215
|
label,
|
|
232
216
|
});
|
|
233
|
-
log.info(`${label} turn done: turn=${turn.id} session=${turn.session} (${Date.now() - startedAt}ms)`);
|
|
234
|
-
await reaction?.complete();
|
|
235
217
|
}
|
|
236
218
|
catch (error) {
|
|
237
|
-
log.error(`${label} turn failed: turn=${turn.id} session=${turn.session}: ${String(error)}`);
|
|
238
219
|
await reaction?.remove();
|
|
220
|
+
throw error;
|
|
239
221
|
}
|
|
240
|
-
|
|
241
|
-
store.remove(turn.id);
|
|
242
|
-
}
|
|
222
|
+
await reaction?.complete();
|
|
243
223
|
},
|
|
244
224
|
});
|
|
245
|
-
|
|
246
|
-
const target = targetOf(turn);
|
|
247
|
-
if (turn.nativeQueueStatus)
|
|
248
|
-
void api.setThreadStatus(target, "").catch(() => { });
|
|
249
|
-
void settleSlackPreview(api, target, turn.previewTs, "⚠️ I couldn’t complete an earlier request — please ask again.").catch((error) => log.warn(`${label} could not notify a dropped turn: ${String(error)}`));
|
|
250
|
-
};
|
|
251
|
-
const submit = (turn, persist) => {
|
|
252
|
-
if (persist) {
|
|
253
|
-
store.add(toStored(turn));
|
|
254
|
-
seen.add(turn.id);
|
|
255
|
-
}
|
|
256
|
-
queue.accept(turn);
|
|
257
|
-
};
|
|
258
|
-
const recovered = store.recover();
|
|
259
|
-
if (recovered.length)
|
|
260
|
-
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
261
|
-
let seq = recovered.reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
|
|
262
|
-
for (const { attempts: _attempts, ...intent } of recovered)
|
|
263
|
-
submit({ ...intent }, false);
|
|
225
|
+
let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
|
|
264
226
|
// Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the
|
|
265
227
|
// delivery dedup ring alone is enough — there is no await for a duplicate delivery to race
|
|
266
228
|
// through. The minutes-long Agent turn remains fire-and-forget.
|
|
@@ -287,7 +249,7 @@ export function slackChannel(options) {
|
|
|
287
249
|
// Listening is not speaking: every message the channel can see refines who takes part in its
|
|
288
250
|
// thread, whether or not it is answered. Humans only — a bot's own posts are recorded where they
|
|
289
251
|
// are known — this channel answering.
|
|
290
|
-
// Structural facts only, never `
|
|
252
|
+
// Structural facts only, never `route` — see thread-participants.ts. Slack
|
|
291
253
|
// adds no delta of its own here; its summon rule is the only consumer.
|
|
292
254
|
if (group && event.thread_ts !== undefined) {
|
|
293
255
|
threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
|
|
@@ -309,7 +271,6 @@ export function slackChannel(options) {
|
|
|
309
271
|
// structural: it reads who THIS message addresses, which is the one thing an observation-only
|
|
310
272
|
// store cannot know about someone it has never heard.
|
|
311
273
|
if (!routed &&
|
|
312
|
-
groupBehavior === "context" &&
|
|
313
274
|
route === undefined &&
|
|
314
275
|
group &&
|
|
315
276
|
event.thread_ts !== undefined &&
|
|
@@ -323,7 +284,7 @@ export function slackChannel(options) {
|
|
|
323
284
|
if (!routed) {
|
|
324
285
|
if (route === undefined && group && botUserId === undefined && mightBeTheBot)
|
|
325
286
|
return;
|
|
326
|
-
if (
|
|
287
|
+
if (route === undefined && group) {
|
|
327
288
|
const body = slackBufferText(slackMessageText(event));
|
|
328
289
|
if (body) {
|
|
329
290
|
const fileIds = slackFileIds(event);
|
|
@@ -367,7 +328,7 @@ export function slackChannel(options) {
|
|
|
367
328
|
const threadTitle = direct && event.thread_ts === undefined
|
|
368
329
|
? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
|
|
369
330
|
: undefined;
|
|
370
|
-
submit({
|
|
331
|
+
runner.submit({
|
|
371
332
|
id: logicalId,
|
|
372
333
|
seq: ++seq,
|
|
373
334
|
session: routed.session ?? defaultSession,
|
|
@@ -486,7 +447,7 @@ export function slackChannel(options) {
|
|
|
486
447
|
acceptEvent(envelope);
|
|
487
448
|
return new Response(null, { status: 200 });
|
|
488
449
|
};
|
|
489
|
-
handler.turnsIdle = () => Promise.all([
|
|
450
|
+
handler.turnsIdle = () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined);
|
|
490
451
|
return { "POST /slack": handler };
|
|
491
452
|
};
|
|
492
453
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Shared SSE response lifecycle for invoke and session observation. Fetch-only, with no Node dependencies. */
|
|
2
|
+
/** The remote client's idle watchdog allows three missed heartbeats. */
|
|
3
|
+
export declare const SSE_HEARTBEAT_MS = 30000;
|
|
4
|
+
export declare function sseResponse<T>(events: AsyncIterable<T>, project?: (event: T) => unknown): Response;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Shared SSE response lifecycle for invoke and session observation. Fetch-only, with no Node dependencies. */
|
|
2
|
+
/** The remote client's idle watchdog allows three missed heartbeats. */
|
|
3
|
+
export const SSE_HEARTBEAT_MS = 30_000;
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const heartbeat = encoder.encode(": ping\n\n");
|
|
6
|
+
export function sseResponse(events, project = (event) => event) {
|
|
7
|
+
const iterator = events[Symbol.asyncIterator]();
|
|
8
|
+
// Subscribe before headers; synchronous startup errors belong to the HTTP error boundary.
|
|
9
|
+
let pending = Promise.resolve(iterator.next());
|
|
10
|
+
// pull() surfaces asynchronous failures. Observe rejection even if the caller cancels before that pull.
|
|
11
|
+
pending.catch(() => { });
|
|
12
|
+
let closed = false;
|
|
13
|
+
let timer;
|
|
14
|
+
const stop = () => {
|
|
15
|
+
closed = true;
|
|
16
|
+
clearInterval(timer);
|
|
17
|
+
};
|
|
18
|
+
const cancel = async () => {
|
|
19
|
+
if (closed)
|
|
20
|
+
return;
|
|
21
|
+
stop();
|
|
22
|
+
await iterator.return?.();
|
|
23
|
+
};
|
|
24
|
+
const stream = new ReadableStream({
|
|
25
|
+
start(controller) {
|
|
26
|
+
timer = setInterval(() => controller.enqueue(heartbeat), SSE_HEARTBEAT_MS);
|
|
27
|
+
},
|
|
28
|
+
async pull(controller) {
|
|
29
|
+
try {
|
|
30
|
+
const next = await (pending ?? iterator.next());
|
|
31
|
+
pending = undefined;
|
|
32
|
+
// Cancellation can settle a pending next(); its value no longer has a reader.
|
|
33
|
+
if (closed)
|
|
34
|
+
return;
|
|
35
|
+
if (next.done) {
|
|
36
|
+
stop();
|
|
37
|
+
controller.close();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(project(next.value))}\n\n`));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (closed)
|
|
45
|
+
return;
|
|
46
|
+
// Errored streams never call cancel(), so iterator and timer cleanup belongs here too.
|
|
47
|
+
try {
|
|
48
|
+
await cancel();
|
|
49
|
+
}
|
|
50
|
+
catch (cleanupError) {
|
|
51
|
+
controller.error(new AggregateError([error, cleanupError], "SSE source and cleanup failed", { cause: error }));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
controller.error(error);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
cancel,
|
|
58
|
+
});
|
|
59
|
+
return new Response(stream, {
|
|
60
|
+
headers: {
|
|
61
|
+
"content-type": "text/event-stream",
|
|
62
|
+
"cache-control": "no-cache",
|
|
63
|
+
connection: "keep-alive",
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type RegistrationOutcome } from "../registration.ts";
|
|
2
2
|
/**
|
|
3
|
-
* Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
|
|
8
|
-
* manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
|
|
3
|
+
* Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Missing tokens print the
|
|
4
|
+
* manual instruction instead of failing. `opts` carries the attempt budget: `--tunnel` takes the
|
|
5
|
+
* default, `deploy --run` passes `DEPLOY_REGISTRATION_ATTEMPTS` (a host starts slower than a tunnel),
|
|
6
|
+
* and tests shrink it.
|
|
9
7
|
*
|
|
10
8
|
* Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
|
|
11
9
|
*/
|
|
12
10
|
export declare function registerTelegramWebhook(baseUrl: string, opts?: {
|
|
13
|
-
|
|
14
|
-
readyIntervalMs?: number;
|
|
11
|
+
attempts?: number;
|
|
15
12
|
retryMs?: number;
|
|
16
13
|
}): Promise<RegistrationOutcome>;
|
|
@@ -2,18 +2,36 @@
|
|
|
2
2
|
* Telegram webhook registration — the TELEGRAM-domain step both `--tunnel` (dev, tunnel.ts) and
|
|
3
3
|
* `deploy … --run` (the host runners' post-deploy step) invoke. What "registering telegram" means lives
|
|
4
4
|
* here, beside the channel it serves; it reads the same .env tokens the channel uses.
|
|
5
|
+
*
|
|
6
|
+
* READINESS IS setWebhook'S OWN VERDICT, not a local probe. Telegram VERIFIES the URL during the call,
|
|
7
|
+
* from Telegram's network — which is the only network that has to reach it. This used to poll
|
|
8
|
+
* `<baseUrl>/health` from here first, and that probe answered a different question badly: a freshly
|
|
9
|
+
* minted hostname (a quick tunnel, a fresh deploy) is routinely unreachable from the machine running
|
|
10
|
+
* the CLI for a minute or more — behind a proxy, or while its own resolver still says ENOTFOUND — long
|
|
11
|
+
* after Telegram can reach it (#421). So the retry loop below IS the wait: Telegram's reachability
|
|
12
|
+
* verdicts ("Failed to resolve host", "Wrong response from the webhook") are retried, and only a
|
|
13
|
+
* configuration error the operator must fix is reported once.
|
|
5
14
|
*/
|
|
6
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
15
|
import { log } from "../../log.js";
|
|
8
|
-
import {
|
|
16
|
+
import { retryWhile } from "../registration.js";
|
|
9
17
|
import { callApi } from "./telegram-api.js";
|
|
10
18
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
19
|
+
* Whether a setWebhook failure is the URL still warming up rather than a configuration error. Two
|
|
20
|
+
* families: transport failures reaching Telegram at all, and Telegram's own verdict after it fails to
|
|
21
|
+
* reach the webhook — which carries its connection layer's words verbatim (`Bad Request: bad webhook:
|
|
22
|
+
* Connection timed out` / `Connection refused` for a host whose DNS answers before anything listens,
|
|
23
|
+
* `Wrong response from the webhook: 530` for a tunnel edge without its origin). This is the ONLY gate
|
|
24
|
+
* between "spend the caller's retry budget" and "fail now", so it is wide on the reachability side.
|
|
25
|
+
* A permanent "bad webhook" — an http:// URL, an unsupported port — matches nothing here and is reported.
|
|
26
|
+
*/
|
|
27
|
+
function isTransientRegistrationError(error) {
|
|
28
|
+
return /resolve host|getaddrinfo|ENOTFOUND|fetch failed|ECONNRESET|timeout|timed out|connection refused|can't connect|connection to the host|wrong response from the webhook/i.test(error);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Missing tokens print the
|
|
32
|
+
* manual instruction instead of failing. `opts` carries the attempt budget: `--tunnel` takes the
|
|
33
|
+
* default, `deploy --run` passes `DEPLOY_REGISTRATION_ATTEMPTS` (a host starts slower than a tunnel),
|
|
34
|
+
* and tests shrink it.
|
|
17
35
|
*
|
|
18
36
|
* Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
|
|
19
37
|
*/
|
|
@@ -25,40 +43,24 @@ export async function registerTelegramWebhook(baseUrl, opts = {}) {
|
|
|
25
43
|
log.info(`[fastagent] telegram: set TELEGRAM_BOT_TOKEN + TELEGRAM_SECRET_TOKEN in .env, then re-run to auto-register. Webhook URL: ${webhookUrl}`);
|
|
26
44
|
return "manual";
|
|
27
45
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
log.
|
|
36
|
-
|
|
37
|
-
return "failed";
|
|
46
|
+
log.info(`[fastagent] telegram: registering the webhook — Telegram verifies ${webhookUrl} as it does…`);
|
|
47
|
+
try {
|
|
48
|
+
await retryWhile(() => callApi("https://api.telegram.org", botToken, "setWebhook", { url: webhookUrl, secret_token: secret }), (error) => isTransientRegistrationError(String(error)), {
|
|
49
|
+
attempts: opts.attempts,
|
|
50
|
+
retryMs: opts.retryMs,
|
|
51
|
+
onRetry: ({ attempt, attempts }) => log.info(`[fastagent] telegram: Telegram cannot reach ${webhookUrl} yet (attempt ${attempt}/${attempts}); retrying…`),
|
|
52
|
+
});
|
|
53
|
+
log.info(`[fastagent] telegram: webhook registered → ${webhookUrl}`);
|
|
54
|
+
return "registered";
|
|
38
55
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return "registered";
|
|
49
|
-
}
|
|
50
|
-
catch (e) {
|
|
51
|
-
const error = String(e);
|
|
52
|
-
if (!/resolve host|getaddrinfo|ENOTFOUND|fetch failed|ECONNRESET|timeout/i.test(error)) {
|
|
53
|
-
log.error(`[fastagent] telegram: setWebhook failed (${error}). Register manually with url=${webhookUrl}`);
|
|
54
|
-
return "failed";
|
|
55
|
-
}
|
|
56
|
-
lastTransientError = error;
|
|
57
|
-
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
// Both endings leave the webhook unregistered and the operator with work to do, so both are ERROR;
|
|
58
|
+
// they differ only in what to do about it.
|
|
59
|
+
const error = String(e);
|
|
60
|
+
log.error(isTransientRegistrationError(error)
|
|
61
|
+
? `[fastagent] telegram: Telegram could not reach ${webhookUrl} after retries (last error: ${error}). ` +
|
|
62
|
+
`Once it is up, register manually: curl "https://api.telegram.org/bot<token>/setWebhook" -d url=${webhookUrl} -d secret_token=<secret>`
|
|
63
|
+
: `[fastagent] telegram: setWebhook failed (${error}). Register manually with url=${webhookUrl}`);
|
|
64
|
+
return "failed";
|
|
58
65
|
}
|
|
59
|
-
// Exhausted retries end in the same state as a permanent error (webhook not registered, manual
|
|
60
|
-
// action required) — report at the same level.
|
|
61
|
-
log.error(`[fastagent] telegram: setWebhook still failing after retries (last error: ${lastTransientError}). ` +
|
|
62
|
-
`Register manually with url=${webhookUrl}`);
|
|
63
|
-
return "failed";
|
|
64
66
|
}
|