@desplega.ai/agent-swarm 1.80.2 → 1.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/openapi.json +486 -29
- package/package.json +3 -3
- package/plugin/commands/user-management.md +85 -46
- package/plugin/pi-skills/user-management/SKILL.md +85 -46
- package/src/agentmail/handlers.ts +25 -3
- package/src/agentmail/types.ts +1 -0
- package/src/be/db.ts +33 -109
- package/src/be/migrations/067_users_first_class.sql +185 -0
- package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
- package/src/be/unmapped-identities.ts +98 -0
- package/src/be/users.ts +531 -0
- package/src/github/handlers.ts +67 -7
- package/src/gitlab/handlers.ts +73 -5
- package/src/http/operator-actor.ts +59 -0
- package/src/http/users.ts +611 -21
- package/src/http/webhooks.ts +9 -0
- package/src/http/workflows.ts +2 -15
- package/src/linear/oauth.ts +61 -1
- package/src/linear/sync.ts +134 -21
- package/src/slack/actions.ts +8 -2
- package/src/slack/assistant.ts +12 -9
- package/src/slack/enrich.ts +162 -0
- package/src/slack/handlers.ts +11 -19
- package/src/tests/agentmail-handlers.test.ts +166 -0
- package/src/tests/github-handlers.test.ts +290 -0
- package/src/tests/gitlab-handlers.test.ts +293 -1
- package/src/tests/http-api-integration.test.ts +8 -4
- package/src/tests/http-users.test.ts +605 -0
- package/src/tests/linear-sync-identity.test.ts +427 -0
- package/src/tests/mcp-tools-user.test.ts +292 -0
- package/src/tests/slack-identity-resolution.test.ts +349 -0
- package/src/tests/user-identity.test.ts +351 -81
- package/src/tests/workflow-triggers-v2.test.ts +261 -20
- package/src/tools/manage-user.ts +119 -24
- package/src/tools/resolve-user.ts +43 -29
- package/src/types.ts +26 -4
- package/src/utils/secret-scrubber.ts +5 -0
- package/src/workflows/input.ts +7 -2
- package/src/workflows/triggers.ts +89 -9
package/src/http/webhooks.ts
CHANGED
|
@@ -412,9 +412,18 @@ export async function handleWebhooks(
|
|
|
412
412
|
|
|
413
413
|
try {
|
|
414
414
|
switch (payload.event_type) {
|
|
415
|
+
case "message.received.unauthenticated":
|
|
416
|
+
console.warn(
|
|
417
|
+
`[AgentMail] Received unauthenticated message - treating as received event for inbox ${payload.message?.inbox_id ?? "unknown"}`,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
await handleMessageReceived(payload);
|
|
421
|
+
break;
|
|
422
|
+
|
|
415
423
|
case "message.received":
|
|
416
424
|
await handleMessageReceived(payload);
|
|
417
425
|
break;
|
|
426
|
+
|
|
418
427
|
default:
|
|
419
428
|
console.log(`[AgentMail] Ignoring event type: ${payload.event_type}`);
|
|
420
429
|
}
|
package/src/http/workflows.ts
CHANGED
|
@@ -341,24 +341,11 @@ export async function handleWorkflows(
|
|
|
341
341
|
}
|
|
342
342
|
const rawBody = Buffer.concat(chunks).toString();
|
|
343
343
|
|
|
344
|
-
// Validate JSON before processing (but pass raw string for HMAC)
|
|
345
|
-
try {
|
|
346
|
-
if (rawBody) JSON.parse(rawBody);
|
|
347
|
-
} catch {
|
|
348
|
-
jsonError(res, "Invalid JSON body", 400);
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const signature =
|
|
353
|
-
(req.headers["x-hub-signature-256"] as string | undefined) ??
|
|
354
|
-
(req.headers["x-signature"] as string | undefined);
|
|
355
|
-
|
|
356
344
|
try {
|
|
357
345
|
const result = await handleWebhookTrigger(
|
|
358
346
|
workflowId,
|
|
359
|
-
rawBody, // Raw body string —
|
|
360
|
-
signature
|
|
361
|
-
signature,
|
|
347
|
+
rawBody, // Raw body string — HMAC is verified against raw bytes; JSON parsing happens inside
|
|
348
|
+
req.headers, // Full header bag — signature header resolved per trigger config
|
|
362
349
|
getExecutorRegistry(),
|
|
363
350
|
);
|
|
364
351
|
json(res, result, 201);
|
package/src/linear/oauth.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { upsertKv } from "../be/db";
|
|
1
2
|
import { getOAuthApp } from "../be/db-queries/oauth";
|
|
2
3
|
import { buildAuthorizationUrl, exchangeCode, type OAuthProviderConfig } from "../oauth/wrapper";
|
|
3
4
|
|
|
5
|
+
/** kv namespace for the Linear bot's appUserId (Q21.C). Keyed by workspace ID. */
|
|
6
|
+
const APP_USER_ID_NAMESPACE = "integration:linear:bot-app-user-id";
|
|
7
|
+
|
|
4
8
|
export function getLinearOAuthConfig(): OAuthProviderConfig | null {
|
|
5
9
|
const app = getOAuthApp("linear");
|
|
6
10
|
if (!app) return null;
|
|
@@ -31,7 +35,63 @@ export async function handleLinearCallback(
|
|
|
31
35
|
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
|
|
32
36
|
const config = getLinearOAuthConfig();
|
|
33
37
|
if (!config) throw new Error("Linear OAuth not configured");
|
|
34
|
-
|
|
38
|
+
const result = await exchangeCode(config, code, state);
|
|
39
|
+
|
|
40
|
+
// Best-effort: capture the Linear bot's appUserId now that we have a fresh
|
|
41
|
+
// token (Q21.C). Persisted in kv_entries so webhook handlers can guard
|
|
42
|
+
// against the swarm hearing itself ("creator.id === storedAppUserId").
|
|
43
|
+
// Failure here is non-fatal — the guard is a no-op until the next refresh.
|
|
44
|
+
captureLinearAppUserId(result.accessToken).catch((err) => {
|
|
45
|
+
console.warn(
|
|
46
|
+
"[Linear] Failed to capture appUserId during OAuth completion (non-fatal):",
|
|
47
|
+
err instanceof Error ? err.message : err,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Query Linear's GraphQL `viewer` to find the OAuth-actor's user id and
|
|
56
|
+
* organization id, then persist it under `integration:linear:bot-app-user-id`.
|
|
57
|
+
*
|
|
58
|
+
* NOTE: when the OAuth app is installed with `actor=app`, Linear's `viewer`
|
|
59
|
+
* resolves to the synthetic app-user — the "bot identity" that emits
|
|
60
|
+
* AgentSessionEvent.created with `agentSession.creator.id === viewer.id`. That
|
|
61
|
+
* is precisely the value Q21.C asks us to compare against.
|
|
62
|
+
*/
|
|
63
|
+
export async function captureLinearAppUserId(accessToken: string): Promise<void> {
|
|
64
|
+
const query = `query { viewer { id organization { id } } }`;
|
|
65
|
+
const res = await fetch("https://api.linear.app/graphql", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
Authorization: `Bearer ${accessToken}`,
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({ query }),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new Error(`Linear viewer query failed: HTTP ${res.status}`);
|
|
75
|
+
}
|
|
76
|
+
const body = (await res.json()) as {
|
|
77
|
+
data?: { viewer?: { id?: string; organization?: { id?: string } } };
|
|
78
|
+
errors?: Array<{ message: string }>;
|
|
79
|
+
};
|
|
80
|
+
if (body.errors?.length) {
|
|
81
|
+
throw new Error(`Linear viewer query returned errors: ${body.errors[0]?.message ?? "?"}`);
|
|
82
|
+
}
|
|
83
|
+
const appUserId = body.data?.viewer?.id;
|
|
84
|
+
const workspaceId = body.data?.viewer?.organization?.id;
|
|
85
|
+
if (!appUserId) {
|
|
86
|
+
throw new Error("Linear viewer query returned no id");
|
|
87
|
+
}
|
|
88
|
+
upsertKv({
|
|
89
|
+
namespace: APP_USER_ID_NAMESPACE,
|
|
90
|
+
key: workspaceId && workspaceId !== "" ? workspaceId : "default",
|
|
91
|
+
value: appUserId,
|
|
92
|
+
valueType: "string",
|
|
93
|
+
expiresAt: null,
|
|
94
|
+
});
|
|
35
95
|
}
|
|
36
96
|
|
|
37
97
|
/**
|
package/src/linear/sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cancelTask, getAllAgents, getTaskById,
|
|
1
|
+
import { cancelTask, getAllAgents, getKv, getTaskById, incrKv, upsertKv } from "../be/db";
|
|
2
2
|
import { getOAuthTokens } from "../be/db-queries/oauth";
|
|
3
3
|
import {
|
|
4
4
|
createTrackerSync,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getTrackerSyncByExternalId,
|
|
7
7
|
updateTrackerSync,
|
|
8
8
|
} from "../be/db-queries/tracker";
|
|
9
|
+
import { findOrCreateUserByEmail, findUserByExternalId, linkIdentity } from "../be/users";
|
|
9
10
|
import { ensureToken } from "../oauth/ensure-token";
|
|
10
11
|
import { resolveTemplate } from "../prompts/resolver";
|
|
11
12
|
import { linearContextKey } from "../tasks/context-key";
|
|
@@ -343,6 +344,100 @@ function findLeadAgent() {
|
|
|
343
344
|
return agents.find((a) => a.isLead) ?? null;
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
// ─── Identity resolution (Q21.A / Q21.C / Q17.B) ──────────────────────────
|
|
348
|
+
//
|
|
349
|
+
// Linear app config is currently agent-session-events-only — only
|
|
350
|
+
// AgentSessionEvent.created/prompted arrive. If subscriptions widen to
|
|
351
|
+
// Issue/Comment events later, handle system-actor case (per Q21.B / Q22).
|
|
352
|
+
// Identity primitives in src/be/users.ts are event-type-agnostic; only the
|
|
353
|
+
// extraction shape changes.
|
|
354
|
+
|
|
355
|
+
const UNMAPPED_NAMESPACE = "integration:unmapped:linear";
|
|
356
|
+
const UNMAPPED_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
357
|
+
const LINEAR_WEBHOOK_ACTOR = { kind: "system", id: "webhook:linear" } as const;
|
|
358
|
+
|
|
359
|
+
/** kv namespace for the Linear bot's appUserId (Q21.C). Keyed by workspace ID. */
|
|
360
|
+
const APP_USER_ID_NAMESPACE = "integration:linear:bot-app-user-id";
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Read the bot's persisted appUserId for the given workspace (or the default
|
|
364
|
+
* slot). Returns null when not yet captured (early OAuth installs).
|
|
365
|
+
*/
|
|
366
|
+
function getStoredAppUserId(workspaceId: string | null): string | null {
|
|
367
|
+
const key = workspaceId && workspaceId !== "" ? workspaceId : "default";
|
|
368
|
+
const entry = getKv(APP_USER_ID_NAMESPACE, key);
|
|
369
|
+
if (!entry) return null;
|
|
370
|
+
return typeof entry.value === "string" ? entry.value : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Cascade per Q17.B:
|
|
375
|
+
* 1. `findUserByExternalId('linear', linearUserId)` (fast path).
|
|
376
|
+
* 2. On miss + email present → `findOrCreateUserByEmail(email, {name})` then
|
|
377
|
+
* `linkIdentity(...)`.
|
|
378
|
+
* 3. Else record unmapped kv entries for operator triage.
|
|
379
|
+
*
|
|
380
|
+
* Q21.C bot-self-link guard: if `linearUserId === storedAppUserId` (the swarm
|
|
381
|
+
* hearing itself), short-circuit — no users row, no unmapped entry.
|
|
382
|
+
*
|
|
383
|
+
* Returns `undefined` when no mapping could be established — callers pass
|
|
384
|
+
* that straight to `requestedByUserId`.
|
|
385
|
+
*/
|
|
386
|
+
function resolveLinearActor(
|
|
387
|
+
linearUserId: string,
|
|
388
|
+
email: string,
|
|
389
|
+
name: string,
|
|
390
|
+
workspaceId: string | null,
|
|
391
|
+
sampleEventType: string,
|
|
392
|
+
sampleContext: string | null,
|
|
393
|
+
): string | undefined {
|
|
394
|
+
if (!linearUserId) {
|
|
395
|
+
// No identifier — nothing to map. We don't even know what to track as
|
|
396
|
+
// unmapped, so just return undefined.
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Q21.C bot-self-link guard.
|
|
401
|
+
const storedAppUserId = getStoredAppUserId(workspaceId);
|
|
402
|
+
if (storedAppUserId && linearUserId === storedAppUserId) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
if (!storedAppUserId) {
|
|
406
|
+
// Non-fatal: log once per call. Real guard re-engages after the next
|
|
407
|
+
// OAuth refresh or admin capture step persists the appUserId.
|
|
408
|
+
console.warn("[linear] appUserId not yet stored; bot-self-link guard disabled");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const existing = findUserByExternalId("linear", linearUserId);
|
|
412
|
+
if (existing) return existing.id;
|
|
413
|
+
|
|
414
|
+
const trimmedEmail = typeof email === "string" ? email.trim() : "";
|
|
415
|
+
if (trimmedEmail !== "") {
|
|
416
|
+
const { user: linked } = findOrCreateUserByEmail(
|
|
417
|
+
trimmedEmail,
|
|
418
|
+
{ name: name?.trim() || undefined },
|
|
419
|
+
LINEAR_WEBHOOK_ACTOR,
|
|
420
|
+
);
|
|
421
|
+
linkIdentity(linked.id, "linear", linearUserId, LINEAR_WEBHOOK_ACTOR);
|
|
422
|
+
return linked.id;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// No mapping + no inline email → unmapped tracker (Q14/Q17.D).
|
|
426
|
+
upsertKv({
|
|
427
|
+
namespace: UNMAPPED_NAMESPACE,
|
|
428
|
+
key: `${linearUserId}:meta`,
|
|
429
|
+
value: {
|
|
430
|
+
lastSeenAt: new Date().toISOString(),
|
|
431
|
+
sampleEventType,
|
|
432
|
+
sampleContext: sampleContext ? sampleContext.slice(0, 100) : null,
|
|
433
|
+
},
|
|
434
|
+
valueType: "json",
|
|
435
|
+
expiresAt: Date.now() + UNMAPPED_TTL_MS,
|
|
436
|
+
});
|
|
437
|
+
incrKv(UNMAPPED_NAMESPACE, `${linearUserId}:count`, 1);
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
346
441
|
/**
|
|
347
442
|
* Handle AgentSession events from Linear.
|
|
348
443
|
* These are fired when an issue is assigned to the Linear agent integration,
|
|
@@ -375,16 +470,26 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
|
|
|
375
470
|
return;
|
|
376
471
|
}
|
|
377
472
|
|
|
378
|
-
// Extract actor identity from Linear webhook payload
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
473
|
+
// Extract actor identity from Linear webhook payload (Q21.A bug fix).
|
|
474
|
+
// The old code read a top-level `actor` field that does NOT exist in
|
|
475
|
+
// AgentSessionEvent payloads. The human is at agentSession.creator for
|
|
476
|
+
// the `created` action and agentActivity.user for `prompted`.
|
|
477
|
+
const session = event.agentSession as Record<string, unknown> | undefined;
|
|
478
|
+
const creator = session?.creator as Record<string, unknown> | undefined;
|
|
479
|
+
const linearUserId = creator ? String(creator.id ?? "") : "";
|
|
480
|
+
const actorEmail = creator ? String(creator.email ?? "") : "";
|
|
481
|
+
const actorName = creator ? String(creator.name ?? "") : "";
|
|
482
|
+
const workspaceId = (event.organizationId ?? event.workspaceId) as string | undefined;
|
|
483
|
+
const sampleContext =
|
|
484
|
+
(session?.comment as { body?: string } | undefined)?.body ?? issueTitle ?? null;
|
|
485
|
+
const requestedByUserId = resolveLinearActor(
|
|
486
|
+
linearUserId,
|
|
487
|
+
actorEmail,
|
|
488
|
+
actorName,
|
|
489
|
+
workspaceId ?? null,
|
|
490
|
+
"AgentSessionEvent.created",
|
|
491
|
+
sampleContext,
|
|
492
|
+
);
|
|
388
493
|
|
|
389
494
|
// Check if we already track this issue
|
|
390
495
|
const existing = getTrackerSyncByExternalId("linear", "task", issueId);
|
|
@@ -687,16 +792,24 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
|
|
|
687
792
|
// Task is completed/failed/cancelled or doesn't exist — create a new follow-up task
|
|
688
793
|
const lead = findLeadAgent();
|
|
689
794
|
|
|
690
|
-
// Extract actor identity from Linear webhook payload
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
const
|
|
694
|
-
const
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
795
|
+
// Extract actor identity from Linear webhook payload (Q21.A bug fix).
|
|
796
|
+
// For `prompted` action, the human is at `event.agentActivity.user`.
|
|
797
|
+
const activity = event.agentActivity as Record<string, unknown> | undefined;
|
|
798
|
+
const promptUser = activity?.user as Record<string, unknown> | undefined;
|
|
799
|
+
const promptedActorLinearId = promptUser ? String(promptUser.id ?? "") : "";
|
|
800
|
+
const promptedActorEmail = promptUser ? String(promptUser.email ?? "") : "";
|
|
801
|
+
const promptedActorName = promptUser ? String(promptUser.name ?? "") : "";
|
|
802
|
+
const promptedWorkspaceId = (event.organizationId ?? event.workspaceId) as string | undefined;
|
|
803
|
+
const promptedSampleContext =
|
|
804
|
+
(activity?.content as { body?: string } | undefined)?.body ?? userMessage ?? null;
|
|
805
|
+
const promptedRequestedByUserId = resolveLinearActor(
|
|
806
|
+
promptedActorLinearId,
|
|
807
|
+
promptedActorEmail,
|
|
808
|
+
promptedActorName,
|
|
809
|
+
promptedWorkspaceId ?? null,
|
|
810
|
+
"AgentSessionEvent.prompted",
|
|
811
|
+
promptedSampleContext,
|
|
812
|
+
);
|
|
700
813
|
|
|
701
814
|
const followupResult = resolveTemplate("linear.issue.followup", {
|
|
702
815
|
issue_identifier: issueIdentifier,
|
package/src/slack/actions.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { App } from "@slack/bolt";
|
|
2
|
-
import { cancelTask, getAgentById, getLeadAgent, getTaskById
|
|
2
|
+
import { cancelTask, getAgentById, getLeadAgent, getTaskById } from "../be/db";
|
|
3
3
|
import { slackContextKey } from "../tasks/context-key";
|
|
4
4
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
5
5
|
import { buildCancelledBlocks, getTaskLink } from "./blocks";
|
|
6
|
+
import { resolveSlackUserId } from "./enrich";
|
|
6
7
|
|
|
7
8
|
export function registerActionHandlers(app: App): void {
|
|
8
9
|
// "View Full Logs" — URL button, just ack (Slack opens the link automatically)
|
|
@@ -67,7 +68,12 @@ export function registerActionHandlers(app: App): void {
|
|
|
67
68
|
if (!originalTask || !originalTask.slackChannelId) return;
|
|
68
69
|
|
|
69
70
|
const lead = getLeadAgent();
|
|
70
|
-
|
|
71
|
+
// Resolve via the shared cascade. Sample context = the modal callback ID
|
|
72
|
+
// so operators can see *which* modal-submit triggered an unmapped entry.
|
|
73
|
+
const requestedByUserId = await resolveSlackUserId(client, body.user.id, {
|
|
74
|
+
sampleEventType: "view_submission",
|
|
75
|
+
sampleContext: view.callback_id || "follow_up_submit",
|
|
76
|
+
});
|
|
71
77
|
const followUpTask = createTaskWithSiblingAwareness(followUpText, {
|
|
72
78
|
agentId: lead?.id,
|
|
73
79
|
source: "slack",
|
package/src/slack/assistant.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { Assistant } from "@slack/bolt";
|
|
2
|
-
import {
|
|
3
|
-
getAgentWorkingOnThread,
|
|
4
|
-
getLeadAgent,
|
|
5
|
-
getMostRecentTaskInThread,
|
|
6
|
-
resolveUser,
|
|
7
|
-
} from "../be/db";
|
|
2
|
+
import { getAgentWorkingOnThread, getLeadAgent, getMostRecentTaskInThread } from "../be/db";
|
|
8
3
|
import { resolveTemplate } from "../prompts/resolver";
|
|
9
4
|
import { slackContextKey } from "../tasks/context-key";
|
|
10
5
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
6
|
+
import { resolveSlackUserId } from "./enrich";
|
|
11
7
|
import { wasEventSeen } from "./event-dedup";
|
|
12
8
|
import { bufferThreadMessage } from "./thread-buffer";
|
|
13
9
|
// Side-effect import: registers all Slack event templates in the in-memory registry
|
|
@@ -41,7 +37,7 @@ export function createAssistant(): Assistant {
|
|
|
41
37
|
await saveThreadContext();
|
|
42
38
|
},
|
|
43
39
|
|
|
44
|
-
userMessage: async ({ message, body, say, setStatus, setTitle, getThreadContext }) => {
|
|
40
|
+
userMessage: async ({ message, body, say, setStatus, setTitle, getThreadContext, client }) => {
|
|
45
41
|
// Slack retries deliveries on 3s timeout / 5xx. Drop duplicates before
|
|
46
42
|
// any task-creation work runs (DES-293).
|
|
47
43
|
const eventId = body?.event_id;
|
|
@@ -76,8 +72,15 @@ export function createAssistant(): Assistant {
|
|
|
76
72
|
const messageText = (msg.text as string) || "";
|
|
77
73
|
const userId = (msg.user as string) || "";
|
|
78
74
|
|
|
79
|
-
// Resolve canonical user identity
|
|
80
|
-
|
|
75
|
+
// Resolve canonical user identity via the shared cascade. On no-email,
|
|
76
|
+
// the cascade records the user in the kv unmapped tracker; this handler
|
|
77
|
+
// proceeds without a `requestedByUserId`.
|
|
78
|
+
const requestedByUserId = userId
|
|
79
|
+
? await resolveSlackUserId(client, userId, {
|
|
80
|
+
sampleEventType: "assistant_message",
|
|
81
|
+
sampleContext: messageText,
|
|
82
|
+
})
|
|
83
|
+
: undefined;
|
|
81
84
|
|
|
82
85
|
// 1. Check if an agent is already working in this thread
|
|
83
86
|
const workingAgent = getAgentWorkingOnThread(channelId, threadTs);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack-side identity enrichment + resolution cascade.
|
|
3
|
+
*
|
|
4
|
+
* Two helpers — together they replace the in-process email-lookup Map that
|
|
5
|
+
* previously lived in `src/slack/handlers.ts` (Q17.E):
|
|
6
|
+
*
|
|
7
|
+
* * `enrichSlackUserEmail(slackUserId)` — kv-backed cache of the user's
|
|
8
|
+
* email + display name from `client.users.info`. 24h TTL on success;
|
|
9
|
+
* failures are NEVER cached so rate-limit recovery just works on retry.
|
|
10
|
+
*
|
|
11
|
+
* * `resolveSlackUserId(client, slackUserId, ctx)` — the three-step cascade
|
|
12
|
+
* each Slack webhook entry point uses to map a Slack user to a canonical
|
|
13
|
+
* `users.id`:
|
|
14
|
+
*
|
|
15
|
+
* 1. `findUserByExternalId('slack', slackUserId)` — fast path.
|
|
16
|
+
* 2. On miss: enrich → `findOrCreateUserByEmail` + `linkIdentity`.
|
|
17
|
+
* 3. On no-email: record into the kv unmapped tracker. Returns
|
|
18
|
+
* `undefined` — task creation proceeds without a `requestedByUserId`.
|
|
19
|
+
*
|
|
20
|
+
* Both helpers are API-side (live under `src/slack/` which is API-only) — they
|
|
21
|
+
* may import from `src/be/`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { WebClient } from "@slack/web-api";
|
|
25
|
+
import { getKv, upsertKv } from "../be/db";
|
|
26
|
+
import { recordUnmappedIdentity } from "../be/unmapped-identities";
|
|
27
|
+
import {
|
|
28
|
+
findOrCreateUserByEmail,
|
|
29
|
+
findUserByExternalId,
|
|
30
|
+
type IdentityActor,
|
|
31
|
+
linkIdentity,
|
|
32
|
+
} from "../be/users";
|
|
33
|
+
|
|
34
|
+
const ENRICHMENT_NAMESPACE = "integration:user-enrichment:slack";
|
|
35
|
+
const ENRICHMENT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Persisted enrichment payload. `email: null` cases are NEVER stored — see
|
|
39
|
+
* Q17.E. We keep `name` so future People-page renders don't need to refetch.
|
|
40
|
+
*/
|
|
41
|
+
interface EnrichedSlackUser {
|
|
42
|
+
email: string;
|
|
43
|
+
name: string | null;
|
|
44
|
+
fetchedAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch a Slack user's email, caching successful results for 24h in the
|
|
49
|
+
* `integration:user-enrichment:slack` kv namespace.
|
|
50
|
+
*
|
|
51
|
+
* Returns `null` (without caching) when:
|
|
52
|
+
* * `client.users.info` throws (network / 5xx / rate-limit).
|
|
53
|
+
* * The profile is missing `profile.email` (bot accounts, restricted users).
|
|
54
|
+
*
|
|
55
|
+
* Caching null would defeat retry-on-recovery, so we intentionally skip the
|
|
56
|
+
* cache write on every failure path.
|
|
57
|
+
*/
|
|
58
|
+
export async function enrichSlackUserEmail(
|
|
59
|
+
client: WebClient,
|
|
60
|
+
slackUserId: string,
|
|
61
|
+
): Promise<string | null> {
|
|
62
|
+
// Cache hit — return the persisted email straight through.
|
|
63
|
+
const cached = getKv(ENRICHMENT_NAMESPACE, slackUserId);
|
|
64
|
+
if (cached !== null) {
|
|
65
|
+
const payload = cached.value as EnrichedSlackUser;
|
|
66
|
+
if (payload?.email) {
|
|
67
|
+
return payload.email;
|
|
68
|
+
}
|
|
69
|
+
// Defensive: if a stale row landed without email somehow, fall through to
|
|
70
|
+
// refetch rather than handing back `null` from cache.
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cache miss — hit the Slack API.
|
|
74
|
+
let email: string | null = null;
|
|
75
|
+
let name: string | null = null;
|
|
76
|
+
try {
|
|
77
|
+
const result = await client.users.info({ user: slackUserId });
|
|
78
|
+
email = result.user?.profile?.email ?? null;
|
|
79
|
+
name = result.user?.profile?.real_name ?? result.user?.real_name ?? null;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`[Slack] enrichSlackUserEmail failed for ${slackUserId}:`, error);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!email) {
|
|
86
|
+
// Q17.E: do NOT cache the no-email case.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
upsertKv({
|
|
91
|
+
namespace: ENRICHMENT_NAMESPACE,
|
|
92
|
+
key: slackUserId,
|
|
93
|
+
value: {
|
|
94
|
+
email,
|
|
95
|
+
name,
|
|
96
|
+
fetchedAt: new Date().toISOString(),
|
|
97
|
+
} satisfies EnrichedSlackUser,
|
|
98
|
+
valueType: "json",
|
|
99
|
+
expiresAt: Date.now() + ENRICHMENT_TTL_MS,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return email;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Audit-trail actor for the auto-link cascade. */
|
|
106
|
+
const SLACK_WEBHOOK_ACTOR: IdentityActor = { kind: "system", id: "webhook:slack" };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Three-step cascade used by every Slack webhook entry point to map a Slack
|
|
110
|
+
* user ID to a canonical `users.id`:
|
|
111
|
+
*
|
|
112
|
+
* 1. Look up the existing `(slack, <userId>)` mapping in `user_external_ids`.
|
|
113
|
+
* 2. If missing, enrich the Slack profile to extract an email; if found,
|
|
114
|
+
* auto-link via `findOrCreateUserByEmail` + `linkIdentity`. Emits an
|
|
115
|
+
* `auto_merge` (existing user) or `identity_added` (new user) event,
|
|
116
|
+
* followed by an `identity_added` event for the Slack alias itself.
|
|
117
|
+
* 3. If no email is recoverable, record into the kv unmapped tracker so the
|
|
118
|
+
* operator can triage manually. Returns `undefined` — task creation
|
|
119
|
+
* proceeds without `requestedByUserId`.
|
|
120
|
+
*
|
|
121
|
+
* `eventContext` shapes the sample written to the unmapped tracker — the
|
|
122
|
+
* sample is truncated to 100 chars inside `recordUnmappedIdentity`.
|
|
123
|
+
*/
|
|
124
|
+
export async function resolveSlackUserId(
|
|
125
|
+
client: WebClient,
|
|
126
|
+
slackUserId: string,
|
|
127
|
+
eventContext: { sampleEventType: string; sampleContext: string },
|
|
128
|
+
): Promise<string | undefined> {
|
|
129
|
+
// 1. Fast path — existing alias.
|
|
130
|
+
const existing = findUserByExternalId("slack", slackUserId);
|
|
131
|
+
if (existing) return existing.id;
|
|
132
|
+
|
|
133
|
+
// 2. Enrich → auto-link by email.
|
|
134
|
+
const email = await enrichSlackUserEmail(client, slackUserId);
|
|
135
|
+
if (email) {
|
|
136
|
+
// Pull the cached name back out for the user-row hints. The kv read is
|
|
137
|
+
// cheap (single primary-key lookup) and avoids a second `users.info`.
|
|
138
|
+
const cached = getKv(ENRICHMENT_NAMESPACE, slackUserId);
|
|
139
|
+
const name = (cached?.value as EnrichedSlackUser | undefined)?.name ?? undefined;
|
|
140
|
+
|
|
141
|
+
const { user } = findOrCreateUserByEmail(email, { name }, SLACK_WEBHOOK_ACTOR);
|
|
142
|
+
|
|
143
|
+
// Link the Slack identity to whichever user we resolved to. PK collision
|
|
144
|
+
// on `(slack, <id>)` shouldn't happen — we just confirmed no existing
|
|
145
|
+
// mapping in step 1 — but guard defensively so a race doesn't 500 the
|
|
146
|
+
// webhook.
|
|
147
|
+
try {
|
|
148
|
+
linkIdentity(user.id, "slack", slackUserId, SLACK_WEBHOOK_ACTOR);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.warn(
|
|
151
|
+
`[Slack] linkIdentity('slack', ${slackUserId}) failed — likely a concurrent enroll`,
|
|
152
|
+
error,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return user.id;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. No email — track as unmapped.
|
|
160
|
+
recordUnmappedIdentity("slack", slackUserId, eventContext);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
package/src/slack/handlers.ts
CHANGED
|
@@ -6,13 +6,13 @@ import {
|
|
|
6
6
|
getLeadAgent,
|
|
7
7
|
getMostRecentTaskInThread,
|
|
8
8
|
getTasksByAgentId,
|
|
9
|
-
resolveUser,
|
|
10
9
|
} from "../be/db";
|
|
11
10
|
import { resolveTemplate } from "../prompts/resolver";
|
|
12
11
|
import { slackContextKey } from "../tasks/context-key";
|
|
13
12
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
14
13
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
15
14
|
import { buildTreeBlocks, type TreeNode } from "./blocks";
|
|
15
|
+
import { enrichSlackUserEmail, resolveSlackUserId } from "./enrich";
|
|
16
16
|
import { wasEventSeen } from "./event-dedup";
|
|
17
17
|
import type { SlackFile } from "./files";
|
|
18
18
|
import { extractTaskFromMessage, hasOtherUserMention, routeMessage } from "./router";
|
|
@@ -34,9 +34,6 @@ const allowedUserIds = (process.env.SLACK_ALLOWED_USER_IDS || "")
|
|
|
34
34
|
|
|
35
35
|
const filteringEnabled = allowedEmailDomains.length > 0 || allowedUserIds.length > 0;
|
|
36
36
|
|
|
37
|
-
// Cache for user email lookups (to avoid repeated API calls)
|
|
38
|
-
const userEmailCache = new Map<string, string | null>();
|
|
39
|
-
|
|
40
37
|
/**
|
|
41
38
|
* Configuration for user filtering.
|
|
42
39
|
*/
|
|
@@ -110,19 +107,8 @@ async function isUserAllowed(client: WebClient, userId: string): Promise<boolean
|
|
|
110
107
|
return false;
|
|
111
108
|
}
|
|
112
109
|
|
|
113
|
-
// Check email domain
|
|
114
|
-
|
|
115
|
-
if (email === undefined) {
|
|
116
|
-
try {
|
|
117
|
-
const result = await client.users.info({ user: userId });
|
|
118
|
-
email = result.user?.profile?.email || null;
|
|
119
|
-
userEmailCache.set(userId, email);
|
|
120
|
-
} catch (error) {
|
|
121
|
-
console.error(`[Slack] Failed to fetch user email for ${userId}:`, error);
|
|
122
|
-
userEmailCache.set(userId, null);
|
|
123
|
-
email = null;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
110
|
+
// Check email domain — uses kv-backed enrichment (24h TTL, only success cached).
|
|
111
|
+
const email = await enrichSlackUserEmail(client, userId);
|
|
126
112
|
|
|
127
113
|
if (!email) {
|
|
128
114
|
console.log(`[Slack] User ${userId} has no email, denying access`);
|
|
@@ -391,8 +377,14 @@ export function registerMessageHandler(app: App): void {
|
|
|
391
377
|
return;
|
|
392
378
|
}
|
|
393
379
|
|
|
394
|
-
// Resolve canonical user identity
|
|
395
|
-
|
|
380
|
+
// Resolve canonical user identity via the three-step cascade:
|
|
381
|
+
// fast-path alias lookup → enrich+auto-link by email → unmapped tracker.
|
|
382
|
+
// Returns undefined when no email is recoverable; task creation proceeds
|
|
383
|
+
// without `requestedByUserId` and the operator triages from the Unmapped tab.
|
|
384
|
+
const requestedByUserId = await resolveSlackUserId(client, msg.user, {
|
|
385
|
+
sampleEventType: "message",
|
|
386
|
+
sampleContext: msg.text ?? "",
|
|
387
|
+
});
|
|
396
388
|
|
|
397
389
|
// Emit workflow trigger event for Slack messages
|
|
398
390
|
workflowEventBus.emit("slack.message", {
|