@finalai/final-agent-provider 0.6.3 → 0.7.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/HOSTING.md +4 -4
- package/README.md +94 -2
- package/dist/modules/authoring-final-channel.mjs +2 -2
- package/dist/modules/authoring-final-channel.mjs.map +2 -2
- package/dist/modules/channel-factories.d.mts +5 -7
- package/dist/modules/channel-factories.mjs +4 -50
- package/dist/modules/channel-factories.mjs.map +2 -2
- package/dist/modules/channels/channel-utils.d.mts +132 -0
- package/dist/modules/channels/channel-utils.mjs +445 -0
- package/dist/modules/channels/channel-utils.mjs.map +7 -0
- package/dist/modules/channels/delivery.d.mts +5 -5
- package/dist/modules/channels/delivery.mjs.map +1 -1
- package/dist/modules/channels/direct-messaging.d.mts +2 -2
- package/dist/modules/channels/direct-messaging.mjs.map +2 -2
- package/dist/modules/channels/discord-dispatch.d.mts +17 -0
- package/dist/modules/channels/discord-dispatch.mjs +139 -0
- package/dist/modules/channels/discord-dispatch.mjs.map +7 -0
- package/dist/modules/channels/discord-gateway.d.mts +19 -0
- package/dist/modules/channels/discord-gateway.mjs +233 -0
- package/dist/modules/channels/discord-gateway.mjs.map +7 -0
- package/dist/modules/channels/discord.d.mts +9 -0
- package/dist/modules/channels/discord.mjs +904 -0
- package/dist/modules/channels/discord.mjs.map +7 -0
- package/dist/modules/channels/fake-platform.d.mts +2 -7
- package/dist/modules/channels/fake-platform.mjs +2 -38
- package/dist/modules/channels/fake-platform.mjs.map +2 -2
- package/dist/modules/channels/github.d.mts +5 -0
- package/dist/modules/channels/github.mjs +720 -0
- package/dist/modules/channels/github.mjs.map +7 -0
- package/dist/modules/channels/group-messaging.d.mts +2 -2
- package/dist/modules/channels/group-messaging.mjs.map +2 -2
- package/dist/modules/channels/index.d.mts +7 -3
- package/dist/modules/channels/index.mjs +7 -3
- package/dist/modules/channels/index.mjs.map +2 -2
- package/dist/modules/channels/linear.d.mts +4 -0
- package/dist/modules/channels/linear.mjs +621 -0
- package/dist/modules/channels/linear.mjs.map +7 -0
- package/dist/modules/channels/messaging-channel.d.mts +3 -3
- package/dist/modules/channels/messaging-channel.mjs +2 -2
- package/dist/modules/channels/messaging-channel.mjs.map +2 -2
- package/dist/modules/channels/phone.d.mts +8 -0
- package/dist/modules/channels/phone.mjs +382 -0
- package/dist/modules/channels/phone.mjs.map +7 -0
- package/dist/modules/channels/projection.mjs.map +1 -1
- package/dist/modules/channels/reply-receipts.d.mts +31 -0
- package/dist/modules/channels/reply-receipts.mjs +63 -0
- package/dist/modules/channels/reply-receipts.mjs.map +7 -0
- package/dist/modules/channels/routing.d.mts +1 -0
- package/dist/modules/channels/routing.mjs +5 -1
- package/dist/modules/channels/routing.mjs.map +2 -2
- package/dist/modules/channels/slack.d.mts +7 -0
- package/dist/modules/channels/slack.mjs +1134 -0
- package/dist/modules/channels/slack.mjs.map +7 -0
- package/dist/modules/channels/voice-recording.d.mts +7 -0
- package/dist/modules/channels/voice-recording.mjs +49 -0
- package/dist/modules/channels/voice-recording.mjs.map +7 -0
- package/dist/modules/channels/voice.d.mts +33 -0
- package/dist/modules/channels/voice.mjs +851 -0
- package/dist/modules/channels/voice.mjs.map +7 -0
- package/dist/modules/channels/webhook.d.mts +5 -0
- package/dist/modules/channels/webhook.mjs +76 -0
- package/dist/modules/channels/webhook.mjs.map +7 -0
- package/dist/modules/harnesses/acp-describe.mjs +1 -1
- package/dist/modules/harnesses/acp-describe.mjs.map +2 -2
- package/dist/modules/harnesses/fidelity.mjs +17 -17
- package/dist/modules/harnesses/fidelity.mjs.map +2 -2
- package/dist/modules/harnesses/remote-url.mjs.map +1 -1
- package/dist/modules/runtime/remote-host.mjs.map +1 -1
- package/dist/modules/sandboxes/build-scripts.mjs.map +1 -1
- package/dist/modules/sandboxes/paths.mjs.map +1 -1
- package/dist/modules/testing/harness.mjs +2 -2
- package/dist/modules/testing/harness.mjs.map +1 -1
- package/package.json +45 -19
- package/dist/modules/channels/address-context.d.mts +0 -7
- package/dist/modules/channels/address-context.mjs +0 -80
- package/dist/modules/channels/address-context.mjs.map +0 -7
- package/dist/modules/channels/agent-events.d.mts +0 -103
- package/dist/modules/channels/agent-events.mjs +0 -341
- package/dist/modules/channels/agent-events.mjs.map +0 -7
- package/dist/modules/channels/agent-schedules.d.mts +0 -25
- package/dist/modules/channels/agent-schedules.mjs +0 -96
- package/dist/modules/channels/agent-schedules.mjs.map +0 -7
- package/dist/modules/channels/service-input.d.mts +0 -22
- package/dist/modules/channels/service-input.mjs +0 -32
- package/dist/modules/channels/service-input.mjs.map +0 -7
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineChannel,
|
|
3
|
+
packageRef,
|
|
4
|
+
jsonValueSchema,
|
|
5
|
+
principalRefSchema
|
|
6
|
+
} from "@finalai/agent-provider";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import {
|
|
9
|
+
mirrorReceiptKey,
|
|
10
|
+
settleMirrorReaction,
|
|
11
|
+
shareReplyReceipt
|
|
12
|
+
} from "./reply-receipts.mjs";
|
|
13
|
+
import { emojiToName, nameToEmoji } from "gemoji";
|
|
14
|
+
import {
|
|
15
|
+
channelBaseUrl,
|
|
16
|
+
addSharedReaction,
|
|
17
|
+
removeSharedReaction,
|
|
18
|
+
providerPlaceKey,
|
|
19
|
+
confirmOAuth,
|
|
20
|
+
deliveryRetention,
|
|
21
|
+
decisionChoices,
|
|
22
|
+
decisionSummary,
|
|
23
|
+
externalKey,
|
|
24
|
+
freshTimestamp,
|
|
25
|
+
inTenant,
|
|
26
|
+
providerOriginSchema,
|
|
27
|
+
parseJson,
|
|
28
|
+
personFor,
|
|
29
|
+
receivePersonMessage,
|
|
30
|
+
receipt,
|
|
31
|
+
refString,
|
|
32
|
+
responseJson,
|
|
33
|
+
startOAuth,
|
|
34
|
+
stateKey,
|
|
35
|
+
takeOAuth,
|
|
36
|
+
textOf,
|
|
37
|
+
completionText,
|
|
38
|
+
verifyHmac
|
|
39
|
+
} from "./channel-utils.mjs";
|
|
40
|
+
const configSchema = z.object({
|
|
41
|
+
baseUrl: z.string().url().optional(),
|
|
42
|
+
history: z.union([z.literal("none"), z.literal("linked-members")]).default("none")
|
|
43
|
+
});
|
|
44
|
+
const envelopeSchema = z.object({
|
|
45
|
+
type: z.string().optional(),
|
|
46
|
+
team_id: z.string().optional(),
|
|
47
|
+
enterprise_id: z.string().nullish(),
|
|
48
|
+
event_id: z.string().optional(),
|
|
49
|
+
event: z.record(z.string(), jsonValueSchema).optional(),
|
|
50
|
+
challenge: z.string().optional()
|
|
51
|
+
});
|
|
52
|
+
const messageSchema = z.object({
|
|
53
|
+
type: z.string(),
|
|
54
|
+
subtype: z.string().optional(),
|
|
55
|
+
user: z.string().optional(),
|
|
56
|
+
channel: z.string().optional(),
|
|
57
|
+
channel_type: z.string().optional(),
|
|
58
|
+
text: z.string().optional(),
|
|
59
|
+
ts: z.string().optional(),
|
|
60
|
+
thread_ts: z.string().optional(),
|
|
61
|
+
bot_id: z.string().optional()
|
|
62
|
+
});
|
|
63
|
+
const slackEmoji = (emoji) => {
|
|
64
|
+
const name = emoji.replace(/^:|:$/g, "");
|
|
65
|
+
if (/^[a-z0-9_+-]+(?:::skin-tone-[2-6])?$/i.test(name)) return name;
|
|
66
|
+
const modifiers = [...emoji].filter((character) => /\p{Emoji_Modifier}/u.test(character));
|
|
67
|
+
if (modifiers.length > 1) return null;
|
|
68
|
+
const base = emoji.replace(/\p{Emoji_Modifier}/gu, "");
|
|
69
|
+
const alias = (Object.hasOwn(emojiToName, base) ? emojiToName[base] : void 0) ?? emojiToName[base + "\uFE0F"] ?? emojiToName[base.replace(/\ufe0f/g, "")];
|
|
70
|
+
if (typeof alias !== "string") return null;
|
|
71
|
+
const modifier = modifiers[0]?.codePointAt(0);
|
|
72
|
+
return alias + (modifier == null ? "" : `::skin-tone-${modifier - 127993}`);
|
|
73
|
+
};
|
|
74
|
+
const finalEmoji = (name) => {
|
|
75
|
+
const match = /^(.+?)(?:::skin-tone-([2-6]))?$/.exec(name);
|
|
76
|
+
if (match == null || match[1] == null) return `:${name}:`;
|
|
77
|
+
const emoji = Object.hasOwn(nameToEmoji, match[1]) ? nameToEmoji[match[1]] : void 0;
|
|
78
|
+
if (emoji == null) return `:${name}:`;
|
|
79
|
+
if (match[2] == null) return emoji;
|
|
80
|
+
const characters = [...emoji];
|
|
81
|
+
characters.splice(1, 0, String.fromCodePoint(127993 + Number(match[2])));
|
|
82
|
+
return characters.join("");
|
|
83
|
+
};
|
|
84
|
+
const safeSlackText = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
85
|
+
const slackResponseSchema = z.looseObject({ ok: z.boolean(), error: z.string().optional() });
|
|
86
|
+
const api = async (ctx, method, body) => {
|
|
87
|
+
const app = await ctx.credentials.app({ tenant: ctx.origin?.place.tenant });
|
|
88
|
+
const read = ["users.info", "conversations.history", "conversations.replies"].includes(method);
|
|
89
|
+
const url = new URL(`https://slack.com/api/${method}`);
|
|
90
|
+
if (read) {
|
|
91
|
+
for (const [key, value] of Object.entries(body))
|
|
92
|
+
if (value != null)
|
|
93
|
+
url.searchParams.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
94
|
+
}
|
|
95
|
+
const data = await responseJson(
|
|
96
|
+
await ctx.credentials.fetch(url, {
|
|
97
|
+
method: read ? "GET" : "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
authorization: `Bearer ${app.accessToken}`,
|
|
100
|
+
...!read ? { "content-type": "application/json" } : {}
|
|
101
|
+
},
|
|
102
|
+
...read ? {} : {
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
...body,
|
|
105
|
+
...typeof body.text === "string" ? { text: safeSlackText(body.text) } : {}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
const status = slackResponseSchema.parse(data);
|
|
111
|
+
const repeated = method === "reactions.add" && status.error === "already_reacted" || method === "reactions.remove" && status.error === "no_reaction" || method === "chat.delete" && status.error === "message_not_found" || method === "chat.update" && (status.error === "message_not_modified" || status.error === "message_not_found");
|
|
112
|
+
if (!status.ok && !repeated)
|
|
113
|
+
throw new Error(`Slack ${method}: ${status.error ?? "unknown error"}`, {
|
|
114
|
+
cause: { code: status.error ?? "unknown_error" }
|
|
115
|
+
});
|
|
116
|
+
return data;
|
|
117
|
+
};
|
|
118
|
+
const classifyMirrorReaction = async (cause) => {
|
|
119
|
+
const error = z.object({ code: z.string() }).safeParse(cause instanceof Error ? cause.cause : null);
|
|
120
|
+
if (!error.success) return "retry";
|
|
121
|
+
if (error.data.code === "message_not_found") return "missing";
|
|
122
|
+
return [
|
|
123
|
+
"missing_scope",
|
|
124
|
+
"not_in_channel",
|
|
125
|
+
"channel_not_found",
|
|
126
|
+
"no_permission",
|
|
127
|
+
"restricted_action",
|
|
128
|
+
"not_allowed_token_type",
|
|
129
|
+
"invalid_name",
|
|
130
|
+
"thread_locked",
|
|
131
|
+
"not_reactable",
|
|
132
|
+
"is_archived",
|
|
133
|
+
"team_access_not_granted",
|
|
134
|
+
"no_access",
|
|
135
|
+
"too_many_reactions",
|
|
136
|
+
"too_many_emoji",
|
|
137
|
+
"bad_timestamp"
|
|
138
|
+
].includes(error.data.code) ? "unavailable" : "retry";
|
|
139
|
+
};
|
|
140
|
+
const responseUrl = (value) => {
|
|
141
|
+
if (value == null) return null;
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(value);
|
|
144
|
+
return url.protocol === "https:" && url.hostname === "hooks.slack.com" && url.port === "" && url.username === "" && url.password === "" && /^\/(?:actions|commands|services)\//.test(url.pathname) ? url.href : null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const confirmDecision = async (args) => {
|
|
150
|
+
const { ctx, decisionId } = args;
|
|
151
|
+
const key = stateKey("slack-decision-confirmed", decisionId);
|
|
152
|
+
const prior = await ctx.store.get(key);
|
|
153
|
+
const metadata = z.object({ url: z.string() }).safeParse(await ctx.store.get(stateKey("slack-decision-message", decisionId)));
|
|
154
|
+
const url = metadata.success ? responseUrl(metadata.data.url) : null;
|
|
155
|
+
if (url != null && prior !== "replaced") {
|
|
156
|
+
try {
|
|
157
|
+
const response = await ctx.credentials.fetch(url, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
redirect: "error",
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
replace_original: true,
|
|
163
|
+
text: args.message ?? "This decision has been answered in Final.",
|
|
164
|
+
blocks: []
|
|
165
|
+
})
|
|
166
|
+
});
|
|
167
|
+
await response.text();
|
|
168
|
+
if (!response.ok) throw new Error("Slack approval confirmation unavailable.");
|
|
169
|
+
await ctx.store.set(key, "replaced", { ttlSeconds: 3600 });
|
|
170
|
+
return;
|
|
171
|
+
} catch {
|
|
172
|
+
ctx.log("Slack approval replacement unavailable.");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (prior != null || args.channel == null || args.user == null) return;
|
|
176
|
+
try {
|
|
177
|
+
await api(ctx, "chat.postEphemeral", {
|
|
178
|
+
channel: args.channel,
|
|
179
|
+
user: args.user,
|
|
180
|
+
...args.threadTs == null ? {} : { thread_ts: args.threadTs },
|
|
181
|
+
text: args.message ?? "This decision has been answered in Final."
|
|
182
|
+
});
|
|
183
|
+
await ctx.store.set(key, "notified", { ttlSeconds: 3600 });
|
|
184
|
+
} catch {
|
|
185
|
+
ctx.log("Slack approval confirmation unavailable.");
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const slackUserSchema = z.object({
|
|
189
|
+
user: z.object({
|
|
190
|
+
id: z.string(),
|
|
191
|
+
name: z.string().optional(),
|
|
192
|
+
deleted: z.boolean().optional(),
|
|
193
|
+
enterprise_user: z.object({ id: z.string(), enterprise_id: z.string() }).optional(),
|
|
194
|
+
profile: z.object({ display_name: z.string().optional(), real_name: z.string().optional() }).optional()
|
|
195
|
+
})
|
|
196
|
+
});
|
|
197
|
+
const enterpriseTeamsSchema = z.object({
|
|
198
|
+
teams: z.array(z.string()).max(256),
|
|
199
|
+
overflow: z.boolean()
|
|
200
|
+
});
|
|
201
|
+
const proveTeam = async (ctx, team, enterprise) => {
|
|
202
|
+
await ctx.store.set(stateKey("slack-enterprise", team), enterprise);
|
|
203
|
+
const key = stateKey("slack-enterprise-teams", enterprise);
|
|
204
|
+
const saved = enterpriseTeamsSchema.safeParse(await ctx.store.get(key));
|
|
205
|
+
if (saved.success && (saved.data.overflow || saved.data.teams.includes(team))) return;
|
|
206
|
+
const teams = [...saved.success ? saved.data.teams : [], team];
|
|
207
|
+
await ctx.store.set(
|
|
208
|
+
key,
|
|
209
|
+
teams.length > 256 ? { teams: [], overflow: true } : { teams, overflow: false }
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
const revokeTeam = async (args) => {
|
|
213
|
+
const { ctx, team, reason } = args;
|
|
214
|
+
const alias = z.string().safeParse(await ctx.store.get(stateKey("slack-enterprise", team)));
|
|
215
|
+
const enterprise = args.enterprise ?? (alias.success ? alias.data : void 0);
|
|
216
|
+
await ctx.people.revoke({ tenant: team, reason });
|
|
217
|
+
if (enterprise != null && enterprise !== team) {
|
|
218
|
+
const key = stateKey("slack-enterprise-teams", enterprise);
|
|
219
|
+
const saved = enterpriseTeamsSchema.safeParse(await ctx.store.get(key));
|
|
220
|
+
const teams = saved.success ? saved.data.teams.filter((known) => known !== team) : [];
|
|
221
|
+
if (!saved.success || saved.data.overflow || teams.length === 0) {
|
|
222
|
+
await ctx.people.revoke({ tenant: enterprise, reason });
|
|
223
|
+
await ctx.store.set(key, { teams: [], overflow: false });
|
|
224
|
+
} else await ctx.store.set(key, { teams, overflow: false });
|
|
225
|
+
}
|
|
226
|
+
await ctx.store.set(stateKey("slack-enterprise", team), null);
|
|
227
|
+
await ctx.store.set(stateKey("slack-bot-user", team), null);
|
|
228
|
+
};
|
|
229
|
+
const principalFor = async (ctx, team, user) => {
|
|
230
|
+
const profile = slackUserSchema.parse(
|
|
231
|
+
await api(inTenant(ctx, team), "users.info", { user })
|
|
232
|
+
).user;
|
|
233
|
+
if (profile.enterprise_user != null)
|
|
234
|
+
await proveTeam(ctx, team, profile.enterprise_user.enterprise_id);
|
|
235
|
+
if (profile.deleted === true)
|
|
236
|
+
await ctx.people.revoke({
|
|
237
|
+
principal: {
|
|
238
|
+
id: profile.enterprise_user?.id ?? profile.id,
|
|
239
|
+
tenant: profile.enterprise_user?.enterprise_id ?? team
|
|
240
|
+
},
|
|
241
|
+
reason: "deactivated"
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
id: profile.enterprise_user?.id ?? profile.id,
|
|
245
|
+
tenant: profile.enterprise_user?.enterprise_id ?? team,
|
|
246
|
+
displayName: [profile.profile?.display_name, profile.profile?.real_name, profile.name, profile.id].find(
|
|
247
|
+
(name) => name != null && name.trim().length > 0
|
|
248
|
+
) ?? profile.id
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
const placeFor = (message, tenant) => {
|
|
252
|
+
if (message.channel == null) return null;
|
|
253
|
+
const channel = {
|
|
254
|
+
kind: message.channel_type === "im" || message.channel.startsWith("D") ? "dm" : "channel",
|
|
255
|
+
id: message.channel,
|
|
256
|
+
tenant,
|
|
257
|
+
title: message.channel
|
|
258
|
+
};
|
|
259
|
+
return message.thread_ts == null ? channel : {
|
|
260
|
+
kind: "thread",
|
|
261
|
+
id: `${message.channel}:${message.thread_ts}`,
|
|
262
|
+
tenant,
|
|
263
|
+
parent: channel,
|
|
264
|
+
title: message.channel
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
const location = (ctx) => ({
|
|
268
|
+
channel: refString(ctx.origin.ref, "channel") ?? (ctx.origin.place.kind === "thread" ? ctx.origin.place.parent?.id ?? ctx.origin.place.id.split(":")[0] : ctx.origin.place.id),
|
|
269
|
+
thread_ts: refString(ctx.origin.ref, "threadTs") ?? refString(ctx.origin.ref, "ts") ?? (ctx.origin.place.kind === "thread" ? ctx.origin.place.id.split(":").at(-1) : null)
|
|
270
|
+
});
|
|
271
|
+
const botFor = async (ctx, tenant) => {
|
|
272
|
+
const key = stateKey("slack-bot-user", tenant);
|
|
273
|
+
const cached = z.string().safeParse(await ctx.store.get(key));
|
|
274
|
+
if (cached.success) return cached.data;
|
|
275
|
+
const bot = z.object({ user_id: z.string().optional() }).parse(await api(ctx, "auth.test", {})).user_id;
|
|
276
|
+
if (bot != null) await ctx.store.set(key, bot, deliveryRetention);
|
|
277
|
+
return bot;
|
|
278
|
+
};
|
|
279
|
+
const normalizedText = async (ctx, text, tenant) => {
|
|
280
|
+
const bot = await botFor(ctx, tenant);
|
|
281
|
+
return text.replace(/<@([A-Z0-9]+)>/g, (_match, id) => id === bot ? "" : `@${id}`).replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").trim();
|
|
282
|
+
};
|
|
283
|
+
const replySchema = z.object({ ts: z.string() });
|
|
284
|
+
const replyKey = (ctx, replyOf) => stateKey(
|
|
285
|
+
"slack-reply",
|
|
286
|
+
replyOf?.externalKey ?? refString(ctx.origin.ref, "externalKey") ?? ctx.origin.place.id,
|
|
287
|
+
replyOf?.turnId ?? ctx.turn.id
|
|
288
|
+
);
|
|
289
|
+
const postReply = async (ctx, text, final, key = replyKey(ctx)) => {
|
|
290
|
+
const { channel, thread_ts } = location(ctx);
|
|
291
|
+
if (channel == null) return;
|
|
292
|
+
const saved = z.object({
|
|
293
|
+
ts: z.string(),
|
|
294
|
+
updatedAt: z.number(),
|
|
295
|
+
final: z.boolean(),
|
|
296
|
+
text: z.string().optional()
|
|
297
|
+
}).safeParse(await ctx.store.get(key));
|
|
298
|
+
if (saved.success && saved.data.final) return;
|
|
299
|
+
if (saved.success && !final && Date.now() - saved.data.updatedAt < 1e3) {
|
|
300
|
+
await ctx.store.set(key, { ...saved.data, text: text.slice(0, 11500) }, deliveryRetention);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const body = text.slice(0, 11500) + (text.length > 11500 ? "\nContinue in Final \u2192" : "");
|
|
304
|
+
if (body.length === 0) return;
|
|
305
|
+
const posted = await api(ctx, saved.success ? "chat.update" : "chat.postMessage", {
|
|
306
|
+
channel,
|
|
307
|
+
...saved.success ? { ts: saved.data.ts } : thread_ts == null ? {} : { thread_ts },
|
|
308
|
+
text: body
|
|
309
|
+
});
|
|
310
|
+
if (saved.success && slackResponseSchema.parse(posted).error === "message_not_found") {
|
|
311
|
+
await ctx.store.set(key, null);
|
|
312
|
+
await postReply(ctx, text, final, key);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const ts = saved.success ? saved.data.ts : replySchema.parse(posted).ts;
|
|
316
|
+
await ctx.store.set(
|
|
317
|
+
key,
|
|
318
|
+
{ ts, updatedAt: Date.now(), final, text: text.slice(0, 11500) },
|
|
319
|
+
deliveryRetention
|
|
320
|
+
);
|
|
321
|
+
await ctx.store.set(
|
|
322
|
+
stateKey("slack-origin", ctx.origin.place.tenant ?? null, channel, ts),
|
|
323
|
+
jsonValueSchema.parse({
|
|
324
|
+
place: ctx.origin.place,
|
|
325
|
+
externalKey: refString(ctx.origin.ref, "externalKey") ?? ctx.origin.place.id,
|
|
326
|
+
turnId: ctx.turn.id
|
|
327
|
+
}),
|
|
328
|
+
deliveryRetention
|
|
329
|
+
);
|
|
330
|
+
};
|
|
331
|
+
const savedPartial = async (ctx) => {
|
|
332
|
+
const receipt2 = z.object({ text: z.string() }).safeParse(await ctx.store.get(replyKey(ctx)));
|
|
333
|
+
return receipt2.success ? receipt2.data.text : "";
|
|
334
|
+
};
|
|
335
|
+
const sharedReply = async (ctx, args) => {
|
|
336
|
+
const { channel, thread_ts } = location(ctx);
|
|
337
|
+
await shareReplyReceipt({
|
|
338
|
+
bindingId: ctx.binding.id,
|
|
339
|
+
store: ctx.store,
|
|
340
|
+
destination: stateKey(
|
|
341
|
+
"slack",
|
|
342
|
+
ctx.origin.place.tenant ?? null,
|
|
343
|
+
channel ?? null,
|
|
344
|
+
thread_ts ?? null
|
|
345
|
+
),
|
|
346
|
+
messageIds: args.messageIds,
|
|
347
|
+
originKey: replyKey(ctx),
|
|
348
|
+
send: (key) => postReply(ctx, args.text, args.final, key),
|
|
349
|
+
removeDuplicate: async (receipt2, canonical) => {
|
|
350
|
+
const saved = replySchema.safeParse(receipt2);
|
|
351
|
+
const target = replySchema.safeParse(canonical);
|
|
352
|
+
if (saved.success && target.success && saved.data.ts !== target.data.ts && channel != null)
|
|
353
|
+
await api(ctx, "chat.delete", { channel, ts: saved.data.ts });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
const slackChannel = (options = {}) => {
|
|
358
|
+
const config = configSchema.parse(options);
|
|
359
|
+
return defineChannel({
|
|
360
|
+
name: "slack",
|
|
361
|
+
config,
|
|
362
|
+
installed: packageRef("@finalai/final-agent-provider/channels/slack"),
|
|
363
|
+
credentials: { app: "slack" },
|
|
364
|
+
identity: {
|
|
365
|
+
kind: "redirect",
|
|
366
|
+
start: async (args, ctx) => {
|
|
367
|
+
const app = await ctx.credentials.appInfo();
|
|
368
|
+
return startOAuth(
|
|
369
|
+
{
|
|
370
|
+
...args,
|
|
371
|
+
authorizeUrl: "https://slack.com/openid/connect/authorize",
|
|
372
|
+
clientId: app.clientId,
|
|
373
|
+
redirectUri: `${channelBaseUrl(config)}/b/${ctx.binding.id}/oauth/callback`,
|
|
374
|
+
scope: "openid profile"
|
|
375
|
+
},
|
|
376
|
+
ctx
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
places: {
|
|
381
|
+
kinds: ["dm", "channel", "thread"],
|
|
382
|
+
defaults: (place) => place.kind === "dm" ? { create: "direct-with-agent", replies: "origin", audience: "members" } : null
|
|
383
|
+
},
|
|
384
|
+
routes: [
|
|
385
|
+
{
|
|
386
|
+
method: "GET",
|
|
387
|
+
path: "/oauth/callback",
|
|
388
|
+
handler: async (ctx) => {
|
|
389
|
+
const saved = await takeOAuth(ctx.request, ctx);
|
|
390
|
+
if (saved == null)
|
|
391
|
+
return new Response("Invalid or expired link request", { status: 400 });
|
|
392
|
+
const app = await ctx.credentials.appInfo();
|
|
393
|
+
const token = z.object({ ok: z.literal(true), access_token: z.string() }).parse(
|
|
394
|
+
await responseJson(
|
|
395
|
+
await ctx.credentials.fetch("https://slack.com/api/openid.connect.token", {
|
|
396
|
+
method: "POST",
|
|
397
|
+
body: new URLSearchParams({
|
|
398
|
+
client_id: app.clientId,
|
|
399
|
+
client_secret: app.clientSecret,
|
|
400
|
+
code: saved.code,
|
|
401
|
+
redirect_uri: saved.redirectUri,
|
|
402
|
+
grant_type: "authorization_code"
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
const identity = z.object({
|
|
408
|
+
ok: z.literal(true),
|
|
409
|
+
sub: z.string(),
|
|
410
|
+
"https://slack.com/team_id": z.string(),
|
|
411
|
+
"https://slack.com/user_id": z.string().optional()
|
|
412
|
+
}).parse(
|
|
413
|
+
await responseJson(
|
|
414
|
+
await ctx.credentials.fetch("https://slack.com/api/openid.connect.userInfo", {
|
|
415
|
+
headers: { authorization: `Bearer ${token.access_token}` }
|
|
416
|
+
})
|
|
417
|
+
)
|
|
418
|
+
);
|
|
419
|
+
const actual = await principalFor(
|
|
420
|
+
ctx,
|
|
421
|
+
identity["https://slack.com/team_id"],
|
|
422
|
+
identity["https://slack.com/user_id"] ?? identity.sub
|
|
423
|
+
);
|
|
424
|
+
const response = await confirmOAuth(
|
|
425
|
+
{ ticket: saved.ticket, expected: saved.principal, actual },
|
|
426
|
+
ctx
|
|
427
|
+
);
|
|
428
|
+
if (response.ok)
|
|
429
|
+
await ctx.store.set(
|
|
430
|
+
stateKey(
|
|
431
|
+
"slack-principal",
|
|
432
|
+
identity["https://slack.com/team_id"],
|
|
433
|
+
identity["https://slack.com/user_id"] ?? identity.sub
|
|
434
|
+
),
|
|
435
|
+
jsonValueSchema.parse(actual),
|
|
436
|
+
deliveryRetention
|
|
437
|
+
);
|
|
438
|
+
return response;
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
method: "POST",
|
|
443
|
+
path: "/webhook",
|
|
444
|
+
handler: async (ctx) => {
|
|
445
|
+
const body = await ctx.request.text();
|
|
446
|
+
let raw = null;
|
|
447
|
+
let completeJson = false;
|
|
448
|
+
try {
|
|
449
|
+
raw = JSON.parse(body);
|
|
450
|
+
completeJson = true;
|
|
451
|
+
} catch {
|
|
452
|
+
}
|
|
453
|
+
if (!completeJson && ctx.request.headers.get("content-type")?.includes("application/x-www-form-urlencoded")) {
|
|
454
|
+
const form = new URLSearchParams(body);
|
|
455
|
+
raw = form.has("payload") ? parseJson(form.get("payload") ?? "") : Object.fromEntries(
|
|
456
|
+
[...new Set(form.keys())].flatMap((key) => {
|
|
457
|
+
const value = form.get(key);
|
|
458
|
+
return value == null ? [] : [[key, value]];
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
const app = await ctx.credentials.appInfo();
|
|
463
|
+
const timestamp = ctx.request.headers.get("x-slack-request-timestamp");
|
|
464
|
+
const signature = ctx.request.headers.get("x-slack-signature");
|
|
465
|
+
if (app.signingSecret == null || !freshTimestamp(timestamp) || signature == null || !signature.startsWith("v0=") || !await verifyHmac({
|
|
466
|
+
secret: app.signingSecret,
|
|
467
|
+
body: `v0:${timestamp}:${body}`,
|
|
468
|
+
signature: signature.slice(3)
|
|
469
|
+
}))
|
|
470
|
+
return new Response(null, { status: 401 });
|
|
471
|
+
const envelope = envelopeSchema.safeParse(raw);
|
|
472
|
+
if (envelope.success && envelope.data.type === "url_verification" && envelope.data.challenge != null)
|
|
473
|
+
return Response.json({ challenge: envelope.data.challenge });
|
|
474
|
+
const slash = z.object({ command: z.literal("/final"), trigger_id: z.string() }).safeParse(raw);
|
|
475
|
+
if (slash.success) {
|
|
476
|
+
try {
|
|
477
|
+
await ctx.receive(jsonValueSchema.parse(raw));
|
|
478
|
+
const key = stateKey("slack-command-response", slash.data.trigger_id);
|
|
479
|
+
const text = await ctx.store.get(key);
|
|
480
|
+
await ctx.store.set(key, null);
|
|
481
|
+
return Response.json({
|
|
482
|
+
response_type: "ephemeral",
|
|
483
|
+
text: typeof text === "string" ? text : "Open Final to check this command."
|
|
484
|
+
});
|
|
485
|
+
} catch {
|
|
486
|
+
return Response.json({
|
|
487
|
+
response_type: "ephemeral",
|
|
488
|
+
text: "This command could not be completed. Open Final to check its state."
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
await ctx.receive(jsonValueSchema.parse(raw));
|
|
493
|
+
return new Response(null, { status: 200 });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
],
|
|
497
|
+
receive: async (input, baseContext) => {
|
|
498
|
+
const tenant = z.object({ team_id: z.string().optional(), team: z.object({ id: z.string() }).optional() }).safeParse(input);
|
|
499
|
+
const teamId = tenant.success ? tenant.data.team_id ?? tenant.data.team?.id : void 0;
|
|
500
|
+
const ctx = teamId == null ? baseContext : inTenant(baseContext, teamId);
|
|
501
|
+
const command = z.object({
|
|
502
|
+
command: z.literal("/final"),
|
|
503
|
+
trigger_id: z.string().optional(),
|
|
504
|
+
text: z.string().default(""),
|
|
505
|
+
user_id: z.string(),
|
|
506
|
+
team_id: z.string(),
|
|
507
|
+
channel_id: z.string()
|
|
508
|
+
}).safeParse(input);
|
|
509
|
+
if (command.success) {
|
|
510
|
+
const principal2 = await principalFor(ctx, command.data.team_id, command.data.user_id);
|
|
511
|
+
const by = await personFor(ctx, principal2);
|
|
512
|
+
const place2 = {
|
|
513
|
+
kind: command.data.channel_id.startsWith("D") ? "dm" : "channel",
|
|
514
|
+
id: command.data.channel_id,
|
|
515
|
+
tenant: command.data.team_id
|
|
516
|
+
};
|
|
517
|
+
const verb = command.data.text.trim().split(/\s+/)[0];
|
|
518
|
+
let text2;
|
|
519
|
+
if (typeof by !== "string") text2 = `${by.message} ${by.linkUrl ?? ""}`;
|
|
520
|
+
else if (verb === "disconnect") {
|
|
521
|
+
try {
|
|
522
|
+
await ctx.places.disconnect(place2, by);
|
|
523
|
+
text2 = "Disconnected this place from Final.";
|
|
524
|
+
} catch {
|
|
525
|
+
text2 = "Could not disconnect this place. Open Final to manage its connection or disconnect its connected parent place.";
|
|
526
|
+
}
|
|
527
|
+
} else if (verb === "unlink") {
|
|
528
|
+
await ctx.people.revoke({ principal: principal2, reason: "provider-revoked" });
|
|
529
|
+
text2 = "Unlinked your Slack account.";
|
|
530
|
+
} else {
|
|
531
|
+
const route = await ctx.places.resolve(place2);
|
|
532
|
+
text2 = route.kind === "connected" ? "This place is connected to Final. Use /final disconnect to disconnect it." : `Connect this place in Final: ${route.connectUrl}`;
|
|
533
|
+
}
|
|
534
|
+
if (command.data.trigger_id != null)
|
|
535
|
+
await ctx.store.set(stateKey("slack-command-response", command.data.trigger_id), text2, {
|
|
536
|
+
ttlSeconds: 60
|
|
537
|
+
});
|
|
538
|
+
else
|
|
539
|
+
await api(ctx, place2.kind === "dm" ? "chat.postMessage" : "chat.postEphemeral", {
|
|
540
|
+
channel: command.data.channel_id,
|
|
541
|
+
user: command.data.user_id,
|
|
542
|
+
text: text2
|
|
543
|
+
}).catch(() => ctx.log("Slack command response unavailable."));
|
|
544
|
+
return { accepted: true };
|
|
545
|
+
}
|
|
546
|
+
const interaction = z.object({
|
|
547
|
+
type: z.literal("block_actions"),
|
|
548
|
+
user: z.object({ id: z.string() }),
|
|
549
|
+
team: z.object({ id: z.string() }),
|
|
550
|
+
actions: z.array(z.object({ value: z.string() })),
|
|
551
|
+
channel: z.object({ id: z.string() }).optional(),
|
|
552
|
+
message: z.object({ thread_ts: z.string().optional(), ts: z.string().optional() }).optional(),
|
|
553
|
+
response_url: z.string().optional()
|
|
554
|
+
}).safeParse(input);
|
|
555
|
+
if (interaction.success) {
|
|
556
|
+
const refuse = async (reason) => {
|
|
557
|
+
if (interaction.data.channel != null)
|
|
558
|
+
await api(ctx, "chat.postEphemeral", {
|
|
559
|
+
channel: interaction.data.channel.id,
|
|
560
|
+
user: interaction.data.user.id,
|
|
561
|
+
...interaction.data.message?.thread_ts == null ? {} : { thread_ts: interaction.data.message.thread_ts },
|
|
562
|
+
text: `${reason.message} ${reason.linkUrl ?? reason.connectUrl ?? ""}`
|
|
563
|
+
}).catch(() => ctx.log("Slack approval response unavailable."));
|
|
564
|
+
return { accepted: false, refusal: reason };
|
|
565
|
+
};
|
|
566
|
+
const principal2 = await principalFor(
|
|
567
|
+
ctx,
|
|
568
|
+
interaction.data.team.id,
|
|
569
|
+
interaction.data.user.id
|
|
570
|
+
);
|
|
571
|
+
const by = await personFor(ctx, principal2);
|
|
572
|
+
if (typeof by !== "string") return refuse(by);
|
|
573
|
+
const value = interaction.data.actions[0]?.value;
|
|
574
|
+
if (value == null) return { ignored: true, reason: "no-action" };
|
|
575
|
+
const saved = z.object({
|
|
576
|
+
decisionId: z.string(),
|
|
577
|
+
principal: z.object({ id: z.string(), tenant: z.string().optional() }),
|
|
578
|
+
outcome: jsonValueSchema
|
|
579
|
+
}).safeParse(await ctx.store.get(stateKey("slack-decision", value)));
|
|
580
|
+
if (!saved.success || saved.data.principal.id !== principal2.id || saved.data.principal.tenant !== principal2.tenant)
|
|
581
|
+
return refuse({
|
|
582
|
+
refused: "no-access",
|
|
583
|
+
message: saved.success ? "Only the requester can answer this decision." : "This approval expired. Answer in Final."
|
|
584
|
+
});
|
|
585
|
+
const url = responseUrl(interaction.data.response_url);
|
|
586
|
+
if (url != null)
|
|
587
|
+
await ctx.store.set(
|
|
588
|
+
stateKey("slack-decision-message", saved.data.decisionId),
|
|
589
|
+
{ url },
|
|
590
|
+
{ ttlSeconds: 1800 }
|
|
591
|
+
);
|
|
592
|
+
const confirm = (message2 = "This decision is no longer waiting in Final.") => confirmDecision({
|
|
593
|
+
ctx,
|
|
594
|
+
decisionId: saved.data.decisionId,
|
|
595
|
+
message: message2,
|
|
596
|
+
channel: interaction.data.channel?.id,
|
|
597
|
+
user: interaction.data.user.id
|
|
598
|
+
});
|
|
599
|
+
const completed = await ctx.store.get(
|
|
600
|
+
stateKey("slack-decision-resolved", saved.data.decisionId)
|
|
601
|
+
);
|
|
602
|
+
if (completed != null) {
|
|
603
|
+
await confirm(
|
|
604
|
+
completed === "answered" ? "This decision has been answered in Final." : void 0
|
|
605
|
+
);
|
|
606
|
+
return { accepted: true };
|
|
607
|
+
}
|
|
608
|
+
const result2 = await ctx.decisions.resolve({
|
|
609
|
+
decisionId: saved.data.decisionId,
|
|
610
|
+
outcome: saved.data.outcome,
|
|
611
|
+
by
|
|
612
|
+
});
|
|
613
|
+
if ("refused" in result2) return refuse(result2);
|
|
614
|
+
await ctx.store.set(
|
|
615
|
+
stateKey("slack-decision-resolved", saved.data.decisionId),
|
|
616
|
+
result2.resolved ? "answered" : "closed",
|
|
617
|
+
{
|
|
618
|
+
ttlSeconds: 3600
|
|
619
|
+
}
|
|
620
|
+
);
|
|
621
|
+
await confirm(result2.resolved ? "This decision has been answered in Final." : void 0);
|
|
622
|
+
return result2.resolved ? { accepted: true } : {
|
|
623
|
+
accepted: false,
|
|
624
|
+
refusal: {
|
|
625
|
+
refused: "no-access",
|
|
626
|
+
message: "This decision is no longer waiting in Final."
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const envelope = envelopeSchema.safeParse(input);
|
|
631
|
+
if (!envelope.success || envelope.data.event == null || envelope.data.team_id == null)
|
|
632
|
+
return { ignored: true, reason: "not-an-event" };
|
|
633
|
+
const event = envelope.data.event;
|
|
634
|
+
if (event.type === "app_uninstalled" || event.type === "tokens_revoked") {
|
|
635
|
+
if (event.type === "app_uninstalled") {
|
|
636
|
+
await revokeTeam({
|
|
637
|
+
ctx,
|
|
638
|
+
team: envelope.data.team_id,
|
|
639
|
+
enterprise: envelope.data.enterprise_id ?? void 0,
|
|
640
|
+
reason: "uninstalled"
|
|
641
|
+
});
|
|
642
|
+
} else {
|
|
643
|
+
const tokens = z.object({
|
|
644
|
+
oauth: z.array(z.string()).default([]),
|
|
645
|
+
bot: z.array(z.string()).default([])
|
|
646
|
+
}).safeParse(event.tokens);
|
|
647
|
+
if (!tokens.success) return { ignored: true, reason: "invalid-revocation" };
|
|
648
|
+
if (tokens.data.bot.length > 0)
|
|
649
|
+
await revokeTeam({
|
|
650
|
+
ctx,
|
|
651
|
+
team: envelope.data.team_id,
|
|
652
|
+
enterprise: envelope.data.enterprise_id ?? void 0,
|
|
653
|
+
reason: "provider-revoked"
|
|
654
|
+
});
|
|
655
|
+
else
|
|
656
|
+
for (const user of tokens.data.oauth) {
|
|
657
|
+
const cached = principalRefSchema.safeParse(
|
|
658
|
+
await ctx.store.get(stateKey("slack-principal", envelope.data.team_id, user))
|
|
659
|
+
);
|
|
660
|
+
const principal2 = cached.success ? cached.data : await principalFor(ctx, envelope.data.team_id, user);
|
|
661
|
+
await ctx.people.revoke({ principal: principal2, reason: "provider-revoked" });
|
|
662
|
+
await ctx.store.set(stateKey("slack-principal", envelope.data.team_id, user), null);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return { accepted: true };
|
|
666
|
+
}
|
|
667
|
+
if (event.type === "user_change") {
|
|
668
|
+
const changed = slackUserSchema.shape.user.safeParse(event.user);
|
|
669
|
+
if (changed.success && changed.data.deleted === true)
|
|
670
|
+
await ctx.people.revoke({
|
|
671
|
+
principal: {
|
|
672
|
+
id: changed.data.enterprise_user?.id ?? changed.data.id,
|
|
673
|
+
tenant: changed.data.enterprise_user?.enterprise_id ?? envelope.data.team_id
|
|
674
|
+
},
|
|
675
|
+
reason: "deactivated"
|
|
676
|
+
});
|
|
677
|
+
return { ignored: true, reason: "lifecycle" };
|
|
678
|
+
}
|
|
679
|
+
if (event.type === "message" && (event.subtype === "message_changed" || event.subtype === "message_deleted")) {
|
|
680
|
+
const deleted = event.subtype === "message_deleted" || z.object({ subtype: z.literal("tombstone") }).safeParse(event.message).success;
|
|
681
|
+
const original = messageSchema.safeParse(deleted ? event.previous_message : event.message);
|
|
682
|
+
if (!original.success || original.data.user == null || typeof event.channel !== "string")
|
|
683
|
+
return { ignored: true, reason: "missing-original" };
|
|
684
|
+
if (original.data.bot_id != null || original.data.subtype === "bot_message")
|
|
685
|
+
return { ignored: true, reason: "own-message" };
|
|
686
|
+
const timestamp = original.data.ts ?? (typeof event.deleted_ts === "string" ? event.deleted_ts : null);
|
|
687
|
+
if (timestamp == null) return { ignored: true, reason: "missing-original" };
|
|
688
|
+
const mapped = providerOriginSchema.safeParse(
|
|
689
|
+
await ctx.store.get(
|
|
690
|
+
stateKey("slack-origin", envelope.data.team_id, event.channel, timestamp)
|
|
691
|
+
)
|
|
692
|
+
);
|
|
693
|
+
if (!mapped.success) return { ignored: true, reason: "unknown-origin" };
|
|
694
|
+
const place2 = mapped.data.place;
|
|
695
|
+
const by = await personFor(
|
|
696
|
+
ctx,
|
|
697
|
+
await principalFor(ctx, envelope.data.team_id, original.data.user)
|
|
698
|
+
);
|
|
699
|
+
if (typeof by !== "string") return { accepted: false, refusal: by };
|
|
700
|
+
const route = await ctx.places.resolve(place2);
|
|
701
|
+
if (route.kind !== "connected") return { ignored: true, reason: "unconnected" };
|
|
702
|
+
const key2 = mapped.data.externalKey;
|
|
703
|
+
const editedText = !deleted ? await normalizedText(ctx, original.data.text ?? "", envelope.data.team_id) : null;
|
|
704
|
+
if (editedText != null && editedText.length === 0)
|
|
705
|
+
return { ignored: true, reason: "empty-message" };
|
|
706
|
+
return receipt(
|
|
707
|
+
!deleted ? await ctx.conversation(route.conversation).edit({
|
|
708
|
+
externalKey: key2,
|
|
709
|
+
by,
|
|
710
|
+
content: [
|
|
711
|
+
{
|
|
712
|
+
type: "text",
|
|
713
|
+
text: editedText ?? ""
|
|
714
|
+
}
|
|
715
|
+
]
|
|
716
|
+
}) : await ctx.conversation(route.conversation).remove({ externalKey: key2, by }).then((result2) => "refused" in result2 ? result2 : { messageId: key2 })
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (event.type === "reaction_added" || event.type === "reaction_removed") {
|
|
720
|
+
const parsed2 = z.object({
|
|
721
|
+
user: z.string(),
|
|
722
|
+
reaction: z.string(),
|
|
723
|
+
item: z.object({ channel: z.string(), ts: z.string() })
|
|
724
|
+
}).safeParse(event);
|
|
725
|
+
if (!parsed2.success) return { ignored: true, reason: "unsupported-reaction" };
|
|
726
|
+
const mapped = providerOriginSchema.safeParse(
|
|
727
|
+
await ctx.store.get(
|
|
728
|
+
stateKey(
|
|
729
|
+
"slack-origin",
|
|
730
|
+
envelope.data.team_id,
|
|
731
|
+
parsed2.data.item.channel,
|
|
732
|
+
parsed2.data.item.ts
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
);
|
|
736
|
+
if (!mapped.success) return { ignored: true, reason: "unknown-origin" };
|
|
737
|
+
if (parsed2.data.user === await botFor(ctx, envelope.data.team_id))
|
|
738
|
+
return { ignored: true, reason: "own-reaction" };
|
|
739
|
+
const by = await personFor(
|
|
740
|
+
ctx,
|
|
741
|
+
await principalFor(ctx, envelope.data.team_id, parsed2.data.user)
|
|
742
|
+
);
|
|
743
|
+
if (typeof by !== "string") return { accepted: false, refusal: by };
|
|
744
|
+
const route = await ctx.places.resolve(mapped.data.place);
|
|
745
|
+
if (route.kind !== "connected") return { ignored: true, reason: "unconnected" };
|
|
746
|
+
const target = mapped.data.turnId != null ? { replyOf: { externalKey: mapped.data.externalKey, turnId: mapped.data.turnId } } : {
|
|
747
|
+
externalKey: mapped.data.externalKey
|
|
748
|
+
};
|
|
749
|
+
const result2 = await ctx.conversation(route.conversation).react({
|
|
750
|
+
target,
|
|
751
|
+
emoji: finalEmoji(parsed2.data.reaction),
|
|
752
|
+
by,
|
|
753
|
+
op: event.type === "reaction_added" ? "add" : "remove"
|
|
754
|
+
});
|
|
755
|
+
return "refused" in result2 ? { accepted: false, refusal: result2 } : { accepted: true };
|
|
756
|
+
}
|
|
757
|
+
const parsed = messageSchema.safeParse(event);
|
|
758
|
+
if (!parsed.success || parsed.data.user == null || parsed.data.ts == null || parsed.data.text == null || parsed.data.bot_id != null || parsed.data.type !== "app_mention" && !(parsed.data.type === "message" && parsed.data.channel_type === "im"))
|
|
759
|
+
return { ignored: true, reason: "not-a-human-mention" };
|
|
760
|
+
const message = messageSchema.extend({ user: z.string(), ts: z.string(), text: z.string(), channel: z.string() }).parse(parsed.data);
|
|
761
|
+
const place = placeFor(message, envelope.data.team_id);
|
|
762
|
+
if (place == null) return { ignored: true, reason: "no-place" };
|
|
763
|
+
const text = await normalizedText(ctx, message.text, envelope.data.team_id);
|
|
764
|
+
if (text.length === 0) return { ignored: true, reason: "empty-message" };
|
|
765
|
+
const principal = await principalFor(ctx, envelope.data.team_id, message.user);
|
|
766
|
+
await api(ctx, "reactions.add", {
|
|
767
|
+
channel: message.channel,
|
|
768
|
+
timestamp: message.ts,
|
|
769
|
+
name: "eyes"
|
|
770
|
+
}).catch(() => ctx.log("Slack acknowledgement unavailable."));
|
|
771
|
+
const key = externalKey(place.parent ?? place, message.ts);
|
|
772
|
+
const historyTeam = envelope.data.team_id;
|
|
773
|
+
const loadContext = async () => config.history === "linked-members" ? historyFor({ ctx, message, team: historyTeam }).catch(() => {
|
|
774
|
+
ctx.log("Slack history is unavailable for this app grant.");
|
|
775
|
+
return [];
|
|
776
|
+
}) : [];
|
|
777
|
+
await ctx.store.set(
|
|
778
|
+
stateKey("slack-origin", envelope.data.team_id, message.channel, message.ts),
|
|
779
|
+
jsonValueSchema.parse({ place, externalKey: key }),
|
|
780
|
+
deliveryRetention
|
|
781
|
+
);
|
|
782
|
+
const result = await receivePersonMessage({
|
|
783
|
+
ctx,
|
|
784
|
+
principal,
|
|
785
|
+
place,
|
|
786
|
+
resumeAfterLink: place.kind === "dm",
|
|
787
|
+
text,
|
|
788
|
+
externalKey: key,
|
|
789
|
+
loadContext,
|
|
790
|
+
ref: {
|
|
791
|
+
externalKey: key,
|
|
792
|
+
channel: message.channel,
|
|
793
|
+
ts: message.ts,
|
|
794
|
+
threadTs: message.thread_ts ?? message.ts,
|
|
795
|
+
requester: principal.id,
|
|
796
|
+
user: message.user,
|
|
797
|
+
tenant: principal.tenant ?? envelope.data.team_id
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
if ("accepted" in result && !result.accepted)
|
|
801
|
+
await api(ctx, place.kind === "dm" ? "chat.postMessage" : "chat.postEphemeral", {
|
|
802
|
+
channel: message.channel,
|
|
803
|
+
user: message.user,
|
|
804
|
+
...message.thread_ts == null ? {} : { thread_ts: message.thread_ts },
|
|
805
|
+
text: `${result.refusal.message} ${result.refusal.linkUrl ?? result.refusal.connectUrl ?? ""}`
|
|
806
|
+
}).catch(() => ctx.log("Slack requester notice unavailable."));
|
|
807
|
+
return result;
|
|
808
|
+
},
|
|
809
|
+
events: {
|
|
810
|
+
"message.posted": async (data, ctx) => {
|
|
811
|
+
const key = stateKey("slack-mirror", providerPlaceKey(ctx.origin.place), data.message.id);
|
|
812
|
+
if (await ctx.store.get(key) != null) return;
|
|
813
|
+
const { channel, thread_ts } = location(ctx);
|
|
814
|
+
if (channel == null) return;
|
|
815
|
+
const result = replySchema.parse(
|
|
816
|
+
await api(ctx, "chat.postMessage", {
|
|
817
|
+
channel,
|
|
818
|
+
...thread_ts == null ? {} : { thread_ts },
|
|
819
|
+
text: `From ${data.author.displayName} in Final
|
|
820
|
+
${textOf(data.message.content)}`.slice(
|
|
821
|
+
0,
|
|
822
|
+
11500
|
|
823
|
+
)
|
|
824
|
+
})
|
|
825
|
+
);
|
|
826
|
+
await ctx.store.set(key, result.ts, deliveryRetention);
|
|
827
|
+
},
|
|
828
|
+
"message.delta": async (data, ctx) => {
|
|
829
|
+
const prior = ctx.state.get()?.text ?? await savedPartial(ctx);
|
|
830
|
+
const delta = data.delta.type === "text" && typeof data.delta.text === "string" ? data.delta.text : "";
|
|
831
|
+
const text = prior + delta;
|
|
832
|
+
ctx.state.set({ text });
|
|
833
|
+
await sharedReply(ctx, { text, final: false, messageIds: [data.messageId] });
|
|
834
|
+
},
|
|
835
|
+
"turn.completed": async (data, ctx) => {
|
|
836
|
+
await sharedReply(ctx, {
|
|
837
|
+
text: completionText(data, ctx.state.get()?.text ?? await savedPartial(ctx)),
|
|
838
|
+
final: true,
|
|
839
|
+
messageIds: data.messages?.map((message) => message.id) ?? []
|
|
840
|
+
});
|
|
841
|
+
},
|
|
842
|
+
"decision.created": async (data, ctx) => {
|
|
843
|
+
const deliveredKey = stateKey("slack-decision-delivered", data.decisionId);
|
|
844
|
+
if (await ctx.store.get(deliveredKey) != null) return;
|
|
845
|
+
const { channel, thread_ts } = location(ctx);
|
|
846
|
+
const user = refString(ctx.origin.ref, "user") ?? refString(ctx.origin.ref, "requester");
|
|
847
|
+
const principalId = refString(ctx.origin.ref, "requester") ?? user;
|
|
848
|
+
const tenant = refString(ctx.origin.ref, "tenant");
|
|
849
|
+
if (channel == null || user == null) return;
|
|
850
|
+
const choices = decisionChoices(data.request);
|
|
851
|
+
const elements = [];
|
|
852
|
+
for (const choice of choices) {
|
|
853
|
+
const value = crypto.randomUUID();
|
|
854
|
+
await ctx.store.set(
|
|
855
|
+
stateKey("slack-decision", value),
|
|
856
|
+
{
|
|
857
|
+
decisionId: data.decisionId,
|
|
858
|
+
principal: { id: principalId ?? user, ...tenant == null ? {} : { tenant } },
|
|
859
|
+
outcome: choice.outcome
|
|
860
|
+
},
|
|
861
|
+
{ ttlSeconds: 3600 }
|
|
862
|
+
);
|
|
863
|
+
elements.push({
|
|
864
|
+
type: "button",
|
|
865
|
+
text: { type: "plain_text", text: choice.label },
|
|
866
|
+
action_id: `final_decision_${elements.length}`,
|
|
867
|
+
value
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
await api(ctx, "chat.postEphemeral", {
|
|
872
|
+
channel,
|
|
873
|
+
user,
|
|
874
|
+
...thread_ts == null ? {} : { thread_ts },
|
|
875
|
+
text: `${decisionSummary(data.request)}
|
|
876
|
+
${elements.length === 0 ? "Answer in Final \u2192" : "Choose an option below."}`,
|
|
877
|
+
...elements.length === 0 ? {} : {
|
|
878
|
+
blocks: [
|
|
879
|
+
{
|
|
880
|
+
type: "section",
|
|
881
|
+
text: { type: "plain_text", text: decisionSummary(data.request) }
|
|
882
|
+
},
|
|
883
|
+
{ type: "actions", elements }
|
|
884
|
+
]
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
} catch {
|
|
888
|
+
await api(ctx, "chat.postMessage", {
|
|
889
|
+
channel,
|
|
890
|
+
...thread_ts == null ? {} : { thread_ts },
|
|
891
|
+
text: "This request needs approval. Answer in Final."
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
await ctx.store.set(deliveredKey, true, { ttlSeconds: 3600 });
|
|
895
|
+
},
|
|
896
|
+
"decision.resolved": async (data, ctx) => {
|
|
897
|
+
await ctx.store.set(stateKey("slack-decision-resolved", data.decisionId), "answered", {
|
|
898
|
+
ttlSeconds: 3600
|
|
899
|
+
});
|
|
900
|
+
const { channel, thread_ts } = location(ctx);
|
|
901
|
+
await confirmDecision({
|
|
902
|
+
ctx,
|
|
903
|
+
decisionId: data.decisionId,
|
|
904
|
+
channel: channel ?? void 0,
|
|
905
|
+
user: refString(ctx.origin.ref, "user") ?? refString(ctx.origin.ref, "requester") ?? void 0,
|
|
906
|
+
threadTs: thread_ts ?? void 0
|
|
907
|
+
});
|
|
908
|
+
},
|
|
909
|
+
"message.edited": async (data, ctx) => {
|
|
910
|
+
if (data.replyOf == null && data.externalKey.startsWith("final:")) {
|
|
911
|
+
const key = mirrorReceiptKey({
|
|
912
|
+
provider: "slack",
|
|
913
|
+
place: ctx.origin.place,
|
|
914
|
+
externalKey: data.externalKey,
|
|
915
|
+
messageId: data.message.id
|
|
916
|
+
});
|
|
917
|
+
if (key == null) return;
|
|
918
|
+
const saved2 = z.string().safeParse(await ctx.store.get(key));
|
|
919
|
+
const { channel: channel2 } = location(ctx);
|
|
920
|
+
if (!saved2.success || channel2 == null) return;
|
|
921
|
+
const result = slackResponseSchema.parse(
|
|
922
|
+
await api(ctx, "chat.update", {
|
|
923
|
+
channel: channel2,
|
|
924
|
+
ts: saved2.data,
|
|
925
|
+
text: `From ${data.author.displayName} in Final
|
|
926
|
+
${textOf(data.message.content)}`.slice(
|
|
927
|
+
0,
|
|
928
|
+
11500
|
|
929
|
+
)
|
|
930
|
+
})
|
|
931
|
+
);
|
|
932
|
+
if (result.error === "message_not_found")
|
|
933
|
+
await ctx.store.set(key, { deleted: true }, deliveryRetention);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (data.replyOf == null) {
|
|
937
|
+
const { channel: channel2, thread_ts } = location(ctx);
|
|
938
|
+
if (channel2 == null) return;
|
|
939
|
+
const key = stateKey(
|
|
940
|
+
"slack-prompt-update",
|
|
941
|
+
providerPlaceKey(ctx.origin.place),
|
|
942
|
+
data.externalKey
|
|
943
|
+
);
|
|
944
|
+
const saved2 = z.object({ ts: z.string(), text: z.string() }).safeParse(await ctx.store.get(key));
|
|
945
|
+
const text = `Updated in Final:
|
|
946
|
+
${textOf(data.message.content)}`.slice(0, 11500);
|
|
947
|
+
if (saved2.success && saved2.data.text === text) return;
|
|
948
|
+
const result = await api(ctx, saved2.success ? "chat.update" : "chat.postMessage", {
|
|
949
|
+
channel: channel2,
|
|
950
|
+
...saved2.success ? { ts: saved2.data.ts } : thread_ts == null ? {} : { thread_ts },
|
|
951
|
+
text
|
|
952
|
+
});
|
|
953
|
+
const missing = saved2.success && slackResponseSchema.parse(result).error === "message_not_found";
|
|
954
|
+
const current = missing ? await api(ctx, "chat.postMessage", {
|
|
955
|
+
channel: channel2,
|
|
956
|
+
...thread_ts == null ? {} : { thread_ts },
|
|
957
|
+
text
|
|
958
|
+
}) : result;
|
|
959
|
+
await ctx.store.set(
|
|
960
|
+
key,
|
|
961
|
+
{ ts: saved2.success && !missing ? saved2.data.ts : replySchema.parse(current).ts, text },
|
|
962
|
+
deliveryRetention
|
|
963
|
+
);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const { channel } = location(ctx);
|
|
967
|
+
const saved = z.object({ ts: z.string() }).safeParse(await ctx.store.get(replyKey(ctx, data.replyOf)));
|
|
968
|
+
const ts = saved.success ? saved.data.ts : null;
|
|
969
|
+
if (channel != null && ts != null)
|
|
970
|
+
await api(ctx, "chat.update", {
|
|
971
|
+
channel,
|
|
972
|
+
ts,
|
|
973
|
+
text: textOf(data.message.content).slice(0, 11500)
|
|
974
|
+
});
|
|
975
|
+
},
|
|
976
|
+
"message.removed": async (data, ctx) => {
|
|
977
|
+
if (data.replyOf == null && data.externalKey.startsWith("final:")) {
|
|
978
|
+
const key = mirrorReceiptKey({
|
|
979
|
+
provider: "slack",
|
|
980
|
+
place: ctx.origin.place,
|
|
981
|
+
externalKey: data.externalKey,
|
|
982
|
+
messageId: data.message.id
|
|
983
|
+
});
|
|
984
|
+
if (key == null) return;
|
|
985
|
+
const saved2 = z.string().safeParse(await ctx.store.get(key));
|
|
986
|
+
const { channel: channel2 } = location(ctx);
|
|
987
|
+
if (!saved2.success || channel2 == null) return;
|
|
988
|
+
await api(ctx, "chat.delete", { channel: channel2, ts: saved2.data });
|
|
989
|
+
await ctx.store.set(key, { deleted: true }, deliveryRetention);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (data.replyOf == null) {
|
|
993
|
+
const key = stateKey(
|
|
994
|
+
"slack-prompt-update",
|
|
995
|
+
providerPlaceKey(ctx.origin.place),
|
|
996
|
+
data.externalKey
|
|
997
|
+
);
|
|
998
|
+
const saved2 = z.object({ ts: z.string() }).safeParse(await ctx.store.get(key));
|
|
999
|
+
const { channel: channel2 } = location(ctx);
|
|
1000
|
+
if (saved2.success && channel2 != null) {
|
|
1001
|
+
await api(ctx, "chat.delete", { channel: channel2, ts: saved2.data.ts });
|
|
1002
|
+
await ctx.store.set(key, null);
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const { channel } = location(ctx);
|
|
1007
|
+
const saved = z.object({ ts: z.string() }).safeParse(await ctx.store.get(replyKey(ctx, data.replyOf)));
|
|
1008
|
+
const ts = saved.success ? saved.data.ts : null;
|
|
1009
|
+
if (channel != null && ts != null) await api(ctx, "chat.delete", { channel, ts });
|
|
1010
|
+
},
|
|
1011
|
+
"reaction.added": async (data, ctx) => {
|
|
1012
|
+
const { channel } = location(ctx);
|
|
1013
|
+
const mirror = data.replyOf == null && data.externalKey.startsWith("final:");
|
|
1014
|
+
const mirrorKey = mirrorReceiptKey({
|
|
1015
|
+
provider: "slack",
|
|
1016
|
+
place: ctx.origin.place,
|
|
1017
|
+
externalKey: data.externalKey
|
|
1018
|
+
});
|
|
1019
|
+
const mirrored = z.string().safeParse(mirrorKey == null ? null : await ctx.store.get(mirrorKey));
|
|
1020
|
+
if (mirror && !mirrored.success) return;
|
|
1021
|
+
const saved = z.object({ ts: z.string() }).safeParse(await ctx.store.get(replyKey(ctx, data.replyOf)));
|
|
1022
|
+
const name = slackEmoji(data.emoji);
|
|
1023
|
+
const timestamp = mirror && mirrored.success ? mirrored.data : data.replyOf == null ? refString(ctx.origin.ref, "ts") : saved.success ? saved.data.ts : null;
|
|
1024
|
+
if (channel != null && timestamp != null && name != null)
|
|
1025
|
+
await settleMirrorReaction({
|
|
1026
|
+
ctx,
|
|
1027
|
+
key: mirror ? mirrorKey : null,
|
|
1028
|
+
classify: classifyMirrorReaction,
|
|
1029
|
+
work: () => addSharedReaction({
|
|
1030
|
+
store: ctx.store,
|
|
1031
|
+
key: stateKey(
|
|
1032
|
+
"slack-shared-reaction",
|
|
1033
|
+
ctx.origin.place.tenant ?? null,
|
|
1034
|
+
channel,
|
|
1035
|
+
timestamp,
|
|
1036
|
+
name
|
|
1037
|
+
),
|
|
1038
|
+
principal: data.principal,
|
|
1039
|
+
add: async () => {
|
|
1040
|
+
const response = slackResponseSchema.parse(
|
|
1041
|
+
await api(ctx, "reactions.add", { channel, timestamp, name })
|
|
1042
|
+
);
|
|
1043
|
+
return response.error !== "already_reacted";
|
|
1044
|
+
}
|
|
1045
|
+
})
|
|
1046
|
+
});
|
|
1047
|
+
},
|
|
1048
|
+
"reaction.removed": async (data, ctx) => {
|
|
1049
|
+
const { channel } = location(ctx);
|
|
1050
|
+
const mirror = data.replyOf == null && data.externalKey.startsWith("final:");
|
|
1051
|
+
const mirrorKey = mirrorReceiptKey({
|
|
1052
|
+
provider: "slack",
|
|
1053
|
+
place: ctx.origin.place,
|
|
1054
|
+
externalKey: data.externalKey
|
|
1055
|
+
});
|
|
1056
|
+
const mirrored = z.string().safeParse(mirrorKey == null ? null : await ctx.store.get(mirrorKey));
|
|
1057
|
+
if (mirror && !mirrored.success) return;
|
|
1058
|
+
const saved = z.object({ ts: z.string() }).safeParse(await ctx.store.get(replyKey(ctx, data.replyOf)));
|
|
1059
|
+
const name = slackEmoji(data.emoji);
|
|
1060
|
+
const timestamp = mirror && mirrored.success ? mirrored.data : data.replyOf == null ? refString(ctx.origin.ref, "ts") : saved.success ? saved.data.ts : null;
|
|
1061
|
+
if (channel != null && timestamp != null && name != null)
|
|
1062
|
+
await settleMirrorReaction({
|
|
1063
|
+
ctx,
|
|
1064
|
+
key: mirror ? mirrorKey : null,
|
|
1065
|
+
classify: classifyMirrorReaction,
|
|
1066
|
+
work: () => removeSharedReaction({
|
|
1067
|
+
store: ctx.store,
|
|
1068
|
+
key: stateKey(
|
|
1069
|
+
"slack-shared-reaction",
|
|
1070
|
+
ctx.origin.place.tenant ?? null,
|
|
1071
|
+
channel,
|
|
1072
|
+
timestamp,
|
|
1073
|
+
name
|
|
1074
|
+
),
|
|
1075
|
+
principal: data.principal,
|
|
1076
|
+
remove: async () => {
|
|
1077
|
+
await api(ctx, "reactions.remove", { channel, timestamp, name });
|
|
1078
|
+
}
|
|
1079
|
+
})
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
};
|
|
1085
|
+
const historyFor = async (args) => {
|
|
1086
|
+
const { ctx, message } = args;
|
|
1087
|
+
if (message.channel == null) return [];
|
|
1088
|
+
const messages = [];
|
|
1089
|
+
let cursor;
|
|
1090
|
+
for (let page = 0; page < 5; page += 1) {
|
|
1091
|
+
const result = z.object({
|
|
1092
|
+
messages: z.array(messageSchema),
|
|
1093
|
+
response_metadata: z.object({ next_cursor: z.string().optional() }).optional()
|
|
1094
|
+
}).parse(
|
|
1095
|
+
await api(
|
|
1096
|
+
ctx,
|
|
1097
|
+
message.thread_ts == null ? "conversations.history" : "conversations.replies",
|
|
1098
|
+
{
|
|
1099
|
+
channel: message.channel,
|
|
1100
|
+
limit: 100,
|
|
1101
|
+
...message.ts == null ? {} : { latest: message.ts, inclusive: false },
|
|
1102
|
+
...message.thread_ts == null ? {} : { ts: message.thread_ts },
|
|
1103
|
+
...cursor == null ? {} : { cursor }
|
|
1104
|
+
}
|
|
1105
|
+
)
|
|
1106
|
+
);
|
|
1107
|
+
messages.push(...result.messages);
|
|
1108
|
+
cursor = result.response_metadata?.next_cursor;
|
|
1109
|
+
if (cursor == null || cursor.length === 0 || message.thread_ts == null) break;
|
|
1110
|
+
}
|
|
1111
|
+
if (message.thread_ts != null && cursor != null && cursor.length > 0) return [];
|
|
1112
|
+
const recent = messages.sort((a, b) => Number(a.ts ?? 0) - Number(b.ts ?? 0)).slice(-20);
|
|
1113
|
+
const lines = [];
|
|
1114
|
+
const authors = /* @__PURE__ */ new Map();
|
|
1115
|
+
for (const earlier of recent) {
|
|
1116
|
+
if (earlier.user == null || earlier.text == null || earlier.bot_id != null || earlier.ts === message.ts)
|
|
1117
|
+
continue;
|
|
1118
|
+
if (!authors.has(earlier.user)) {
|
|
1119
|
+
const principal = await principalFor(ctx, args.team, earlier.user);
|
|
1120
|
+
const linked = await ctx.people.resolve(principal);
|
|
1121
|
+
authors.set(earlier.user, linked.kind === "member" ? linked.displayName : null);
|
|
1122
|
+
}
|
|
1123
|
+
const name = authors.get(earlier.user);
|
|
1124
|
+
if (name != null) {
|
|
1125
|
+
const text = await normalizedText(ctx, earlier.text, args.team);
|
|
1126
|
+
if (text.length > 0) lines.push(`${name}: ${text}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return lines;
|
|
1130
|
+
};
|
|
1131
|
+
export {
|
|
1132
|
+
slackChannel
|
|
1133
|
+
};
|
|
1134
|
+
//# sourceMappingURL=slack.mjs.map
|