@pellux/goodvibes-daemon 1.28.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,300 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Minimal, dependency-free IMAPS client (RFC 3501 subset) over node:tls.
3
+ //
4
+ // Implements exactly what the inbound poller needs:
5
+ // LOGIN, SELECT, UID SEARCH (SINCE / ALL), UID FETCH (ENVELOPE + body peek),
6
+ // LOGOUT. No external npm dependency — uses node:tls (Bun-compatible).
7
+ //
8
+ // This is intentionally conservative: line-buffered tagged-command protocol,
9
+ // per-command timeout, and a hard cap on response size to avoid unbounded
10
+ // memory growth from a hostile/large mailbox.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { connect as tlsConnect } from 'node:tls';
14
+ import type { TLSSocket } from 'node:tls';
15
+
16
+ export interface ImapConfig {
17
+ host: string;
18
+ port: number; // 993 for IMAPS
19
+ user: string;
20
+ password: string;
21
+ /** Per-command timeout in ms. */
22
+ timeoutMs?: number;
23
+ /** Hard cap on bytes buffered per command (defense against huge fetches). */
24
+ maxResponseBytes?: number;
25
+ }
26
+
27
+ export interface ImapEnvelope {
28
+ uid: number;
29
+ from: string; // raw From header value (digested by the adapter)
30
+ subject: string;
31
+ date: number; // Unix ms (0 when unparseable)
32
+ seen: boolean;
33
+ bodyPreview: string; // first text fragment, raw (sanitized by the adapter)
34
+ }
35
+
36
+ const DEFAULT_TIMEOUT_MS = 20_000;
37
+ const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
38
+
39
+ export class ImapClient {
40
+ private socket: TLSSocket | null = null;
41
+ private tagCounter = 0;
42
+ private buffer = '';
43
+ private readonly cfg: Required<ImapConfig>;
44
+
45
+ constructor(cfg: ImapConfig) {
46
+ this.cfg = {
47
+ timeoutMs: DEFAULT_TIMEOUT_MS,
48
+ maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES,
49
+ ...cfg,
50
+ };
51
+ }
52
+
53
+ async connect(): Promise<void> {
54
+ await new Promise<void>((resolve, reject) => {
55
+ const socket = tlsConnect(
56
+ { host: this.cfg.host, port: this.cfg.port, servername: this.cfg.host },
57
+ () => {
58
+ resolve();
59
+ },
60
+ );
61
+ socket.setEncoding('utf-8');
62
+ socket.once('error', reject);
63
+ this.socket = socket;
64
+ });
65
+ // Consume the server greeting (untagged * OK ...).
66
+ await this.readUntil((chunk) => /\r?\n/.test(chunk), 'greeting');
67
+ }
68
+
69
+ async login(): Promise<void> {
70
+ const user = quote(this.cfg.user);
71
+ const pass = quote(this.cfg.password);
72
+ await this.command(`LOGIN ${user} ${pass}`);
73
+ }
74
+
75
+ /** SELECT a mailbox (default INBOX). */
76
+ async select(mailbox = 'INBOX'): Promise<void> {
77
+ await this.command(`SELECT ${quote(mailbox)}`);
78
+ }
79
+
80
+ /** UID SEARCH; returns matching UIDs. `since` filters by internal date. */
81
+ async searchUids(since?: number): Promise<number[]> {
82
+ const criteria = since ? `SINCE ${imapDate(since)}` : 'ALL';
83
+ const lines = await this.command(`UID SEARCH ${criteria}`);
84
+ const uids: number[] = [];
85
+ for (const line of lines) {
86
+ const match = /^\* SEARCH(.*)$/i.exec(line.trim());
87
+ if (match) {
88
+ for (const tok of match[1]!.trim().split(/\s+/)) {
89
+ const n = Number.parseInt(tok, 10);
90
+ if (Number.isFinite(n)) uids.push(n);
91
+ }
92
+ }
93
+ }
94
+ return uids;
95
+ }
96
+
97
+ /**
98
+ * UID FETCH envelope + flags + a small text body peek for the given uids.
99
+ * Returns one ImapEnvelope per uid that parsed successfully.
100
+ */
101
+ async fetchEnvelopes(uids: readonly number[]): Promise<ImapEnvelope[]> {
102
+ if (uids.length === 0) return [];
103
+ const set = uids.join(',');
104
+ // BODY.PEEK[HEADER.FIELDS (...)] avoids setting \Seen; TEXT peek for preview.
105
+ const lines = await this.command(
106
+ `UID FETCH ${set} (UID FLAGS INTERNALDATE `
107
+ + `BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] `
108
+ + `BODY.PEEK[TEXT]<0.600>)`,
109
+ );
110
+ return parseFetchResponse(lines.join('\r\n'));
111
+ }
112
+
113
+ async logout(): Promise<void> {
114
+ if (!this.socket) return;
115
+ try {
116
+ await this.command('LOGOUT');
117
+ } catch {
118
+ // ignore logout failures
119
+ }
120
+ }
121
+
122
+ close(): void {
123
+ if (this.socket) {
124
+ this.socket.destroy();
125
+ this.socket = null;
126
+ }
127
+ }
128
+
129
+ // -------------------------------------------------------------------------
130
+ // Protocol plumbing
131
+ // -------------------------------------------------------------------------
132
+
133
+ private nextTag(): string {
134
+ this.tagCounter += 1;
135
+ return `A${this.tagCounter.toString().padStart(4, '0')}`;
136
+ }
137
+
138
+ private requireSocket(): TLSSocket {
139
+ if (!this.socket) throw new Error('IMAP socket not connected');
140
+ return this.socket;
141
+ }
142
+
143
+ /** Send a tagged command and collect all response lines up to the tagged OK. */
144
+ private async command(text: string): Promise<string[]> {
145
+ const tag = this.nextTag();
146
+ const socket = this.requireSocket();
147
+ socket.write(`${tag} ${text}\r\n`);
148
+ const taggedOk = new RegExp(`^${tag} (OK|NO|BAD)\\b`, 'm');
149
+ const raw = await this.readUntil((buf) => taggedOk.test(buf), text);
150
+ const lines = raw.split(/\r?\n/);
151
+ const statusLine = lines.find((l) => new RegExp(`^${tag} `).test(l)) ?? '';
152
+ const status = /^A\d+ (OK|NO|BAD)/.exec(statusLine)?.[1];
153
+ if (status !== 'OK') {
154
+ throw new Error(`IMAP command failed: ${redactCommand(text)} -> ${statusLine.trim()}`);
155
+ }
156
+ return lines.filter((l) => l.startsWith('*'));
157
+ }
158
+
159
+ /** Read from the socket until `predicate(buffer)` is true or timeout. */
160
+ private readUntil(predicate: (buf: string) => boolean, label: string): Promise<string> {
161
+ const socket = this.requireSocket();
162
+ return new Promise<string>((resolve, reject) => {
163
+ const onData = (chunk: string): void => {
164
+ this.buffer += chunk;
165
+ if (this.buffer.length > this.cfg.maxResponseBytes) {
166
+ cleanup();
167
+ reject(new Error(`IMAP response exceeded ${this.cfg.maxResponseBytes} bytes (${label})`));
168
+ return;
169
+ }
170
+ if (predicate(this.buffer)) {
171
+ const out = this.buffer;
172
+ this.buffer = '';
173
+ cleanup();
174
+ resolve(out);
175
+ }
176
+ };
177
+ const onError = (err: Error): void => {
178
+ cleanup();
179
+ reject(err);
180
+ };
181
+ const onClose = (): void => {
182
+ cleanup();
183
+ reject(new Error(`IMAP connection closed during ${label}`));
184
+ };
185
+ const timer = setTimeout(() => {
186
+ cleanup();
187
+ reject(new Error(`IMAP timeout after ${this.cfg.timeoutMs}ms (${label})`));
188
+ }, this.cfg.timeoutMs);
189
+ const cleanup = (): void => {
190
+ clearTimeout(timer);
191
+ socket.off('data', onData);
192
+ socket.off('error', onError);
193
+ socket.off('close', onClose);
194
+ };
195
+ socket.on('data', onData);
196
+ socket.on('error', onError);
197
+ socket.on('close', onClose);
198
+ });
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Pure parsers (exported for unit testing)
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** Quote an IMAP astring, escaping backslashes and double quotes. */
207
+ function quote(value: string): string {
208
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
209
+ }
210
+
211
+ /** Never echo a LOGIN password in an error message. */
212
+ function redactCommand(text: string): string {
213
+ return /^LOGIN\b/i.test(text) ? 'LOGIN <redacted>' : text;
214
+ }
215
+
216
+ /** Format a Unix-ms timestamp as an IMAP date (dd-Mon-yyyy). */
217
+ export function imapDate(ms: number): string {
218
+ const d = new Date(ms);
219
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
220
+ const day = String(d.getUTCDate()).padStart(2, '0');
221
+ return `${day}-${months[d.getUTCMonth()]}-${d.getUTCFullYear()}`;
222
+ }
223
+
224
+ const HEADER_FROM_RE = /^From:\s*(.*)$/im;
225
+ const HEADER_SUBJECT_RE = /^Subject:\s*(.*)$/im;
226
+ const HEADER_DATE_RE = /^Date:\s*(.*)$/im;
227
+
228
+ /**
229
+ * Parse a UID FETCH response block into envelopes. Robust to interleaving and
230
+ * partial fields; entries that lack a UID are skipped.
231
+ */
232
+ export function parseFetchResponse(raw: string): ImapEnvelope[] {
233
+ const envelopes: ImapEnvelope[] = [];
234
+ // Split on each "* <n> FETCH (" boundary.
235
+ const blocks = raw.split(/\* \d+ FETCH \(/i).slice(1);
236
+ for (const block of blocks) {
237
+ const uidMatch = /UID (\d+)/i.exec(block);
238
+ if (!uidMatch) continue;
239
+ const uid = Number.parseInt(uidMatch[1]!, 10);
240
+ const seen = /FLAGS \([^)]*\\Seen/i.test(block);
241
+
242
+ // Header literal: BODY[HEADER.FIELDS (...)] {n}\r\n<header bytes>
243
+ const headerText = extractLiteral(block, /BODY\[HEADER\.FIELDS[^\]]*\]/i);
244
+ const bodyText = extractLiteral(block, /BODY\[TEXT\](?:<\d+(?:\.\d+)?>)?/i);
245
+
246
+ const from = HEADER_FROM_RE.exec(headerText)?.[1]?.trim() ?? '';
247
+ const subject = decodeHeader(HEADER_SUBJECT_RE.exec(headerText)?.[1]?.trim() ?? '');
248
+ const dateRaw = HEADER_DATE_RE.exec(headerText)?.[1]?.trim() ?? '';
249
+ const parsedDate = dateRaw ? Date.parse(dateRaw) : NaN;
250
+
251
+ envelopes.push({
252
+ uid,
253
+ from,
254
+ subject,
255
+ date: Number.isFinite(parsedDate) ? parsedDate : 0,
256
+ seen,
257
+ bodyPreview: bodyText,
258
+ });
259
+ }
260
+ return envelopes;
261
+ }
262
+
263
+ /**
264
+ * Extract a literal `{n}\r\n<bytes>` that follows a section header matched by
265
+ * `sectionRe`. Returns the literal content (n bytes) or ''.
266
+ */
267
+ function extractLiteral(block: string, sectionRe: RegExp): string {
268
+ const sectionMatch = sectionRe.exec(block);
269
+ if (!sectionMatch) return '';
270
+ const after = block.slice(sectionMatch.index + sectionMatch[0].length);
271
+ const litMatch = /^\s*\{(\d+)\}\r?\n/.exec(after);
272
+ if (!litMatch) {
273
+ // Quoted-string form: BODY[...] "value"
274
+ const q = /^\s*"((?:[^"\\]|\\.)*)"/.exec(after);
275
+ return q ? q[1]!.replace(/\\"/g, '"') : '';
276
+ }
277
+ const n = Number.parseInt(litMatch[1]!, 10);
278
+ const start = litMatch.index + litMatch[0].length;
279
+ return after.slice(start, start + n);
280
+ }
281
+
282
+ /** Decode RFC 2047 encoded-word subjects (UTF-8 B/Q) best-effort. */
283
+ export function decodeHeader(value: string): string {
284
+ return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_m, _charset, enc, text) => {
285
+ try {
286
+ if (enc.toUpperCase() === 'B') {
287
+ return Buffer.from(text, 'base64').toString('utf-8');
288
+ }
289
+ // Q-encoding: _ -> space, =XX -> byte
290
+ const replaced = String(text)
291
+ .replace(/_/g, ' ')
292
+ .replace(/=([0-9A-Fa-f]{2})/g, (_s: string, hex: string) =>
293
+ String.fromCharCode(Number.parseInt(hex, 16)),
294
+ );
295
+ return replaced;
296
+ } catch {
297
+ return text;
298
+ }
299
+ });
300
+ }
@@ -0,0 +1,24 @@
1
+ // Shared best-effort route resolution wrapper used by all adapters.
2
+ // Swallows resolver failures (routing is an optional, concurrently-wired
3
+ // surface) so a route lookup can never crash a provider poll.
4
+
5
+ import type { AdapterContext, InboundChannelItem } from '../provider-adapter.ts';
6
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
7
+
8
+ export async function resolveRouteId(
9
+ ctx: AdapterContext,
10
+ provider: string,
11
+ fromDigest: string,
12
+ kind: InboundChannelItem['kind'],
13
+ ): Promise<string | undefined> {
14
+ if (!ctx.resolveRouteId) return undefined;
15
+ try {
16
+ return (await ctx.resolveRouteId({ provider, fromDigest, kind })) ?? undefined;
17
+ } catch (error) {
18
+ ctx.logger.warn('route resolution failed', {
19
+ provider,
20
+ error: summarizeError(error),
21
+ });
22
+ return undefined;
23
+ }
24
+ }
@@ -0,0 +1,287 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Slack inbound adapter.
3
+ //
4
+ // Transport note (contract fidelity): the daemon-handoff checklist names the
5
+ // Slack Events API / RTM for DM polling. Those are push transports (HTTP event
6
+ // callbacks / a persistent websocket) and cannot be driven by the inbound
7
+ // poller, whose contract is a stateless, cadence-driven adapter.poll() that
8
+ // MUST resolve each call (see provider-adapter.ts). A websocket/event-callback
9
+ // would require an inbound HTTP endpoint or a long-lived socket the poller
10
+ // neither owns nor supervises. We therefore satisfy the DM-polling goal over
11
+ // the supported request/response surface (the Slack Web API), which the Events
12
+ // API itself documents as the canonical pull-based equivalent for reading DM
13
+ // history on an interval:
14
+ // conversations.list (types=im) -> open DM channels (cursor-paginated)
15
+ // conversations.history -> recent messages per DM
16
+ // auth.test -> our own user id (for @-mention class.);
17
+ // id is digested, never emitted raw.
18
+ //
19
+ // Credential: surfaces.slack.botToken. Missing => 'unavailable'.
20
+ // Cadence: 30s (realtime tier).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import type {
24
+ AdapterContext,
25
+ InboundChannelItem,
26
+ InboundProviderAdapter,
27
+ ProviderPollOptions,
28
+ ProviderPollResult,
29
+ } from '../provider-adapter.ts';
30
+ import { POLL_CADENCE_MS } from '../provider-adapter.ts';
31
+ import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
32
+ import { resolveRouteId } from './route-util.ts';
33
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
34
+
35
+ const SLACK_API = 'https://slack.com/api';
36
+ export const SLACK_PROVIDER_ID = 'slack';
37
+ export const SLACK_CREDENTIAL_KEY = 'surfaces.slack.botToken';
38
+
39
+ interface SlackConversationsListResponse {
40
+ ok: boolean;
41
+ error?: string;
42
+ channels?: Array<{ id: string; user?: string }>;
43
+ response_metadata?: { next_cursor?: string };
44
+ }
45
+
46
+ interface SlackAuthTestResponse {
47
+ ok: boolean;
48
+ error?: string;
49
+ user_id?: string;
50
+ }
51
+
52
+ interface SlackReaction {
53
+ name?: string;
54
+ count?: number;
55
+ users?: string[];
56
+ }
57
+
58
+ interface SlackMessage {
59
+ type?: string;
60
+ subtype?: string;
61
+ user?: string;
62
+ bot_id?: string;
63
+ text?: string;
64
+ ts?: string; // "1700000000.000200"
65
+ thread_ts?: string;
66
+ reactions?: SlackReaction[];
67
+ }
68
+
69
+ interface SlackHistoryResponse {
70
+ ok: boolean;
71
+ error?: string;
72
+ messages?: SlackMessage[];
73
+ has_more?: boolean;
74
+ response_metadata?: { next_cursor?: string };
75
+ }
76
+
77
+ /**
78
+ * Classify a Slack message into the InboundChannelItem `kind`.
79
+ * - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
80
+ * event. Requires the message to be authored by us (its user id
81
+ * is selfUserId) AND to carry a non-empty reactions[]. A message
82
+ * authored by someone else that merely carries reactions[] is a
83
+ * normal DM, not a reaction event.
84
+ * - mention: the message @-mentions us (text contains `<@SELF>`)
85
+ * - thread: a threaded reply (thread_ts present and not the root ts)
86
+ * - dm: a plain direct message
87
+ * Reaction outranks mention which outranks thread/dm (most-specific first).
88
+ */
89
+ function classifySlackKind(
90
+ msg: SlackMessage,
91
+ selfUserId: string | undefined,
92
+ ): InboundChannelItem['kind'] {
93
+ if (
94
+ selfUserId &&
95
+ msg.user === selfUserId &&
96
+ Array.isArray(msg.reactions) &&
97
+ msg.reactions.length > 0
98
+ ) {
99
+ return 'reaction';
100
+ }
101
+ if (selfUserId && typeof msg.text === 'string' && msg.text.includes(`<@${selfUserId}>`)) {
102
+ return 'mention';
103
+ }
104
+ if (msg.thread_ts && msg.thread_ts !== msg.ts) return 'thread';
105
+ return 'dm';
106
+ }
107
+
108
+ /** Slack ts ("1700000000.000200") -> Unix ms. */
109
+ function tsToMs(ts: string | undefined): number {
110
+ if (!ts) return 0;
111
+ const seconds = Number.parseFloat(ts);
112
+ return Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0;
113
+ }
114
+
115
+ async function slackGet<T>(
116
+ token: string,
117
+ method: string,
118
+ params: Record<string, string>,
119
+ ): Promise<T> {
120
+ const url = new URL(`${SLACK_API}/${method}`);
121
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
122
+ const res = await fetch(url, {
123
+ method: 'GET',
124
+ headers: {
125
+ Authorization: `Bearer ${token}`,
126
+ Accept: 'application/json',
127
+ },
128
+ });
129
+ if (!res.ok) {
130
+ throw new Error(`Slack ${method} HTTP ${res.status}`);
131
+ }
132
+ return (await res.json()) as T;
133
+ }
134
+
135
+ export function createSlackAdapter(ctx: AdapterContext): InboundProviderAdapter {
136
+ return {
137
+ id: SLACK_PROVIDER_ID,
138
+ pollIntervalMs: POLL_CADENCE_MS.realtime,
139
+ async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
140
+ let token: string | null;
141
+ try {
142
+ token = await ctx.credentials.resolveConfigSecret(SLACK_CREDENTIAL_KEY);
143
+ } catch (error) {
144
+ return unavailable(`credential lookup failed: ${errMsg(error)}`);
145
+ }
146
+ if (!token || token.trim().length === 0) {
147
+ return notConfigured('missing surfaces.slack.botToken');
148
+ }
149
+ if (!token.startsWith('xoxb-') && !token.startsWith('xoxp-')) {
150
+ return notConfigured('surfaces.slack.botToken is not a valid Slack bot/user token');
151
+ }
152
+
153
+ // Resolve our own user id so we can distinguish @-mentions of us from
154
+ // plain DMs. Best-effort: a failure here only means mentions are not
155
+ // separately classified, it never fails the provider.
156
+ let selfUserId: string | undefined;
157
+ try {
158
+ const auth = await slackGet<SlackAuthTestResponse>(token, 'auth.test', {});
159
+ if (auth.ok && auth.user_id) selfUserId = auth.user_id;
160
+ } catch (error) {
161
+ ctx.logger.warn('slack auth.test failed; mentions will not be classified', {
162
+ error: errMsg(error),
163
+ });
164
+ }
165
+
166
+ try {
167
+ // Page through ALL open DM channels. conversations.list returns at most
168
+ // `limit` channels per page and a response_metadata.next_cursor when more
169
+ // remain; without following the cursor, accounts with >100 open DMs would
170
+ // silently lose every channel past the first page. MAX_LIST_PAGES bounds
171
+ // the loop so a misbehaving cursor can never spin forever.
172
+ const MAX_LIST_PAGES = 50;
173
+ const channels: Array<{ id: string; user?: string }> = [];
174
+ let cursor: string | undefined;
175
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
176
+ const list = await slackGet<SlackConversationsListResponse>(token, 'conversations.list', {
177
+ types: 'im',
178
+ limit: '100',
179
+ ...(cursor ? { cursor } : {}),
180
+ });
181
+ if (!list.ok) {
182
+ return failed(`conversations.list: ${list.error ?? 'unknown_error'}`);
183
+ }
184
+ if (list.channels) channels.push(...list.channels);
185
+ const next = list.response_metadata?.next_cursor;
186
+ if (!next || next.length === 0) break;
187
+ cursor = next;
188
+ }
189
+ const oldest = opts.since ? (opts.since / 1000).toFixed(6) : undefined;
190
+ // Page through conversations.history per channel: a single call returns at
191
+ // most `limit` (<=50) messages and sets has_more + a next_cursor when a DM
192
+ // accrued more new messages than fit in one page. Without following the
193
+ // cursor a busy DM would silently drop everything past the first page.
194
+ // MAX_HISTORY_PAGES bounds the loop so a misbehaving cursor cannot spin.
195
+ const MAX_HISTORY_PAGES = 20;
196
+ const items: InboundChannelItem[] = [];
197
+ channelLoop: for (const channel of channels) {
198
+ if (items.length >= opts.limit) break;
199
+ let historyCursor: string | undefined;
200
+ for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
201
+ const history = await slackGet<SlackHistoryResponse>(token, 'conversations.history', {
202
+ channel: channel.id,
203
+ limit: String(Math.min(opts.limit, 50)),
204
+ ...(oldest ? { oldest } : {}),
205
+ ...(historyCursor ? { cursor: historyCursor } : {}),
206
+ });
207
+ if (!history.ok) {
208
+ // Skip this DM but keep going; do not fail the whole provider.
209
+ ctx.logger.warn('slack conversations.history failed', {
210
+ channel: channel.id,
211
+ error: history.error,
212
+ });
213
+ continue channelLoop;
214
+ }
215
+ for (const msg of history.messages ?? []) {
216
+ if (items.length >= opts.limit) break;
217
+ if (msg.subtype === 'bot_message' || msg.bot_id) continue;
218
+ const senderId = msg.user ?? channel.user ?? channel.id;
219
+ const receivedAt = tsToMs(msg.ts);
220
+ if (opts.since && receivedAt <= opts.since) continue;
221
+ // Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
222
+ // provider user id. Slack user ids (U...) are unique within a
223
+ // workspace, and the item.id / provider fields already namespace
224
+ // by provider.
225
+ const fromDigest = digestSender(senderId);
226
+ const kind = classifySlackKind(msg, selfUserId);
227
+ const item: InboundChannelItem = {
228
+ id: `slack:${channel.id}:${msg.ts ?? String(receivedAt)}`,
229
+ provider: SLACK_PROVIDER_ID,
230
+ kind,
231
+ fromDigest,
232
+ subjectPreview: toSubjectPreview(`Direct message`),
233
+ bodyPreview: toBodyPreview(msg.text),
234
+ receivedAt,
235
+ unread: true,
236
+ };
237
+ const routeId = await resolveRouteId(ctx, SLACK_PROVIDER_ID, fromDigest, kind);
238
+ if (routeId) item.routeId = routeId;
239
+ items.push(item);
240
+ }
241
+ // Advance to the next history page only while the channel reported
242
+ // more messages AND we still have item budget left.
243
+ const nextHistory = history.response_metadata?.next_cursor;
244
+ if (!history.has_more || !nextHistory || nextHistory.length === 0) break;
245
+ if (items.length >= opts.limit) break;
246
+ historyCursor = nextHistory;
247
+ }
248
+ }
249
+ return { items, state: items.length > 0 ? 'ready' : 'empty', configured: true };
250
+ } catch (error) {
251
+ return failed(errMsg(error));
252
+ }
253
+ },
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The provider is wired up but this attempt failed — an outage, a refusal, a
259
+ * bad response. Items that exist are missing from the feed, which is what
260
+ * `configured: true` here tells the aggregator to report as a partial answer
261
+ * rather than as an empty one.
262
+ */
263
+ function failed(error: string): ProviderPollResult {
264
+ return { items: [], state: 'unavailable', error, configured: true };
265
+ }
266
+
267
+ /**
268
+ * Nothing to poll with: no credential, or an unusable one. Normal on a fresh
269
+ * install, and deliberately NOT a partial answer — nothing is missing from a
270
+ * provider nobody asked us to read.
271
+ */
272
+ function notConfigured(error: string): ProviderPollResult {
273
+ return { items: [], state: 'unavailable', error, configured: false };
274
+ }
275
+
276
+ /**
277
+ * The credential store itself failed, so we do not know whether this provider
278
+ * is configured. Neither claim is made — reporting a guess here is how a
279
+ * transient store fault would get read as "you never set this up".
280
+ */
281
+ function unavailable(error: string): ProviderPollResult {
282
+ return { items: [], state: 'unavailable', error };
283
+ }
284
+
285
+ function errMsg(error: unknown): string {
286
+ return summarizeError(error);
287
+ }