@agent-native/dispatch 0.13.13 → 0.14.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/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/navigate.js +1 -1
- package/dist/actions/navigate.js.map +1 -1
- 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/actions/view-screen.js +9 -0
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +75 -70
- package/dist/components/layout/Layout.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/index.d.ts.map +1 -1
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/messaging.js +1 -1
- package/dist/routes/pages/messaging.js.map +1 -1
- package/dist/routes/pages/operations.d.ts +5 -0
- package/dist/routes/pages/operations.d.ts.map +1 -0
- package/dist/routes/pages/operations.js +51 -0
- package/dist/routes/pages/operations.js.map +1 -0
- package/dist/routes/pages/overview.d.ts +2 -2
- 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/navigate.ts +1 -1
- 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/actions/view-screen.ts +11 -0
- package/src/components/layout/Layout.spec.tsx +154 -0
- package/src/components/layout/Layout.tsx +196 -189
- package/src/components/messaging-setup-panel.spec.tsx +191 -0
- package/src/components/messaging-setup-panel.tsx +472 -193
- package/src/routes/index.spec.ts +23 -0
- package/src/routes/index.ts +1 -0
- package/src/routes/pages/messaging.tsx +1 -1
- package/src/routes/pages/operations.tsx +134 -0
- 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 +44 -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
- package/src/styles/dispatch-css.spec.ts +11 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { dispatchRoutes } from "./index.js";
|
|
4
|
+
|
|
5
|
+
describe("Dispatch route registration", () => {
|
|
6
|
+
it("registers chat and operator routes before the workspace-app fallback", () => {
|
|
7
|
+
const routes = dispatchRoutes as Array<{
|
|
8
|
+
path?: string;
|
|
9
|
+
file?: string;
|
|
10
|
+
index?: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
const paths = routes.map((route) => route.path);
|
|
13
|
+
|
|
14
|
+
expect(paths).toContain("chat");
|
|
15
|
+
expect(paths).toContain("chat/:threadId");
|
|
16
|
+
expect(paths).toContain("operations");
|
|
17
|
+
expect(paths.indexOf("chat")).toBeLessThan(paths.indexOf(":appId"));
|
|
18
|
+
expect(paths.indexOf("chat/:threadId")).toBeLessThan(
|
|
19
|
+
paths.indexOf(":appId"),
|
|
20
|
+
);
|
|
21
|
+
expect(paths.indexOf("operations")).toBeLessThan(paths.indexOf(":appId"));
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/routes/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const dispatchRoutes: RouteConfig = [
|
|
|
35
35
|
route("chat/:threadId", "./pages/chat.js"),
|
|
36
36
|
route("overview", "./pages/overview.js"),
|
|
37
37
|
route("metrics", "./pages/metrics.js"),
|
|
38
|
+
route("operations", "./pages/operations.js"),
|
|
38
39
|
route("apps", "./pages/apps.js"),
|
|
39
40
|
route("apps/:appId", "./pages/apps.$appId.js"),
|
|
40
41
|
route("new-app", "./pages/new-app.js"),
|
|
@@ -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>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DbAdminPage,
|
|
3
|
+
ObservabilityDashboard,
|
|
4
|
+
useT,
|
|
5
|
+
} from "@agent-native/core/client";
|
|
6
|
+
import {
|
|
7
|
+
IconActivity,
|
|
8
|
+
IconArrowUpRight,
|
|
9
|
+
IconDatabase,
|
|
10
|
+
IconHistory,
|
|
11
|
+
IconMessages,
|
|
12
|
+
IconSend,
|
|
13
|
+
} from "@tabler/icons-react";
|
|
14
|
+
import { Link, useSearchParams } from "react-router";
|
|
15
|
+
|
|
16
|
+
import { DispatchShell } from "../../components/dispatch-shell";
|
|
17
|
+
import {
|
|
18
|
+
Tabs,
|
|
19
|
+
TabsContent,
|
|
20
|
+
TabsList,
|
|
21
|
+
TabsTrigger,
|
|
22
|
+
} from "../../components/ui/tabs";
|
|
23
|
+
|
|
24
|
+
type OperationsView = "monitoring" | "database";
|
|
25
|
+
|
|
26
|
+
function selectedView(value: string | null): OperationsView {
|
|
27
|
+
return value === "database" ? "database" : "monitoring";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function OperationsShortcuts() {
|
|
31
|
+
const t = useT();
|
|
32
|
+
|
|
33
|
+
const tools = [
|
|
34
|
+
{
|
|
35
|
+
to: "/thread-debug",
|
|
36
|
+
icon: IconMessages,
|
|
37
|
+
title: t("dispatch.nav.threadDebug", { defaultValue: "Thread debug" }),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
to: "/audit",
|
|
41
|
+
icon: IconHistory,
|
|
42
|
+
title: t("dispatch.nav.audit"),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
to: "/destinations",
|
|
46
|
+
icon: IconSend,
|
|
47
|
+
title: t("dispatch.pages.deliveryQueue"),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section className="border-t pt-5">
|
|
53
|
+
<h2 className="text-sm font-semibold text-foreground">
|
|
54
|
+
{t("dispatch.nav.advanced", { defaultValue: "Related tools" })}
|
|
55
|
+
</h2>
|
|
56
|
+
<div className="mt-2 grid gap-x-6 gap-y-1 lg:grid-cols-3">
|
|
57
|
+
{tools.map(({ to, icon: Icon, title }) => (
|
|
58
|
+
<Link
|
|
59
|
+
key={to}
|
|
60
|
+
to={to}
|
|
61
|
+
className="group flex min-w-0 items-start gap-2 rounded-md px-2 py-2 text-sm transition-colors hover:bg-muted"
|
|
62
|
+
>
|
|
63
|
+
<Icon
|
|
64
|
+
size={16}
|
|
65
|
+
className="mt-0.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
|
|
66
|
+
/>
|
|
67
|
+
<span className="min-w-0">
|
|
68
|
+
<span className="flex items-center gap-1 font-medium text-foreground">
|
|
69
|
+
<span className="truncate">{title}</span>
|
|
70
|
+
<IconArrowUpRight
|
|
71
|
+
size={13}
|
|
72
|
+
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
73
|
+
/>
|
|
74
|
+
</span>
|
|
75
|
+
</span>
|
|
76
|
+
</Link>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function meta() {
|
|
84
|
+
return [{ title: "Operations — Dispatch" }];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default function OperationsRoute() {
|
|
88
|
+
const t = useT();
|
|
89
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
90
|
+
const view = selectedView(searchParams.get("view"));
|
|
91
|
+
|
|
92
|
+
function setView(nextView: OperationsView) {
|
|
93
|
+
const next = new URLSearchParams(searchParams);
|
|
94
|
+
if (nextView === "monitoring") next.delete("view");
|
|
95
|
+
else next.set("view", nextView);
|
|
96
|
+
setSearchParams(next, { replace: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<DispatchShell title={t("dispatch.nav.operations")}>
|
|
101
|
+
<Tabs
|
|
102
|
+
value={view}
|
|
103
|
+
onValueChange={(value) => {
|
|
104
|
+
if (value === "monitoring" || value === "database") setView(value);
|
|
105
|
+
}}
|
|
106
|
+
className="flex min-w-0 flex-col gap-5"
|
|
107
|
+
>
|
|
108
|
+
<TabsList className="w-fit">
|
|
109
|
+
<TabsTrigger value="monitoring">
|
|
110
|
+
<IconActivity size={15} />
|
|
111
|
+
{t("dispatch.pages.monitoring")}
|
|
112
|
+
</TabsTrigger>
|
|
113
|
+
<TabsTrigger value="database">
|
|
114
|
+
<IconDatabase size={15} />
|
|
115
|
+
{t("dispatch.pages.database")}
|
|
116
|
+
</TabsTrigger>
|
|
117
|
+
</TabsList>
|
|
118
|
+
|
|
119
|
+
<TabsContent value="monitoring" className="mt-0 min-w-0">
|
|
120
|
+
<ObservabilityDashboard />
|
|
121
|
+
<div className="mt-8">
|
|
122
|
+
<OperationsShortcuts />
|
|
123
|
+
</div>
|
|
124
|
+
</TabsContent>
|
|
125
|
+
|
|
126
|
+
<TabsContent value="database" className="mt-0 min-w-0">
|
|
127
|
+
<div className="min-h-[620px] overflow-hidden rounded-lg border bg-background">
|
|
128
|
+
<DbAdminPage title={t("dispatch.pages.database")} />
|
|
129
|
+
</div>
|
|
130
|
+
</TabsContent>
|
|
131
|
+
</Tabs>
|
|
132
|
+
</DispatchShell>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -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,
|