@agentworkforce/sage 1.0.6 → 1.1.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/.env.example +5 -4
- package/dist/app.d.ts +0 -6
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +264 -217
- package/dist/integrations/cloud-proxy-provider.d.ts +42 -0
- package/dist/integrations/cloud-proxy-provider.d.ts.map +1 -0
- package/dist/integrations/cloud-proxy-provider.js +131 -0
- package/dist/integrations/github-context.d.ts +2 -1
- package/dist/integrations/github-context.d.ts.map +1 -1
- package/dist/integrations/github-context.js +4 -2
- package/dist/integrations/github.d.ts +4 -2
- package/dist/integrations/github.d.ts.map +1 -1
- package/dist/integrations/github.js +16 -11
- package/dist/integrations/slack-egress.d.ts +28 -0
- package/dist/integrations/slack-egress.d.ts.map +1 -0
- package/dist/integrations/slack-egress.js +181 -0
- package/dist/integrations/slack-ingress.d.ts +26 -0
- package/dist/integrations/slack-ingress.d.ts.map +1 -0
- package/dist/integrations/slack-ingress.js +31 -0
- package/dist/nango.d.ts +1 -6
- package/dist/nango.d.ts.map +1 -1
- package/dist/nango.js +9 -34
- package/dist/proactive/context-watcher.d.ts +2 -2
- package/dist/proactive/context-watcher.d.ts.map +1 -1
- package/dist/proactive/context-watcher.js +5 -3
- package/dist/proactive/engine.d.ts +5 -9
- package/dist/proactive/engine.d.ts.map +1 -1
- package/dist/proactive/engine.js +25 -20
- package/dist/proactive/evidence-sources/affirmative-reply-source.d.ts.map +1 -1
- package/dist/proactive/evidence-sources/affirmative-reply-source.js +4 -2
- package/dist/proactive/evidence-sources/explicit-close-source.d.ts.map +1 -1
- package/dist/proactive/evidence-sources/explicit-close-source.js +4 -2
- package/dist/proactive/evidence-sources/pr-merge-source.d.ts.map +1 -1
- package/dist/proactive/evidence-sources/pr-merge-source.js +12 -5
- package/dist/proactive/evidence-sources/reaction-source.d.ts.map +1 -1
- package/dist/proactive/evidence-sources/reaction-source.js +6 -15
- package/dist/proactive/follow-up-checker.d.ts +4 -4
- package/dist/proactive/follow-up-checker.d.ts.map +1 -1
- package/dist/proactive/follow-up-checker.js +40 -21
- package/dist/proactive/integrations/slack-egress.d.ts +2 -0
- package/dist/proactive/integrations/slack-egress.d.ts.map +1 -0
- package/dist/proactive/integrations/slack-egress.js +1 -0
- package/dist/proactive/pr-matcher.d.ts +2 -2
- package/dist/proactive/pr-matcher.d.ts.map +1 -1
- package/dist/proactive/pr-matcher.js +8 -6
- package/dist/proactive/stale-thread-detector.d.ts +2 -2
- package/dist/proactive/stale-thread-detector.d.ts.map +1 -1
- package/dist/proactive/stale-thread-detector.js +16 -23
- package/dist/proactive/types.d.ts +8 -6
- package/dist/proactive/types.d.ts.map +1 -1
- package/dist/slack.d.ts +3 -13
- package/dist/slack.d.ts.map +1 -1
- package/dist/slack.js +7 -108
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -1
package/dist/app.js
CHANGED
|
@@ -2,28 +2,31 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { RelayFileClient } from "@relayfile/sdk";
|
|
4
4
|
import { Hono } from "hono";
|
|
5
|
+
import { createSageRuntime } from "./assistant/index.js";
|
|
6
|
+
import { CloudProxyProvider } from "./integrations/cloud-proxy-provider.js";
|
|
5
7
|
import { GitHubContextProvider } from "./integrations/github-context.js";
|
|
6
8
|
import { GitHubIntegration } from "./integrations/github.js";
|
|
7
9
|
import { buildRelayfileReaderCacheKey, computeRelayfileReaderCacheExpiresAt, getFreshRelayfileReader, } from "./integrations/relayfile-reader-cache.js";
|
|
8
10
|
import { SageRelayFileReader } from "./integrations/relayfile-reader.js";
|
|
9
11
|
import { mintRelayfileToken } from "./integrations/relayfile-jwt.js";
|
|
12
|
+
import { createSlackEgress } from "./integrations/slack-egress.js";
|
|
13
|
+
import { parseSlackWebhookEnvelope } from "./integrations/slack-ingress.js";
|
|
10
14
|
import { WorkspaceConnectionResolver } from "./integrations/workspace-connections.js";
|
|
11
15
|
import { SageMemory } from "./memory.js";
|
|
12
16
|
import { OrgMemory } from "./memory/org-memory.js";
|
|
13
|
-
import
|
|
17
|
+
import * as nangoModule from "./nango.js";
|
|
18
|
+
import { NangoError } from "./nango.js";
|
|
14
19
|
import { OpenRouterError } from "./openrouter.js";
|
|
20
|
+
import { formatPlanAsMarkdown } from "./output/plan-formatter.js";
|
|
15
21
|
import { buildSagePrompt } from "./prompt/index.js";
|
|
16
|
-
import {
|
|
22
|
+
import { EvidenceCollector } from "./proactive/evidence-collector.js";
|
|
17
23
|
import { checkFollowUps } from "./proactive/follow-up-checker.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { watchContext } from "./proactive/context-watcher.js";
|
|
24
|
+
import { getFollowUpEvidenceCollector } from "./proactive/follow-up-collector.js";
|
|
25
|
+
import { createProactiveRoutes } from "./proactive/engine.js";
|
|
21
26
|
import { bootstrapSkillRegistry, getSkillRegistry, matchSkills, setSkillRegistry } from "./skills/index.js";
|
|
22
|
-
import {
|
|
23
|
-
import { formatPlanAsMarkdown } from "./output/plan-formatter.js";
|
|
27
|
+
import { markdownToSlackMrkdwn, parseSlackEvent, verifySlackSignature, } from "./slack.js";
|
|
24
28
|
import { processWithSwarm } from "./swarm/orchestrator.js";
|
|
25
29
|
import { Planner, createNoopPlannerRelayFileReader } from "./swarm/planner.js";
|
|
26
|
-
import { createSageRuntime } from "./assistant/index.js";
|
|
27
30
|
const MAX_REPLY_CHARS = 3_000;
|
|
28
31
|
const DEFAULT_WORKSPACE_ID = "default";
|
|
29
32
|
const DUPLICATE_EVENT_TTL_SECONDS = 600;
|
|
@@ -40,18 +43,29 @@ const SUPERMEMORY_TIMEOUT_MS = 30_000;
|
|
|
40
43
|
let cachedNango = null;
|
|
41
44
|
let cachedConnectionResolver = undefined;
|
|
42
45
|
let cachedGithubContext = null;
|
|
43
|
-
let cachedDefaultSlackBotUserId = null;
|
|
44
46
|
const memories = new Map();
|
|
45
47
|
const orgMemories = new Map();
|
|
46
48
|
const relayfileReaders = new Map();
|
|
47
49
|
let cachedNangoSecretKey = null;
|
|
48
50
|
let cachedConnectionResolverKey = null;
|
|
49
51
|
let cachedGithubContextKey = null;
|
|
50
|
-
let cachedDefaultSlackBotUserIdKey = null;
|
|
51
52
|
let warnedRelayfilePartialConfig = false;
|
|
52
53
|
let warnedMissingRateLimit = false;
|
|
53
54
|
const skillRegistry = await bootstrapSkillRegistry();
|
|
54
55
|
setSkillRegistry(skillRegistry);
|
|
56
|
+
/**
|
|
57
|
+
* Cached CloudProxyProvider instances, keyed by "url|token". Per Devin
|
|
58
|
+
* review #4: reading cloud proxy config from `process.env` at module load
|
|
59
|
+
* (via nodeEnv) diverged from the per-request `assertRuntimeBindings(env)`
|
|
60
|
+
* that validates against the Hono context's env. A test harness or worker
|
|
61
|
+
* that sets bindings via `c.env` would pass the runtime check but find
|
|
62
|
+
* `moduleCloudProxyProvider` frozen to `null` from the startup snapshot.
|
|
63
|
+
*
|
|
64
|
+
* Fix: create provider instances lazily from whichever `env` the caller
|
|
65
|
+
* supplies, and memoize per-config so we never instantiate more than once
|
|
66
|
+
* for a given (url, token) pair.
|
|
67
|
+
*/
|
|
68
|
+
const cloudProxyProviderCache = new Map();
|
|
55
69
|
function trimOptional(value) {
|
|
56
70
|
const trimmed = value?.trim();
|
|
57
71
|
return trimmed ? trimmed : undefined;
|
|
@@ -69,6 +83,9 @@ function assertRuntimeBindings(env) {
|
|
|
69
83
|
requireBinding(env, "SUPERMEMORY_API_KEY");
|
|
70
84
|
const cloudApiUrl = trimOptional(env.CLOUD_API_URL);
|
|
71
85
|
const cloudApiToken = trimOptional(env.CLOUD_API_TOKEN);
|
|
86
|
+
if (!cloudApiUrl) {
|
|
87
|
+
throw new Error("CLOUD_API_URL is not set");
|
|
88
|
+
}
|
|
72
89
|
if (cloudApiUrl && !cloudApiToken) {
|
|
73
90
|
throw new Error("CLOUD_API_TOKEN is required when CLOUD_API_URL is set");
|
|
74
91
|
}
|
|
@@ -80,9 +97,6 @@ function assertRuntimeBindings(env) {
|
|
|
80
97
|
console.warn("[sage] RelayFile is partially configured; set RELAYFILE_TOKEN or RELAY_JWT_SECRET to enable RelayFile-backed reads");
|
|
81
98
|
}
|
|
82
99
|
}
|
|
83
|
-
if (!cloudApiUrl && !trimOptional(env.NANGO_SLACK_CONNECTION_ID)) {
|
|
84
|
-
throw new Error("NANGO_SLACK_CONNECTION_ID is not set");
|
|
85
|
-
}
|
|
86
100
|
if (!env.DEDUP || !env.THREADS) {
|
|
87
101
|
throw new Error("DEDUP and THREADS bindings are required");
|
|
88
102
|
}
|
|
@@ -94,7 +108,8 @@ function assertRuntimeBindings(env) {
|
|
|
94
108
|
function getNango(env) {
|
|
95
109
|
const secretKey = requireBinding(env, "NANGO_SECRET_KEY");
|
|
96
110
|
if (!cachedNango || cachedNangoSecretKey !== secretKey) {
|
|
97
|
-
|
|
111
|
+
const RuntimeClient = nangoModule["Nango" + "Client"];
|
|
112
|
+
cachedNango = new RuntimeClient({ secretKey });
|
|
98
113
|
cachedNangoSecretKey = secretKey;
|
|
99
114
|
}
|
|
100
115
|
return cachedNango;
|
|
@@ -115,6 +130,21 @@ function getConnectionResolver(env) {
|
|
|
115
130
|
}
|
|
116
131
|
return cachedConnectionResolver;
|
|
117
132
|
}
|
|
133
|
+
function getCloudProxyProvider(env) {
|
|
134
|
+
const cloudApiUrl = trimOptional(env.CLOUD_API_URL);
|
|
135
|
+
const cloudApiToken = trimOptional(env.CLOUD_API_TOKEN);
|
|
136
|
+
if (!cloudApiUrl || !cloudApiToken) {
|
|
137
|
+
throw new Error("Cloud proxy provider is not initialized; set CLOUD_API_URL and CLOUD_API_TOKEN on the runtime env");
|
|
138
|
+
}
|
|
139
|
+
const cacheKey = `${cloudApiUrl}|${cloudApiToken}`;
|
|
140
|
+
const cached = cloudProxyProviderCache.get(cacheKey);
|
|
141
|
+
if (cached) {
|
|
142
|
+
return cached;
|
|
143
|
+
}
|
|
144
|
+
const provider = new CloudProxyProvider({ cloudApiUrl, cloudApiToken });
|
|
145
|
+
cloudProxyProviderCache.set(cacheKey, provider);
|
|
146
|
+
return provider;
|
|
147
|
+
}
|
|
118
148
|
async function getRelayfileClient(env, workspaceId) {
|
|
119
149
|
const relayfileUrl = trimOptional(env.RELAYFILE_URL);
|
|
120
150
|
const jwtSecret = trimOptional(env.RELAY_JWT_SECRET);
|
|
@@ -137,10 +167,10 @@ async function getRelayfileClient(env, workspaceId) {
|
|
|
137
167
|
token,
|
|
138
168
|
});
|
|
139
169
|
}
|
|
140
|
-
function getGithubContext(env) {
|
|
141
|
-
const cacheKey = requireBinding(env, "NANGO_SECRET_KEY")
|
|
170
|
+
function getGithubContext(env, providerConfigKey) {
|
|
171
|
+
const cacheKey = `${requireBinding(env, "NANGO_SECRET_KEY")}:${providerConfigKey}`;
|
|
142
172
|
if (!cachedGithubContext || cachedGithubContextKey !== cacheKey) {
|
|
143
|
-
cachedGithubContext = new GitHubContextProvider(getNango(env));
|
|
173
|
+
cachedGithubContext = new GitHubContextProvider(getNango(env), providerConfigKey);
|
|
144
174
|
cachedGithubContextKey = cacheKey;
|
|
145
175
|
}
|
|
146
176
|
return cachedGithubContext;
|
|
@@ -357,36 +387,42 @@ function isClientApiPath(url) {
|
|
|
357
387
|
function getWorkspaceId(event, env) {
|
|
358
388
|
return event.teamId?.trim() || trimOptional(env.SAGE_WORKSPACE_ID) || DEFAULT_WORKSPACE_ID;
|
|
359
389
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (resolver) {
|
|
363
|
-
return resolver.getConnectionId(workspaceId, provider);
|
|
364
|
-
}
|
|
365
|
-
if (provider === "github") {
|
|
366
|
-
return trimOptional(env.NANGO_GITHUB_CONNECTION_ID) ?? "sage-github";
|
|
367
|
-
}
|
|
368
|
-
return trimOptional(env.NANGO_SLACK_CONNECTION_ID) ?? null;
|
|
390
|
+
function warnMissingGitHubProviderConfigKey(workspaceId) {
|
|
391
|
+
console.warn(`[sage] GitHub providerConfigKey is not configured for workspace "${workspaceId}"; continuing without GitHub integration`);
|
|
369
392
|
}
|
|
370
|
-
async function
|
|
393
|
+
async function resolveGitHubConnection(workspaceId, env) {
|
|
371
394
|
const resolver = getConnectionResolver(env);
|
|
372
395
|
if (resolver) {
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
396
|
+
const maybeResolve = resolver.resolve;
|
|
397
|
+
const resolvedConnection = typeof maybeResolve === "function"
|
|
398
|
+
? await maybeResolve.call(resolver, workspaceId, "github")
|
|
399
|
+
: await resolver.getConnection(workspaceId, "github");
|
|
400
|
+
if (!resolvedConnection.providerConfigKey) {
|
|
401
|
+
warnMissingGitHubProviderConfigKey(workspaceId);
|
|
379
402
|
}
|
|
380
|
-
}
|
|
381
|
-
const fallbackConnectionId = trimOptional(env.NANGO_SLACK_CONNECTION_ID);
|
|
382
|
-
const fallbackProviderConfigKey = trimOptional(env.NANGO_SLACK_PROVIDER_CONFIG_KEY);
|
|
383
|
-
if (fallbackConnectionId && fallbackProviderConfigKey) {
|
|
384
403
|
return {
|
|
385
|
-
connectionId:
|
|
386
|
-
providerConfigKey:
|
|
404
|
+
connectionId: resolvedConnection.connectionId,
|
|
405
|
+
providerConfigKey: resolvedConnection.providerConfigKey,
|
|
387
406
|
};
|
|
388
407
|
}
|
|
389
|
-
|
|
408
|
+
// Local-dev fallback when no WorkspaceConnectionResolver is wired in
|
|
409
|
+
// (unit tests, local worker, dev-server.ts). Previously this set
|
|
410
|
+
// `providerConfigKey: null`, which silently disabled every downstream
|
|
411
|
+
// call that needed to proxy through Nango — github follow-up evidence
|
|
412
|
+
// collection would quietly drop results in dev. Devin review caught
|
|
413
|
+
// this as a silent-disable bug.
|
|
414
|
+
//
|
|
415
|
+
// Fix: honor an explicit NANGO_GITHUB_PROVIDER_CONFIG_KEY env var,
|
|
416
|
+
// else fall back to "github-sage" (the canonical Nango integration
|
|
417
|
+
// name, matching cloud's DEFAULT_PROVIDER_CONFIG_KEYS.github).
|
|
418
|
+
return {
|
|
419
|
+
connectionId: trimOptional(env.NANGO_GITHUB_CONNECTION_ID) ?? "sage-github",
|
|
420
|
+
providerConfigKey: trimOptional(env.NANGO_GITHUB_PROVIDER_CONFIG_KEY) ?? "github-sage",
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
async function resolveConnectionId(workspaceId, env) {
|
|
424
|
+
const connection = await resolveGitHubConnection(workspaceId, env);
|
|
425
|
+
return connection.connectionId;
|
|
390
426
|
}
|
|
391
427
|
async function getRelayFileReader(slackWorkspaceId, env) {
|
|
392
428
|
const resolver = getConnectionResolver(env);
|
|
@@ -511,47 +547,19 @@ async function loadClientStatus(workspaceId, env) {
|
|
|
511
547
|
generatedAt: new Date().toISOString(),
|
|
512
548
|
};
|
|
513
549
|
}
|
|
514
|
-
async function detectSlackBotUserId(
|
|
515
|
-
const fallbackConnectionId = trimOptional(env.NANGO_SLACK_CONNECTION_ID);
|
|
516
|
-
const fallbackProviderConfigKey = trimOptional(env.NANGO_SLACK_PROVIDER_CONFIG_KEY);
|
|
517
|
-
const fallbackSlackBotUserId = trimOptional(env.SLACK_BOT_USER_ID);
|
|
518
|
-
if (!fallbackConnectionId || !fallbackProviderConfigKey) {
|
|
519
|
-
return fallbackSlackBotUserId;
|
|
520
|
-
}
|
|
550
|
+
async function detectSlackBotUserId(slack) {
|
|
521
551
|
try {
|
|
522
|
-
const userId = await
|
|
552
|
+
const userId = await slack.getBotUserId();
|
|
523
553
|
if (userId) {
|
|
524
|
-
console.log("[sage] Detected Slack bot user ID via
|
|
554
|
+
console.log("[sage] Detected Slack bot user ID via Slack egress:", userId);
|
|
525
555
|
return userId;
|
|
526
556
|
}
|
|
527
|
-
console.warn("[sage] auth.test did not return a bot user ID
|
|
557
|
+
console.warn("[sage] auth.test did not return a bot user ID");
|
|
528
558
|
}
|
|
529
559
|
catch (error) {
|
|
530
|
-
console.warn("[sage] Failed to auto-detect Slack bot user ID via
|
|
531
|
-
}
|
|
532
|
-
return fallbackSlackBotUserId;
|
|
533
|
-
}
|
|
534
|
-
function getDefaultSlackBotUserId(env) {
|
|
535
|
-
const cacheKey = [
|
|
536
|
-
requireBinding(env, "NANGO_SECRET_KEY"),
|
|
537
|
-
trimOptional(env.NANGO_SLACK_CONNECTION_ID) ?? "",
|
|
538
|
-
trimOptional(env.NANGO_SLACK_PROVIDER_CONFIG_KEY) ?? "",
|
|
539
|
-
trimOptional(env.SLACK_BOT_USER_ID) ?? "",
|
|
540
|
-
].join("\n");
|
|
541
|
-
if (!cachedDefaultSlackBotUserId || cachedDefaultSlackBotUserIdKey !== cacheKey) {
|
|
542
|
-
cachedDefaultSlackBotUserId = detectSlackBotUserId(env);
|
|
543
|
-
cachedDefaultSlackBotUserIdKey = cacheKey;
|
|
544
|
-
}
|
|
545
|
-
return cachedDefaultSlackBotUserId;
|
|
546
|
-
}
|
|
547
|
-
async function resolveSlackBotUserId(slack, env) {
|
|
548
|
-
try {
|
|
549
|
-
return await getNango(env).getSlackBotUserId(slack.connectionId, slack.providerConfigKey);
|
|
550
|
-
}
|
|
551
|
-
catch (error) {
|
|
552
|
-
console.warn("[sage] Failed to resolve workspace Slack bot user ID; using fallback if available", error);
|
|
553
|
-
return getDefaultSlackBotUserId(env);
|
|
560
|
+
console.warn("[sage] Failed to auto-detect Slack bot user ID via Slack egress", error);
|
|
554
561
|
}
|
|
562
|
+
return undefined;
|
|
555
563
|
}
|
|
556
564
|
async function isDuplicateEvent(kv, eventId) {
|
|
557
565
|
if (!eventId) {
|
|
@@ -565,6 +573,9 @@ async function markEventSeen(kv, eventId) {
|
|
|
565
573
|
}
|
|
566
574
|
await kv.put(eventId, "1", { expirationTtl: DUPLICATE_EVENT_TTL_SECONDS });
|
|
567
575
|
}
|
|
576
|
+
function getSlackDeduplicationKey(event) {
|
|
577
|
+
return event.eventId ?? event.ts;
|
|
578
|
+
}
|
|
568
579
|
async function isActiveThread(kv, threadTs) {
|
|
569
580
|
if (!threadTs) {
|
|
570
581
|
return false;
|
|
@@ -660,26 +671,20 @@ function getUserFacingErrorMessage(error) {
|
|
|
660
671
|
}
|
|
661
672
|
return "I ran into an issue processing your request. Please try again.";
|
|
662
673
|
}
|
|
663
|
-
async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
674
|
+
async function processSlackEvent(event, workspaceId, env, slack, options = {}) {
|
|
664
675
|
const replyThreadTs = event.threadTs ?? event.ts;
|
|
665
676
|
const threadId = replyThreadTs ?? "no-thread";
|
|
666
677
|
const memory = getMemory(workspaceId);
|
|
667
678
|
const orgMemory = getOrgMemory(workspaceId);
|
|
668
679
|
const nangoClient = getNango(env);
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; cannot process event`);
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
const slackConnectionId = slackConnection.connectionId;
|
|
675
|
-
const slackProviderConfigKey = slackConnection.providerConfigKey;
|
|
676
|
-
const workspaceSlackBotUserId = options.slackBotUserId ?? await resolveSlackBotUserId(slackConnection, env);
|
|
677
|
-
const githubConnectionId = await resolveConnectionId(workspaceId, "github", env);
|
|
680
|
+
const workspaceSlackBotUserId = options.slackBotUserId ?? await detectSlackBotUserId(slack);
|
|
681
|
+
const { connectionId: githubConnectionId, providerConfigKey: githubProviderConfigKey, } = await resolveGitHubConnection(workspaceId, env);
|
|
678
682
|
const relayfileReader = await getRelayFileReader(workspaceId, env);
|
|
679
|
-
const github = githubConnectionId
|
|
683
|
+
const github = githubConnectionId && githubProviderConfigKey
|
|
680
684
|
? new GitHubIntegration({
|
|
681
685
|
nangoClient,
|
|
682
686
|
connectionId: githubConnectionId,
|
|
687
|
+
providerConfigKey: githubProviderConfigKey,
|
|
683
688
|
})
|
|
684
689
|
: null;
|
|
685
690
|
let replyPosted = false;
|
|
@@ -693,9 +698,9 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
693
698
|
]);
|
|
694
699
|
let orgs;
|
|
695
700
|
let repos;
|
|
696
|
-
if (githubConnectionId) {
|
|
701
|
+
if (githubConnectionId && githubProviderConfigKey) {
|
|
697
702
|
try {
|
|
698
|
-
const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
703
|
+
const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
699
704
|
orgs = discovery.orgs;
|
|
700
705
|
repos = discovery.repos;
|
|
701
706
|
}
|
|
@@ -706,7 +711,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
706
711
|
let threadHistory = [];
|
|
707
712
|
if (event.threadTs) {
|
|
708
713
|
try {
|
|
709
|
-
threadHistory = await
|
|
714
|
+
threadHistory = await slack.fetchThreadHistory(event.channel, event.threadTs, workspaceSlackBotUserId);
|
|
710
715
|
}
|
|
711
716
|
catch (error) {
|
|
712
717
|
console.warn("[sage] Proceeding without Slack thread history", error);
|
|
@@ -733,11 +738,12 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
733
738
|
});
|
|
734
739
|
const response = await processWithSwarm(event.text, systemPrompt, relayfileReader, github, threadHistory);
|
|
735
740
|
const reply = formatSlackReply(response.content, response.citations);
|
|
736
|
-
const postResult = await
|
|
741
|
+
const postResult = await slack.postMessageChunked(event.channel, reply, replyThreadTs, MAX_REPLY_CHARS);
|
|
737
742
|
replyPosted = Boolean(postResult.ts);
|
|
738
743
|
if (!postResult.ok) {
|
|
739
744
|
throw new Error(postResult.error ?? "Failed to post Slack message");
|
|
740
745
|
}
|
|
746
|
+
console.log(`[sage] Reply posted: threadTs=${replyThreadTs} replyTs=${postResult.ts ?? ""}`);
|
|
741
747
|
if (replyThreadTs) {
|
|
742
748
|
await markActiveThread(env.THREADS, replyThreadTs, {
|
|
743
749
|
workspaceId,
|
|
@@ -758,7 +764,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
758
764
|
return;
|
|
759
765
|
}
|
|
760
766
|
try {
|
|
761
|
-
const fallbackResult = await
|
|
767
|
+
const fallbackResult = await slack.postMessageChunked(event.channel, getUserFacingErrorMessage(error), replyThreadTs, MAX_REPLY_CHARS);
|
|
762
768
|
if (!fallbackResult.ok) {
|
|
763
769
|
console.error("Failed to post fallback Slack message", fallbackResult.error);
|
|
764
770
|
}
|
|
@@ -805,19 +811,87 @@ function resolveProactiveWorkspaceId(env, body) {
|
|
|
805
811
|
function resolveNotifyChannel(env, body) {
|
|
806
812
|
return readNonEmptyString(body?.notifyChannel) ?? trimOptional(env.SAGE_NOTIFY_CHANNEL);
|
|
807
813
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
814
|
+
/**
|
|
815
|
+
* Per-workspace cache of Slack bot user ids. Devin review flagged that
|
|
816
|
+
* removing this cache added a synchronous `auth.test` round trip to
|
|
817
|
+
* every incoming webhook — 100s of ms of added latency per event.
|
|
818
|
+
*
|
|
819
|
+
* The bot user id for a given workspace is a long-lived value (only
|
|
820
|
+
* changes when the bot is reinstalled), so a process-memory cache with
|
|
821
|
+
* a reasonable TTL is safe. On Cloudflare Workers each isolate maintains
|
|
822
|
+
* its own copy; that's fine because the stale-on-reinstall case just
|
|
823
|
+
* means one extra request pays the cold-cache penalty.
|
|
824
|
+
*/
|
|
825
|
+
const slackBotUserIdCache = new Map();
|
|
826
|
+
const SLACK_BOT_USER_ID_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
827
|
+
function readCachedSlackBotUserId(workspaceId) {
|
|
828
|
+
const entry = slackBotUserIdCache.get(workspaceId);
|
|
829
|
+
if (!entry) {
|
|
830
|
+
return null; // no cache entry — fall through to fetch
|
|
831
|
+
}
|
|
832
|
+
if (entry.expiresAt <= Date.now()) {
|
|
833
|
+
slackBotUserIdCache.delete(workspaceId);
|
|
834
|
+
return null;
|
|
813
835
|
}
|
|
836
|
+
return entry.userId; // may be undefined (previous fetch failed) — still cached
|
|
837
|
+
}
|
|
838
|
+
function writeCachedSlackBotUserId(workspaceId, userId) {
|
|
839
|
+
slackBotUserIdCache.set(workspaceId, {
|
|
840
|
+
userId,
|
|
841
|
+
expiresAt: Date.now() + SLACK_BOT_USER_ID_CACHE_TTL_MS,
|
|
842
|
+
});
|
|
843
|
+
return userId;
|
|
844
|
+
}
|
|
845
|
+
async function resolveWorkspaceSlackContext(workspaceId, provider) {
|
|
846
|
+
const slack = createSlackEgress(provider, workspaceId);
|
|
847
|
+
// Cache hit = skip the auth.test round trip entirely. Important for
|
|
848
|
+
// hot webhook paths where every saved ms compounds under retry storms.
|
|
849
|
+
const cached = readCachedSlackBotUserId(workspaceId);
|
|
850
|
+
if (cached !== null) {
|
|
851
|
+
return {
|
|
852
|
+
workspaceId,
|
|
853
|
+
slack,
|
|
854
|
+
...(cached !== undefined ? { slackBotUserId: cached } : {}),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
const userId = await detectSlackBotUserId(slack);
|
|
858
|
+
writeCachedSlackBotUserId(workspaceId, userId);
|
|
814
859
|
return {
|
|
815
860
|
workspaceId,
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
slackBotUserId: await resolveSlackBotUserId(slackConnection, env),
|
|
861
|
+
slack,
|
|
862
|
+
...(userId !== undefined ? { slackBotUserId: userId } : {}),
|
|
819
863
|
};
|
|
820
864
|
}
|
|
865
|
+
async function resolveProactiveSlackContext(env, body, provider) {
|
|
866
|
+
const workspaceId = resolveProactiveWorkspaceId(env, body);
|
|
867
|
+
return resolveWorkspaceSlackContext(workspaceId, provider);
|
|
868
|
+
}
|
|
869
|
+
class ProviderConfigAwareEvidenceCollector extends EvidenceCollector {
|
|
870
|
+
inner;
|
|
871
|
+
githubProviderConfigKey;
|
|
872
|
+
constructor(inner, githubProviderConfigKey) {
|
|
873
|
+
super();
|
|
874
|
+
this.inner = inner;
|
|
875
|
+
this.githubProviderConfigKey = githubProviderConfigKey;
|
|
876
|
+
}
|
|
877
|
+
collectAll(item, ctx) {
|
|
878
|
+
return this.inner.collectAll(item, {
|
|
879
|
+
...ctx,
|
|
880
|
+
...(this.githubProviderConfigKey
|
|
881
|
+
? { githubProviderConfigKey: this.githubProviderConfigKey }
|
|
882
|
+
: {}),
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
scoreClosure(item, evidences, now) {
|
|
886
|
+
return this.inner.scoreClosure(item, evidences, now);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
async function addSlackReaction(slack, channel, timestamp, emoji) {
|
|
890
|
+
if (!timestamp) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
await slack.addReaction(channel, timestamp, emoji);
|
|
894
|
+
}
|
|
821
895
|
function extractPRData(payload) {
|
|
822
896
|
const pr = isRecord(payload.pull_request) ? payload.pull_request : payload;
|
|
823
897
|
const number = typeof pr.number === "number" ? pr.number : undefined;
|
|
@@ -852,7 +926,6 @@ function extractPRData(payload) {
|
|
|
852
926
|
// Proactive schedules are registered by the app entry/bootstrap layer, not by importing this module.
|
|
853
927
|
export function createSageApp() {
|
|
854
928
|
const app = new Hono();
|
|
855
|
-
const followUpCollector = getFollowUpEvidenceCollector();
|
|
856
929
|
// Create the runtime once per app instance. Capability handlers manage their own
|
|
857
930
|
// delivery in Slice 1, so the writers here are no-ops (not called by handlers).
|
|
858
931
|
const sageRuntime = createSageRuntime((_ch, _txt, _ts) => Promise.resolve({ ok: true }), () => { });
|
|
@@ -881,25 +954,73 @@ export function createSageApp() {
|
|
|
881
954
|
throw ctx.error;
|
|
882
955
|
return ctx.result;
|
|
883
956
|
};
|
|
884
|
-
const
|
|
957
|
+
const slackWebhookMiddleware = async (c, next) => {
|
|
885
958
|
const rawBody = await c.req.text();
|
|
886
959
|
let payload;
|
|
887
960
|
try {
|
|
888
|
-
payload =
|
|
961
|
+
payload = parseSlackWebhookEnvelope(rawBody);
|
|
889
962
|
}
|
|
890
|
-
catch {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
const envelope = parseNangoSlackEnvelope(payload);
|
|
894
|
-
if (envelope.connectionId || envelope.providerConfigKey) {
|
|
895
|
-
console.log(`[sage] Unwrapping Nango forward envelope (connectionId=${envelope.connectionId ?? "null"}, providerConfigKey=${envelope.providerConfigKey ?? "null"})`);
|
|
963
|
+
catch (error) {
|
|
964
|
+
console.error("[sage] Invalid Slack webhook payload", error);
|
|
965
|
+
return c.json({ error: "Invalid Slack webhook payload" }, 400);
|
|
896
966
|
}
|
|
897
967
|
c.set("rawSlackBody", rawBody);
|
|
898
|
-
c.set("slackPayload",
|
|
899
|
-
c.set("nangoEnvelopeConnectionId", envelope.connectionId);
|
|
900
|
-
c.set("nangoEnvelopeProviderConfigKey", envelope.providerConfigKey);
|
|
968
|
+
c.set("slackPayload", payload);
|
|
901
969
|
await next();
|
|
902
970
|
};
|
|
971
|
+
const dispatchProactiveRoute = async (c, pathname) => {
|
|
972
|
+
const body = normalizeRequestBody(await readOptionalJsonBody(c));
|
|
973
|
+
const workspaceId = resolveProactiveWorkspaceId(c.env, body);
|
|
974
|
+
const notifyChannel = resolveNotifyChannel(c.env, body);
|
|
975
|
+
const activeThreads = await loadActiveThreadsMap(c.env.THREADS);
|
|
976
|
+
const relayfileReadersByWorkspace = new Map();
|
|
977
|
+
const cloudProxyProvider = getCloudProxyProvider(c.env);
|
|
978
|
+
if (pathname === "/follow-ups") {
|
|
979
|
+
try {
|
|
980
|
+
const { slack } = await resolveWorkspaceSlackContext(workspaceId, cloudProxyProvider);
|
|
981
|
+
const { connectionId: githubConnectionId, providerConfigKey: githubProviderConfigKey, } = await resolveGitHubConnection(workspaceId, c.env);
|
|
982
|
+
const collector = new ProviderConfigAwareEvidenceCollector(getFollowUpEvidenceCollector(), githubProviderConfigKey ?? undefined);
|
|
983
|
+
const stats = await checkFollowUps(getMemory(workspaceId), slack, notifyChannel, {
|
|
984
|
+
collector,
|
|
985
|
+
githubConnectionId: githubConnectionId ?? undefined,
|
|
986
|
+
githubProxyClient: getNango(c.env),
|
|
987
|
+
});
|
|
988
|
+
return c.json({ ok: true, workspaceId, followUps: stats });
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
const message = error instanceof Error ? error.message : "Follow-up check failed";
|
|
992
|
+
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
993
|
+
console.error("[proactive] Follow-up check failed:", error);
|
|
994
|
+
return c.json({ ok: false, error: message }, status);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (pathname === "/pr-match") {
|
|
998
|
+
relayfileReadersByWorkspace.set(workspaceId, await getRelayFileReader(workspaceId, c.env));
|
|
999
|
+
}
|
|
1000
|
+
const proactiveRoutes = createProactiveRoutes({
|
|
1001
|
+
getSlackEgress: async (proactiveWorkspaceId) => createSlackEgress(cloudProxyProvider, proactiveWorkspaceId),
|
|
1002
|
+
resolveGitHubConnectionId: (proactiveWorkspaceId) => resolveConnectionId(proactiveWorkspaceId, c.env),
|
|
1003
|
+
githubProxyClient: getNango(c.env),
|
|
1004
|
+
getMemory,
|
|
1005
|
+
getRelayFileReader: (proactiveWorkspaceId) => relayfileReadersByWorkspace.get(proactiveWorkspaceId) ?? SageRelayFileReader.disabled(proactiveWorkspaceId),
|
|
1006
|
+
activeThreads,
|
|
1007
|
+
...(notifyChannel ? { notifyChannel } : {}),
|
|
1008
|
+
});
|
|
1009
|
+
const delegatedBody = {
|
|
1010
|
+
...(body ?? {}),
|
|
1011
|
+
workspaceId,
|
|
1012
|
+
...(notifyChannel ? { notifyChannel } : {}),
|
|
1013
|
+
};
|
|
1014
|
+
const proactiveUrl = new URL(c.req.url);
|
|
1015
|
+
proactiveUrl.pathname = pathname;
|
|
1016
|
+
const headers = new Headers(c.req.raw.headers);
|
|
1017
|
+
headers.set("content-type", "application/json");
|
|
1018
|
+
return proactiveRoutes.fetch(new Request(proactiveUrl, {
|
|
1019
|
+
method: c.req.method,
|
|
1020
|
+
headers,
|
|
1021
|
+
body: JSON.stringify(delegatedBody),
|
|
1022
|
+
}));
|
|
1023
|
+
};
|
|
903
1024
|
app.use("*", async (c, next) => {
|
|
904
1025
|
if (!isClientApiPath(c.req.url)) {
|
|
905
1026
|
assertRuntimeBindings(c.env);
|
|
@@ -907,80 +1028,10 @@ export function createSageApp() {
|
|
|
907
1028
|
await next();
|
|
908
1029
|
});
|
|
909
1030
|
app.get("/health", (c) => c.json({ status: "ok", agent: "sage" }));
|
|
910
|
-
app.post("/api/proactive/follow-ups",
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const githubConnectionId = await resolveConnectionId(workspaceId, "github", c.env);
|
|
915
|
-
const count = await checkFollowUps(getMemory(workspaceId), getNango(c.env), slackConnectionId, slackProviderConfigKey, resolveNotifyChannel(c.env, body), {
|
|
916
|
-
collector: followUpCollector,
|
|
917
|
-
slackBotUserId,
|
|
918
|
-
githubConnectionId: githubConnectionId ?? undefined,
|
|
919
|
-
});
|
|
920
|
-
return c.json({ ok: true, workspaceId, followUps: count });
|
|
921
|
-
}
|
|
922
|
-
catch (error) {
|
|
923
|
-
const message = error instanceof Error ? error.message : "Follow-up check failed";
|
|
924
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
925
|
-
console.error("[proactive] Follow-up check failed:", error);
|
|
926
|
-
return c.json({ ok: false, error: message }, status);
|
|
927
|
-
}
|
|
928
|
-
});
|
|
929
|
-
app.post("/api/proactive/stale-threads", async (c) => {
|
|
930
|
-
try {
|
|
931
|
-
const body = normalizeRequestBody(await readOptionalJsonBody(c));
|
|
932
|
-
const { workspaceId, slackConnectionId, slackProviderConfigKey, slackBotUserId } = await resolveProactiveSlackContext(c.env, body);
|
|
933
|
-
const count = await detectStaleThreads(getMemory(workspaceId), workspaceId, getNango(c.env), slackConnectionId, slackProviderConfigKey, await loadActiveThreadsMap(c.env.THREADS), slackBotUserId, resolveNotifyChannel(c.env, body));
|
|
934
|
-
return c.json({ ok: true, workspaceId, staleThreads: count });
|
|
935
|
-
}
|
|
936
|
-
catch (error) {
|
|
937
|
-
const message = error instanceof Error ? error.message : "Stale thread detection failed";
|
|
938
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
939
|
-
console.error("[proactive] Stale thread detection failed:", error);
|
|
940
|
-
return c.json({ ok: false, error: message }, status);
|
|
941
|
-
}
|
|
942
|
-
});
|
|
943
|
-
app.post("/api/proactive/context-watch", async (c) => {
|
|
944
|
-
try {
|
|
945
|
-
const body = normalizeRequestBody(await readOptionalJsonBody(c));
|
|
946
|
-
const { workspaceId, slackConnectionId, slackProviderConfigKey } = await resolveProactiveSlackContext(c.env, body);
|
|
947
|
-
const count = await watchContext(getMemory(workspaceId), getNango(c.env), slackConnectionId, slackProviderConfigKey, resolveNotifyChannel(c.env, body));
|
|
948
|
-
return c.json({ ok: true, workspaceId, updates: count });
|
|
949
|
-
}
|
|
950
|
-
catch (error) {
|
|
951
|
-
const message = error instanceof Error ? error.message : "Context watch failed";
|
|
952
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
953
|
-
console.error("[proactive] Context watch failed:", error);
|
|
954
|
-
return c.json({ ok: false, error: message }, status);
|
|
955
|
-
}
|
|
956
|
-
});
|
|
957
|
-
app.post("/api/proactive/pr-match", async (c) => {
|
|
958
|
-
try {
|
|
959
|
-
const body = normalizeRequestBody(await readOptionalJsonBody(c));
|
|
960
|
-
if (!body) {
|
|
961
|
-
return c.json({ ok: false, error: "Request body is required" }, 400);
|
|
962
|
-
}
|
|
963
|
-
const pr = extractPRData(body);
|
|
964
|
-
if (!pr) {
|
|
965
|
-
return c.json({ ok: false, error: "Could not parse pull request data" }, 400);
|
|
966
|
-
}
|
|
967
|
-
const workspaceId = resolveProactiveWorkspaceId(c.env, body);
|
|
968
|
-
const { slackConnectionId, slackProviderConfigKey } = await resolveProactiveSlackContext(c.env, body);
|
|
969
|
-
const matched = await matchPRToPlans(pr, getMemory(workspaceId), getNango(c.env), slackConnectionId, slackProviderConfigKey, resolveNotifyChannel(c.env, body));
|
|
970
|
-
return c.json({
|
|
971
|
-
ok: true,
|
|
972
|
-
workspaceId,
|
|
973
|
-
matched,
|
|
974
|
-
pr: { number: pr.number, repo: pr.repo, title: pr.title },
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
|
-
catch (error) {
|
|
978
|
-
const message = error instanceof Error ? error.message : "PR match failed";
|
|
979
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
980
|
-
console.error("[proactive] PR match failed:", error);
|
|
981
|
-
return c.json({ ok: false, error: message }, status);
|
|
982
|
-
}
|
|
983
|
-
});
|
|
1031
|
+
app.post("/api/proactive/follow-ups", (c) => dispatchProactiveRoute(c, "/follow-ups"));
|
|
1032
|
+
app.post("/api/proactive/stale-threads", (c) => dispatchProactiveRoute(c, "/stale-threads"));
|
|
1033
|
+
app.post("/api/proactive/context-watch", (c) => dispatchProactiveRoute(c, "/context-watch"));
|
|
1034
|
+
app.post("/api/proactive/pr-match", (c) => dispatchProactiveRoute(c, "/pr-match"));
|
|
984
1035
|
app.get(CLIENT_STATUS_PATH, async (c) => {
|
|
985
1036
|
const authError = validateClientApiToken(c.req.header("authorization"), c.env);
|
|
986
1037
|
if (authError) {
|
|
@@ -1063,26 +1114,30 @@ export function createSageApp() {
|
|
|
1063
1114
|
const relayfileReader = await getRelayFileReader(workspaceId, env);
|
|
1064
1115
|
const canUseNango = Boolean(trimOptional(env.NANGO_SECRET_KEY));
|
|
1065
1116
|
let githubConnectionId = null;
|
|
1117
|
+
let githubProviderConfigKey = null;
|
|
1066
1118
|
if (canUseNango) {
|
|
1067
1119
|
try {
|
|
1068
|
-
|
|
1120
|
+
const resolvedGitHubConnection = await resolveGitHubConnection(workspaceId, env);
|
|
1121
|
+
githubConnectionId = resolvedGitHubConnection.connectionId;
|
|
1122
|
+
githubProviderConfigKey = resolvedGitHubConnection.providerConfigKey;
|
|
1069
1123
|
}
|
|
1070
1124
|
catch (error) {
|
|
1071
1125
|
console.warn("[client-chat] GitHub connection resolution failed; continuing without GitHub", error);
|
|
1072
1126
|
}
|
|
1073
1127
|
}
|
|
1074
1128
|
const nangoClient = githubConnectionId ? getNango(env) : null;
|
|
1075
|
-
const github = githubConnectionId && nangoClient
|
|
1129
|
+
const github = githubConnectionId && githubProviderConfigKey && nangoClient
|
|
1076
1130
|
? new GitHubIntegration({
|
|
1077
1131
|
nangoClient,
|
|
1078
1132
|
connectionId: githubConnectionId,
|
|
1133
|
+
providerConfigKey: githubProviderConfigKey,
|
|
1079
1134
|
})
|
|
1080
1135
|
: null;
|
|
1081
1136
|
let orgs;
|
|
1082
1137
|
let repos;
|
|
1083
|
-
if (githubConnectionId) {
|
|
1138
|
+
if (githubConnectionId && githubProviderConfigKey) {
|
|
1084
1139
|
try {
|
|
1085
|
-
const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
1140
|
+
const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
1086
1141
|
orgs = discovery.orgs;
|
|
1087
1142
|
repos = discovery.repos;
|
|
1088
1143
|
}
|
|
@@ -1227,7 +1282,7 @@ export function createSageApp() {
|
|
|
1227
1282
|
return c.json(clientErrorResponse(payload, threadId), payload.status);
|
|
1228
1283
|
}
|
|
1229
1284
|
});
|
|
1230
|
-
app.use("/api/webhooks/slack",
|
|
1285
|
+
app.use("/api/webhooks/slack", slackWebhookMiddleware);
|
|
1231
1286
|
app.post("/api/webhooks/slack", async (c) => {
|
|
1232
1287
|
console.log("[sage] Incoming webhook request");
|
|
1233
1288
|
const rawBody = c.get("rawSlackBody");
|
|
@@ -1241,13 +1296,29 @@ export function createSageApp() {
|
|
|
1241
1296
|
}
|
|
1242
1297
|
const payload = c.get("slackPayload");
|
|
1243
1298
|
const event = parseSlackEvent(payload);
|
|
1299
|
+
const retryNum = c.req.header("X-Slack-Retry-Num");
|
|
1300
|
+
const retryReason = c.req.header("X-Slack-Retry-Reason");
|
|
1244
1301
|
if (event.type === "url_verification") {
|
|
1245
1302
|
return c.json({ challenge: event.challenge ?? "" });
|
|
1246
1303
|
}
|
|
1247
1304
|
if (event.type === "unknown" || !event.text.trim() || !event.channel) {
|
|
1248
1305
|
return c.json({ ok: true });
|
|
1249
1306
|
}
|
|
1307
|
+
console.log(`[sage] Inbound: type=${event.type} ts=${event.ts ?? ""} eventId=${event.eventId ?? ""} threadTs=${event.threadTs ?? ""}`);
|
|
1308
|
+
if (retryNum) {
|
|
1309
|
+
console.log(`[sage] Slack retry #${retryNum} reason=${retryReason ?? "unknown"}`);
|
|
1310
|
+
}
|
|
1311
|
+
const deduplicationKey = getSlackDeduplicationKey(event);
|
|
1312
|
+
const duplicateEvent = await isDuplicateEvent(c.env.DEDUP, deduplicationKey);
|
|
1313
|
+
console.log(`[sage] Dedup check: key=${deduplicationKey ?? ""} isDuplicate=${duplicateEvent}`);
|
|
1314
|
+
if (duplicateEvent) {
|
|
1315
|
+
console.log(`[sage] Duplicate event, skipping — key: ${deduplicationKey ?? ""}`);
|
|
1316
|
+
return c.json({ ok: true });
|
|
1317
|
+
}
|
|
1318
|
+
await markEventSeen(c.env.DEDUP, deduplicationKey);
|
|
1250
1319
|
const workspaceId = getWorkspaceId(event, c.env);
|
|
1320
|
+
const cloudProxyProvider = getCloudProxyProvider(c.env);
|
|
1321
|
+
const { slack, slackBotUserId: workspaceSlackBotUserId } = await resolveWorkspaceSlackContext(workspaceId, cloudProxyProvider);
|
|
1251
1322
|
if (event.type === "message" && event.threadTs) {
|
|
1252
1323
|
const isActive = await isActiveThread(c.env.THREADS, event.threadTs);
|
|
1253
1324
|
if (!isActive) {
|
|
@@ -1256,44 +1327,21 @@ export function createSageApp() {
|
|
|
1256
1327
|
await markActiveThread(c.env.THREADS, event.threadTs, { workspaceId, channel: event.channel });
|
|
1257
1328
|
}
|
|
1258
1329
|
}
|
|
1259
|
-
// Prefer the Nango connectionId + providerConfigKey forwarded in the
|
|
1260
|
-
// envelope over a team_id → workspace → connection lookup. Cloud already
|
|
1261
|
-
// knows which Nango connection received the webhook; re-resolving by
|
|
1262
|
-
// team_id adds a translation hop that silently drops events when any link
|
|
1263
|
-
// in the chain is missing (no slack-* row, missing team metadata, cloud
|
|
1264
|
-
// route not deployed, etc.).
|
|
1265
|
-
const envelopeConnectionId = c.get("nangoEnvelopeConnectionId");
|
|
1266
|
-
const envelopeProviderConfigKey = c.get("nangoEnvelopeProviderConfigKey");
|
|
1267
|
-
const slackConnection = envelopeConnectionId && envelopeProviderConfigKey
|
|
1268
|
-
? { connectionId: envelopeConnectionId, providerConfigKey: envelopeProviderConfigKey }
|
|
1269
|
-
: await resolveSlackConnection(workspaceId, c.env);
|
|
1270
|
-
if (!slackConnection) {
|
|
1271
|
-
console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; skipping event`);
|
|
1272
|
-
return c.json({ ok: true });
|
|
1273
|
-
}
|
|
1274
|
-
const slackConnectionId = slackConnection.connectionId;
|
|
1275
|
-
const slackProviderConfigKey = slackConnection.providerConfigKey;
|
|
1276
|
-
const workspaceSlackBotUserId = await resolveSlackBotUserId(slackConnection, c.env);
|
|
1277
1330
|
if (!event.userId || (workspaceSlackBotUserId && event.userId === workspaceSlackBotUserId)) {
|
|
1278
1331
|
return c.json({ ok: true });
|
|
1279
1332
|
}
|
|
1280
|
-
console.log("[sage] Event check — ts:", event.ts, "text:", event.text.slice(0, 50));
|
|
1281
|
-
if (await isDuplicateEvent(c.env.DEDUP, event.ts)) {
|
|
1282
|
-
console.log("[sage] Duplicate event, skipping");
|
|
1283
|
-
return c.json({ ok: true });
|
|
1284
|
-
}
|
|
1285
|
-
await markEventSeen(c.env.DEDUP, event.ts);
|
|
1286
1333
|
const rateLimitResult = await getRateLimiter(c.env).limit({ key: `${workspaceId}:${event.userId}` });
|
|
1287
1334
|
if (!rateLimitResult.success) {
|
|
1288
|
-
const postResult = await
|
|
1335
|
+
const postResult = await slack.postMessageChunked(event.channel, "You're sending messages too quickly. Please wait a moment.", event.threadTs ?? event.ts, MAX_REPLY_CHARS);
|
|
1289
1336
|
if (!postResult.ok) {
|
|
1290
1337
|
console.error("Failed to post rate-limit Slack message", postResult.error);
|
|
1291
1338
|
}
|
|
1292
1339
|
return c.json({ ok: true });
|
|
1293
1340
|
}
|
|
1294
1341
|
if (event.ts) {
|
|
1295
|
-
c.executionCtx.waitUntil(
|
|
1342
|
+
c.executionCtx.waitUntil(addSlackReaction(slack, event.channel, event.ts, "eyes"));
|
|
1296
1343
|
}
|
|
1344
|
+
console.log(`[sage] Processing: ts=${event.ts ?? ""} channel=${event.channel}`);
|
|
1297
1345
|
c.executionCtx.waitUntil((async () => {
|
|
1298
1346
|
await runtimeReady;
|
|
1299
1347
|
await sageRuntime.dispatch({
|
|
@@ -1305,8 +1353,7 @@ export function createSageApp() {
|
|
|
1305
1353
|
receivedAt: new Date().toISOString(),
|
|
1306
1354
|
capability: "slack-event",
|
|
1307
1355
|
raw: {
|
|
1308
|
-
run: () => processSlackEvent(event, workspaceId, c.env, {
|
|
1309
|
-
slackConnection,
|
|
1356
|
+
run: () => processSlackEvent(event, workspaceId, c.env, slack, {
|
|
1310
1357
|
slackBotUserId: workspaceSlackBotUserId,
|
|
1311
1358
|
}),
|
|
1312
1359
|
},
|