@pellux/goodvibes-tui 0.24.0 → 0.25.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 (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -5
  3. package/docs/foundation-artifacts/operator-contract.json +304 -230
  4. package/package.json +2 -2
  5. package/src/daemon/calendar/caldav-client.ts +657 -0
  6. package/src/daemon/calendar/ics.ts +556 -0
  7. package/src/daemon/calendar/index.ts +52 -0
  8. package/src/daemon/calendar/register.ts +527 -0
  9. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  10. package/src/daemon/channels/drafts/index.ts +22 -0
  11. package/src/daemon/channels/drafts/register.ts +449 -0
  12. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  13. package/src/daemon/channels/inbox/index.ts +58 -0
  14. package/src/daemon/channels/inbox/mapping.ts +190 -0
  15. package/src/daemon/channels/inbox/poller.ts +155 -0
  16. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  17. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  18. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  19. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  20. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  21. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  22. package/src/daemon/channels/inbox/register.ts +247 -0
  23. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  24. package/src/daemon/channels/routing/index.ts +39 -0
  25. package/src/daemon/channels/routing/register.ts +296 -0
  26. package/src/daemon/channels/routing/route-store.ts +278 -0
  27. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  28. package/src/daemon/email/imap-connector.ts +441 -0
  29. package/src/daemon/email/imap-parsing.ts +499 -0
  30. package/src/daemon/email/index.ts +68 -0
  31. package/src/daemon/email/register.ts +715 -0
  32. package/src/daemon/email/smtp-connector.ts +557 -0
  33. package/src/daemon/operator/credential-store.ts +129 -0
  34. package/src/daemon/operator/index.ts +43 -0
  35. package/src/daemon/operator/register-helper.ts +150 -0
  36. package/src/daemon/operator/sqlite-store.ts +124 -0
  37. package/src/daemon/operator/surfaces.ts +137 -0
  38. package/src/daemon/operator/types.ts +207 -0
  39. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  40. package/src/daemon/remote/backends/docker.ts +80 -0
  41. package/src/daemon/remote/backends/index.ts +34 -0
  42. package/src/daemon/remote/backends/local-process.ts +113 -0
  43. package/src/daemon/remote/backends/process-runner.ts +151 -0
  44. package/src/daemon/remote/backends/ssh.ts +120 -0
  45. package/src/daemon/remote/backends/types.ts +71 -0
  46. package/src/daemon/remote/dispatcher.ts +160 -0
  47. package/src/daemon/remote/index.ts +74 -0
  48. package/src/daemon/remote/peer-registry.ts +321 -0
  49. package/src/daemon/remote/register.ts +411 -0
  50. package/src/daemon/triage/index.ts +59 -0
  51. package/src/daemon/triage/integration.ts +179 -0
  52. package/src/daemon/triage/pipeline.ts +285 -0
  53. package/src/daemon/triage/register.ts +231 -0
  54. package/src/daemon/triage/scorer.ts +287 -0
  55. package/src/daemon/triage/tagger.ts +777 -0
  56. package/src/runtime/services.ts +35 -0
  57. package/src/version.ts +1 -1
@@ -0,0 +1,155 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Inbound provider poller.
3
+ //
4
+ // Runs one setInterval per provider at the provider's own cadence (Slack/
5
+ // Discord 30s, email 60s, others 120s). Each tick:
6
+ // 1. resolves the provider's persisted cursor (nextSince)
7
+ // 2. calls adapter.poll({ since, limit })
8
+ // 3. dedups + persists items into the cursor store (INSERT OR IGNORE / upsert)
9
+ // 4. advances the cursor monotonically to max(receivedAt)
10
+ // 5. records the last per-provider state for channels.inbox.list to report
11
+ //
12
+ // One bad provider can never crash the loop: adapter.poll() resolves with
13
+ // state:'unavailable' instead of rejecting, and any thrown error is caught and
14
+ // downgraded to an 'unavailable' status here.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ import type { InboundProviderAdapter, ProviderState } from './provider-adapter.ts';
18
+ import type { InboxCursorStore } from './cursor-store.ts';
19
+ import type { OperatorLogger } from '../../operator/index.ts';
20
+
21
+ export interface ProviderStatus {
22
+ id: string;
23
+ state: ProviderState;
24
+ itemCount: number;
25
+ error?: string;
26
+ lastPolledAt?: number;
27
+ }
28
+
29
+ export interface PollerOptions {
30
+ adapters: Map<string, InboundProviderAdapter>;
31
+ store: InboxCursorStore;
32
+ logger: OperatorLogger;
33
+ /** Max items fetched per provider per tick. */
34
+ perProviderLimit?: number;
35
+ /** Inject a timer factory for tests (defaults to global setInterval). */
36
+ setIntervalImpl?: typeof setInterval;
37
+ clearIntervalImpl?: typeof clearInterval;
38
+ }
39
+
40
+ const DEFAULT_PER_PROVIDER_LIMIT = 50;
41
+
42
+ export class InboundPoller {
43
+ private readonly adapters: Map<string, InboundProviderAdapter>;
44
+ private readonly store: InboxCursorStore;
45
+ private readonly logger: OperatorLogger;
46
+ private readonly perProviderLimit: number;
47
+ private readonly setIntervalImpl: typeof setInterval;
48
+ private readonly clearIntervalImpl: typeof clearInterval;
49
+ private readonly timers = new Map<string, ReturnType<typeof setInterval>>();
50
+ private readonly statuses = new Map<string, ProviderStatus>();
51
+ private readonly inFlight = new Set<string>();
52
+ private started = false;
53
+
54
+ constructor(options: PollerOptions) {
55
+ this.adapters = options.adapters;
56
+ this.store = options.store;
57
+ this.logger = options.logger;
58
+ this.perProviderLimit = options.perProviderLimit ?? DEFAULT_PER_PROVIDER_LIMIT;
59
+ this.setIntervalImpl = options.setIntervalImpl ?? setInterval;
60
+ this.clearIntervalImpl = options.clearIntervalImpl ?? clearInterval;
61
+ for (const id of this.adapters.keys()) {
62
+ this.statuses.set(id, { id, state: 'empty', itemCount: 0 });
63
+ }
64
+ }
65
+
66
+ /** Begin per-provider interval loops. Idempotent. Does NOT poll immediately. */
67
+ start(): void {
68
+ if (this.started) return;
69
+ this.started = true;
70
+ for (const [id, adapter] of this.adapters) {
71
+ const handle = this.setIntervalImpl(() => {
72
+ void this.pollProvider(id, adapter);
73
+ }, adapter.pollIntervalMs);
74
+ // Do not keep the event loop alive solely for polling (Bun/Node unref).
75
+ (handle as unknown as { unref?: () => void }).unref?.();
76
+ this.timers.set(id, handle);
77
+ }
78
+ }
79
+
80
+ /** Run a single poll across all providers now (used on register + tests). */
81
+ async pollOnce(): Promise<void> {
82
+ await Promise.all(
83
+ [...this.adapters.entries()].map(([id, adapter]) => this.pollProvider(id, adapter)),
84
+ );
85
+ }
86
+
87
+ /** Poll a single provider, dedup + persist, update status. Never throws. */
88
+ async pollProvider(id: string, adapter: InboundProviderAdapter): Promise<void> {
89
+ if (this.inFlight.has(id)) return; // skip overlapping ticks
90
+ this.inFlight.add(id);
91
+ const since = this.store.getCursor(id) || undefined;
92
+ try {
93
+ const result = await adapter.poll({ since, limit: this.perProviderLimit });
94
+ if (result.state === 'unavailable') {
95
+ this.setStatus(id, {
96
+ id,
97
+ state: 'unavailable',
98
+ itemCount: 0,
99
+ error: result.error ?? 'provider unavailable',
100
+ lastPolledAt: Date.now(),
101
+ });
102
+ return;
103
+ }
104
+ const newCount = this.store.upsertItems(result.items);
105
+ let maxReceived = since ?? 0;
106
+ for (const item of result.items) {
107
+ if (item.receivedAt > maxReceived) maxReceived = item.receivedAt;
108
+ }
109
+ if (maxReceived > 0) this.store.advanceCursor(id, maxReceived);
110
+ await this.store.flush();
111
+ this.setStatus(id, {
112
+ id,
113
+ state: result.items.length > 0 ? 'ready' : 'empty',
114
+ itemCount: newCount,
115
+ lastPolledAt: Date.now(),
116
+ });
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ this.logger.warn('inbound poll failed', { provider: id, error: message });
120
+ this.setStatus(id, {
121
+ id,
122
+ state: 'unavailable',
123
+ itemCount: 0,
124
+ error: message,
125
+ lastPolledAt: Date.now(),
126
+ });
127
+ } finally {
128
+ this.inFlight.delete(id);
129
+ }
130
+ }
131
+
132
+ /** Snapshot of the last known status for each provider. */
133
+ snapshotStatuses(providerIds?: readonly string[]): ProviderStatus[] {
134
+ const ids = providerIds && providerIds.length > 0
135
+ ? providerIds.filter((id) => this.statuses.has(id))
136
+ : [...this.statuses.keys()];
137
+ return ids.map((id) => {
138
+ const status = this.statuses.get(id)!;
139
+ return { ...status };
140
+ });
141
+ }
142
+
143
+ /** Stop all interval loops. Idempotent. */
144
+ stop(): void {
145
+ for (const handle of this.timers.values()) {
146
+ this.clearIntervalImpl(handle);
147
+ }
148
+ this.timers.clear();
149
+ this.started = false;
150
+ }
151
+
152
+ private setStatus(id: string, status: ProviderStatus): void {
153
+ this.statuses.set(id, status);
154
+ }
155
+ }
@@ -0,0 +1,152 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Inbound provider adapter contract + registry.
3
+ //
4
+ // Each adapter owns ONE provider (slack, discord, email, ...). The poller calls
5
+ // adapter.poll() on a cadence, dedups by item.id, and persists into the cursor
6
+ // store. Adapters resolve credentials ONLY through the daemon credential store
7
+ // and NEVER return raw sender ids or unredacted bodies — mapping/redaction is
8
+ // the adapter's responsibility (see mapping helpers in `./mapping.ts`).
9
+ //
10
+ // CRITICAL: when a credential is missing/misconfigured an adapter returns
11
+ // state 'unavailable' WITH an error string. It is NEVER silently omitted — the
12
+ // caller must be able to distinguish "configured-but-empty" from "not wired".
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import type { DaemonCredentialStore } from '../../operator/index.ts';
16
+ import type { OperatorLogger } from '../../operator/index.ts';
17
+
18
+ /** Wire-shape inbound item as published by `channels.inbox.list`. */
19
+ export interface InboundChannelItem {
20
+ /** Stable, provider-scoped dedup key. Idempotent across polls. */
21
+ id: string;
22
+ provider: string;
23
+ kind: 'dm' | 'thread' | 'mention' | 'reaction';
24
+ /**
25
+ * sha256First(senderExternalId, 16) — NEVER the raw id. 16 hex chars == the
26
+ * first 8 bytes of the SHA-256 digest (handoff: 'first-8' bytes / acceptance
27
+ * checklist: 'first 16 hex chars' — the same value).
28
+ */
29
+ fromDigest: string;
30
+ /** <= 200 chars, safe for display. */
31
+ subjectPreview: string;
32
+ /** <= 500 chars, plain text, PII-stripped. */
33
+ bodyPreview: string;
34
+ /** Daemon route binding id, when resolvable. */
35
+ routeId?: string;
36
+ /** Unix ms. */
37
+ receivedAt: number;
38
+ unread: boolean;
39
+ /** Optional triage metadata written by the triage surface (read-only here). */
40
+ triageScore?: number;
41
+ triageTags?: string[];
42
+ }
43
+
44
+ export type ProviderState = 'ready' | 'unavailable' | 'empty';
45
+
46
+ export interface ProviderPollResult {
47
+ items: InboundChannelItem[];
48
+ state: ProviderState;
49
+ /** Present only when state === 'unavailable'. */
50
+ error?: string;
51
+ }
52
+
53
+ export interface ProviderPollOptions {
54
+ /** Only return items newer than this Unix-ms timestamp, when supported. */
55
+ since?: number;
56
+ /** Max items to return this poll. */
57
+ limit: number;
58
+ }
59
+
60
+ /** Context handed to every adapter at construction time. */
61
+ export interface AdapterContext {
62
+ readonly credentials: DaemonCredentialStore;
63
+ readonly logger: OperatorLogger;
64
+ /**
65
+ * Optional route resolver supplied by the routing surface. Returns the route
66
+ * binding id for a given inbound item, or undefined when no route matches /
67
+ * the routing surface is not wired yet. Adapters/poller call this best-effort.
68
+ */
69
+ readonly resolveRouteId?: RouteResolver;
70
+ }
71
+
72
+ /**
73
+ * Best-effort route resolution seam. The routing surface (concurrent work)
74
+ * injects its `resolveProfile`-backed implementation. Until then it is
75
+ * undefined and items carry no routeId.
76
+ */
77
+ export type RouteResolver = (input: {
78
+ provider: string;
79
+ fromDigest: string;
80
+ kind: InboundChannelItem['kind'];
81
+ }) => Promise<string | undefined> | string | undefined;
82
+
83
+ export interface InboundProviderAdapter {
84
+ /** Provider id, e.g. 'slack'. Must be unique within the registry. */
85
+ readonly id: string;
86
+ /**
87
+ * Poll cadence in ms. Slack/Discord 30s, email 60s, everything else 120s.
88
+ * The poller reads this to schedule its per-provider interval.
89
+ */
90
+ readonly pollIntervalMs: number;
91
+ /**
92
+ * Pull recent DMs/threads/mentions. MUST resolve (never reject) — failures
93
+ * are reported via state:'unavailable' + error so one bad provider cannot
94
+ * crash the aggregate feed.
95
+ */
96
+ poll(opts: ProviderPollOptions): Promise<ProviderPollResult>;
97
+ }
98
+
99
+ /** Factory signature: adapters are constructed lazily with shared context. */
100
+ export type AdapterFactory = (ctx: AdapterContext) => InboundProviderAdapter;
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Cadence constants (single source of truth, referenced by adapters + tests).
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export const POLL_CADENCE_MS = {
107
+ realtime: 30_000, // slack, discord
108
+ email: 60_000,
109
+ default: 120_000,
110
+ } as const;
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Registry
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const FACTORIES = new Map<string, AdapterFactory>();
117
+
118
+ /** Register a provider factory. Last registration for an id wins (idempotent). */
119
+ export function registerAdapterFactory(id: string, factory: AdapterFactory): void {
120
+ FACTORIES.set(id, factory);
121
+ }
122
+
123
+ /** All registered provider ids, in insertion order. */
124
+ export function registeredProviderIds(): string[] {
125
+ return [...FACTORIES.keys()];
126
+ }
127
+
128
+ /**
129
+ * Construct adapters for the requested provider ids (or all registered ids when
130
+ * `requested` is undefined/empty). Unknown ids are ignored by the caller via the
131
+ * returned map only containing known providers.
132
+ */
133
+ export function buildAdapters(
134
+ ctx: AdapterContext,
135
+ requested?: readonly string[],
136
+ ): Map<string, InboundProviderAdapter> {
137
+ const ids = requested && requested.length > 0
138
+ ? requested.filter((id) => FACTORIES.has(id))
139
+ : registeredProviderIds();
140
+ const out = new Map<string, InboundProviderAdapter>();
141
+ for (const id of ids) {
142
+ const factory = FACTORIES.get(id);
143
+ if (!factory) continue;
144
+ out.set(id, factory(ctx));
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** Test/seam hook: clear the registry. */
150
+ export function clearAdapterRegistry(): void {
151
+ FACTORIES.clear();
152
+ }
@@ -0,0 +1,253 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Discord inbound adapter.
3
+ //
4
+ // Transport note (contract fidelity): the daemon-handoff checklist names the
5
+ // Discord Gateway for DM polling. The Gateway is a persistent websocket push
6
+ // transport and cannot be driven by the inbound poller, whose contract is a
7
+ // stateless, cadence-driven adapter.poll() that MUST resolve each call (see
8
+ // provider-adapter.ts) — a long-lived socket the poller neither owns nor
9
+ // supervises is out of scope for that contract. We therefore satisfy the
10
+ // DM-polling goal (handoff acceptance checklist: "Daemon polls Discord DMs via
11
+ // Discord Gateway") over Discord's supported request/response surface, the REST
12
+ // API — the same DM data the Gateway streams, fetched on the poll cadence and
13
+ // paged so a busy DM is never truncated:
14
+ // GET /users/@me/channels -> list DM channels the bot participates in
15
+ // GET /channels/{id}/messages -> recent messages per DM channel (paged via
16
+ // the `before` snowflake cursor)
17
+ //
18
+ // Discord snowflake ids encode their creation timestamp, so receivedAt is
19
+ // derived from the message id (snowflake) when no explicit timestamp is present.
20
+ //
21
+ // Credential: surfaces.discord.botToken. Missing => 'unavailable'.
22
+ // Cadence: 30s (realtime tier).
23
+ // ---------------------------------------------------------------------------
24
+
25
+ import type {
26
+ AdapterContext,
27
+ InboundChannelItem,
28
+ InboundProviderAdapter,
29
+ ProviderPollOptions,
30
+ ProviderPollResult,
31
+ } from '../provider-adapter.ts';
32
+ import { POLL_CADENCE_MS } from '../provider-adapter.ts';
33
+ import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
34
+ import { resolveRouteId } from './route-util.ts';
35
+
36
+ const DISCORD_API = 'https://discord.com/api/v10';
37
+ // Discord epoch (2015-01-01T00:00:00Z) in ms, used to decode snowflakes.
38
+ const DISCORD_EPOCH_MS = 1_420_070_400_000;
39
+ export const DISCORD_PROVIDER_ID = 'discord';
40
+ export const DISCORD_CREDENTIAL_KEY = 'surfaces.discord.botToken';
41
+
42
+ interface DiscordChannel {
43
+ id: string;
44
+ type: number; // 1 = DM, 3 = group DM
45
+ }
46
+
47
+ interface DiscordSelf {
48
+ id: string;
49
+ }
50
+
51
+ interface DiscordReaction {
52
+ count?: number;
53
+ emoji?: { id?: string | null; name?: string | null };
54
+ }
55
+
56
+ interface DiscordMessage {
57
+ id: string;
58
+ channel_id?: string;
59
+ content?: string;
60
+ timestamp?: string; // ISO-8601
61
+ author?: { id: string; bot?: boolean };
62
+ referenced_message?: unknown;
63
+ mentions?: Array<{ id: string }>;
64
+ reactions?: DiscordReaction[];
65
+ }
66
+
67
+ /**
68
+ * Classify a Discord message into the InboundChannelItem `kind`.
69
+ * - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
70
+ * event. This requires the message to be authored by us (its
71
+ * author id is selfId) AND to carry a non-empty reactions[].
72
+ * A message authored by someone else that merely carries
73
+ * reactions[] is a normal DM, not a reaction event.
74
+ * - mention: the message mentions us (our id is in mentions[])
75
+ * - thread: the message is a reply (referenced_message present)
76
+ * - dm: a plain direct message
77
+ * Reaction outranks mention which outranks thread/dm (most-specific first).
78
+ */
79
+ function classifyDiscordKind(
80
+ msg: DiscordMessage,
81
+ selfId: string | undefined,
82
+ ): InboundChannelItem['kind'] {
83
+ if (
84
+ selfId &&
85
+ msg.author?.id === selfId &&
86
+ Array.isArray(msg.reactions) &&
87
+ msg.reactions.length > 0
88
+ ) {
89
+ return 'reaction';
90
+ }
91
+ if (selfId && Array.isArray(msg.mentions) && msg.mentions.some((m) => m.id === selfId)) {
92
+ return 'mention';
93
+ }
94
+ if (msg.referenced_message) return 'thread';
95
+ return 'dm';
96
+ }
97
+
98
+ /** Decode a Discord snowflake id to its creation Unix-ms timestamp. */
99
+ function snowflakeToMs(id: string): number {
100
+ try {
101
+ const asBig = BigInt(id);
102
+ return Number((asBig >> 22n)) + DISCORD_EPOCH_MS;
103
+ } catch {
104
+ return 0;
105
+ }
106
+ }
107
+
108
+ async function discordGet<T>(token: string, path: string): Promise<T> {
109
+ const res = await fetch(`${DISCORD_API}${path}`, {
110
+ method: 'GET',
111
+ headers: {
112
+ Authorization: `Bot ${token}`,
113
+ Accept: 'application/json',
114
+ },
115
+ });
116
+ if (res.status === 401) throw new Error('Discord authentication failed (401)');
117
+ if (!res.ok) throw new Error(`Discord GET ${path} HTTP ${res.status}`);
118
+ return (await res.json()) as T;
119
+ }
120
+
121
+ export function createDiscordAdapter(ctx: AdapterContext): InboundProviderAdapter {
122
+ return {
123
+ id: DISCORD_PROVIDER_ID,
124
+ pollIntervalMs: POLL_CADENCE_MS.realtime,
125
+ async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
126
+ let token: string | null;
127
+ try {
128
+ token = await ctx.credentials.resolveConfigSecret(DISCORD_CREDENTIAL_KEY);
129
+ } catch (error) {
130
+ return unavailable(`credential lookup failed: ${errMsg(error)}`);
131
+ }
132
+ if (!token || token.trim().length === 0) {
133
+ return unavailable('missing surfaces.discord.botToken');
134
+ }
135
+
136
+ // Resolve our own user id so we can classify @-mentions of us. Best-effort:
137
+ // a failure only disables mention classification, never the provider.
138
+ let selfId: string | undefined;
139
+ try {
140
+ const self = await discordGet<DiscordSelf>(token, '/users/@me');
141
+ if (self.id) selfId = self.id;
142
+ } catch (error) {
143
+ ctx.logger.warn('discord /users/@me failed; mentions will not be classified', {
144
+ error: errMsg(error),
145
+ });
146
+ }
147
+
148
+ try {
149
+ const channels = await discordGet<DiscordChannel[]>(token, '/users/@me/channels');
150
+ const dmChannels = channels.filter((c) => c.type === 1 || c.type === 3);
151
+ const items: InboundChannelItem[] = [];
152
+ // /channels/{id}/messages returns at most `perChannel` (<=50) messages
153
+ // newest-first. A DM that accrued more than one page of new messages in
154
+ // the `since` window would silently drop the oldest beyond the first page
155
+ // unless we page backwards via the `before` cursor (the id of the oldest
156
+ // message seen so far). MAX_HISTORY_PAGES bounds the walk.
157
+ const MAX_HISTORY_PAGES = 20;
158
+ const perChannel = Math.min(opts.limit, 50);
159
+ channelLoop: for (const channel of dmChannels) {
160
+ if (items.length >= opts.limit) break;
161
+ let before: string | undefined;
162
+ for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
163
+ const params = new URLSearchParams({ limit: String(perChannel) });
164
+ // Discord treats after/before/around as mutually exclusive: sending
165
+ // both silently drops older in-window messages. Use `after` only on
166
+ // the first page (to bound the window at `since`); once paging
167
+ // backwards via `before`, drop `after` and rely on the
168
+ // oldestMs<=since stop condition below to terminate the walk.
169
+ if (before) params.set('before', before);
170
+ else if (opts.since) params.set('after', msToSnowflake(opts.since));
171
+ let messages: DiscordMessage[];
172
+ try {
173
+ messages = await discordGet<DiscordMessage[]>(
174
+ token,
175
+ `/channels/${channel.id}/messages?${params.toString()}`,
176
+ );
177
+ } catch (error) {
178
+ ctx.logger.warn('discord channel messages failed', {
179
+ channel: channel.id,
180
+ error: errMsg(error),
181
+ });
182
+ continue channelLoop;
183
+ }
184
+ if (messages.length === 0) break;
185
+ // Track the oldest id on this page for the next `before` cursor before
186
+ // we filter/skip, so pagination is driven by the raw page, not by what
187
+ // survived classification.
188
+ let oldestId: string | undefined;
189
+ let oldestMs = Number.POSITIVE_INFINITY;
190
+ for (const msg of messages) {
191
+ const msgMs = msg.timestamp
192
+ ? Date.parse(msg.timestamp) || snowflakeToMs(msg.id)
193
+ : snowflakeToMs(msg.id);
194
+ if (msgMs < oldestMs) {
195
+ oldestMs = msgMs;
196
+ oldestId = msg.id;
197
+ }
198
+ if (items.length >= opts.limit) break;
199
+ if (msg.author?.bot) continue;
200
+ const receivedAt = msgMs;
201
+ if (opts.since && receivedAt <= opts.since) continue;
202
+ const senderId = msg.author?.id ?? channel.id;
203
+ // Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
204
+ // provider user id (see provider-adapter.ts InboundChannelItem and
205
+ // handoff acceptance checklist 'first 16 hex chars'). Discord user
206
+ // ids (snowflakes) are globally unique, and the item.id / provider
207
+ // fields already namespace by provider.
208
+ const fromDigest = digestSender(senderId);
209
+ const kind = classifyDiscordKind(msg, selfId);
210
+ const item: InboundChannelItem = {
211
+ id: `discord:${channel.id}:${msg.id}`,
212
+ provider: DISCORD_PROVIDER_ID,
213
+ kind,
214
+ fromDigest,
215
+ subjectPreview: toSubjectPreview('Direct message'),
216
+ bodyPreview: toBodyPreview(msg.content),
217
+ receivedAt,
218
+ unread: true,
219
+ };
220
+ const routeId = await resolveRouteId(ctx, DISCORD_PROVIDER_ID, fromDigest, kind);
221
+ if (routeId) item.routeId = routeId;
222
+ items.push(item);
223
+ }
224
+ // Stop paging this channel when the page was not full (no older
225
+ // messages exist), the item budget is spent, or the oldest message on
226
+ // this page is already at/older than the `since` floor.
227
+ if (messages.length < perChannel || items.length >= opts.limit) break;
228
+ if (opts.since && oldestMs <= opts.since) break;
229
+ if (!oldestId) break;
230
+ before = oldestId;
231
+ }
232
+ }
233
+ return { items, state: items.length > 0 ? 'ready' : 'empty' };
234
+ } catch (error) {
235
+ return unavailable(errMsg(error));
236
+ }
237
+ },
238
+ };
239
+ }
240
+
241
+ /** Build a synthetic snowflake from a Unix-ms timestamp for the `after` cursor. */
242
+ function msToSnowflake(ms: number): string {
243
+ const delta = Math.max(0, Math.floor(ms) - DISCORD_EPOCH_MS);
244
+ return (BigInt(delta) << 22n).toString();
245
+ }
246
+
247
+ function unavailable(error: string): ProviderPollResult {
248
+ return { items: [], state: 'unavailable', error };
249
+ }
250
+
251
+ function errMsg(error: unknown): string {
252
+ return error instanceof Error ? error.message : String(error);
253
+ }