@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.
- package/CHANGELOG.md +15 -0
- package/README.md +4 -5
- package/docs/foundation-artifacts/operator-contract.json +304 -230
- package/package.json +2 -2
- package/src/daemon/calendar/caldav-client.ts +657 -0
- package/src/daemon/calendar/ics.ts +556 -0
- package/src/daemon/calendar/index.ts +52 -0
- package/src/daemon/calendar/register.ts +527 -0
- package/src/daemon/channels/drafts/draft-store.ts +363 -0
- package/src/daemon/channels/drafts/index.ts +22 -0
- package/src/daemon/channels/drafts/register.ts +449 -0
- package/src/daemon/channels/inbox/cursor-store.ts +298 -0
- package/src/daemon/channels/inbox/index.ts +58 -0
- package/src/daemon/channels/inbox/mapping.ts +190 -0
- package/src/daemon/channels/inbox/poller.ts +155 -0
- package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
- package/src/daemon/channels/inbox/providers/discord.ts +253 -0
- package/src/daemon/channels/inbox/providers/email.ts +151 -0
- package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
- package/src/daemon/channels/inbox/providers/slack.ts +264 -0
- package/src/daemon/channels/inbox/register.ts +247 -0
- package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
- package/src/daemon/channels/routing/index.ts +39 -0
- package/src/daemon/channels/routing/register.ts +296 -0
- package/src/daemon/channels/routing/route-store.ts +278 -0
- package/src/daemon/channels/routing/routing-resolver.ts +75 -0
- package/src/daemon/email/imap-connector.ts +441 -0
- package/src/daemon/email/imap-parsing.ts +499 -0
- package/src/daemon/email/index.ts +68 -0
- package/src/daemon/email/register.ts +715 -0
- package/src/daemon/email/smtp-connector.ts +557 -0
- package/src/daemon/operator/credential-store.ts +129 -0
- package/src/daemon/operator/index.ts +43 -0
- package/src/daemon/operator/register-helper.ts +150 -0
- package/src/daemon/operator/sqlite-store.ts +124 -0
- package/src/daemon/operator/surfaces.ts +137 -0
- package/src/daemon/operator/types.ts +207 -0
- package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
- package/src/daemon/remote/backends/docker.ts +80 -0
- package/src/daemon/remote/backends/index.ts +34 -0
- package/src/daemon/remote/backends/local-process.ts +113 -0
- package/src/daemon/remote/backends/process-runner.ts +151 -0
- package/src/daemon/remote/backends/ssh.ts +120 -0
- package/src/daemon/remote/backends/types.ts +71 -0
- package/src/daemon/remote/dispatcher.ts +160 -0
- package/src/daemon/remote/index.ts +74 -0
- package/src/daemon/remote/peer-registry.ts +321 -0
- package/src/daemon/remote/register.ts +411 -0
- package/src/daemon/triage/index.ts +59 -0
- package/src/daemon/triage/integration.ts +179 -0
- package/src/daemon/triage/pipeline.ts +285 -0
- package/src/daemon/triage/register.ts +231 -0
- package/src/daemon/triage/scorer.ts +287 -0
- package/src/daemon/triage/tagger.ts +777 -0
- package/src/runtime/services.ts +35 -0
- 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
|
+
}
|