@intentface/latch-channels 0.9.1
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/LICENSE +21 -0
- package/README.md +27 -0
- package/dist/ask.d.ts +69 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +94 -0
- package/dist/ask.js.map +1 -0
- package/dist/commands.d.ts +41 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +54 -0
- package/dist/commands.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +207 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +883 -0
- package/dist/kernel.js.map +1 -0
- package/dist/render.d.ts +43 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +192 -0
- package/dist/render.js.map +1 -0
- package/dist/slack/api.d.ts +207 -0
- package/dist/slack/api.d.ts.map +1 -0
- package/dist/slack/api.js +249 -0
- package/dist/slack/api.js.map +1 -0
- package/dist/slack/blocks.d.ts +36 -0
- package/dist/slack/blocks.d.ts.map +1 -0
- package/dist/slack/blocks.js +161 -0
- package/dist/slack/blocks.js.map +1 -0
- package/dist/slack/ids.d.ts +40 -0
- package/dist/slack/ids.d.ts.map +1 -0
- package/dist/slack/ids.js +47 -0
- package/dist/slack/ids.js.map +1 -0
- package/dist/slack/index.d.ts +24 -0
- package/dist/slack/index.d.ts.map +1 -0
- package/dist/slack/index.js +18 -0
- package/dist/slack/index.js.map +1 -0
- package/dist/slack/manifest.d.ts +23 -0
- package/dist/slack/manifest.d.ts.map +1 -0
- package/dist/slack/manifest.js +102 -0
- package/dist/slack/manifest.js.map +1 -0
- package/dist/slack/normalize.d.ts +35 -0
- package/dist/slack/normalize.d.ts.map +1 -0
- package/dist/slack/normalize.js +63 -0
- package/dist/slack/normalize.js.map +1 -0
- package/dist/slack/payloads.d.ts +36 -0
- package/dist/slack/payloads.d.ts.map +1 -0
- package/dist/slack/payloads.js +80 -0
- package/dist/slack/payloads.js.map +1 -0
- package/dist/slack/slackChannel.d.ts +152 -0
- package/dist/slack/slackChannel.d.ts.map +1 -0
- package/dist/slack/slackChannel.js +650 -0
- package/dist/slack/slackChannel.js.map +1 -0
- package/dist/slack/stream.d.ts +88 -0
- package/dist/slack/stream.d.ts.map +1 -0
- package/dist/slack/stream.js +418 -0
- package/dist/slack/stream.js.map +1 -0
- package/dist/slack/verify.d.ts +20 -0
- package/dist/slack/verify.d.ts.map +1 -0
- package/dist/slack/verify.js +32 -0
- package/dist/slack/verify.js.map +1 -0
- package/dist/telegram/api.d.ts +110 -0
- package/dist/telegram/api.d.ts.map +1 -0
- package/dist/telegram/api.js +170 -0
- package/dist/telegram/api.js.map +1 -0
- package/dist/telegram/format.d.ts +26 -0
- package/dist/telegram/format.d.ts.map +1 -0
- package/dist/telegram/format.js +56 -0
- package/dist/telegram/format.js.map +1 -0
- package/dist/telegram/html.d.ts +11 -0
- package/dist/telegram/html.d.ts.map +1 -0
- package/dist/telegram/html.js +35 -0
- package/dist/telegram/html.js.map +1 -0
- package/dist/telegram/index.d.ts +15 -0
- package/dist/telegram/index.d.ts.map +1 -0
- package/dist/telegram/index.js +15 -0
- package/dist/telegram/index.js.map +1 -0
- package/dist/telegram/stream.d.ts +59 -0
- package/dist/telegram/stream.d.ts.map +1 -0
- package/dist/telegram/stream.js +185 -0
- package/dist/telegram/stream.js.map +1 -0
- package/dist/telegram/telegramChannel.d.ts +136 -0
- package/dist/telegram/telegramChannel.d.ts.map +1 -0
- package/dist/telegram/telegramChannel.js +277 -0
- package/dist/telegram/telegramChannel.js.map +1 -0
- package/dist/telegram/verify.d.ts +7 -0
- package/dist/telegram/verify.d.ts.map +1 -0
- package/dist/telegram/verify.js +14 -0
- package/dist/telegram/verify.js.map +1 -0
- package/dist/types.d.ts +240 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/whatsapp/api.d.ts +59 -0
- package/dist/whatsapp/api.d.ts.map +1 -0
- package/dist/whatsapp/api.js +154 -0
- package/dist/whatsapp/api.js.map +1 -0
- package/dist/whatsapp/index.d.ts +13 -0
- package/dist/whatsapp/index.d.ts.map +1 -0
- package/dist/whatsapp/index.js +13 -0
- package/dist/whatsapp/index.js.map +1 -0
- package/dist/whatsapp/verify.d.ts +17 -0
- package/dist/whatsapp/verify.d.ts.map +1 -0
- package/dist/whatsapp/verify.js +32 -0
- package/dist/whatsapp/verify.js.map +1 -0
- package/dist/whatsapp/whatsappChannel.d.ts +104 -0
- package/dist/whatsapp/whatsappChannel.d.ts.map +1 -0
- package/dist/whatsapp/whatsappChannel.js +217 -0
- package/dist/whatsapp/whatsappChannel.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { consumeRun, isChatBusyError, pendingApprovals } from "../render.js";
|
|
2
|
+
import { withTurnLease } from "../kernel.js";
|
|
3
|
+
import { slackApi } from "./api.js";
|
|
4
|
+
import { verifySlackSignature } from "./verify.js";
|
|
5
|
+
import { DM_ROOT, parseSlackChatId, slackChatId } from "./ids.js";
|
|
6
|
+
import { isRenderableUserMessage, normalizeSlackText } from "./normalize.js";
|
|
7
|
+
import { decodeAction, encodeAction } from "./payloads.js";
|
|
8
|
+
import { APPROVE_ACTION, DENY_ACTION, approvalBlocksV2, approvalResolvedBlocks, connectBlocks, signInCard, } from "./blocks.js";
|
|
9
|
+
import { renderSlackStream } from "./stream.js";
|
|
10
|
+
const ok = () => new Response(null, { status: 200 });
|
|
11
|
+
// Claim TTLs. Dedupe keys are never released — they expire.
|
|
12
|
+
const EVT_TTL = 6 * 3600_000; // Slack retries within the hour; 6h is margin
|
|
13
|
+
const MSG_TTL = 6 * 3600_000;
|
|
14
|
+
const ACT_TTL = 15 * 60_000;
|
|
15
|
+
const NOTE_TTL = 7 * 24 * 3600_000;
|
|
16
|
+
const GENERIC_ERROR = ":warning: Something went wrong running the agent — the details are in Latch.";
|
|
17
|
+
const BUSY_NOTICE = "Still working on the previous message here — send that again when I've answered.";
|
|
18
|
+
const MID_TURN_NOTICE = "The agent is mid-turn here — try again in a moment.";
|
|
19
|
+
function newId() {
|
|
20
|
+
return globalThis.crypto?.randomUUID?.() ?? `m_${Date.now()}_${Math.round(Math.random() * 1e9)}`;
|
|
21
|
+
}
|
|
22
|
+
/** thread_ts for posting into a chat — the `_dm` chat posts un-threaded. */
|
|
23
|
+
const threadTsOf = (root) => (root === DM_ROOT ? undefined : root);
|
|
24
|
+
export function slackChannel(config) {
|
|
25
|
+
const api = slackApi(config.botToken, config.apiOptions);
|
|
26
|
+
const resolveSigningSecret = async () => typeof config.signingSecret === "function" ? config.signingSecret() : config.signingSecret;
|
|
27
|
+
const defer = (p) => {
|
|
28
|
+
const guarded = p.catch((e) => console.error("[slack] deferred worker failed:", e));
|
|
29
|
+
if (config.waitUntil)
|
|
30
|
+
config.waitUntil(guarded);
|
|
31
|
+
return guarded;
|
|
32
|
+
};
|
|
33
|
+
/** Session-surface status; a no-op outside the bot's DM channel. */
|
|
34
|
+
async function setSession(channel, root, status) {
|
|
35
|
+
if (!channel.startsWith("D"))
|
|
36
|
+
return;
|
|
37
|
+
const threadTs = threadTsOf(root);
|
|
38
|
+
await api
|
|
39
|
+
.sessionsSetStatus({
|
|
40
|
+
channel_id: channel,
|
|
41
|
+
status,
|
|
42
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {
|
|
45
|
+
/* plain DMs aren't sessions; status is best-effort */
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// ── Turn rendering (shared by messages, resumes, replays) ─────────────────
|
|
49
|
+
async function renderTurn(res, ctx) {
|
|
50
|
+
const threadTs = threadTsOf(ctx.root) ?? ctx.anchorTs;
|
|
51
|
+
const result = await renderSlackStream(consumeRun(res), {
|
|
52
|
+
api,
|
|
53
|
+
channel: ctx.channel,
|
|
54
|
+
...(threadTs ? { threadTs } : {}),
|
|
55
|
+
recipientTeamId: config.teamId,
|
|
56
|
+
recipientUserId: ctx.userId,
|
|
57
|
+
shouldStop: () => config.threads.stoppedSince(ctx.chatId, ctx.turnStartedAt),
|
|
58
|
+
...(ctx.resumedToolName ? { resumedToolName: ctx.resumedToolName } : {}),
|
|
59
|
+
...(config.onStreamMode
|
|
60
|
+
? {
|
|
61
|
+
// Return the promise — the renderer awaits it so the persistence
|
|
62
|
+
// write is inside the turn the host's waitUntil tracks.
|
|
63
|
+
onStreamMode: (info) => config.onStreamMode?.({ chatId: ctx.chatId, ...info }),
|
|
64
|
+
}
|
|
65
|
+
: {}),
|
|
66
|
+
// The legacy free-text status box ("is thinking…"). Assistant/DM
|
|
67
|
+
// surfaces only, and it needs a thread — best-effort by construction
|
|
68
|
+
// (the api call swallows its own errors).
|
|
69
|
+
...(threadTs
|
|
70
|
+
? {
|
|
71
|
+
setStatus: (text) => api.setStatus({ channel_id: ctx.channel, thread_ts: threadTs, status: text }),
|
|
72
|
+
}
|
|
73
|
+
: {}),
|
|
74
|
+
});
|
|
75
|
+
if (result.stopped) {
|
|
76
|
+
await setSession(ctx.channel, ctx.root, "active");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (result.errorMessage) {
|
|
80
|
+
// The stream's own error surface already rendered; don't hunt approvals
|
|
81
|
+
// on a failed turn.
|
|
82
|
+
await setSession(ctx.channel, ctx.root, "active");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Approvals are message state, not stream chunks — check fresh history.
|
|
86
|
+
let hasPending = false;
|
|
87
|
+
try {
|
|
88
|
+
const history = await config.runtime.loadHistory({
|
|
89
|
+
chatId: ctx.chatId,
|
|
90
|
+
principal: ctx.principal,
|
|
91
|
+
});
|
|
92
|
+
const secret = await resolveSigningSecret();
|
|
93
|
+
for (const pa of pendingApprovals(history)) {
|
|
94
|
+
hasPending = true;
|
|
95
|
+
if (pa.isConnect && pa.connectionName && config.authorizeUrl) {
|
|
96
|
+
const url = config.authorizeUrl(pa.connectionName, {
|
|
97
|
+
chatId: ctx.chatId,
|
|
98
|
+
channelId: ctx.channel,
|
|
99
|
+
// The EFFECTIVE thread (anchor included) — the host carries it
|
|
100
|
+
// through connect-done so the resume renders where the card is.
|
|
101
|
+
threadTs: threadTs ?? ctx.root,
|
|
102
|
+
userId: ctx.userId,
|
|
103
|
+
approvalId: pa.approvalId,
|
|
104
|
+
});
|
|
105
|
+
await api.postEphemeral({
|
|
106
|
+
channel: ctx.channel,
|
|
107
|
+
user: ctx.userId,
|
|
108
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
109
|
+
blocks: connectBlocks(pa.connectionName, url),
|
|
110
|
+
text: `Connect ${pa.connectionName} to continue.`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const value = encodeAction({
|
|
115
|
+
kind: "approval",
|
|
116
|
+
chatId: ctx.chatId,
|
|
117
|
+
approvalId: pa.approvalId,
|
|
118
|
+
tool: pa.toolName,
|
|
119
|
+
actor: ctx.userId,
|
|
120
|
+
}, secret);
|
|
121
|
+
await api.postMessage({
|
|
122
|
+
channel: ctx.channel,
|
|
123
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
124
|
+
blocks: approvalBlocksV2(value, pa.toolName, pa.input),
|
|
125
|
+
text: `Approve ${pa.toolName}?`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
console.error("[slack] approval rendering failed:", e);
|
|
132
|
+
}
|
|
133
|
+
await setSession(ctx.channel, ctx.root, hasPending ? "suspended" : "active");
|
|
134
|
+
}
|
|
135
|
+
/** Run one gated message inside the chat's turn lease. */
|
|
136
|
+
async function runLeasedMessage(ctx) {
|
|
137
|
+
const turnStartedAt = Date.now();
|
|
138
|
+
await setSession(ctx.channel, ctx.root, "processing");
|
|
139
|
+
const text = ctx.context
|
|
140
|
+
? `${ctx.context}\n\nThe message addressed to you:\n${ctx.prompt}`
|
|
141
|
+
: ctx.prompt;
|
|
142
|
+
const message = {
|
|
143
|
+
id: newId(),
|
|
144
|
+
role: "user",
|
|
145
|
+
parts: [{ type: "text", text }],
|
|
146
|
+
metadata: { visibility: "user", createdAt: Date.now() },
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
const res = await config.runtime.handleChat({
|
|
150
|
+
agent: config.agent,
|
|
151
|
+
chatId: ctx.chatId,
|
|
152
|
+
principal: ctx.principal,
|
|
153
|
+
message,
|
|
154
|
+
});
|
|
155
|
+
await renderTurn(res, { ...ctx, turnStartedAt });
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
// Core's one-turn-per-chat refusal: the same quiet notice the lease gives.
|
|
159
|
+
if (isChatBusyError(e)) {
|
|
160
|
+
await api
|
|
161
|
+
.postEphemeral({
|
|
162
|
+
channel: ctx.channel,
|
|
163
|
+
user: ctx.userId,
|
|
164
|
+
...(threadTsOf(ctx.root) ?? ctx.anchorTs ? { thread_ts: threadTsOf(ctx.root) ?? ctx.anchorTs } : {}),
|
|
165
|
+
text: BUSY_NOTICE,
|
|
166
|
+
})
|
|
167
|
+
.catch(() => { });
|
|
168
|
+
await setSession(ctx.channel, ctx.root, "active");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Never post raw internals into the channel: masked stream errors render
|
|
172
|
+
// through the renderer; a pre-stream throw gets the generic line.
|
|
173
|
+
console.error("[slack] turn failed:", e);
|
|
174
|
+
await api
|
|
175
|
+
.postMessage({
|
|
176
|
+
channel: ctx.channel,
|
|
177
|
+
thread_ts: threadTsOf(ctx.root) ?? ctx.anchorTs,
|
|
178
|
+
text: GENERIC_ERROR,
|
|
179
|
+
})
|
|
180
|
+
.catch(() => { });
|
|
181
|
+
await setSession(ctx.channel, ctx.root, "active");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ── Deferred workers ───────────────────────────────────────────────────────
|
|
185
|
+
async function handleUserMessage(body, mode) {
|
|
186
|
+
const ev = body.event;
|
|
187
|
+
const teamId = body.team_id ?? "";
|
|
188
|
+
if (!ev.channel || !ev.ts)
|
|
189
|
+
return;
|
|
190
|
+
// Narrowed copy: the lease body below is a closure, where TS forgets the guard.
|
|
191
|
+
const channel = ev.channel;
|
|
192
|
+
// 1. Delivery dedupe — Slack retries reuse the event_id.
|
|
193
|
+
if (body.event_id && !(await config.claimOnce(`evt:${body.event_id}`, EVT_TTL)))
|
|
194
|
+
return;
|
|
195
|
+
// 2. Shape check — humans only, allowlisted subtypes, never the bot itself.
|
|
196
|
+
if (!isRenderableUserMessage(ev, config.botUserId))
|
|
197
|
+
return;
|
|
198
|
+
const userId = ev.user;
|
|
199
|
+
// 3. Message identity claim — one message arrives as several event shapes
|
|
200
|
+
// (a channel mention fires BOTH app_mention and message.channels).
|
|
201
|
+
// Claimed before any user-visible side effect so even a refusal notice
|
|
202
|
+
// posts exactly once.
|
|
203
|
+
if (!(await config.claimOnce(`msg:${teamId}:${channel}:${ev.ts}`, MSG_TTL)))
|
|
204
|
+
return;
|
|
205
|
+
// 4. The chat this message belongs to. Un-threaded messages in a DM
|
|
206
|
+
// channel always land on the `_dm` chat REGARDLESS of event shape —
|
|
207
|
+
// a DM mention can arrive as app_mention too, and both shapes must
|
|
208
|
+
// resolve to the same chat.
|
|
209
|
+
const root = ev.thread_ts ?? (channel.startsWith("D") ? DM_ROOT : ev.ts);
|
|
210
|
+
const chatId = slackChatId(config.appId, channel, root);
|
|
211
|
+
// For `_dm` chats the asking message anchors everything this turn posts
|
|
212
|
+
// (streams need a thread there; notices stay next to what they answer).
|
|
213
|
+
const anchorTs = ev.thread_ts ?? ev.ts;
|
|
214
|
+
const threadTs = threadTsOf(root) ?? anchorTs;
|
|
215
|
+
const existing = await config.threads.get(chatId);
|
|
216
|
+
if (mode === "thread-follow" && !existing)
|
|
217
|
+
return; // not our thread
|
|
218
|
+
// 5. Initiator ownership — one quiet notice, then silence.
|
|
219
|
+
if (existing && existing.initiatorUserId !== userId) {
|
|
220
|
+
if (await config.claimOnce(`note:${chatId}:${userId}`, NOTE_TTL)) {
|
|
221
|
+
await api
|
|
222
|
+
.postEphemeral({
|
|
223
|
+
channel: channel,
|
|
224
|
+
user: userId,
|
|
225
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
226
|
+
text: `This is <@${existing.initiatorUserId}>'s conversation with the agent. Mention me in a new thread (or DM me) to start your own.`,
|
|
227
|
+
})
|
|
228
|
+
.catch(() => { });
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// 6. The prompt.
|
|
233
|
+
const prompt = normalizeSlackText(ev.text ?? "", config.botUserId);
|
|
234
|
+
if (!prompt)
|
|
235
|
+
return;
|
|
236
|
+
// 7. Identity gate.
|
|
237
|
+
let principal = config.principal;
|
|
238
|
+
if (config.authorizeUser) {
|
|
239
|
+
let auth;
|
|
240
|
+
try {
|
|
241
|
+
auth = await config.authorizeUser({
|
|
242
|
+
teamId,
|
|
243
|
+
userId,
|
|
244
|
+
channelId: channel,
|
|
245
|
+
threadRoot: root,
|
|
246
|
+
anchorTs,
|
|
247
|
+
prompt,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
console.error("[slack] auth check failed:", e);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!auth.allowed) {
|
|
255
|
+
if (auth.silent)
|
|
256
|
+
return;
|
|
257
|
+
// Only pass thread_ts for a real thread reply — threading an ephemeral
|
|
258
|
+
// under a top-level mention hides it in a pane the user isn't viewing.
|
|
259
|
+
const ephemeralThread = ev.thread_ts ? { thread_ts: ev.thread_ts } : {};
|
|
260
|
+
try {
|
|
261
|
+
if (auth.linkUrl) {
|
|
262
|
+
await api.postEphemeral({
|
|
263
|
+
channel: channel,
|
|
264
|
+
user: userId,
|
|
265
|
+
...ephemeralThread,
|
|
266
|
+
blocks: signInCard(auth.linkUrl, auth.message),
|
|
267
|
+
text: auth.message ?? "Sign in to Latch to use this agent.",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
await api.postEphemeral({
|
|
272
|
+
channel: channel,
|
|
273
|
+
user: userId,
|
|
274
|
+
...ephemeralThread,
|
|
275
|
+
text: auth.message ?? "You don't have access to this agent.",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
console.error("[slack] ephemeral refused-notice failed, trying DM:", e);
|
|
281
|
+
try {
|
|
282
|
+
const dm = await api.openDm(userId);
|
|
283
|
+
if (dm) {
|
|
284
|
+
await api.postMessage({
|
|
285
|
+
channel: dm,
|
|
286
|
+
...(auth.linkUrl ? { blocks: signInCard(auth.linkUrl, auth.message) } : {}),
|
|
287
|
+
text: auth.message ?? "Sign in to Latch to use this agent.",
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (e2) {
|
|
292
|
+
console.error("[slack] DM fallback failed:", e2);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (auth.principal !== undefined)
|
|
298
|
+
principal = auth.principal;
|
|
299
|
+
}
|
|
300
|
+
// 8. Turn lease — ONE live run per chat.
|
|
301
|
+
const busy = () => api.postEphemeral({
|
|
302
|
+
channel: channel,
|
|
303
|
+
user: userId,
|
|
304
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
305
|
+
text: BUSY_NOTICE,
|
|
306
|
+
});
|
|
307
|
+
await withTurnLease(config, chatId, busy, async () => {
|
|
308
|
+
// 9. Ownership record (inside the lease → no create race).
|
|
309
|
+
if (!existing) {
|
|
310
|
+
const owner = await config.threads.claim(chatId, {
|
|
311
|
+
initiatorUserId: userId,
|
|
312
|
+
teamId,
|
|
313
|
+
createdAt: Date.now(),
|
|
314
|
+
});
|
|
315
|
+
if (owner.initiatorUserId !== userId)
|
|
316
|
+
return; // lost an unlikely race
|
|
317
|
+
}
|
|
318
|
+
// 10. First engagement of a pre-existing human thread: seed context.
|
|
319
|
+
let context;
|
|
320
|
+
if (mode === "mention" && ev.thread_ts && !existing) {
|
|
321
|
+
try {
|
|
322
|
+
const history = await config.runtime.loadHistory({ chatId, principal });
|
|
323
|
+
if (history.length === 0) {
|
|
324
|
+
const replies = await api.threadReplies(channel, ev.thread_ts);
|
|
325
|
+
const prior = replies
|
|
326
|
+
.filter((m) => m.ts !== ev.ts && !m.isBot && m.text.trim())
|
|
327
|
+
.slice(-30);
|
|
328
|
+
if (prior.length) {
|
|
329
|
+
const lines = prior
|
|
330
|
+
.map((m) => `${m.userId ? `@${m.userId}` : "user"}: ${normalizeSlackText(m.text, config.botUserId)}`)
|
|
331
|
+
.join("\n");
|
|
332
|
+
context = `Earlier messages in this Slack thread (for context):\n${lines}`;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
console.error("[slack] thread context fetch failed:", e);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
await runLeasedMessage({
|
|
341
|
+
chatId,
|
|
342
|
+
channel: channel,
|
|
343
|
+
root,
|
|
344
|
+
anchorTs,
|
|
345
|
+
userId,
|
|
346
|
+
prompt,
|
|
347
|
+
...(context !== undefined ? { context } : {}),
|
|
348
|
+
principal,
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function handleSessionStop(body) {
|
|
353
|
+
const ev = body.event;
|
|
354
|
+
if (body.event_id && !(await config.claimOnce(`evt:${body.event_id}`, EVT_TTL)))
|
|
355
|
+
return;
|
|
356
|
+
if (!ev.channel)
|
|
357
|
+
return;
|
|
358
|
+
const root = ev.thread_ts ?? DM_ROOT;
|
|
359
|
+
const chatId = slackChatId(config.appId, ev.channel, root);
|
|
360
|
+
await config.threads.markStopped(chatId).catch((e) => {
|
|
361
|
+
console.error("[slack] markStopped failed:", e);
|
|
362
|
+
});
|
|
363
|
+
// Belt and braces: close any stream Slack says was live, and clear the
|
|
364
|
+
// status — it does NOT auto-clear on stop.
|
|
365
|
+
for (const streamTs of ev.streaming_message_ts ?? []) {
|
|
366
|
+
await api
|
|
367
|
+
.stopStream({ channel: ev.channel, ts: streamTs, markdown_text: "\n\n_Stopped._" })
|
|
368
|
+
.catch(() => { });
|
|
369
|
+
}
|
|
370
|
+
await setSession(ev.channel, root, "active");
|
|
371
|
+
}
|
|
372
|
+
async function handleInteraction(payload) {
|
|
373
|
+
const action = payload.actions?.[0];
|
|
374
|
+
const clicker = payload.user?.id ?? "";
|
|
375
|
+
const channel = payload.channel?.id ?? "";
|
|
376
|
+
if (!action || !clicker)
|
|
377
|
+
return;
|
|
378
|
+
// Interaction dedupe — Slack retries interactivity POSTs too.
|
|
379
|
+
if (payload.trigger_id && !(await config.claimOnce(`act:${payload.trigger_id}`, ACT_TTL))) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (action.action_id !== APPROVE_ACTION && action.action_id !== DENY_ACTION)
|
|
383
|
+
return;
|
|
384
|
+
const secret = await resolveSigningSecret();
|
|
385
|
+
const decoded = action.value ? decodeAction(action.value, secret) : null;
|
|
386
|
+
const ephemeral = (text) => api
|
|
387
|
+
.postEphemeral({
|
|
388
|
+
channel,
|
|
389
|
+
user: clicker,
|
|
390
|
+
...(payload.message?.thread_ts ? { thread_ts: payload.message.thread_ts } : {}),
|
|
391
|
+
text,
|
|
392
|
+
})
|
|
393
|
+
.catch(() => { });
|
|
394
|
+
if (!decoded) {
|
|
395
|
+
await ephemeral("This button has expired — ask the agent again.");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// Actor-only: the tool executes with the REQUESTER's credentials, so only
|
|
399
|
+
// they may authorize it.
|
|
400
|
+
if (clicker !== decoded.actor) {
|
|
401
|
+
await ephemeral(`Only <@${decoded.actor}> can act on this approval.`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const parsed = parseSlackChatId(decoded.chatId);
|
|
405
|
+
if (!parsed || parsed.app !== config.appId)
|
|
406
|
+
return;
|
|
407
|
+
const approved = action.action_id === APPROVE_ACTION;
|
|
408
|
+
// Gate the clicker like a message author — the resume is a real turn.
|
|
409
|
+
let principal = config.principal;
|
|
410
|
+
if (config.authorizeUser) {
|
|
411
|
+
let auth;
|
|
412
|
+
try {
|
|
413
|
+
auth = await config.authorizeUser({
|
|
414
|
+
teamId: payload.team?.id ?? payload.user?.team_id ?? "",
|
|
415
|
+
userId: clicker,
|
|
416
|
+
channelId: parsed.channel,
|
|
417
|
+
threadRoot: parsed.root,
|
|
418
|
+
prompt: "",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
console.error("[slack] interaction auth check failed:", e);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!auth.allowed) {
|
|
426
|
+
if (!auth.silent) {
|
|
427
|
+
await ephemeral(auth.message ?? "Sign in to Latch to act on approvals.");
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (auth.principal !== undefined)
|
|
432
|
+
principal = auth.principal;
|
|
433
|
+
}
|
|
434
|
+
// An approval resume IS a turn — same lease as a message.
|
|
435
|
+
const busy = () => ephemeral(MID_TURN_NOTICE);
|
|
436
|
+
await withTurnLease(config, decoded.chatId, busy, async () => {
|
|
437
|
+
const turnStartedAt = Date.now();
|
|
438
|
+
try {
|
|
439
|
+
const res = await config.runtime.applyApproval({
|
|
440
|
+
chatId: decoded.chatId,
|
|
441
|
+
principal,
|
|
442
|
+
decisions: [{ approvalId: decoded.approvalId, approved }],
|
|
443
|
+
});
|
|
444
|
+
if (payload.message?.ts) {
|
|
445
|
+
await api
|
|
446
|
+
.update({
|
|
447
|
+
channel,
|
|
448
|
+
ts: payload.message.ts,
|
|
449
|
+
blocks: approvalResolvedBlocks(decoded.tool ?? "tool", approved),
|
|
450
|
+
text: approved ? "Approved" : "Denied",
|
|
451
|
+
})
|
|
452
|
+
.catch((e) => console.error("[slack] card settle failed:", e));
|
|
453
|
+
}
|
|
454
|
+
await renderTurn(res, {
|
|
455
|
+
chatId: decoded.chatId,
|
|
456
|
+
channel: parsed.channel,
|
|
457
|
+
root: parsed.root,
|
|
458
|
+
// The card lives where the turn rendered — its thread anchors the
|
|
459
|
+
// resume for `_dm` chats (whose id carries no root).
|
|
460
|
+
...(payload.message?.thread_ts || payload.message?.ts
|
|
461
|
+
? { anchorTs: payload.message.thread_ts ?? payload.message.ts }
|
|
462
|
+
: {}),
|
|
463
|
+
userId: clicker,
|
|
464
|
+
...(decoded.tool ? { resumedToolName: decoded.tool } : {}),
|
|
465
|
+
principal,
|
|
466
|
+
turnStartedAt,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
if (isChatBusyError(e))
|
|
471
|
+
return void (await ephemeral(MID_TURN_NOTICE));
|
|
472
|
+
console.error("[slack] approval resume failed:", e);
|
|
473
|
+
await ephemeral(GENERIC_ERROR);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
async function handleLifecycle(body) {
|
|
478
|
+
if (body.event_id && !(await config.claimOnce(`evt:${body.event_id}`, EVT_TTL)))
|
|
479
|
+
return;
|
|
480
|
+
if (!config.onLifecycle)
|
|
481
|
+
return;
|
|
482
|
+
const type = body.event.type === "tokens_revoked" ? "tokens_revoked" : "app_uninstalled";
|
|
483
|
+
await config.onLifecycle({ type, teamId: body.team_id ?? "" }).catch((e) => {
|
|
484
|
+
console.error("[slack] lifecycle hook failed:", e);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// ── The single webhook entrypoint ─────────────────────────────────────────
|
|
488
|
+
//
|
|
489
|
+
// PRE-ACK WORK IS EXACTLY THIS: read body → verify signature → minimal parse
|
|
490
|
+
// → route to a deferred worker → 200. Anything unroutable is 200 too, so
|
|
491
|
+
// Slack never retries what we'll never handle.
|
|
492
|
+
async function handleRequest(request) {
|
|
493
|
+
const raw = await request.text();
|
|
494
|
+
const validSig = verifySlackSignature({
|
|
495
|
+
signingSecret: await resolveSigningSecret(),
|
|
496
|
+
rawBody: raw,
|
|
497
|
+
signature: request.headers.get("x-slack-signature"),
|
|
498
|
+
timestamp: request.headers.get("x-slack-request-timestamp"),
|
|
499
|
+
});
|
|
500
|
+
if (!validSig)
|
|
501
|
+
return new Response("invalid signature", { status: 401 });
|
|
502
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
503
|
+
// Interactivity: form-encoded `payload=<json>`.
|
|
504
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
505
|
+
const payloadStr = new URLSearchParams(raw).get("payload");
|
|
506
|
+
if (payloadStr) {
|
|
507
|
+
try {
|
|
508
|
+
const payload = JSON.parse(payloadStr);
|
|
509
|
+
if (payload.type === "block_actions") {
|
|
510
|
+
defer(handleInteraction(payload));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
console.error("[slack] interaction payload parse failed:", e);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return ok();
|
|
518
|
+
}
|
|
519
|
+
// Events API (JSON).
|
|
520
|
+
let body;
|
|
521
|
+
try {
|
|
522
|
+
body = JSON.parse(raw || "{}");
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return ok();
|
|
526
|
+
}
|
|
527
|
+
if (body.type === "url_verification") {
|
|
528
|
+
return new Response(body.challenge ?? "", {
|
|
529
|
+
status: 200,
|
|
530
|
+
headers: { "content-type": "text/plain" },
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (body.type !== "event_callback" || !body.event)
|
|
534
|
+
return ok();
|
|
535
|
+
const ev = body.event;
|
|
536
|
+
const withEvent = body;
|
|
537
|
+
switch (ev.type) {
|
|
538
|
+
case "app_mention":
|
|
539
|
+
defer(handleUserMessage(withEvent, "mention"));
|
|
540
|
+
break;
|
|
541
|
+
case "message":
|
|
542
|
+
if (ev.channel_type === "im") {
|
|
543
|
+
defer(handleUserMessage(withEvent, "dm"));
|
|
544
|
+
}
|
|
545
|
+
else if (ev.thread_ts) {
|
|
546
|
+
defer(handleUserMessage(withEvent, "thread-follow"));
|
|
547
|
+
}
|
|
548
|
+
break;
|
|
549
|
+
case "agent_session_stopped":
|
|
550
|
+
defer(handleSessionStop(withEvent));
|
|
551
|
+
break;
|
|
552
|
+
case "tokens_revoked":
|
|
553
|
+
case "app_uninstalled":
|
|
554
|
+
defer(handleLifecycle(withEvent));
|
|
555
|
+
break;
|
|
556
|
+
default:
|
|
557
|
+
// app_home_opened, agent_session_title_changed, app_context_changed
|
|
558
|
+
// (v2.1), everything else: acknowledged and dropped.
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
return ok();
|
|
562
|
+
}
|
|
563
|
+
// ── Host-driven entries (replays, OAuth resumes) ───────────────────────────
|
|
564
|
+
/**
|
|
565
|
+
* Run one already-gated message (the post-link replay). Takes the chat's
|
|
566
|
+
* turn lease like any other turn; a busy chat drops the replay (the user
|
|
567
|
+
* just linked — their next message will land normally).
|
|
568
|
+
*/
|
|
569
|
+
async function runMessage(ctx) {
|
|
570
|
+
const chatId = slackChatId(config.appId, ctx.channel, ctx.root);
|
|
571
|
+
const principal = ctx.principal ?? config.principal;
|
|
572
|
+
const busy = async () => console.warn(`[slack] runMessage: chat ${chatId} busy — replay dropped`);
|
|
573
|
+
await withTurnLease(config, chatId, busy, async () => {
|
|
574
|
+
await config.threads.claim(chatId, {
|
|
575
|
+
initiatorUserId: ctx.userId,
|
|
576
|
+
teamId: config.teamId,
|
|
577
|
+
createdAt: Date.now(),
|
|
578
|
+
});
|
|
579
|
+
await runLeasedMessage({
|
|
580
|
+
chatId,
|
|
581
|
+
channel: ctx.channel,
|
|
582
|
+
root: ctx.root,
|
|
583
|
+
...(ctx.anchorTs !== undefined ? { anchorTs: ctx.anchorTs } : {}),
|
|
584
|
+
userId: ctx.userId,
|
|
585
|
+
prompt: ctx.prompt,
|
|
586
|
+
...(ctx.context !== undefined ? { context: ctx.context } : {}),
|
|
587
|
+
principal,
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Approve a parked `connect_<name>` after its OAuth completed in the
|
|
593
|
+
* browser. Connect-only, verified against fresh history — a browser
|
|
594
|
+
* redirect must never be able to approve an arbitrary gated tool.
|
|
595
|
+
*/
|
|
596
|
+
async function resumeApproval(ctx) {
|
|
597
|
+
const parsed = parseSlackChatId(ctx.chatId);
|
|
598
|
+
if (!parsed || parsed.app !== config.appId)
|
|
599
|
+
return;
|
|
600
|
+
const principal = ctx.principal ?? config.principal;
|
|
601
|
+
const history = await config.runtime.loadHistory({ chatId: ctx.chatId, principal });
|
|
602
|
+
const pending = pendingApprovals(history).find((pa) => pa.approvalId === ctx.approvalId);
|
|
603
|
+
if (!pending?.isConnect)
|
|
604
|
+
return;
|
|
605
|
+
const busy = async () => console.warn(`[slack] resumeApproval: chat ${ctx.chatId} busy — resume dropped`);
|
|
606
|
+
await withTurnLease(config, ctx.chatId, busy, async () => {
|
|
607
|
+
const turnStartedAt = Date.now();
|
|
608
|
+
await setSession(parsed.channel, parsed.root, "processing");
|
|
609
|
+
let res;
|
|
610
|
+
try {
|
|
611
|
+
res = await config.runtime.applyApproval({
|
|
612
|
+
chatId: ctx.chatId,
|
|
613
|
+
principal,
|
|
614
|
+
decisions: [{ approvalId: ctx.approvalId, approved: true }],
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
if (isChatBusyError(e)) {
|
|
619
|
+
// Tell the person, not just the log: their connect finished but the
|
|
620
|
+
// thread is mid-turn — the card stays, they press again.
|
|
621
|
+
await busy();
|
|
622
|
+
const threadTs = threadTsOf(parsed.root) ?? ctx.anchorTs;
|
|
623
|
+
await api
|
|
624
|
+
.postEphemeral({
|
|
625
|
+
channel: parsed.channel,
|
|
626
|
+
user: ctx.userId,
|
|
627
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
628
|
+
text: MID_TURN_NOTICE,
|
|
629
|
+
})
|
|
630
|
+
.catch(() => { });
|
|
631
|
+
await setSession(parsed.channel, parsed.root, "active");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
throw e;
|
|
635
|
+
}
|
|
636
|
+
await renderTurn(res, {
|
|
637
|
+
chatId: ctx.chatId,
|
|
638
|
+
channel: parsed.channel,
|
|
639
|
+
root: parsed.root,
|
|
640
|
+
...(ctx.anchorTs !== undefined ? { anchorTs: ctx.anchorTs } : {}),
|
|
641
|
+
userId: ctx.userId,
|
|
642
|
+
resumedToolName: pending.toolName,
|
|
643
|
+
principal,
|
|
644
|
+
turnStartedAt,
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return { handleRequest, runMessage, resumeApproval };
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=slackChannel.js.map
|