@agentworkforce/sage 1.0.5 → 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 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +268 -190
- 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 -7
- package/dist/nango.d.ts.map +1 -1
- package/dist/nango.js +9 -30
- 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 -5
- package/dist/proactive/engine.d.ts.map +1 -1
- package/dist/proactive/engine.js +25 -19
- 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 -16
- 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 -20
- 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 -5
- 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 -1
- 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,15 +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
|
-
|
|
390
|
+
function warnMissingGitHubProviderConfigKey(workspaceId) {
|
|
391
|
+
console.warn(`[sage] GitHub providerConfigKey is not configured for workspace "${workspaceId}"; continuing without GitHub integration`);
|
|
392
|
+
}
|
|
393
|
+
async function resolveGitHubConnection(workspaceId, env) {
|
|
361
394
|
const resolver = getConnectionResolver(env);
|
|
362
395
|
if (resolver) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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);
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
connectionId: resolvedConnection.connectionId,
|
|
405
|
+
providerConfigKey: resolvedConnection.providerConfigKey,
|
|
406
|
+
};
|
|
367
407
|
}
|
|
368
|
-
|
|
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;
|
|
369
426
|
}
|
|
370
427
|
async function getRelayFileReader(slackWorkspaceId, env) {
|
|
371
428
|
const resolver = getConnectionResolver(env);
|
|
@@ -490,45 +547,19 @@ async function loadClientStatus(workspaceId, env) {
|
|
|
490
547
|
generatedAt: new Date().toISOString(),
|
|
491
548
|
};
|
|
492
549
|
}
|
|
493
|
-
async function detectSlackBotUserId(
|
|
494
|
-
const staticSlackConnectionId = trimOptional(env.NANGO_SLACK_CONNECTION_ID);
|
|
495
|
-
const fallbackSlackBotUserId = trimOptional(env.SLACK_BOT_USER_ID);
|
|
496
|
-
if (!staticSlackConnectionId) {
|
|
497
|
-
return fallbackSlackBotUserId;
|
|
498
|
-
}
|
|
550
|
+
async function detectSlackBotUserId(slack) {
|
|
499
551
|
try {
|
|
500
|
-
const userId = await
|
|
552
|
+
const userId = await slack.getBotUserId();
|
|
501
553
|
if (userId) {
|
|
502
|
-
console.log("[sage] Detected Slack bot user ID via
|
|
554
|
+
console.log("[sage] Detected Slack bot user ID via Slack egress:", userId);
|
|
503
555
|
return userId;
|
|
504
556
|
}
|
|
505
|
-
console.warn("[sage] auth.test did not return a bot user ID
|
|
506
|
-
}
|
|
507
|
-
catch (error) {
|
|
508
|
-
console.warn("[sage] Failed to auto-detect Slack bot user ID via auth.test", error);
|
|
509
|
-
}
|
|
510
|
-
return fallbackSlackBotUserId;
|
|
511
|
-
}
|
|
512
|
-
function getDefaultSlackBotUserId(env) {
|
|
513
|
-
const cacheKey = [
|
|
514
|
-
requireBinding(env, "NANGO_SECRET_KEY"),
|
|
515
|
-
trimOptional(env.NANGO_SLACK_CONNECTION_ID) ?? "",
|
|
516
|
-
trimOptional(env.SLACK_BOT_USER_ID) ?? "",
|
|
517
|
-
].join("\n");
|
|
518
|
-
if (!cachedDefaultSlackBotUserId || cachedDefaultSlackBotUserIdKey !== cacheKey) {
|
|
519
|
-
cachedDefaultSlackBotUserId = detectSlackBotUserId(env);
|
|
520
|
-
cachedDefaultSlackBotUserIdKey = cacheKey;
|
|
521
|
-
}
|
|
522
|
-
return cachedDefaultSlackBotUserId;
|
|
523
|
-
}
|
|
524
|
-
async function resolveSlackBotUserId(slackConnectionId, env) {
|
|
525
|
-
try {
|
|
526
|
-
return await getNango(env).getSlackBotUserId(slackConnectionId);
|
|
557
|
+
console.warn("[sage] auth.test did not return a bot user ID");
|
|
527
558
|
}
|
|
528
559
|
catch (error) {
|
|
529
|
-
console.warn("[sage] Failed to
|
|
530
|
-
return getDefaultSlackBotUserId(env);
|
|
560
|
+
console.warn("[sage] Failed to auto-detect Slack bot user ID via Slack egress", error);
|
|
531
561
|
}
|
|
562
|
+
return undefined;
|
|
532
563
|
}
|
|
533
564
|
async function isDuplicateEvent(kv, eventId) {
|
|
534
565
|
if (!eventId) {
|
|
@@ -542,6 +573,9 @@ async function markEventSeen(kv, eventId) {
|
|
|
542
573
|
}
|
|
543
574
|
await kv.put(eventId, "1", { expirationTtl: DUPLICATE_EVENT_TTL_SECONDS });
|
|
544
575
|
}
|
|
576
|
+
function getSlackDeduplicationKey(event) {
|
|
577
|
+
return event.eventId ?? event.ts;
|
|
578
|
+
}
|
|
545
579
|
async function isActiveThread(kv, threadTs) {
|
|
546
580
|
if (!threadTs) {
|
|
547
581
|
return false;
|
|
@@ -637,24 +671,20 @@ function getUserFacingErrorMessage(error) {
|
|
|
637
671
|
}
|
|
638
672
|
return "I ran into an issue processing your request. Please try again.";
|
|
639
673
|
}
|
|
640
|
-
async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
674
|
+
async function processSlackEvent(event, workspaceId, env, slack, options = {}) {
|
|
641
675
|
const replyThreadTs = event.threadTs ?? event.ts;
|
|
642
676
|
const threadId = replyThreadTs ?? "no-thread";
|
|
643
677
|
const memory = getMemory(workspaceId);
|
|
644
678
|
const orgMemory = getOrgMemory(workspaceId);
|
|
645
679
|
const nangoClient = getNango(env);
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; cannot process event`);
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
const workspaceSlackBotUserId = options.slackBotUserId ?? await resolveSlackBotUserId(slackConnectionId, env);
|
|
652
|
-
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);
|
|
653
682
|
const relayfileReader = await getRelayFileReader(workspaceId, env);
|
|
654
|
-
const github = githubConnectionId
|
|
683
|
+
const github = githubConnectionId && githubProviderConfigKey
|
|
655
684
|
? new GitHubIntegration({
|
|
656
685
|
nangoClient,
|
|
657
686
|
connectionId: githubConnectionId,
|
|
687
|
+
providerConfigKey: githubProviderConfigKey,
|
|
658
688
|
})
|
|
659
689
|
: null;
|
|
660
690
|
let replyPosted = false;
|
|
@@ -668,9 +698,9 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
668
698
|
]);
|
|
669
699
|
let orgs;
|
|
670
700
|
let repos;
|
|
671
|
-
if (githubConnectionId) {
|
|
701
|
+
if (githubConnectionId && githubProviderConfigKey) {
|
|
672
702
|
try {
|
|
673
|
-
const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
703
|
+
const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
674
704
|
orgs = discovery.orgs;
|
|
675
705
|
repos = discovery.repos;
|
|
676
706
|
}
|
|
@@ -681,7 +711,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
681
711
|
let threadHistory = [];
|
|
682
712
|
if (event.threadTs) {
|
|
683
713
|
try {
|
|
684
|
-
threadHistory = await
|
|
714
|
+
threadHistory = await slack.fetchThreadHistory(event.channel, event.threadTs, workspaceSlackBotUserId);
|
|
685
715
|
}
|
|
686
716
|
catch (error) {
|
|
687
717
|
console.warn("[sage] Proceeding without Slack thread history", error);
|
|
@@ -708,11 +738,12 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
708
738
|
});
|
|
709
739
|
const response = await processWithSwarm(event.text, systemPrompt, relayfileReader, github, threadHistory);
|
|
710
740
|
const reply = formatSlackReply(response.content, response.citations);
|
|
711
|
-
const postResult = await
|
|
741
|
+
const postResult = await slack.postMessageChunked(event.channel, reply, replyThreadTs, MAX_REPLY_CHARS);
|
|
712
742
|
replyPosted = Boolean(postResult.ts);
|
|
713
743
|
if (!postResult.ok) {
|
|
714
744
|
throw new Error(postResult.error ?? "Failed to post Slack message");
|
|
715
745
|
}
|
|
746
|
+
console.log(`[sage] Reply posted: threadTs=${replyThreadTs} replyTs=${postResult.ts ?? ""}`);
|
|
716
747
|
if (replyThreadTs) {
|
|
717
748
|
await markActiveThread(env.THREADS, replyThreadTs, {
|
|
718
749
|
workspaceId,
|
|
@@ -733,7 +764,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
|
|
|
733
764
|
return;
|
|
734
765
|
}
|
|
735
766
|
try {
|
|
736
|
-
const fallbackResult = await
|
|
767
|
+
const fallbackResult = await slack.postMessageChunked(event.channel, getUserFacingErrorMessage(error), replyThreadTs, MAX_REPLY_CHARS);
|
|
737
768
|
if (!fallbackResult.ok) {
|
|
738
769
|
console.error("Failed to post fallback Slack message", fallbackResult.error);
|
|
739
770
|
}
|
|
@@ -780,18 +811,87 @@ function resolveProactiveWorkspaceId(env, body) {
|
|
|
780
811
|
function resolveNotifyChannel(env, body) {
|
|
781
812
|
return readNonEmptyString(body?.notifyChannel) ?? trimOptional(env.SAGE_NOTIFY_CHANNEL);
|
|
782
813
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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;
|
|
788
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);
|
|
789
859
|
return {
|
|
790
860
|
workspaceId,
|
|
791
|
-
|
|
792
|
-
slackBotUserId:
|
|
861
|
+
slack,
|
|
862
|
+
...(userId !== undefined ? { slackBotUserId: userId } : {}),
|
|
793
863
|
};
|
|
794
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
|
+
}
|
|
795
895
|
function extractPRData(payload) {
|
|
796
896
|
const pr = isRecord(payload.pull_request) ? payload.pull_request : payload;
|
|
797
897
|
const number = typeof pr.number === "number" ? pr.number : undefined;
|
|
@@ -826,7 +926,6 @@ function extractPRData(payload) {
|
|
|
826
926
|
// Proactive schedules are registered by the app entry/bootstrap layer, not by importing this module.
|
|
827
927
|
export function createSageApp() {
|
|
828
928
|
const app = new Hono();
|
|
829
|
-
const followUpCollector = getFollowUpEvidenceCollector();
|
|
830
929
|
// Create the runtime once per app instance. Capability handlers manage their own
|
|
831
930
|
// delivery in Slice 1, so the writers here are no-ops (not called by handlers).
|
|
832
931
|
const sageRuntime = createSageRuntime((_ch, _txt, _ts) => Promise.resolve({ ok: true }), () => { });
|
|
@@ -855,25 +954,73 @@ export function createSageApp() {
|
|
|
855
954
|
throw ctx.error;
|
|
856
955
|
return ctx.result;
|
|
857
956
|
};
|
|
858
|
-
const
|
|
957
|
+
const slackWebhookMiddleware = async (c, next) => {
|
|
859
958
|
const rawBody = await c.req.text();
|
|
860
959
|
let payload;
|
|
861
960
|
try {
|
|
862
|
-
payload =
|
|
863
|
-
}
|
|
864
|
-
catch {
|
|
865
|
-
return c.json({ error: "Invalid JSON" }, 400);
|
|
961
|
+
payload = parseSlackWebhookEnvelope(rawBody);
|
|
866
962
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
963
|
+
catch (error) {
|
|
964
|
+
console.error("[sage] Invalid Slack webhook payload", error);
|
|
965
|
+
return c.json({ error: "Invalid Slack webhook payload" }, 400);
|
|
870
966
|
}
|
|
871
967
|
c.set("rawSlackBody", rawBody);
|
|
872
|
-
c.set("slackPayload",
|
|
873
|
-
c.set("nangoEnvelopeConnectionId", envelope.connectionId);
|
|
874
|
-
c.set("nangoEnvelopeProviderConfigKey", envelope.providerConfigKey);
|
|
968
|
+
c.set("slackPayload", payload);
|
|
875
969
|
await next();
|
|
876
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
|
+
};
|
|
877
1024
|
app.use("*", async (c, next) => {
|
|
878
1025
|
if (!isClientApiPath(c.req.url)) {
|
|
879
1026
|
assertRuntimeBindings(c.env);
|
|
@@ -881,80 +1028,10 @@ export function createSageApp() {
|
|
|
881
1028
|
await next();
|
|
882
1029
|
});
|
|
883
1030
|
app.get("/health", (c) => c.json({ status: "ok", agent: "sage" }));
|
|
884
|
-
app.post("/api/proactive/follow-ups",
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const githubConnectionId = await resolveConnectionId(workspaceId, "github", c.env);
|
|
889
|
-
const count = await checkFollowUps(getMemory(workspaceId), getNango(c.env), slackConnectionId, resolveNotifyChannel(c.env, body), {
|
|
890
|
-
collector: followUpCollector,
|
|
891
|
-
slackBotUserId,
|
|
892
|
-
githubConnectionId: githubConnectionId ?? undefined,
|
|
893
|
-
});
|
|
894
|
-
return c.json({ ok: true, workspaceId, followUps: count });
|
|
895
|
-
}
|
|
896
|
-
catch (error) {
|
|
897
|
-
const message = error instanceof Error ? error.message : "Follow-up check failed";
|
|
898
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
899
|
-
console.error("[proactive] Follow-up check failed:", error);
|
|
900
|
-
return c.json({ ok: false, error: message }, status);
|
|
901
|
-
}
|
|
902
|
-
});
|
|
903
|
-
app.post("/api/proactive/stale-threads", async (c) => {
|
|
904
|
-
try {
|
|
905
|
-
const body = normalizeRequestBody(await readOptionalJsonBody(c));
|
|
906
|
-
const { workspaceId, slackConnectionId, slackBotUserId } = await resolveProactiveSlackContext(c.env, body);
|
|
907
|
-
const count = await detectStaleThreads(getMemory(workspaceId), workspaceId, getNango(c.env), slackConnectionId, await loadActiveThreadsMap(c.env.THREADS), slackBotUserId, resolveNotifyChannel(c.env, body));
|
|
908
|
-
return c.json({ ok: true, workspaceId, staleThreads: count });
|
|
909
|
-
}
|
|
910
|
-
catch (error) {
|
|
911
|
-
const message = error instanceof Error ? error.message : "Stale thread detection failed";
|
|
912
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
913
|
-
console.error("[proactive] Stale thread detection failed:", error);
|
|
914
|
-
return c.json({ ok: false, error: message }, status);
|
|
915
|
-
}
|
|
916
|
-
});
|
|
917
|
-
app.post("/api/proactive/context-watch", async (c) => {
|
|
918
|
-
try {
|
|
919
|
-
const body = normalizeRequestBody(await readOptionalJsonBody(c));
|
|
920
|
-
const { workspaceId, slackConnectionId } = await resolveProactiveSlackContext(c.env, body);
|
|
921
|
-
const count = await watchContext(getMemory(workspaceId), getNango(c.env), slackConnectionId, resolveNotifyChannel(c.env, body));
|
|
922
|
-
return c.json({ ok: true, workspaceId, updates: count });
|
|
923
|
-
}
|
|
924
|
-
catch (error) {
|
|
925
|
-
const message = error instanceof Error ? error.message : "Context watch failed";
|
|
926
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
927
|
-
console.error("[proactive] Context watch failed:", error);
|
|
928
|
-
return c.json({ ok: false, error: message }, status);
|
|
929
|
-
}
|
|
930
|
-
});
|
|
931
|
-
app.post("/api/proactive/pr-match", async (c) => {
|
|
932
|
-
try {
|
|
933
|
-
const body = normalizeRequestBody(await readOptionalJsonBody(c));
|
|
934
|
-
if (!body) {
|
|
935
|
-
return c.json({ ok: false, error: "Request body is required" }, 400);
|
|
936
|
-
}
|
|
937
|
-
const pr = extractPRData(body);
|
|
938
|
-
if (!pr) {
|
|
939
|
-
return c.json({ ok: false, error: "Could not parse pull request data" }, 400);
|
|
940
|
-
}
|
|
941
|
-
const workspaceId = resolveProactiveWorkspaceId(c.env, body);
|
|
942
|
-
const { slackConnectionId } = await resolveProactiveSlackContext(c.env, body);
|
|
943
|
-
const matched = await matchPRToPlans(pr, getMemory(workspaceId), getNango(c.env), slackConnectionId, resolveNotifyChannel(c.env, body));
|
|
944
|
-
return c.json({
|
|
945
|
-
ok: true,
|
|
946
|
-
workspaceId,
|
|
947
|
-
matched,
|
|
948
|
-
pr: { number: pr.number, repo: pr.repo, title: pr.title },
|
|
949
|
-
});
|
|
950
|
-
}
|
|
951
|
-
catch (error) {
|
|
952
|
-
const message = error instanceof Error ? error.message : "PR match failed";
|
|
953
|
-
const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
|
|
954
|
-
console.error("[proactive] PR match failed:", error);
|
|
955
|
-
return c.json({ ok: false, error: message }, status);
|
|
956
|
-
}
|
|
957
|
-
});
|
|
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"));
|
|
958
1035
|
app.get(CLIENT_STATUS_PATH, async (c) => {
|
|
959
1036
|
const authError = validateClientApiToken(c.req.header("authorization"), c.env);
|
|
960
1037
|
if (authError) {
|
|
@@ -1037,26 +1114,30 @@ export function createSageApp() {
|
|
|
1037
1114
|
const relayfileReader = await getRelayFileReader(workspaceId, env);
|
|
1038
1115
|
const canUseNango = Boolean(trimOptional(env.NANGO_SECRET_KEY));
|
|
1039
1116
|
let githubConnectionId = null;
|
|
1117
|
+
let githubProviderConfigKey = null;
|
|
1040
1118
|
if (canUseNango) {
|
|
1041
1119
|
try {
|
|
1042
|
-
|
|
1120
|
+
const resolvedGitHubConnection = await resolveGitHubConnection(workspaceId, env);
|
|
1121
|
+
githubConnectionId = resolvedGitHubConnection.connectionId;
|
|
1122
|
+
githubProviderConfigKey = resolvedGitHubConnection.providerConfigKey;
|
|
1043
1123
|
}
|
|
1044
1124
|
catch (error) {
|
|
1045
1125
|
console.warn("[client-chat] GitHub connection resolution failed; continuing without GitHub", error);
|
|
1046
1126
|
}
|
|
1047
1127
|
}
|
|
1048
1128
|
const nangoClient = githubConnectionId ? getNango(env) : null;
|
|
1049
|
-
const github = githubConnectionId && nangoClient
|
|
1129
|
+
const github = githubConnectionId && githubProviderConfigKey && nangoClient
|
|
1050
1130
|
? new GitHubIntegration({
|
|
1051
1131
|
nangoClient,
|
|
1052
1132
|
connectionId: githubConnectionId,
|
|
1133
|
+
providerConfigKey: githubProviderConfigKey,
|
|
1053
1134
|
})
|
|
1054
1135
|
: null;
|
|
1055
1136
|
let orgs;
|
|
1056
1137
|
let repos;
|
|
1057
|
-
if (githubConnectionId) {
|
|
1138
|
+
if (githubConnectionId && githubProviderConfigKey) {
|
|
1058
1139
|
try {
|
|
1059
|
-
const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
1140
|
+
const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
|
|
1060
1141
|
orgs = discovery.orgs;
|
|
1061
1142
|
repos = discovery.repos;
|
|
1062
1143
|
}
|
|
@@ -1201,7 +1282,7 @@ export function createSageApp() {
|
|
|
1201
1282
|
return c.json(clientErrorResponse(payload, threadId), payload.status);
|
|
1202
1283
|
}
|
|
1203
1284
|
});
|
|
1204
|
-
app.use("/api/webhooks/slack",
|
|
1285
|
+
app.use("/api/webhooks/slack", slackWebhookMiddleware);
|
|
1205
1286
|
app.post("/api/webhooks/slack", async (c) => {
|
|
1206
1287
|
console.log("[sage] Incoming webhook request");
|
|
1207
1288
|
const rawBody = c.get("rawSlackBody");
|
|
@@ -1215,13 +1296,29 @@ export function createSageApp() {
|
|
|
1215
1296
|
}
|
|
1216
1297
|
const payload = c.get("slackPayload");
|
|
1217
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");
|
|
1218
1301
|
if (event.type === "url_verification") {
|
|
1219
1302
|
return c.json({ challenge: event.challenge ?? "" });
|
|
1220
1303
|
}
|
|
1221
1304
|
if (event.type === "unknown" || !event.text.trim() || !event.channel) {
|
|
1222
1305
|
return c.json({ ok: true });
|
|
1223
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);
|
|
1224
1319
|
const workspaceId = getWorkspaceId(event, c.env);
|
|
1320
|
+
const cloudProxyProvider = getCloudProxyProvider(c.env);
|
|
1321
|
+
const { slack, slackBotUserId: workspaceSlackBotUserId } = await resolveWorkspaceSlackContext(workspaceId, cloudProxyProvider);
|
|
1225
1322
|
if (event.type === "message" && event.threadTs) {
|
|
1226
1323
|
const isActive = await isActiveThread(c.env.THREADS, event.threadTs);
|
|
1227
1324
|
if (!isActive) {
|
|
@@ -1230,39 +1327,21 @@ export function createSageApp() {
|
|
|
1230
1327
|
await markActiveThread(c.env.THREADS, event.threadTs, { workspaceId, channel: event.channel });
|
|
1231
1328
|
}
|
|
1232
1329
|
}
|
|
1233
|
-
// Prefer the Nango connectionId forwarded in the envelope over a
|
|
1234
|
-
// team_id → workspace → connection lookup. Cloud already knows which
|
|
1235
|
-
// Nango connection received the webhook; re-resolving by team_id adds a
|
|
1236
|
-
// translation hop that silently drops events when any link in the chain
|
|
1237
|
-
// is missing (no slack-* row, missing team metadata, cloud route not
|
|
1238
|
-
// deployed, etc.).
|
|
1239
|
-
const envelopeConnectionId = c.get("nangoEnvelopeConnectionId");
|
|
1240
|
-
const slackConnectionId = envelopeConnectionId ?? (await resolveConnectionId(workspaceId, "slack", c.env));
|
|
1241
|
-
if (!slackConnectionId) {
|
|
1242
|
-
console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; skipping event`);
|
|
1243
|
-
return c.json({ ok: true });
|
|
1244
|
-
}
|
|
1245
|
-
const workspaceSlackBotUserId = await resolveSlackBotUserId(slackConnectionId, c.env);
|
|
1246
1330
|
if (!event.userId || (workspaceSlackBotUserId && event.userId === workspaceSlackBotUserId)) {
|
|
1247
1331
|
return c.json({ ok: true });
|
|
1248
1332
|
}
|
|
1249
|
-
console.log("[sage] Event check — ts:", event.ts, "text:", event.text.slice(0, 50));
|
|
1250
|
-
if (await isDuplicateEvent(c.env.DEDUP, event.ts)) {
|
|
1251
|
-
console.log("[sage] Duplicate event, skipping");
|
|
1252
|
-
return c.json({ ok: true });
|
|
1253
|
-
}
|
|
1254
|
-
await markEventSeen(c.env.DEDUP, event.ts);
|
|
1255
1333
|
const rateLimitResult = await getRateLimiter(c.env).limit({ key: `${workspaceId}:${event.userId}` });
|
|
1256
1334
|
if (!rateLimitResult.success) {
|
|
1257
|
-
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);
|
|
1258
1336
|
if (!postResult.ok) {
|
|
1259
1337
|
console.error("Failed to post rate-limit Slack message", postResult.error);
|
|
1260
1338
|
}
|
|
1261
1339
|
return c.json({ ok: true });
|
|
1262
1340
|
}
|
|
1263
1341
|
if (event.ts) {
|
|
1264
|
-
c.executionCtx.waitUntil(
|
|
1342
|
+
c.executionCtx.waitUntil(addSlackReaction(slack, event.channel, event.ts, "eyes"));
|
|
1265
1343
|
}
|
|
1344
|
+
console.log(`[sage] Processing: ts=${event.ts ?? ""} channel=${event.channel}`);
|
|
1266
1345
|
c.executionCtx.waitUntil((async () => {
|
|
1267
1346
|
await runtimeReady;
|
|
1268
1347
|
await sageRuntime.dispatch({
|
|
@@ -1274,8 +1353,7 @@ export function createSageApp() {
|
|
|
1274
1353
|
receivedAt: new Date().toISOString(),
|
|
1275
1354
|
capability: "slack-event",
|
|
1276
1355
|
raw: {
|
|
1277
|
-
run: () => processSlackEvent(event, workspaceId, c.env, {
|
|
1278
|
-
slackConnectionId,
|
|
1356
|
+
run: () => processSlackEvent(event, workspaceId, c.env, slack, {
|
|
1279
1357
|
slackBotUserId: workspaceSlackBotUserId,
|
|
1280
1358
|
}),
|
|
1281
1359
|
},
|