@agent-native/dispatch 0.13.13 → 0.14.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/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/provider-api-audit.d.ts +7 -0
- package/dist/actions/provider-api-audit.d.ts.map +1 -0
- package/dist/actions/provider-api-audit.js +74 -0
- package/dist/actions/provider-api-audit.js.map +1 -0
- package/dist/actions/provider-api-catalog.d.ts +1 -1
- package/dist/actions/provider-api-request.js +10 -0
- package/dist/actions/provider-api-request.js.map +1 -1
- package/dist/actions/send-platform-message.d.ts +1 -0
- package/dist/actions/send-platform-message.js +43 -22
- package/dist/actions/send-platform-message.js.map +1 -1
- package/dist/components/messaging-setup-panel.d.ts.map +1 -1
- package/dist/components/messaging-setup-panel.js +203 -127
- package/dist/components/messaging-setup-panel.js.map +1 -1
- package/dist/routes/pages/messaging.js +1 -1
- package/dist/routes/pages/messaging.js.map +1 -1
- package/dist/server/lib/dispatch-integrations.d.ts +7 -1
- package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
- package/dist/server/lib/dispatch-integrations.js +178 -8
- package/dist/server/lib/dispatch-integrations.js.map +1 -1
- package/dist/server/lib/dispatch-routing.d.ts +6 -0
- package/dist/server/lib/dispatch-routing.d.ts.map +1 -0
- package/dist/server/lib/dispatch-routing.js +30 -0
- package/dist/server/lib/dispatch-routing.js.map +1 -0
- package/dist/server/lib/env-config.d.ts.map +1 -1
- package/dist/server/lib/env-config.js +50 -0
- package/dist/server/lib/env-config.js.map +1 -1
- package/dist/server/lib/onboarding-steps.d.ts.map +1 -1
- package/dist/server/lib/onboarding-steps.js +25 -0
- package/dist/server/lib/onboarding-steps.js.map +1 -1
- package/dist/server/lib/provider-api.d.ts +2 -2
- package/dist/server/lib/provider-api.d.ts.map +1 -1
- package/dist/server/plugins/integrations.d.ts.map +1 -1
- package/dist/server/plugins/integrations.js +5 -2
- package/dist/server/plugins/integrations.js.map +1 -1
- package/package.json +2 -2
- package/src/actions/provider-api-audit.spec.ts +52 -0
- package/src/actions/provider-api-audit.ts +88 -0
- package/src/actions/provider-api-request.ts +10 -0
- package/src/actions/send-platform-message.spec.ts +108 -0
- package/src/actions/send-platform-message.ts +73 -25
- package/src/components/messaging-setup-panel.spec.tsx +191 -0
- package/src/components/messaging-setup-panel.tsx +472 -193
- package/src/routes/pages/messaging.tsx +1 -1
- package/src/server/lib/dispatch-integrations.spec.ts +113 -0
- package/src/server/lib/dispatch-integrations.ts +239 -6
- package/src/server/lib/dispatch-routing.spec.ts +42 -0
- package/src/server/lib/dispatch-routing.ts +42 -0
- package/src/server/lib/env-config.ts +50 -0
- package/src/server/lib/onboarding-steps.ts +32 -0
- package/src/server/plugins/integrations.ts +5 -2
|
@@ -9,7 +9,7 @@ export default function MessagingRoute() {
|
|
|
9
9
|
return (
|
|
10
10
|
<DispatchShell
|
|
11
11
|
title="Messaging"
|
|
12
|
-
description="Connect Slack
|
|
12
|
+
description="Connect Slack, Microsoft Teams, Discord interactions, Telegram, WhatsApp Cloud API, or provider-webhook email so supported inbound conversations reach one Dispatch inbox."
|
|
13
13
|
>
|
|
14
14
|
<MessagingSetupPanel />
|
|
15
15
|
</DispatchShell>
|
|
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
4
4
|
consumeLinkToken: vi.fn(),
|
|
5
5
|
resolveLinkedOwner: vi.fn(),
|
|
6
6
|
resolveOrgIdForEmail: vi.fn(),
|
|
7
|
+
resolveSecret: vi.fn(),
|
|
7
8
|
}));
|
|
8
9
|
|
|
9
10
|
vi.mock("./dispatch-store.js", () => ({
|
|
@@ -15,6 +16,16 @@ vi.mock("@agent-native/core/org", () => ({
|
|
|
15
16
|
resolveOrgIdForEmail: mocks.resolveOrgIdForEmail,
|
|
16
17
|
}));
|
|
17
18
|
|
|
19
|
+
vi.mock("@agent-native/core/server", async () => {
|
|
20
|
+
const actual = await vi.importActual<
|
|
21
|
+
typeof import("@agent-native/core/server")
|
|
22
|
+
>("@agent-native/core/server");
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
resolveSecret: mocks.resolveSecret,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
18
29
|
import type {
|
|
19
30
|
IncomingMessage,
|
|
20
31
|
PlatformAdapter,
|
|
@@ -24,6 +35,7 @@ import {
|
|
|
24
35
|
beforeDispatchProcess,
|
|
25
36
|
identityKeyForIncoming,
|
|
26
37
|
resolveDispatchOwner,
|
|
38
|
+
resolveDispatchExecutionContext,
|
|
27
39
|
} from "./dispatch-integrations.js";
|
|
28
40
|
|
|
29
41
|
const originalFetch = globalThis.fetch;
|
|
@@ -94,6 +106,9 @@ beforeEach(() => {
|
|
|
94
106
|
mocks.resolveLinkedOwner.mockResolvedValue(null);
|
|
95
107
|
mocks.consumeLinkToken.mockResolvedValue("owner@example.test");
|
|
96
108
|
mocks.resolveOrgIdForEmail.mockResolvedValue(null);
|
|
109
|
+
mocks.resolveSecret.mockImplementation(
|
|
110
|
+
async (key: string) => process.env[key] ?? null,
|
|
111
|
+
);
|
|
97
112
|
vi.stubGlobal(
|
|
98
113
|
"fetch",
|
|
99
114
|
vi.fn(async () => new Response(JSON.stringify({ ok: false }))),
|
|
@@ -130,6 +145,7 @@ describe("resolveDispatchOwner", () => {
|
|
|
130
145
|
|
|
131
146
|
it("uses the verified Slack email for org members", async () => {
|
|
132
147
|
vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
|
|
148
|
+
mocks.resolveSecret.mockResolvedValueOnce(null);
|
|
133
149
|
mocks.resolveOrgIdForEmail.mockResolvedValueOnce("org_123");
|
|
134
150
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
|
135
151
|
new Response(
|
|
@@ -153,6 +169,37 @@ describe("resolveDispatchOwner", () => {
|
|
|
153
169
|
expect(incoming.platformContext.senderEmail).toBe("user@example.test");
|
|
154
170
|
});
|
|
155
171
|
|
|
172
|
+
it("uses the request-scoped Slack token when no env token exists", async () => {
|
|
173
|
+
mocks.resolveSecret.mockResolvedValueOnce("configured-slack-token");
|
|
174
|
+
mocks.resolveOrgIdForEmail.mockResolvedValueOnce("org_123");
|
|
175
|
+
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
|
176
|
+
new Response(
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
ok: true,
|
|
179
|
+
user: {
|
|
180
|
+
profile: { email: "member@example.test", display_name: "Member" },
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await expect(
|
|
187
|
+
resolveDispatchOwner(
|
|
188
|
+
slackIncoming({
|
|
189
|
+
senderId: "U999",
|
|
190
|
+
platformContext: { teamId: "T999", channelId: "C1" },
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
).resolves.toBe("member@example.test");
|
|
194
|
+
expect(mocks.resolveSecret).toHaveBeenCalledWith("SLACK_BOT_TOKEN");
|
|
195
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
196
|
+
"https://slack.com/api/users.info?user=U999",
|
|
197
|
+
{
|
|
198
|
+
headers: { Authorization: "Bearer configured-slack-token" },
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
156
203
|
it("falls back to the configured Slack owner when the sender is not an org member", async () => {
|
|
157
204
|
vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
|
|
158
205
|
vi.stubEnv("DISPATCH_DEFAULT_OWNER_EMAIL", "default@example.test");
|
|
@@ -226,6 +273,20 @@ describe("resolveDispatchOwner", () => {
|
|
|
226
273
|
});
|
|
227
274
|
|
|
228
275
|
describe("beforeDispatchProcess", () => {
|
|
276
|
+
it("attaches capability-based guidance for structured intake", async () => {
|
|
277
|
+
const incoming = slackIncoming({
|
|
278
|
+
text: "File this review request using our intake form",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await expect(beforeDispatchProcess(incoming, noopAdapter)).resolves.toEqual(
|
|
282
|
+
{ handled: false },
|
|
283
|
+
);
|
|
284
|
+
expect((incoming as any).routingHint.targetAgent).toBeUndefined();
|
|
285
|
+
expect((incoming as any).routingHint.instruction).toContain(
|
|
286
|
+
"workspace instructions/resources",
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
229
290
|
it("asks unlinked Telegram users to link before using org context", async () => {
|
|
230
291
|
vi.stubEnv("APP_URL", "https://dispatch.agent-native.test");
|
|
231
292
|
|
|
@@ -274,4 +335,56 @@ describe("beforeDispatchProcess", () => {
|
|
|
274
335
|
externalUserName: "Steve",
|
|
275
336
|
});
|
|
276
337
|
});
|
|
338
|
+
|
|
339
|
+
it("replies with linking guidance instead of silently dropping an unlinked Slack DM", async () => {
|
|
340
|
+
vi.stubEnv("APP_URL", "https://dispatch.agent-native.test");
|
|
341
|
+
const incoming = slackIncoming({
|
|
342
|
+
triggerKind: "dm",
|
|
343
|
+
conversationType: "dm",
|
|
344
|
+
platformContext: {
|
|
345
|
+
teamId: "T123",
|
|
346
|
+
channelId: "D123",
|
|
347
|
+
channelType: "im",
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const execution = await resolveDispatchExecutionContext(incoming);
|
|
352
|
+
const result = await beforeDispatchProcess(incoming, noopAdapter);
|
|
353
|
+
|
|
354
|
+
expect(execution.ownerEmail).toMatch(/@integration\.local$/);
|
|
355
|
+
expect(incoming.platformContext.identityLinkRequired).toBe(true);
|
|
356
|
+
expect(result).toEqual({
|
|
357
|
+
handled: true,
|
|
358
|
+
responseText:
|
|
359
|
+
"Agent Native is ready, but this Slack account is not linked to an Agent Native user yet. Open https://dispatch.agent-native.test/identities, create a Slack link token, then send `/link <token>` in this DM.",
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("lets an unlinked Slack DM consume a link token before the agent gate", async () => {
|
|
364
|
+
const incoming = slackIncoming({
|
|
365
|
+
text: "/link token-123",
|
|
366
|
+
triggerKind: "dm",
|
|
367
|
+
conversationType: "dm",
|
|
368
|
+
platformContext: {
|
|
369
|
+
teamId: "T123",
|
|
370
|
+
channelId: "D123",
|
|
371
|
+
channelType: "im",
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await resolveDispatchExecutionContext(incoming);
|
|
376
|
+
const result = await beforeDispatchProcess(incoming, noopAdapter);
|
|
377
|
+
|
|
378
|
+
expect(result).toEqual({
|
|
379
|
+
handled: true,
|
|
380
|
+
responseText:
|
|
381
|
+
"Linked successfully. Future slack messages will use owner@example.test's personal dispatch context.",
|
|
382
|
+
});
|
|
383
|
+
expect(mocks.consumeLinkToken).toHaveBeenCalledWith({
|
|
384
|
+
platform: "slack",
|
|
385
|
+
token: "token-123",
|
|
386
|
+
externalUserId: "T123:U123",
|
|
387
|
+
externalUserName: "U123",
|
|
388
|
+
});
|
|
389
|
+
});
|
|
277
390
|
});
|
|
@@ -1,18 +1,36 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
evaluateIntegrationScopePolicy,
|
|
5
|
+
getActiveIntegrationInstallationByKey,
|
|
6
|
+
getIntegrationScope,
|
|
7
|
+
resolveIntegrationTokenBundle,
|
|
8
|
+
saveIntegrationScope,
|
|
9
|
+
slackInstallationKey,
|
|
10
|
+
} from "@agent-native/core/integrations";
|
|
3
11
|
import { resolveOrgIdForEmail } from "@agent-native/core/org";
|
|
4
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
readDeployCredentialEnv,
|
|
14
|
+
resolveSecret,
|
|
15
|
+
withConfiguredAppBasePath,
|
|
16
|
+
} from "@agent-native/core/server";
|
|
5
17
|
import type {
|
|
6
18
|
IncomingMessage,
|
|
19
|
+
IntegrationExecutionContext,
|
|
7
20
|
PlatformAdapter,
|
|
8
21
|
} from "@agent-native/core/server";
|
|
9
22
|
|
|
10
23
|
import { handleRemoteCodeCommand } from "./dispatch-remote-commands.js";
|
|
24
|
+
import {
|
|
25
|
+
dispatchIntegrationRoutingHint,
|
|
26
|
+
type DispatchIntegrationRoutingHint,
|
|
27
|
+
} from "./dispatch-routing.js";
|
|
11
28
|
import { consumeLinkToken, resolveLinkedOwner } from "./dispatch-store.js";
|
|
12
29
|
|
|
13
30
|
type SlackSenderProfile = {
|
|
14
31
|
email: string | null;
|
|
15
32
|
name: string | null;
|
|
33
|
+
trust: "trusted" | "guest" | "external_shared" | "unknown";
|
|
16
34
|
};
|
|
17
35
|
|
|
18
36
|
const slackProfileCache = new Map<
|
|
@@ -104,6 +122,33 @@ function configuredDispatchIdentitiesUrl(): string | null {
|
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
124
|
|
|
125
|
+
async function resolveManagedSlackInstallation(incoming: IncomingMessage) {
|
|
126
|
+
if (incoming.platform !== "slack") return null;
|
|
127
|
+
const teamId = contextString(incoming.platformContext.teamId);
|
|
128
|
+
const apiAppId = contextString(incoming.platformContext.apiAppId);
|
|
129
|
+
if (!teamId) return null;
|
|
130
|
+
try {
|
|
131
|
+
return await getActiveIntegrationInstallationByKey(
|
|
132
|
+
"slack",
|
|
133
|
+
slackInstallationKey({ teamId, apiAppId }),
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function resolveManagedSlackToken(
|
|
141
|
+
incoming: IncomingMessage,
|
|
142
|
+
): Promise<string | null> {
|
|
143
|
+
const installation = await resolveManagedSlackInstallation(incoming);
|
|
144
|
+
if (!installation) return null;
|
|
145
|
+
const bundle = await resolveIntegrationTokenBundle(
|
|
146
|
+
"slack",
|
|
147
|
+
installation.installationKey,
|
|
148
|
+
);
|
|
149
|
+
return bundle?.accessToken ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
107
152
|
function formatTelegramLinkRequiredMessage(): string {
|
|
108
153
|
const identitiesUrl = configuredDispatchIdentitiesUrl();
|
|
109
154
|
const linkStep = identitiesUrl
|
|
@@ -112,14 +157,30 @@ function formatTelegramLinkRequiredMessage(): string {
|
|
|
112
157
|
return `Telegram is connected, but this Telegram account is not linked to an Agent-Native user yet. ${linkStep} After that I can use your Builder.io org and connected apps.`;
|
|
113
158
|
}
|
|
114
159
|
|
|
160
|
+
function formatSlackLinkRequiredMessage(): string {
|
|
161
|
+
const identitiesUrl = configuredDispatchIdentitiesUrl();
|
|
162
|
+
const linkStep = identitiesUrl
|
|
163
|
+
? `Open ${identitiesUrl}, create a Slack link token, then send \`/link <token>\` in this DM.`
|
|
164
|
+
: "Open Dispatch while signed in, create a Slack link token, then send `/link <token>` in this DM.";
|
|
165
|
+
return `Agent Native is ready, but this Slack account is not linked to an Agent Native user yet. ${linkStep}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
115
168
|
async function resolveSlackSenderProfile(
|
|
116
169
|
incoming: IncomingMessage,
|
|
117
170
|
): Promise<SlackSenderProfile> {
|
|
118
|
-
if (incoming.platform !== "slack")
|
|
119
|
-
|
|
171
|
+
if (incoming.platform !== "slack") {
|
|
172
|
+
return { email: null, name: null, trust: "unknown" };
|
|
173
|
+
}
|
|
174
|
+
const managedToken = await resolveManagedSlackToken(incoming);
|
|
175
|
+
const token =
|
|
176
|
+
managedToken ??
|
|
177
|
+
(await resolveSecret("SLACK_BOT_TOKEN")) ??
|
|
178
|
+
readDeployCredentialEnv("SLACK_BOT_TOKEN");
|
|
120
179
|
const userId = contextString(incoming.senderId);
|
|
121
180
|
const teamId = contextString(incoming.platformContext.teamId);
|
|
122
|
-
if (!token || !userId)
|
|
181
|
+
if (!token || !userId) {
|
|
182
|
+
return { email: null, name: null, trust: "unknown" };
|
|
183
|
+
}
|
|
123
184
|
|
|
124
185
|
// Slack user IDs are scoped per workspace, so without a teamId we can't
|
|
125
186
|
// safely cache: two installs of the bot in different workspaces could
|
|
@@ -146,6 +207,8 @@ async function resolveSlackSenderProfile(
|
|
|
146
207
|
real_name?: string;
|
|
147
208
|
display_name?: string;
|
|
148
209
|
};
|
|
210
|
+
is_restricted?: boolean;
|
|
211
|
+
is_ultra_restricted?: boolean;
|
|
149
212
|
};
|
|
150
213
|
};
|
|
151
214
|
const profile = data.ok
|
|
@@ -157,8 +220,14 @@ async function resolveSlackSenderProfile(
|
|
|
157
220
|
data.user?.real_name?.trim() ||
|
|
158
221
|
data.user?.name?.trim() ||
|
|
159
222
|
null,
|
|
223
|
+
trust:
|
|
224
|
+
data.user?.is_ultra_restricted === true
|
|
225
|
+
? ("external_shared" as const)
|
|
226
|
+
: data.user?.is_restricted === true
|
|
227
|
+
? ("guest" as const)
|
|
228
|
+
: ("trusted" as const),
|
|
160
229
|
}
|
|
161
|
-
: { email: null, name: null };
|
|
230
|
+
: { email: null, name: null, trust: "unknown" as const };
|
|
162
231
|
if (cacheKey) {
|
|
163
232
|
slackProfileCache.set(cacheKey, {
|
|
164
233
|
profile,
|
|
@@ -167,7 +236,7 @@ async function resolveSlackSenderProfile(
|
|
|
167
236
|
}
|
|
168
237
|
return profile;
|
|
169
238
|
} catch {
|
|
170
|
-
return { email: null, name: null };
|
|
239
|
+
return { email: null, name: null, trust: "unknown" };
|
|
171
240
|
}
|
|
172
241
|
}
|
|
173
242
|
|
|
@@ -188,6 +257,51 @@ async function resolveSlackOwnerFromVerifiedEmail(
|
|
|
188
257
|
return orgId ? profile.email : null;
|
|
189
258
|
}
|
|
190
259
|
|
|
260
|
+
async function resolveSlackConversationTrust(
|
|
261
|
+
incoming: IncomingMessage,
|
|
262
|
+
actorTrust: SlackSenderProfile["trust"],
|
|
263
|
+
): Promise<{
|
|
264
|
+
trust: SlackSenderProfile["trust"];
|
|
265
|
+
conversationType: "channel" | "direct_message" | "group_direct_message";
|
|
266
|
+
}> {
|
|
267
|
+
if (incoming.triggerKind === "dm") {
|
|
268
|
+
return { trust: actorTrust, conversationType: "direct_message" };
|
|
269
|
+
}
|
|
270
|
+
const token = await resolveManagedSlackToken(incoming);
|
|
271
|
+
const channel = contextString(incoming.platformContext.channelId);
|
|
272
|
+
if (!token || !channel) {
|
|
273
|
+
return { trust: "unknown", conversationType: "channel" };
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const params = new URLSearchParams({ channel });
|
|
277
|
+
const response = await fetch(
|
|
278
|
+
`https://slack.com/api/conversations.info?${params}`,
|
|
279
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
280
|
+
);
|
|
281
|
+
const data = (await response.json()) as {
|
|
282
|
+
ok?: boolean;
|
|
283
|
+
channel?: {
|
|
284
|
+
is_ext_shared?: boolean;
|
|
285
|
+
is_im?: boolean;
|
|
286
|
+
is_mpim?: boolean;
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
if (!data.ok) {
|
|
290
|
+
return { trust: "unknown", conversationType: "channel" };
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
trust: data.channel?.is_ext_shared ? "external_shared" : actorTrust,
|
|
294
|
+
conversationType: data.channel?.is_im
|
|
295
|
+
? "direct_message"
|
|
296
|
+
: data.channel?.is_mpim
|
|
297
|
+
? "group_direct_message"
|
|
298
|
+
: "channel",
|
|
299
|
+
};
|
|
300
|
+
} catch {
|
|
301
|
+
return { trust: "unknown", conversationType: "channel" };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
191
305
|
export async function resolveDispatchOwner(
|
|
192
306
|
incoming: IncomingMessage,
|
|
193
307
|
): Promise<string> {
|
|
@@ -254,6 +368,111 @@ export async function resolveDispatchOwner(
|
|
|
254
368
|
}
|
|
255
369
|
}
|
|
256
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Resolve a personal DM user or a workspace-qualified channel service
|
|
373
|
+
* principal. Managed Slack channels never silently inherit one requester's
|
|
374
|
+
* personal credentials or memory.
|
|
375
|
+
*/
|
|
376
|
+
export async function resolveDispatchExecutionContext(
|
|
377
|
+
incoming: IncomingMessage,
|
|
378
|
+
): Promise<IntegrationExecutionContext> {
|
|
379
|
+
if (incoming.platform !== "slack" || incoming.triggerKind === "dm") {
|
|
380
|
+
const ownerEmail = await resolveDispatchOwner(incoming);
|
|
381
|
+
if (
|
|
382
|
+
incoming.platform === "slack" &&
|
|
383
|
+
ownerEmail.endsWith("@integration.local")
|
|
384
|
+
) {
|
|
385
|
+
// Preserve a credential-less principal long enough for beforeProcess to
|
|
386
|
+
// deliver linking guidance or consume `/link <token>`. Never run the
|
|
387
|
+
// agent under this synthetic owner.
|
|
388
|
+
incoming.platformContext.identityLinkRequired = true;
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
ownerEmail,
|
|
392
|
+
orgId: (await resolveOrgIdForEmail(ownerEmail)) ?? null,
|
|
393
|
+
principalType: "user",
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const installation = await resolveManagedSlackInstallation(incoming);
|
|
398
|
+
if (!installation) {
|
|
399
|
+
// Preserve the legacy manually configured app path while managed installs
|
|
400
|
+
// roll out. Its behavior remains explicit and visible in Settings.
|
|
401
|
+
const ownerEmail = await resolveDispatchOwner(incoming);
|
|
402
|
+
return {
|
|
403
|
+
ownerEmail,
|
|
404
|
+
orgId: (await resolveOrgIdForEmail(ownerEmail)) ?? null,
|
|
405
|
+
principalType: "user",
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const teamId = contextString(incoming.platformContext.teamId);
|
|
410
|
+
const channelId = contextString(incoming.platformContext.channelId);
|
|
411
|
+
if (!teamId || !channelId) {
|
|
412
|
+
throw new Error("Slack channel identity is incomplete");
|
|
413
|
+
}
|
|
414
|
+
const profile = await resolveSlackSenderProfile(incoming);
|
|
415
|
+
const conversation = await resolveSlackConversationTrust(
|
|
416
|
+
incoming,
|
|
417
|
+
profile.trust,
|
|
418
|
+
);
|
|
419
|
+
const access = {
|
|
420
|
+
ownerEmail: installation.ownerEmail,
|
|
421
|
+
orgId: installation.orgId,
|
|
422
|
+
};
|
|
423
|
+
const key = {
|
|
424
|
+
platform: "slack",
|
|
425
|
+
tenantId: teamId,
|
|
426
|
+
conversationId: channelId,
|
|
427
|
+
};
|
|
428
|
+
let scope = await getIntegrationScope(key, access);
|
|
429
|
+
if (!scope && incoming.triggerKind === "mention") {
|
|
430
|
+
scope = await saveIntegrationScope(
|
|
431
|
+
{
|
|
432
|
+
...key,
|
|
433
|
+
conversationType: conversation.conversationType,
|
|
434
|
+
trust: conversation.trust,
|
|
435
|
+
orgId: installation.orgId,
|
|
436
|
+
installationId: installation.id,
|
|
437
|
+
},
|
|
438
|
+
access,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (!scope) throw new Error("Slack channel is not enabled for Agent Native");
|
|
442
|
+
const decision = evaluateIntegrationScopePolicy(scope, {
|
|
443
|
+
// Thread replies reached this point only after the adapter's active-task
|
|
444
|
+
// gate, so they are continuation steering rather than ambient chatter.
|
|
445
|
+
mentioned:
|
|
446
|
+
incoming.triggerKind === "mention" ||
|
|
447
|
+
incoming.triggerKind === "thread_reply",
|
|
448
|
+
});
|
|
449
|
+
if (!decision.allowed) {
|
|
450
|
+
throw new Error(`Slack channel policy denied: ${decision.reason}`);
|
|
451
|
+
}
|
|
452
|
+
incoming.integrationScopeId = scope.id;
|
|
453
|
+
if (scope.defaultModel) {
|
|
454
|
+
incoming.platformContext.defaultModel = scope.defaultModel;
|
|
455
|
+
}
|
|
456
|
+
incoming.actorTrust = {
|
|
457
|
+
memberType:
|
|
458
|
+
conversation.trust === "guest"
|
|
459
|
+
? "guest"
|
|
460
|
+
: conversation.trust === "external_shared"
|
|
461
|
+
? "external"
|
|
462
|
+
: conversation.trust === "trusted"
|
|
463
|
+
? "member"
|
|
464
|
+
: "unknown",
|
|
465
|
+
verified: conversation.trust !== "unknown",
|
|
466
|
+
};
|
|
467
|
+
return {
|
|
468
|
+
ownerEmail: scope.serviceOwnerEmail,
|
|
469
|
+
orgId: scope.orgId,
|
|
470
|
+
principalType: "service",
|
|
471
|
+
installationId: installation.id,
|
|
472
|
+
scopeId: scope.id,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
257
476
|
export async function beforeDispatchProcess(
|
|
258
477
|
incoming: IncomingMessage,
|
|
259
478
|
adapter: PlatformAdapter,
|
|
@@ -263,6 +482,20 @@ export async function beforeDispatchProcess(
|
|
|
263
482
|
contextString(incoming.platformContext.rawText) || trimmed;
|
|
264
483
|
const match = commandText.match(/^\/link(?:@\w+)?\s+([a-zA-Z0-9_-]+)$/);
|
|
265
484
|
if (!match) {
|
|
485
|
+
const routedIncoming = incoming as IncomingMessage & {
|
|
486
|
+
routingHint?: DispatchIntegrationRoutingHint;
|
|
487
|
+
};
|
|
488
|
+
routedIncoming.routingHint ??= dispatchIntegrationRoutingHint(trimmed);
|
|
489
|
+
if (
|
|
490
|
+
incoming.platform === "slack" &&
|
|
491
|
+
incoming.triggerKind === "dm" &&
|
|
492
|
+
incoming.platformContext.identityLinkRequired === true
|
|
493
|
+
) {
|
|
494
|
+
return {
|
|
495
|
+
handled: true,
|
|
496
|
+
responseText: formatSlackLinkRequiredMessage(),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
266
499
|
if (platformRequiresExplicitLink(incoming)) {
|
|
267
500
|
const owner = await resolveLinkedOwner(
|
|
268
501
|
incoming.platform,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { dispatchIntegrationRoutingHint } from "./dispatch-routing.js";
|
|
4
|
+
|
|
5
|
+
describe("dispatchIntegrationRoutingHint", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
"File a security review request for the platform team",
|
|
8
|
+
"Add this hiring ask to the intake board",
|
|
9
|
+
"Create a vendor request form with the required fields",
|
|
10
|
+
"What is currently in the editorial requests queue?",
|
|
11
|
+
])(
|
|
12
|
+
"resolves structured intake through workspace capabilities: %s",
|
|
13
|
+
(text) => {
|
|
14
|
+
const hint = dispatchIntegrationRoutingHint(text);
|
|
15
|
+
expect(hint?.targetAgent).toBeUndefined();
|
|
16
|
+
expect(hint?.instruction).toContain("workspace instructions/resources");
|
|
17
|
+
expect(hint?.instruction).toContain("do not assume a particular app");
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
"Design a homepage for the launch",
|
|
23
|
+
"Create a visual mockup for this settings screen",
|
|
24
|
+
"Redesign the product UI",
|
|
25
|
+
])("routes visual output to Design: %s", (text) => {
|
|
26
|
+
expect(dispatchIntegrationRoutingHint(text)).toMatchObject({
|
|
27
|
+
targetAgent: "design",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("lets unrelated domain questions use normal agent discovery", () => {
|
|
32
|
+
expect(
|
|
33
|
+
dispatchIntegrationRoutingHint(
|
|
34
|
+
"What were the reasons for closed-lost deals this quarter?",
|
|
35
|
+
),
|
|
36
|
+
).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("leaves organization-specific shorthand to learned workspace instructions", () => {
|
|
40
|
+
expect(dispatchIntegrationRoutingHint("Apoorva queue")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const STRUCTURED_INTAKE_PATTERNS = [
|
|
2
|
+
/\b(?:file|submit|add|log|track|triage|prioriti[sz]e)\b.{0,64}\b(?:asks?|requests?|tickets?|intake)\b/i,
|
|
3
|
+
/\b(?:asks?|requests?|tickets?|intake)\b.{0,64}\b(?:database|table|board|form|queue|priority|deadline|urgency)\b/i,
|
|
4
|
+
/\b(?:database|table|board|form|queue)\b.{0,64}\b(?:asks?|requests?|tickets?|intake|priority|deadline|urgency)\b/i,
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const VISUAL_DESIGN_PATTERNS = [
|
|
8
|
+
/\b(?:design|redesign|create|make|generate|mock(?:\s+up)?)\b.{0,64}\b(?:visual|mockup|wireframe|screen|interface|ui|website|landing\s+page|homepage|logo|graphic|illustration)\b/i,
|
|
9
|
+
/\b(?:visual|ui|website|product|brand)\s+design\b/i,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export interface DispatchIntegrationRoutingHint {
|
|
13
|
+
targetAgent?: string;
|
|
14
|
+
instruction: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function dispatchIntegrationRoutingHint(
|
|
18
|
+
text: string,
|
|
19
|
+
): DispatchIntegrationRoutingHint | undefined {
|
|
20
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
21
|
+
if (!normalized) return undefined;
|
|
22
|
+
|
|
23
|
+
// Route by the requested artifact type, not organization-specific names.
|
|
24
|
+
// Exact destinations, schemas, and required fields come from workspace
|
|
25
|
+
// resources such as shared LEARNINGS.md rather than this classifier.
|
|
26
|
+
if (STRUCTURED_INTAKE_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
|
27
|
+
return {
|
|
28
|
+
instruction:
|
|
29
|
+
"Resolve this structured-intake request from loaded workspace instructions/resources and discovered app capabilities. Follow any workspace-defined canonical destination and form contract; do not assume a particular app, database, schema, or owner. Preserve the source thread URL, submit once, verify the saved record, and return its exact link.",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (VISUAL_DESIGN_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
|
34
|
+
return {
|
|
35
|
+
targetAgent: "design",
|
|
36
|
+
instruction:
|
|
37
|
+
"Delegate to Design because the requested output is a visual design, mockup, or interface rather than an intake record.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
@@ -4,6 +4,16 @@ export const envKeys: EnvKeyConfig[] = [
|
|
|
4
4
|
{
|
|
5
5
|
key: "SLACK_BOT_TOKEN",
|
|
6
6
|
label: "Slack bot token",
|
|
7
|
+
required: false,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
key: "SLACK_CLIENT_ID",
|
|
11
|
+
label: "Slack OAuth client ID",
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
key: "SLACK_CLIENT_SECRET",
|
|
16
|
+
label: "Slack OAuth client secret",
|
|
7
17
|
required: true,
|
|
8
18
|
},
|
|
9
19
|
{
|
|
@@ -16,6 +26,41 @@ export const envKeys: EnvKeyConfig[] = [
|
|
|
16
26
|
label: "Telegram bot token",
|
|
17
27
|
required: true,
|
|
18
28
|
},
|
|
29
|
+
{
|
|
30
|
+
key: "TELEGRAM_WEBHOOK_SECRET",
|
|
31
|
+
label: "Telegram webhook secret",
|
|
32
|
+
required: true,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "MICROSOFT_TEAMS_APP_ID",
|
|
36
|
+
label: "Microsoft Bot app ID",
|
|
37
|
+
required: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "MICROSOFT_TEAMS_APP_PASSWORD",
|
|
41
|
+
label: "Microsoft Bot client secret",
|
|
42
|
+
required: false,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "MICROSOFT_TEAMS_APP_TENANT_ID",
|
|
46
|
+
label: "Microsoft Bot tenant ID",
|
|
47
|
+
required: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: "MICROSOFT_TEAMS_ALLOWED_TENANT_IDS",
|
|
51
|
+
label: "Allowed Microsoft Teams tenant IDs",
|
|
52
|
+
required: false,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "DISCORD_APPLICATION_ID",
|
|
56
|
+
label: "Discord application ID",
|
|
57
|
+
required: false,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "DISCORD_PUBLIC_KEY",
|
|
61
|
+
label: "Discord public key",
|
|
62
|
+
required: false,
|
|
63
|
+
},
|
|
19
64
|
{
|
|
20
65
|
key: "EMAIL_AGENT_ADDRESS",
|
|
21
66
|
label: "Agent email address",
|
|
@@ -41,6 +86,11 @@ export const envKeys: EnvKeyConfig[] = [
|
|
|
41
86
|
label: "WhatsApp phone number ID",
|
|
42
87
|
required: false,
|
|
43
88
|
},
|
|
89
|
+
{
|
|
90
|
+
key: "WHATSAPP_APP_SECRET",
|
|
91
|
+
label: "WhatsApp app secret",
|
|
92
|
+
required: false,
|
|
93
|
+
},
|
|
44
94
|
{
|
|
45
95
|
key: "PYLON_API_KEY",
|
|
46
96
|
label: "Pylon API key",
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* their first workspace app first.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { listIntegrationInstallations } from "@agent-native/core/integrations";
|
|
12
13
|
import { registerOnboardingStep } from "@agent-native/core/onboarding";
|
|
14
|
+
import {
|
|
15
|
+
getRequestOrgId,
|
|
16
|
+
getRequestUserEmail,
|
|
17
|
+
} from "@agent-native/core/server/request-context";
|
|
13
18
|
|
|
14
19
|
import { listWorkspaceApps } from "./app-creation-store.js";
|
|
15
20
|
|
|
@@ -47,4 +52,31 @@ export function registerDispatchOnboardingSteps(): void {
|
|
|
47
52
|
}
|
|
48
53
|
},
|
|
49
54
|
});
|
|
55
|
+
|
|
56
|
+
registerOnboardingStep({
|
|
57
|
+
id: "dispatch:connect-slack-workspace",
|
|
58
|
+
title: "Connect a Slack workspace",
|
|
59
|
+
description:
|
|
60
|
+
"Install Agent Native with OAuth, then configure channel identities and access policies.",
|
|
61
|
+
order: 60,
|
|
62
|
+
required: false,
|
|
63
|
+
methods: [
|
|
64
|
+
{
|
|
65
|
+
id: "open-messaging",
|
|
66
|
+
kind: "link",
|
|
67
|
+
primary: true,
|
|
68
|
+
label: "Open Messaging",
|
|
69
|
+
payload: { url: "/messaging", external: false },
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
isComplete: async () => {
|
|
73
|
+
const userEmail = getRequestUserEmail();
|
|
74
|
+
if (!userEmail) return false;
|
|
75
|
+
const rows = await listIntegrationInstallations(
|
|
76
|
+
{ userEmail, orgId: getRequestOrgId() ?? null },
|
|
77
|
+
"slack",
|
|
78
|
+
).catch(() => []);
|
|
79
|
+
return rows.some((row) => row.status === "connected");
|
|
80
|
+
},
|
|
81
|
+
});
|
|
50
82
|
}
|