shraga 0.0.3 → 0.1.2
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 +82 -27
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// Slack agent-glue — the app-side half of the Slack bot. The Slack transport/protocol (routes, HMAC
|
|
2
|
+
// verify, dedupe, DM identity, file hydrate, streamer wiring, reaction lifecycle) lives in the
|
|
3
|
+
// mcp-slack-use package `ingress`. This module owns only what this app owns: sessions, locks, contacts,
|
|
4
|
+
// thread-context sync, artifacts, broadcast, and message persistence — surfaced to the ingress as
|
|
5
|
+
// callbacks (shouldRespond / onMessage / onReplied) plus the crash-recovery resume path.
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { streamChat, type AttachmentMeta, type WsEvent } from '../claude.ts';
|
|
10
|
+
import { handleArtifactToolUse } from '../artifacts/artifacts.handler.ts';
|
|
11
|
+
import { getMcpConfig } from '../mcp.ts';
|
|
12
|
+
import { dataPath } from '../paths.ts';
|
|
13
|
+
import { appendMessage, setSlackContext, setRunStatus, setVisibleTo, writePartial, clearPartial, registerLivePartial, unregisterLivePartial, acquireSessionLock, releaseSessionLock, getSession, recordSeenSlackTs, type ConvBlock, type SessionMeta } from '../sessions.ts';
|
|
14
|
+
import { injectFile } from '../file-inject.ts';
|
|
15
|
+
import {
|
|
16
|
+
postMessage, addReaction, removeReaction, getBotUserId, getAgentUserId, getThreadMessages, getMessage,
|
|
17
|
+
getChannelName, getUserName, getUserProfile, resolveUserMentions, isSupportedFile, SUPPORTED_FILE_MIMES,
|
|
18
|
+
downloadSlackFileBuffer,
|
|
19
|
+
} from './api.ts';
|
|
20
|
+
import { pipeAgentReply, type AgentEvent, type IngressMessage } from 'mcp-slack-use/src/ingress.ts';
|
|
21
|
+
import { makeSlackQuestionHandler } from './questions.ts';
|
|
22
|
+
import * as contacts from '../contacts.ts';
|
|
23
|
+
import { getChannelContext, invalidateChannelContext } from './context-cache.ts';
|
|
24
|
+
import { getOrCreateSession, registerThreadAlias, setLastMessageTs, setUseUserToken, findSlackSessionBySessionId, getProactiveOrigin, hasSessionForThread, isSlackBotPlaceholderEmail } from './sessions.ts';
|
|
25
|
+
|
|
26
|
+
const MAX_DOWNLOAD_SIZE = 25 * 1024 * 1024;
|
|
27
|
+
const isDownloadable = (f: { url_private?: string; mimetype?: string; size?: number; name?: string }): boolean =>
|
|
28
|
+
!!(f.url_private && f.mimetype) &&
|
|
29
|
+
(isSupportedFile(f as any) || (!SUPPORTED_FILE_MIMES.has(f.mimetype) && (f.size ?? 0) <= MAX_DOWNLOAD_SIZE));
|
|
30
|
+
|
|
31
|
+
const AGENT_CHANNEL = 'C0AT9K7AXEZ';
|
|
32
|
+
const SLACK_UID = 'slack-bot';
|
|
33
|
+
|
|
34
|
+
type Broadcast = (data: object) => void;
|
|
35
|
+
let broadcastFn: Broadcast = () => {};
|
|
36
|
+
export function setBroadcast(fn: Broadcast): void { broadcastFn = fn; }
|
|
37
|
+
|
|
38
|
+
const mdText = (b: ConvBlock): b is { type: 'text'; text: string } => b.type === 'text';
|
|
39
|
+
|
|
40
|
+
// ── Semantic gate ─────────────────────────────────────────────────────────────
|
|
41
|
+
// Protocol-level routing (bot echoes, DM identity, dedupe) is the ingress's job. This is the app-side
|
|
42
|
+
// half: agent-channel summon rules + threads the agent already owns. Referenced app session state
|
|
43
|
+
// (proactive origins, known threads) is why it can't live in the package.
|
|
44
|
+
export function shouldRespond(msg: IngressMessage): boolean {
|
|
45
|
+
const isAgentChannel = msg.channel === AGENT_CHANNEL;
|
|
46
|
+
const isAgentOriginatedThread = msg.isThreadReply && !!(msg.rawThreadTs && getProactiveOrigin(msg.channel, msg.rawThreadTs));
|
|
47
|
+
const isKnownAgentChannelThread = isAgentChannel && msg.isThreadReply && !!(msg.rawThreadTs && hasSessionForThread(msg.channel, msg.rawThreadTs));
|
|
48
|
+
if (isAgentChannel && msg.isThreadReply && !msg.isMention && !msg.textMentionsAgent && !isAgentOriginatedThread && !isKnownAgentChannelThread) {
|
|
49
|
+
console.log(`[slack-bot] Skipping thread reply without mention in agent channel (user=${msg.user} thread=${msg.rawThreadTs})`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const agentChannelAutoRespond = process.env.SLACK_AGENT_CHANNEL_AUTORESPOND === '1';
|
|
53
|
+
const isAgentChannelSummon = isAgentChannel && (agentChannelAutoRespond || isKnownAgentChannelThread);
|
|
54
|
+
if (!(isAgentChannelSummon || msg.isMention || msg.textMentionsAgent || msg.isDM || isAgentOriginatedThread)) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Shared stream pump ─────────────────────────────────────────────────────────
|
|
59
|
+
// Drive a streamChat generator: accumulate assistant blocks, mirror to the web UI via broadcast,
|
|
60
|
+
// yield the events the SlackStreamer consumes (text_delta / tool_use), then persist the assistant
|
|
61
|
+
// message. The ingress finishes the streamer after this generator returns.
|
|
62
|
+
async function* pumpStream(
|
|
63
|
+
gen: AsyncGenerator<WsEvent>,
|
|
64
|
+
sessionId: string,
|
|
65
|
+
opts: { partial?: boolean; artifacts?: boolean } = {},
|
|
66
|
+
): AsyncGenerator<AgentEvent> {
|
|
67
|
+
let assistantText = '';
|
|
68
|
+
const assistantBlocks: ConvBlock[] = [];
|
|
69
|
+
const collect = () => [...assistantBlocks, ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : [])];
|
|
70
|
+
let partialInterval: ReturnType<typeof setInterval> | undefined;
|
|
71
|
+
if (opts.partial) {
|
|
72
|
+
registerLivePartial(sessionId, collect);
|
|
73
|
+
partialInterval = setInterval(() => { const b = collect(); if (b.length) writePartial(sessionId, b); }, 5_000);
|
|
74
|
+
}
|
|
75
|
+
let stopReason = '';
|
|
76
|
+
try {
|
|
77
|
+
for await (const ev of gen) {
|
|
78
|
+
if (ev.type === 'text_delta') {
|
|
79
|
+
assistantText += ev.text;
|
|
80
|
+
broadcastFn({ type: 'session_stream', sessionId, event: { type: 'text_delta', text: ev.text } });
|
|
81
|
+
yield { type: 'text_delta', text: ev.text };
|
|
82
|
+
} else if (ev.type === 'tool_use') {
|
|
83
|
+
if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
|
|
84
|
+
assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
85
|
+
broadcastFn({ type: 'session_stream', sessionId, event: { type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input } });
|
|
86
|
+
if (opts.artifacts) { const e = handleArtifactToolUse(sessionId, ev.tool, ev.input); if (e) broadcastFn(e); }
|
|
87
|
+
yield { type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input };
|
|
88
|
+
} else if (ev.type === 'tool_result') {
|
|
89
|
+
assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
90
|
+
const trimmed = ev.output.length > 2000 ? ev.output.slice(0, 2000) + '…' : ev.output;
|
|
91
|
+
broadcastFn({ type: 'session_stream', sessionId, event: { type: 'tool_result', toolUseId: ev.toolUseId, output: trimmed } });
|
|
92
|
+
} else if (ev.type === 'tool_result_image') {
|
|
93
|
+
assistantBlocks.push({ type: 'image', src: ev.dataUrl });
|
|
94
|
+
} else if (ev.type === 'done') {
|
|
95
|
+
stopReason = ev.stopReason ?? 'end_turn';
|
|
96
|
+
break;
|
|
97
|
+
} else if (ev.type === 'error') {
|
|
98
|
+
const t = `\n⚠️ ${ev.message}`;
|
|
99
|
+
assistantText += t;
|
|
100
|
+
yield { type: 'text_delta', text: t };
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (stopReason === 'max_turns_reached') {
|
|
105
|
+
const notice = '\n\n---\n⚠️ _Reached the maximum number of steps for this turn. Reply "continue" to pick up where I left off._';
|
|
106
|
+
assistantText += notice;
|
|
107
|
+
yield { type: 'text_delta', text: notice };
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
if (partialInterval) clearInterval(partialInterval);
|
|
111
|
+
if (opts.partial) unregisterLivePartial(sessionId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (assistantText) assistantBlocks.push({ type: 'text', text: assistantText });
|
|
115
|
+
for (const b of assistantBlocks) if (mdText(b)) (b as any).text = await resolveUserMentions(b.text);
|
|
116
|
+
if (assistantBlocks.length) {
|
|
117
|
+
if (opts.partial) clearPartial(sessionId);
|
|
118
|
+
appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks: assistantBlocks });
|
|
119
|
+
broadcastFn({ type: 'session_messages_changed', sessionId });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── onMessage: one agent turn as an AsyncIterable<AgentEvent> ──────────────────────
|
|
124
|
+
export async function* runAgentTurn(msg: IngressMessage): AsyncGenerator<AgentEvent> {
|
|
125
|
+
const channel = msg.channel;
|
|
126
|
+
const isDM = msg.isDM;
|
|
127
|
+
const threadTs = msg.threadTs;
|
|
128
|
+
const useUserToken = msg.useUserToken;
|
|
129
|
+
|
|
130
|
+
const botId = await getBotUserId();
|
|
131
|
+
const agentUserId = await getAgentUserId();
|
|
132
|
+
let text = (msg.text || '').replace(new RegExp(`<@${botId}>\\s*`, 'g'), '').trim();
|
|
133
|
+
if (agentUserId) text = text.replace(new RegExp(`<@${agentUserId}>\\s*`, 'g'), '').trim();
|
|
134
|
+
|
|
135
|
+
// Files are already hydrated by the ingress; this app downloads + saves them into the session dir.
|
|
136
|
+
const files = msg.files;
|
|
137
|
+
const downloadableFiles = files.filter(isDownloadable);
|
|
138
|
+
const skippedFiles = files.filter((f: any) => !isDownloadable(f));
|
|
139
|
+
if (skippedFiles.length) {
|
|
140
|
+
const labels = skippedFiles.map((f: any) => f.mimetype
|
|
141
|
+
? `[Attached file: ${f.name} (${f.mimetype}, ${Math.round(f.size / 1024)}KB) — too large to download]`
|
|
142
|
+
: `[Attached file: could not be retrieved from Slack]`);
|
|
143
|
+
text = text ? `${text}\n${labels.join('\n')}` : labels.join('\n');
|
|
144
|
+
}
|
|
145
|
+
if (!text && !downloadableFiles.length) return;
|
|
146
|
+
|
|
147
|
+
const downloadedFiles = downloadableFiles.length
|
|
148
|
+
? (await Promise.all(downloadableFiles.map(async (f: any) => {
|
|
149
|
+
const allowHtml = f.mimetype === 'text/html' || /\.html?$/i.test(f.name);
|
|
150
|
+
try { return { buffer: await downloadSlackFileBuffer(f.url_private, useUserToken, allowHtml), name: f.name, mimeType: f.mimetype }; }
|
|
151
|
+
catch (err: any) { console.warn(`[slack-bot] File download failed: ${err.message}`); return null; }
|
|
152
|
+
}))).filter((f): f is { buffer: Buffer; name: string; mimeType: string } => !!f)
|
|
153
|
+
: [];
|
|
154
|
+
if (!text && !downloadedFiles.length) return;
|
|
155
|
+
if (!text) text = 'Describe this attachment.';
|
|
156
|
+
|
|
157
|
+
const userMessageTs = msg.ts;
|
|
158
|
+
const isAgentChannel = channel === AGENT_CHANNEL;
|
|
159
|
+
const isMention = msg.isMention;
|
|
160
|
+
const resolvedText = await resolveUserMentions(text);
|
|
161
|
+
|
|
162
|
+
// Attribute the session to the real human (enables nightly reconcile user-scope writes).
|
|
163
|
+
let contact: contacts.Contact | null = null;
|
|
164
|
+
if (msg.user) {
|
|
165
|
+
const profile = await getUserProfile(msg.user).catch(err => { console.error('[slack-bot] getUserProfile failed:', err.message); return { name: null, email: null }; });
|
|
166
|
+
contact = contacts.upsert({ slackId: msg.user, email: profile.email || undefined, name: profile.name || undefined });
|
|
167
|
+
}
|
|
168
|
+
const sessionUser = contact?.emails.length ? { uid: contact.id, email: contact.emails[0], name: contact.name } : undefined;
|
|
169
|
+
|
|
170
|
+
let isNew: boolean;
|
|
171
|
+
let sessionId: string;
|
|
172
|
+
({ sessionId, isNew } = getOrCreateSession(channel, threadTs, resolvedText, false, isDM ? 'user' : 'system', sessionUser));
|
|
173
|
+
msg.sessionId = sessionId;
|
|
174
|
+
|
|
175
|
+
if (isNew && isDM && contact?.emails.length) setVisibleTo(sessionId, contact.emails);
|
|
176
|
+
|
|
177
|
+
if (isNew) {
|
|
178
|
+
const ctxType = isDM ? 'dm' as const : isMention ? 'mention' as const : 'channel' as const;
|
|
179
|
+
const [channelName, channelCtx] = await Promise.all([
|
|
180
|
+
isDM ? null : getChannelName(channel).catch(err => { console.error('[slack-bot] getChannelName failed:', err.message); return null; }),
|
|
181
|
+
isDM ? null : getChannelContext(channel).catch(err => { console.error('[slack-bot] getChannelContext failed:', err.message); return null; }),
|
|
182
|
+
]);
|
|
183
|
+
const userName = contact?.name || null;
|
|
184
|
+
const resolvedChannel = isDM ? undefined : (channelName || undefined);
|
|
185
|
+
setSlackContext(sessionId, { type: ctxType, ...(resolvedChannel ? { channelName: resolvedChannel } : {}), ...(userName ? { userName } : {}) });
|
|
186
|
+
if (channelCtx) {
|
|
187
|
+
const label = isMention && !isAgentChannel
|
|
188
|
+
? `@mentioned in${channelName ? ` #${channelName}` : ' channel'}`
|
|
189
|
+
: `#${channelName || 'channel'} context`;
|
|
190
|
+
appendMessage(sessionId, {
|
|
191
|
+
id: crypto.randomUUID(), role: 'user',
|
|
192
|
+
blocks: [{ type: 'context', label, text: `${channelCtx}\n\n[Use mcp-slack tools (get_slack_history_by_channel, post_slack_message) for further context or replies. Always read channel history before posting.]` }],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sync the live Slack thread into context on every reply — sibling bot messages from other
|
|
198
|
+
// sessions in the same human-visible thread are invisible to this session's JSONL. Dedup by ts.
|
|
199
|
+
if (msg.rawThreadTs) {
|
|
200
|
+
const threadMsgs = await getThreadMessages(channel, msg.rawThreadTs, useUserToken).catch(err => {
|
|
201
|
+
console.error(`[slack] Failed to fetch thread messages for ${channel}:${msg.rawThreadTs}:`, err?.message || err);
|
|
202
|
+
return [];
|
|
203
|
+
});
|
|
204
|
+
if (threadMsgs.length > 0) {
|
|
205
|
+
const seen = new Set(getSession(sessionId)?.seenSlackTs ?? []);
|
|
206
|
+
const seedOnly = !isNew && seen.size === 0;
|
|
207
|
+
const processedTs: string[] = [];
|
|
208
|
+
for (const m of threadMsgs) {
|
|
209
|
+
if (m.ts) processedTs.push(m.ts);
|
|
210
|
+
if (m.ts === msg.ts || (m.ts && seen.has(m.ts)) || seedOnly) continue;
|
|
211
|
+
const isBot = m.bot_id || m.user === botId || (agentUserId && m.user === agentUserId);
|
|
212
|
+
const role = isBot ? 'assistant' : 'user';
|
|
213
|
+
let msgText = (m.text || '').replace(new RegExp(`<@${botId}>\\s*`, 'g'), '').trim();
|
|
214
|
+
if (agentUserId) msgText = msgText.replace(new RegExp(`<@${agentUserId}>\\s*`, 'g'), '').trim();
|
|
215
|
+
if (!msgText) continue;
|
|
216
|
+
const resolved = await resolveUserMentions(msgText);
|
|
217
|
+
const speakerName = !isBot && m.user ? await getUserName(m.user).catch(() => null) : null;
|
|
218
|
+
const prefixed = speakerName ? `[${speakerName}]: ${resolved}` : resolved;
|
|
219
|
+
appendMessage(sessionId, { id: crypto.randomUUID(), role, blocks: [{ type: 'text', text: prefixed }], channel: 'slack' });
|
|
220
|
+
}
|
|
221
|
+
recordSeenSlackTs(sessionId, [...processedTs, msg.ts]);
|
|
222
|
+
} else if (isNew) {
|
|
223
|
+
const rootText = await getMessage(channel, msg.rawThreadTs, useUserToken).catch(err => {
|
|
224
|
+
console.error(`[slack] Failed to fetch root message for ${channel}:${msg.rawThreadTs}:`, err?.message || err);
|
|
225
|
+
return null;
|
|
226
|
+
});
|
|
227
|
+
if (rootText) {
|
|
228
|
+
let cleanRoot = rootText.replace(new RegExp(`<@${botId}>\\s*`, 'g'), '').trim();
|
|
229
|
+
if (agentUserId) cleanRoot = cleanRoot.replace(new RegExp(`<@${agentUserId}>\\s*`, 'g'), '').trim();
|
|
230
|
+
const resolved = await resolveUserMentions(cleanRoot);
|
|
231
|
+
if (resolved) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'context', label: 'Thread root message', text: resolved }], channel: 'slack' });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isNew && msg.rawThreadTs) {
|
|
237
|
+
const origin = getProactiveOrigin(channel, msg.rawThreadTs);
|
|
238
|
+
if (origin) {
|
|
239
|
+
const convoPath = dataPath(`conversations/${origin.sessionId}.jsonl`);
|
|
240
|
+
const snippet = injectFile(convoPath, { label: 'origin-conversation', maxChars: 1500, transform: 'conversation' });
|
|
241
|
+
const t = [
|
|
242
|
+
`IMPORTANT: This Slack thread was started by YOU (the agent) during session "${origin.sessionTitle}" (${origin.sessionId}).`,
|
|
243
|
+
`The user is replying to a proactive message you sent. The conversation below is YOUR prior session — treat all facts, data, and statements in it as context you already know.`,
|
|
244
|
+
`If the user asks about something mentioned in that session, answer from this context. For full detail, Read the file path in the snippet tag.`,
|
|
245
|
+
snippet || '(origin conversation not found)',
|
|
246
|
+
].join('\n');
|
|
247
|
+
appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'context', label: 'Origin', text: t }] });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const replyCtx = `Slack channel: ${channel}, thread_ts: ${threadTs}`;
|
|
252
|
+
appendMessage(sessionId, {
|
|
253
|
+
id: crypto.randomUUID(), role: 'user',
|
|
254
|
+
blocks: [{ type: 'context', label: 'Slack reply coordinates', text: `${replyCtx}\nYour text responses are AUTOMATICALLY streamed to this Slack conversation — do NOT use post_slack_message to reply. Only use post_slack_files (with these coordinates) to share files/images, or post_slack_message to post in a DIFFERENT channel.` }],
|
|
255
|
+
channel: 'slack',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Save downloaded files to data/uploads/{sid}/ (same as the web UI).
|
|
259
|
+
const attachments: AttachmentMeta[] = [];
|
|
260
|
+
if (downloadedFiles.length) {
|
|
261
|
+
const uploadsDir = dataPath(`uploads/${sessionId}`);
|
|
262
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
263
|
+
for (const f of downloadedFiles) {
|
|
264
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
265
|
+
const safeName = path.basename(f.name);
|
|
266
|
+
const filename = `${id}-${safeName}`;
|
|
267
|
+
const dest = path.join(uploadsDir, filename);
|
|
268
|
+
writeFileSync(dest, f.buffer);
|
|
269
|
+
attachments.push({ url: `/uploads/${sessionId}/${filename}`, name: safeName, mimeType: f.mimeType, path: dest });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const userBlocks: ConvBlock[] = [
|
|
274
|
+
...attachments.map(a => a.mimeType.startsWith('image/')
|
|
275
|
+
? { type: 'image' as const, src: a.url }
|
|
276
|
+
: { type: 'file' as const, src: a.url, name: a.name, mimeType: a.mimeType }),
|
|
277
|
+
{ type: 'text' as const, text: resolvedText },
|
|
278
|
+
];
|
|
279
|
+
const senderName = contact?.name || contact?.emails[0]?.split('@')[0] || undefined;
|
|
280
|
+
appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: userBlocks, channel: 'slack', senderName });
|
|
281
|
+
setLastMessageTs(channel, threadTs, userMessageTs);
|
|
282
|
+
if (useUserToken) setUseUserToken(channel, threadTs, true);
|
|
283
|
+
|
|
284
|
+
const mcpServers = getMcpConfig(SLACK_UID);
|
|
285
|
+
const sessionChannelName = getSession(sessionId)?.slackContext?.channelName;
|
|
286
|
+
const triggerContext: Record<string, string> = { source: 'slack' };
|
|
287
|
+
if (isDM) triggerContext.dm = 'true';
|
|
288
|
+
if (sessionChannelName) triggerContext.channel = `#${sessionChannelName}`;
|
|
289
|
+
if (msg.rawThreadTs) triggerContext.thread = msg.rawThreadTs;
|
|
290
|
+
if (contact?.emails[0]) triggerContext.user = contact.emails[0];
|
|
291
|
+
|
|
292
|
+
const abortController = new AbortController();
|
|
293
|
+
if (!acquireSessionLock(sessionId, 'slack', abortController)) {
|
|
294
|
+
console.warn(`[slack-bot] Session ${sessionId.slice(0, 8)} already locked, queuing in Slack thread`);
|
|
295
|
+
await postMessage(channel, '⏳ _Session is busy — please wait for the current task to finish._', threadTs, useUserToken);
|
|
296
|
+
msg.sessionId = undefined; // nothing to bookkeep — no reply produced
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
setRunStatus(sessionId, 'running', 'slack');
|
|
300
|
+
broadcastFn({ type: 'session_messages_changed', sessionId });
|
|
301
|
+
broadcastFn({ type: 'session_busy', sessionId, busy: true });
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
yield* pumpStream(
|
|
305
|
+
streamChat({
|
|
306
|
+
prompt: resolvedText,
|
|
307
|
+
attachments: attachments.length ? attachments : undefined,
|
|
308
|
+
sessionId,
|
|
309
|
+
uid: SLACK_UID,
|
|
310
|
+
userEmail: contact?.emails[0],
|
|
311
|
+
userName: contact?.name,
|
|
312
|
+
mcpServers,
|
|
313
|
+
abortController,
|
|
314
|
+
context: triggerContext,
|
|
315
|
+
onPermissionRequest: async () => ({ allow: true }),
|
|
316
|
+
onUserQuestion: makeSlackQuestionHandler({ channel, threadTs, useUserToken }),
|
|
317
|
+
}),
|
|
318
|
+
sessionId,
|
|
319
|
+
{ partial: true, artifacts: true },
|
|
320
|
+
);
|
|
321
|
+
} finally {
|
|
322
|
+
if (releaseSessionLock(sessionId, abortController)) {
|
|
323
|
+
setRunStatus(sessionId, 'idle');
|
|
324
|
+
broadcastFn({ type: 'session_busy', sessionId, busy: false });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── onReplied: post-reply bookkeeping once the streamed reply ts is known ───────
|
|
330
|
+
export function onReplied(msg: IngressMessage, replyTs: string | null): void {
|
|
331
|
+
if (!replyTs || !msg.sessionId) return;
|
|
332
|
+
registerThreadAlias(msg.channel, replyTs, msg.sessionId);
|
|
333
|
+
recordSeenSlackTs(msg.sessionId, [replyTs]);
|
|
334
|
+
invalidateChannelContext(msg.channel);
|
|
335
|
+
console.log(`[slack-bot] Reply posted via ${msg.useUserToken ? 'user' : 'bot'} token: ${msg.channel} ts=${replyTs}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Crash-recovery resume ───────────────────────────────────────────────────────
|
|
339
|
+
export async function retrySlackSession(session: SessionMeta, prompt: string): Promise<void> {
|
|
340
|
+
const slackInfo = findSlackSessionBySessionId(session.sessionId);
|
|
341
|
+
if (!slackInfo) {
|
|
342
|
+
console.warn(`[slack-bot] recovery: no slack mapping for ${session.sessionId.slice(0, 8)}`);
|
|
343
|
+
setRunStatus(session.sessionId, 'idle');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const { channel, threadTs, lastMessageTs, useUserToken } = slackInfo;
|
|
347
|
+
console.log(`[slack-bot] recovery: retrying ${session.sessionId.slice(0, 8)} in ${channel} (${useUserToken ? 'user' : 'bot'} token)`);
|
|
348
|
+
|
|
349
|
+
const recoveryAc = new AbortController();
|
|
350
|
+
if (!acquireSessionLock(session.sessionId, 'slack', recoveryAc)) {
|
|
351
|
+
console.warn(`[slack-bot] recovery: session ${session.sessionId.slice(0, 8)} already locked, skipping`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
setRunStatus(session.sessionId, 'running', 'slack');
|
|
355
|
+
|
|
356
|
+
const recoveryContext: Record<string, string> = { source: 'slack' };
|
|
357
|
+
if (session.slackContext?.channelName) recoveryContext.channel = `#${session.slackContext.channelName}`;
|
|
358
|
+
if (session.slackContext?.type === 'dm') recoveryContext.dm = 'true';
|
|
359
|
+
if (threadTs) recoveryContext.thread = threadTs;
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const replyTs = await pipeAgentReply(
|
|
363
|
+
{ channel, threadTs, useUserToken, transform: undefined, finalTransform: resolveUserMentions },
|
|
364
|
+
pumpStream(
|
|
365
|
+
streamChat({
|
|
366
|
+
prompt,
|
|
367
|
+
sessionId: session.sessionId,
|
|
368
|
+
uid: SLACK_UID,
|
|
369
|
+
userEmail: isSlackBotPlaceholderEmail(session.userEmail) ? undefined : session.userEmail,
|
|
370
|
+
userName: session.slackContext?.userName,
|
|
371
|
+
mcpServers: getMcpConfig(SLACK_UID),
|
|
372
|
+
abortController: recoveryAc,
|
|
373
|
+
context: recoveryContext,
|
|
374
|
+
onPermissionRequest: async () => ({ allow: true }),
|
|
375
|
+
onUserQuestion: makeSlackQuestionHandler({ channel, threadTs, useUserToken }),
|
|
376
|
+
}),
|
|
377
|
+
session.sessionId,
|
|
378
|
+
{ partial: false, artifacts: false },
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
if (replyTs) { console.log(`[slack-bot] recovery reply posted: ${channel} ts=${replyTs}`); invalidateChannelContext(channel); }
|
|
382
|
+
} catch (err: any) {
|
|
383
|
+
console.error(`[slack-bot] recovery error for ${session.sessionId.slice(0, 8)}:`, err.message);
|
|
384
|
+
} finally {
|
|
385
|
+
if (releaseSessionLock(session.sessionId, recoveryAc)) {
|
|
386
|
+
setRunStatus(session.sessionId, 'idle');
|
|
387
|
+
broadcastFn({ type: 'session_busy', sessionId: session.sessionId, busy: false });
|
|
388
|
+
}
|
|
389
|
+
if (lastMessageTs) await removeReaction(channel, lastMessageTs, 'hourglass_flowing_sand', useUserToken).catch(() => {});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getChannelHistory, getBotUserId, getAgentUserId, getUserName } from './api.ts';
|
|
2
|
+
import { summarizeText } from '../summarize.ts';
|
|
3
|
+
|
|
4
|
+
interface CacheEntry {
|
|
5
|
+
summary: string;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
10
|
+
const cache = new Map<string, CacheEntry>();
|
|
11
|
+
|
|
12
|
+
export async function getChannelContext(channel: string): Promise<string | null> {
|
|
13
|
+
const cached = cache.get(channel);
|
|
14
|
+
if (cached && cached.expiresAt > Date.now()) return cached.summary;
|
|
15
|
+
|
|
16
|
+
const messages = await getChannelHistory(channel, 20).catch(() => []);
|
|
17
|
+
if (!messages.length) return null;
|
|
18
|
+
|
|
19
|
+
const botId = await getBotUserId();
|
|
20
|
+
const agentUid = await getAgentUserId();
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
for (const msg of messages) {
|
|
23
|
+
const isBot = !!msg.bot_id || msg.user === botId || (agentUid && msg.user === agentUid);
|
|
24
|
+
const name = isBot ? 'Bot' : (msg.user ? await getUserName(msg.user).catch(() => null) ?? 'User' : 'User');
|
|
25
|
+
const text = (msg.text || '').replace(/<@[A-Z0-9]+>/g, '@user').trim();
|
|
26
|
+
if (text) lines.push(`${name}: ${text}`);
|
|
27
|
+
}
|
|
28
|
+
if (!lines.length) return null;
|
|
29
|
+
|
|
30
|
+
const summary = await summarizeText(
|
|
31
|
+
lines.join('\n'),
|
|
32
|
+
'Summarize this Slack channel conversation in 2-4 sentences. Capture the key topics, questions, and any pending action items. Be concise.'
|
|
33
|
+
);
|
|
34
|
+
if (!summary) return null;
|
|
35
|
+
|
|
36
|
+
cache.set(channel, { summary, expiresAt: Date.now() + TTL_MS });
|
|
37
|
+
return summary;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function invalidateChannelContext(channel: string): void {
|
|
41
|
+
cache.delete(channel);
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// slackFeature — the ServerFeature that mounts Slack in this app. It wires the mcp-slack package
|
|
2
|
+
// `ingress` (protocol) to the agent-glue (bot.ts) and subscribes the data-sync deploy notifier
|
|
3
|
+
// (event bus → owner DMs). Slack ships in this app, so index.ts registers this directly.
|
|
4
|
+
import type { ServerFeature, FeatureContext } from '../features.ts';
|
|
5
|
+
import { registerSlackIngress } from 'mcp-slack-use/src/ingress.ts';
|
|
6
|
+
import { subscribeEvents } from '../events/bus.ts';
|
|
7
|
+
import { postMessage, resolveUserMentions } from './api.ts';
|
|
8
|
+
import { runAgentTurn, shouldRespond, onReplied, retrySlackSession, setBroadcast } from './bot.ts';
|
|
9
|
+
import { handleSlackInteraction } from './questions.ts';
|
|
10
|
+
import { registerSlackOAuthRoutes } from './oauth.ts';
|
|
11
|
+
import type { SessionMeta } from '../sessions.ts';
|
|
12
|
+
|
|
13
|
+
interface DeployNotice { kind: 'deploy'; owners: { name?: string; slackId: string }[]; text: string }
|
|
14
|
+
|
|
15
|
+
// mountFeatures can run twice in the passive→active promotion path; guard the once-only wiring.
|
|
16
|
+
let oauthMounted = false;
|
|
17
|
+
let ingressMounted = false;
|
|
18
|
+
let busSubscribed = false;
|
|
19
|
+
|
|
20
|
+
export const slackFeature: ServerFeature = {
|
|
21
|
+
name: 'slack',
|
|
22
|
+
|
|
23
|
+
register(ctx: FeatureContext): void {
|
|
24
|
+
setBroadcast(ctx.broadcast);
|
|
25
|
+
|
|
26
|
+
if (!oauthMounted) { oauthMounted = true; registerSlackOAuthRoutes(ctx.app); }
|
|
27
|
+
|
|
28
|
+
// Data-sync deploy notices arrive on the event bus (data-sync.ts has no Slack coupling); DM owners.
|
|
29
|
+
if (!ctx.passive && !busSubscribed) {
|
|
30
|
+
busSubscribed = true;
|
|
31
|
+
subscribeEvents((evt) => {
|
|
32
|
+
if (evt.source !== 'data-sync') return;
|
|
33
|
+
const payload = evt.payload as DeployNotice;
|
|
34
|
+
if (payload?.kind !== 'deploy' || !payload.owners?.length) return;
|
|
35
|
+
for (const owner of payload.owners) {
|
|
36
|
+
postMessage(owner.slackId, payload.text)
|
|
37
|
+
.then(() => console.log(`[slack] Notified ${owner.name ?? owner.slackId} via Slack DM`))
|
|
38
|
+
.catch((err) => console.warn(`[slack] Deploy DM to ${owner.name ?? owner.slackId} failed:`, (err as Error).message));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (ctx.passive || !process.env.SLACK_SIGNING_SECRET || ingressMounted) return;
|
|
44
|
+
ingressMounted = true;
|
|
45
|
+
registerSlackIngress(ctx.app as any, {
|
|
46
|
+
shouldRespond,
|
|
47
|
+
onMessage: runAgentTurn,
|
|
48
|
+
onReplied,
|
|
49
|
+
// ingress expects a void-returning handler; handleSlackInteraction's boolean is
|
|
50
|
+
// unobserved here, so await it and discard — same execution, no behavior change.
|
|
51
|
+
onInteraction: async (p: any) => { await handleSlackInteraction(p); },
|
|
52
|
+
finalTransform: resolveUserMentions,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
resumeSession(session: unknown, prompt: string): Promise<void> {
|
|
57
|
+
return retrySlackSession(session as SessionMeta, prompt);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound Slack mention resolution — the reverse of `resolveUserMentions`.
|
|
3
|
+
*
|
|
4
|
+
* `resolveUserMentions` (api.ts) rewrites inbound Slack `<@U…>` tokens into the
|
|
5
|
+
* human-readable display form the agent reads, e.g. `@talshriki (operator)`.
|
|
6
|
+
* There was no reverse step, so when the agent pasted that display string back
|
|
7
|
+
* into an outbound message, Slack posted it as literal text and nobody got
|
|
8
|
+
* pinged. This closes that asymmetry: it turns the display form back into a
|
|
9
|
+
* real `<@U…>` mention token using the same contacts registry that produced it.
|
|
10
|
+
*
|
|
11
|
+
* Handles the two display shapes the agent ever sees:
|
|
12
|
+
* "@talshriki (operator)" -> "<@U0731UGPPMY>"
|
|
13
|
+
* "@talshriki" -> "<@U0731UGPPMY>"
|
|
14
|
+
*
|
|
15
|
+
* Safety: only rewrites a name that maps to exactly ONE contact slackId
|
|
16
|
+
* (unambiguous). Slack keywords (@channel/@here/@everyone) and already-tokenized
|
|
17
|
+
* `<@U…>` mentions are left untouched.
|
|
18
|
+
*/
|
|
19
|
+
import { getAll } from '../contacts.ts';
|
|
20
|
+
|
|
21
|
+
const SLACK_KEYWORDS = new Set(['channel', 'here', 'everyone']);
|
|
22
|
+
|
|
23
|
+
function escapeRegex(s: string): string {
|
|
24
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function rewriteSlackMentions(text: string): { text: string; changed: boolean } {
|
|
28
|
+
if (!text || !text.includes('@')) return { text, changed: false };
|
|
29
|
+
|
|
30
|
+
// Aggregate slackIds per display name to detect ambiguity (same name, two people).
|
|
31
|
+
const byName = new Map<string, Set<string>>();
|
|
32
|
+
for (const c of getAll()) {
|
|
33
|
+
if (!c.slackIds.length) continue;
|
|
34
|
+
const key = c.name?.trim();
|
|
35
|
+
if (!key || SLACK_KEYWORDS.has(key.toLowerCase())) continue;
|
|
36
|
+
let set = byName.get(key);
|
|
37
|
+
if (!set) { set = new Set(); byName.set(key, set); }
|
|
38
|
+
for (const s of c.slackIds) set.add(s);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let result = text;
|
|
42
|
+
let changed = false;
|
|
43
|
+
// Longest names first so a longer name is consumed before a shorter one it contains.
|
|
44
|
+
const names = [...byName.keys()].sort((a, b) => b.length - a.length);
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const ids = byName.get(name)!;
|
|
47
|
+
if (ids.size !== 1) continue; // ambiguous — leave as-is
|
|
48
|
+
const token = `<@${[...ids][0]}>`;
|
|
49
|
+
const n = escapeRegex(name);
|
|
50
|
+
// Tagged form first: "@name (operator|owner|contact)" — highly specific.
|
|
51
|
+
const tagged = new RegExp(`@${n}\\s*\\((?:operator|owner|contact)\\)`, 'g');
|
|
52
|
+
if (tagged.test(result)) { result = result.replace(tagged, token); changed = true; }
|
|
53
|
+
// Bare form: "@name" not followed by a word/handle char, so "@Adam" never
|
|
54
|
+
// matches inside "@Adamson" or an email/handle.
|
|
55
|
+
const bare = new RegExp(`@${n}(?![\\w@.\\-])`, 'g');
|
|
56
|
+
if (bare.test(result)) { result = result.replace(bare, token); changed = true; }
|
|
57
|
+
}
|
|
58
|
+
return { text: result, changed };
|
|
59
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { requireAuth } from '../auth.ts';
|
|
4
|
+
import { dataPath } from '../paths.ts';
|
|
5
|
+
|
|
6
|
+
const TOKENS_FILE = 'slack-tokens.json';
|
|
7
|
+
const TOKENS_PATH = () => dataPath(TOKENS_FILE);
|
|
8
|
+
|
|
9
|
+
interface SlackTokens {
|
|
10
|
+
botToken?: string;
|
|
11
|
+
userToken?: string;
|
|
12
|
+
authedUser?: { id: string; scope: string };
|
|
13
|
+
updatedAt?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function loadTokens(): SlackTokens {
|
|
17
|
+
const p = TOKENS_PATH();
|
|
18
|
+
if (!existsSync(p)) return {};
|
|
19
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); }
|
|
20
|
+
catch (e) { console.warn(`[slack-oauth] Failed to parse ${TOKENS_FILE}:`, e); return {}; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveTokens(tokens: SlackTokens) {
|
|
24
|
+
writeFileSync(TOKENS_PATH(), JSON.stringify(tokens, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getBaseUrl(req: { protocol: string; get(name: string): string | undefined }): string {
|
|
28
|
+
const proto = req.get('x-forwarded-proto') || req.protocol;
|
|
29
|
+
return `${proto}://${req.get('host')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Load file-based tokens into process.env if not already set. */
|
|
33
|
+
export function hydrateSlackTokens() {
|
|
34
|
+
const tokens = loadTokens();
|
|
35
|
+
if (!process.env.SLACK_USER_TOKEN && tokens.userToken) {
|
|
36
|
+
process.env.SLACK_USER_TOKEN = tokens.userToken;
|
|
37
|
+
console.log(`[slack-oauth] Hydrated SLACK_USER_TOKEN from ${TOKENS_FILE} (user=${tokens.authedUser?.id})`);
|
|
38
|
+
}
|
|
39
|
+
if (!process.env.SLACK_BOT_TOKEN && tokens.botToken) {
|
|
40
|
+
process.env.SLACK_BOT_TOKEN = tokens.botToken;
|
|
41
|
+
console.log(`[slack-oauth] Hydrated SLACK_BOT_TOKEN from ${TOKENS_FILE}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** @deprecated Use hydrateSlackTokens */
|
|
45
|
+
export const hydrateSlackUserToken = hydrateSlackTokens;
|
|
46
|
+
|
|
47
|
+
export function registerSlackOAuthRoutes(app: Express) {
|
|
48
|
+
const clientId = process.env.SLACK_CLIENT_ID;
|
|
49
|
+
const clientSecret = process.env.SLACK_CLIENT_SECRET;
|
|
50
|
+
if (!clientId || !clientSecret) return;
|
|
51
|
+
|
|
52
|
+
const USER_SCOPES = 'search:read,chat:write,groups:read,im:history,im:read,im:write,links:read,links:write,users:read,users:read.email,users:write,reactions:write,reactions:read';
|
|
53
|
+
|
|
54
|
+
app.get('/api/slack/oauth/start', (req, res) => {
|
|
55
|
+
const redirectUri = `${getBaseUrl(req)}/api/slack/oauth/callback`;
|
|
56
|
+
const url = `https://slack.com/oauth/v2/authorize?client_id=${clientId}&user_scope=${encodeURIComponent(USER_SCOPES)}&redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
57
|
+
res.redirect(url);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.get('/api/slack/oauth/callback', async (req, res) => {
|
|
61
|
+
const { code, error } = req.query;
|
|
62
|
+
if (error) return res.status(400).send(`OAuth error: ${error}`);
|
|
63
|
+
if (!code || typeof code !== 'string') return res.status(400).send('Missing code');
|
|
64
|
+
|
|
65
|
+
const redirectUri = `${getBaseUrl(req)}/api/slack/oauth/callback`;
|
|
66
|
+
try {
|
|
67
|
+
const resp = await fetch('https://slack.com/api/oauth.v2.access', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
70
|
+
body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }).toString(),
|
|
71
|
+
});
|
|
72
|
+
const data = await resp.json() as any;
|
|
73
|
+
if (!data.ok) return res.status(400).send(`Slack error: ${data.error}`);
|
|
74
|
+
|
|
75
|
+
const tokens = loadTokens();
|
|
76
|
+
if (data.authed_user?.access_token) {
|
|
77
|
+
tokens.userToken = data.authed_user.access_token;
|
|
78
|
+
tokens.authedUser = { id: data.authed_user.id, scope: data.authed_user.scope };
|
|
79
|
+
process.env.SLACK_USER_TOKEN = tokens.userToken;
|
|
80
|
+
}
|
|
81
|
+
if (data.access_token) tokens.botToken = data.access_token;
|
|
82
|
+
tokens.updatedAt = new Date().toISOString();
|
|
83
|
+
saveTokens(tokens);
|
|
84
|
+
|
|
85
|
+
console.log(`[slack-oauth] Token exchange success — user=${data.authed_user?.id}, scopes=${data.authed_user?.scope}`);
|
|
86
|
+
res.send(`<h2>Slack OAuth complete</h2><p>User token saved for ${data.authed_user?.id || 'bot'}.</p><p>You can close this tab.</p>`);
|
|
87
|
+
} catch (e: any) {
|
|
88
|
+
console.error('[slack-oauth] Token exchange failed:', e.message);
|
|
89
|
+
res.status(500).send(`Token exchange failed: ${e.message}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
app.get('/api/slack/oauth/status', requireAuth, (_req, res) => {
|
|
94
|
+
const tokens = loadTokens();
|
|
95
|
+
res.json({
|
|
96
|
+
hasUserToken: !!tokens.userToken,
|
|
97
|
+
hasEnvUserToken: !!process.env.SLACK_USER_TOKEN,
|
|
98
|
+
authedUser: tokens.authedUser || null,
|
|
99
|
+
updatedAt: tokens.updatedAt || null,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|