@pellux/goodvibes-tui 0.24.1 → 0.26.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 +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
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 '../credentials.ts';
|
|
16
|
+
import type { HandlerLogger } from '../context.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Daemon-internal inbound item. This is NOT the SDK wire shape — the inbox
|
|
20
|
+
* surface maps it onto the SDK `CHANNEL_INBOX_ITEM_SCHEMA` shape
|
|
21
|
+
* (`from`/`subject`/`bodyPreview`/...) before returning. `fromDigest` is the
|
|
22
|
+
* redacted sender (the only sender value that ever leaves the daemon).
|
|
23
|
+
*/
|
|
24
|
+
export interface InboundChannelItem {
|
|
25
|
+
/** Stable, provider-scoped dedup key. Idempotent across polls. */
|
|
26
|
+
id: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
kind: 'dm' | 'thread' | 'mention' | 'reaction';
|
|
29
|
+
/**
|
|
30
|
+
* sha256First(senderExternalId, 16) — NEVER the raw id. 16 hex chars == the
|
|
31
|
+
* first 8 bytes of the SHA-256 digest.
|
|
32
|
+
*/
|
|
33
|
+
fromDigest: string;
|
|
34
|
+
/** <= 200 chars, safe for display. */
|
|
35
|
+
subjectPreview: string;
|
|
36
|
+
/** <= 500 chars, plain text, PII-stripped. */
|
|
37
|
+
bodyPreview: string;
|
|
38
|
+
/** Daemon route binding id, when resolvable. */
|
|
39
|
+
routeId?: string;
|
|
40
|
+
/** Unix ms. */
|
|
41
|
+
receivedAt: number;
|
|
42
|
+
unread: boolean;
|
|
43
|
+
/** Optional triage metadata written by the triage surface (read-only here). */
|
|
44
|
+
triageScore?: number;
|
|
45
|
+
triageTags?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ProviderState = 'ready' | 'unavailable' | 'empty';
|
|
49
|
+
|
|
50
|
+
export interface ProviderPollResult {
|
|
51
|
+
items: InboundChannelItem[];
|
|
52
|
+
state: ProviderState;
|
|
53
|
+
/** Present only when state === 'unavailable'. */
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ProviderPollOptions {
|
|
58
|
+
/** Only return items newer than this Unix-ms timestamp, when supported. */
|
|
59
|
+
since?: number;
|
|
60
|
+
/** Max items to return this poll. */
|
|
61
|
+
limit: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Context handed to every adapter at construction time. */
|
|
65
|
+
export interface AdapterContext {
|
|
66
|
+
readonly credentials: DaemonCredentialStore;
|
|
67
|
+
readonly logger: HandlerLogger;
|
|
68
|
+
/**
|
|
69
|
+
* Optional route resolver supplied by the routing surface. Returns the route
|
|
70
|
+
* binding id for a given inbound item, or undefined when no route matches /
|
|
71
|
+
* the routing surface is not wired yet. Adapters/poller call this best-effort.
|
|
72
|
+
*/
|
|
73
|
+
readonly resolveRouteId?: RouteResolver;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Best-effort route resolution seam. The routing surface injects its
|
|
78
|
+
* profile-backed implementation via the inbox bridge. Until then it is
|
|
79
|
+
* undefined and items carry no routeId.
|
|
80
|
+
*/
|
|
81
|
+
export type RouteResolver = (input: {
|
|
82
|
+
provider: string;
|
|
83
|
+
fromDigest: string;
|
|
84
|
+
kind: InboundChannelItem['kind'];
|
|
85
|
+
}) => Promise<string | undefined> | string | undefined;
|
|
86
|
+
|
|
87
|
+
export interface InboundProviderAdapter {
|
|
88
|
+
/** Provider id, e.g. 'slack'. Must be unique within the registry. */
|
|
89
|
+
readonly id: string;
|
|
90
|
+
/**
|
|
91
|
+
* Poll cadence in ms. Slack/Discord 30s, email 60s, everything else 120s.
|
|
92
|
+
* The poller reads this to schedule its per-provider interval.
|
|
93
|
+
*/
|
|
94
|
+
readonly pollIntervalMs: number;
|
|
95
|
+
/**
|
|
96
|
+
* Pull recent DMs/threads/mentions. MUST resolve (never reject) — failures
|
|
97
|
+
* are reported via state:'unavailable' + error so one bad provider cannot
|
|
98
|
+
* crash the aggregate feed.
|
|
99
|
+
*/
|
|
100
|
+
poll(opts: ProviderPollOptions): Promise<ProviderPollResult>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Factory signature: adapters are constructed lazily with shared context. */
|
|
104
|
+
export type AdapterFactory = (ctx: AdapterContext) => InboundProviderAdapter;
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Cadence constants (single source of truth, referenced by adapters + tests).
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export const POLL_CADENCE_MS = {
|
|
111
|
+
realtime: 30_000, // slack, discord
|
|
112
|
+
email: 60_000,
|
|
113
|
+
default: 120_000,
|
|
114
|
+
} as const;
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Registry
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
const FACTORIES = new Map<string, AdapterFactory>();
|
|
121
|
+
|
|
122
|
+
/** Register a provider factory. Last registration for an id wins (idempotent). */
|
|
123
|
+
export function registerAdapterFactory(id: string, factory: AdapterFactory): void {
|
|
124
|
+
FACTORIES.set(id, factory);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** All registered provider ids, in insertion order. */
|
|
128
|
+
export function registeredProviderIds(): string[] {
|
|
129
|
+
return [...FACTORIES.keys()];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Construct adapters for the requested provider ids (or all registered ids when
|
|
134
|
+
* `requested` is undefined/empty). Unknown ids are dropped: the returned map
|
|
135
|
+
* only contains known providers.
|
|
136
|
+
*/
|
|
137
|
+
export function buildAdapters(
|
|
138
|
+
ctx: AdapterContext,
|
|
139
|
+
requested?: readonly string[],
|
|
140
|
+
): Map<string, InboundProviderAdapter> {
|
|
141
|
+
const ids = requested && requested.length > 0
|
|
142
|
+
? requested.filter((id) => FACTORIES.has(id))
|
|
143
|
+
: registeredProviderIds();
|
|
144
|
+
const out = new Map<string, InboundProviderAdapter>();
|
|
145
|
+
for (const id of ids) {
|
|
146
|
+
const factory = FACTORIES.get(id);
|
|
147
|
+
if (!factory) continue;
|
|
148
|
+
out.set(id, factory(ctx));
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Test/seam hook: clear the registry. */
|
|
154
|
+
export function clearAdapterRegistry(): void {
|
|
155
|
+
FACTORIES.clear();
|
|
156
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
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 over Discord's supported request/response surface, the REST
|
|
11
|
+
// API — the same DM data the Gateway streams, fetched on the poll cadence and
|
|
12
|
+
// paged so a busy DM is never truncated:
|
|
13
|
+
// GET /users/@me/channels -> list DM channels the bot participates in
|
|
14
|
+
// GET /channels/{id}/messages -> recent messages per DM channel (paged via
|
|
15
|
+
// the `before` snowflake cursor)
|
|
16
|
+
//
|
|
17
|
+
// Discord snowflake ids encode their creation timestamp, so receivedAt is
|
|
18
|
+
// derived from the message id (snowflake) when no explicit timestamp is present.
|
|
19
|
+
//
|
|
20
|
+
// Credential: surfaces.discord.botToken. Missing => 'unavailable'.
|
|
21
|
+
// Cadence: 30s (realtime tier).
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
AdapterContext,
|
|
26
|
+
InboundChannelItem,
|
|
27
|
+
InboundProviderAdapter,
|
|
28
|
+
ProviderPollOptions,
|
|
29
|
+
ProviderPollResult,
|
|
30
|
+
} from '../provider-adapter.ts';
|
|
31
|
+
import { POLL_CADENCE_MS } from '../provider-adapter.ts';
|
|
32
|
+
import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
|
|
33
|
+
import { resolveRouteId } from './route-util.ts';
|
|
34
|
+
|
|
35
|
+
const DISCORD_API = 'https://discord.com/api/v10';
|
|
36
|
+
// Discord epoch (2015-01-01T00:00:00Z) in ms, used to decode snowflakes.
|
|
37
|
+
const DISCORD_EPOCH_MS = 1_420_070_400_000;
|
|
38
|
+
export const DISCORD_PROVIDER_ID = 'discord';
|
|
39
|
+
export const DISCORD_CREDENTIAL_KEY = 'surfaces.discord.botToken';
|
|
40
|
+
|
|
41
|
+
interface DiscordChannel {
|
|
42
|
+
id: string;
|
|
43
|
+
type: number; // 1 = DM, 3 = group DM
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface DiscordSelf {
|
|
47
|
+
id: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface DiscordReaction {
|
|
51
|
+
count?: number;
|
|
52
|
+
emoji?: { id?: string | null; name?: string | null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DiscordMessage {
|
|
56
|
+
id: string;
|
|
57
|
+
channel_id?: string;
|
|
58
|
+
content?: string;
|
|
59
|
+
timestamp?: string; // ISO-8601
|
|
60
|
+
author?: { id: string; bot?: boolean };
|
|
61
|
+
referenced_message?: unknown;
|
|
62
|
+
mentions?: Array<{ id: string }>;
|
|
63
|
+
reactions?: DiscordReaction[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Classify a Discord message into the InboundChannelItem `kind`.
|
|
68
|
+
* - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
|
|
69
|
+
* event. Requires the message to be authored by us (its author id
|
|
70
|
+
* is selfId) AND to carry a non-empty reactions[]. A message
|
|
71
|
+
* authored by someone else that merely carries reactions[] is a
|
|
72
|
+
* normal DM, not a reaction event.
|
|
73
|
+
* - mention: the message mentions us (our id is in mentions[])
|
|
74
|
+
* - thread: the message is a reply (referenced_message present)
|
|
75
|
+
* - dm: a plain direct message
|
|
76
|
+
* Reaction outranks mention which outranks thread/dm (most-specific first).
|
|
77
|
+
*/
|
|
78
|
+
function classifyDiscordKind(
|
|
79
|
+
msg: DiscordMessage,
|
|
80
|
+
selfId: string | undefined,
|
|
81
|
+
): InboundChannelItem['kind'] {
|
|
82
|
+
if (
|
|
83
|
+
selfId &&
|
|
84
|
+
msg.author?.id === selfId &&
|
|
85
|
+
Array.isArray(msg.reactions) &&
|
|
86
|
+
msg.reactions.length > 0
|
|
87
|
+
) {
|
|
88
|
+
return 'reaction';
|
|
89
|
+
}
|
|
90
|
+
if (selfId && Array.isArray(msg.mentions) && msg.mentions.some((m) => m.id === selfId)) {
|
|
91
|
+
return 'mention';
|
|
92
|
+
}
|
|
93
|
+
if (msg.referenced_message) return 'thread';
|
|
94
|
+
return 'dm';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Decode a Discord snowflake id to its creation Unix-ms timestamp. */
|
|
98
|
+
function snowflakeToMs(id: string): number {
|
|
99
|
+
try {
|
|
100
|
+
const asBig = BigInt(id);
|
|
101
|
+
return Number((asBig >> 22n)) + DISCORD_EPOCH_MS;
|
|
102
|
+
} catch {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function discordGet<T>(token: string, path: string): Promise<T> {
|
|
108
|
+
const res = await fetch(`${DISCORD_API}${path}`, {
|
|
109
|
+
method: 'GET',
|
|
110
|
+
headers: {
|
|
111
|
+
Authorization: `Bot ${token}`,
|
|
112
|
+
Accept: 'application/json',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (res.status === 401) throw new Error('Discord authentication failed (401)');
|
|
116
|
+
if (!res.ok) throw new Error(`Discord GET ${path} HTTP ${res.status}`);
|
|
117
|
+
return (await res.json()) as T;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createDiscordAdapter(ctx: AdapterContext): InboundProviderAdapter {
|
|
121
|
+
return {
|
|
122
|
+
id: DISCORD_PROVIDER_ID,
|
|
123
|
+
pollIntervalMs: POLL_CADENCE_MS.realtime,
|
|
124
|
+
async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
|
|
125
|
+
let token: string | null;
|
|
126
|
+
try {
|
|
127
|
+
token = await ctx.credentials.resolveConfigSecret(DISCORD_CREDENTIAL_KEY);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return unavailable(`credential lookup failed: ${errMsg(error)}`);
|
|
130
|
+
}
|
|
131
|
+
if (!token || token.trim().length === 0) {
|
|
132
|
+
return unavailable('missing surfaces.discord.botToken');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Resolve our own user id so we can classify @-mentions of us. Best-effort:
|
|
136
|
+
// a failure only disables mention classification, never the provider.
|
|
137
|
+
let selfId: string | undefined;
|
|
138
|
+
try {
|
|
139
|
+
const self = await discordGet<DiscordSelf>(token, '/users/@me');
|
|
140
|
+
if (self.id) selfId = self.id;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
ctx.logger.warn('discord /users/@me failed; mentions will not be classified', {
|
|
143
|
+
error: errMsg(error),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const channels = await discordGet<DiscordChannel[]>(token, '/users/@me/channels');
|
|
149
|
+
const dmChannels = channels.filter((c) => c.type === 1 || c.type === 3);
|
|
150
|
+
const items: InboundChannelItem[] = [];
|
|
151
|
+
// /channels/{id}/messages returns at most `perChannel` (<=50) messages
|
|
152
|
+
// newest-first. A DM that accrued more than one page of new messages in
|
|
153
|
+
// the `since` window would silently drop the oldest beyond the first page
|
|
154
|
+
// unless we page backwards via the `before` cursor (the id of the oldest
|
|
155
|
+
// message seen so far). MAX_HISTORY_PAGES bounds the walk.
|
|
156
|
+
const MAX_HISTORY_PAGES = 20;
|
|
157
|
+
const perChannel = Math.min(opts.limit, 50);
|
|
158
|
+
channelLoop: for (const channel of dmChannels) {
|
|
159
|
+
if (items.length >= opts.limit) break;
|
|
160
|
+
let before: string | undefined;
|
|
161
|
+
for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
|
|
162
|
+
const params = new URLSearchParams({ limit: String(perChannel) });
|
|
163
|
+
// Discord treats after/before/around as mutually exclusive: sending
|
|
164
|
+
// both silently drops older in-window messages. Use `after` only on
|
|
165
|
+
// the first page (to bound the window at `since`); once paging
|
|
166
|
+
// backwards via `before`, drop `after` and rely on the
|
|
167
|
+
// oldestMs<=since stop condition below to terminate the walk.
|
|
168
|
+
if (before) params.set('before', before);
|
|
169
|
+
else if (opts.since) params.set('after', msToSnowflake(opts.since));
|
|
170
|
+
let messages: DiscordMessage[];
|
|
171
|
+
try {
|
|
172
|
+
messages = await discordGet<DiscordMessage[]>(
|
|
173
|
+
token,
|
|
174
|
+
`/channels/${channel.id}/messages?${params.toString()}`,
|
|
175
|
+
);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
ctx.logger.warn('discord channel messages failed', {
|
|
178
|
+
channel: channel.id,
|
|
179
|
+
error: errMsg(error),
|
|
180
|
+
});
|
|
181
|
+
continue channelLoop;
|
|
182
|
+
}
|
|
183
|
+
if (messages.length === 0) break;
|
|
184
|
+
// Track the oldest id on this page for the next `before` cursor before
|
|
185
|
+
// we filter/skip, so pagination is driven by the raw page, not by what
|
|
186
|
+
// survived classification.
|
|
187
|
+
let oldestId: string | undefined;
|
|
188
|
+
let oldestMs = Number.POSITIVE_INFINITY;
|
|
189
|
+
for (const msg of messages) {
|
|
190
|
+
const msgMs = msg.timestamp
|
|
191
|
+
? Date.parse(msg.timestamp) || snowflakeToMs(msg.id)
|
|
192
|
+
: snowflakeToMs(msg.id);
|
|
193
|
+
if (msgMs < oldestMs) {
|
|
194
|
+
oldestMs = msgMs;
|
|
195
|
+
oldestId = msg.id;
|
|
196
|
+
}
|
|
197
|
+
if (items.length >= opts.limit) break;
|
|
198
|
+
if (msg.author?.bot) continue;
|
|
199
|
+
const receivedAt = msgMs;
|
|
200
|
+
if (opts.since && receivedAt <= opts.since) continue;
|
|
201
|
+
const senderId = msg.author?.id ?? channel.id;
|
|
202
|
+
// Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
|
|
203
|
+
// provider user id. Discord user ids (snowflakes) are globally
|
|
204
|
+
// unique, and the item.id / provider fields already namespace by
|
|
205
|
+
// provider.
|
|
206
|
+
const fromDigest = digestSender(senderId);
|
|
207
|
+
const kind = classifyDiscordKind(msg, selfId);
|
|
208
|
+
const item: InboundChannelItem = {
|
|
209
|
+
id: `discord:${channel.id}:${msg.id}`,
|
|
210
|
+
provider: DISCORD_PROVIDER_ID,
|
|
211
|
+
kind,
|
|
212
|
+
fromDigest,
|
|
213
|
+
subjectPreview: toSubjectPreview('Direct message'),
|
|
214
|
+
bodyPreview: toBodyPreview(msg.content),
|
|
215
|
+
receivedAt,
|
|
216
|
+
unread: true,
|
|
217
|
+
};
|
|
218
|
+
const routeId = await resolveRouteId(ctx, DISCORD_PROVIDER_ID, fromDigest, kind);
|
|
219
|
+
if (routeId) item.routeId = routeId;
|
|
220
|
+
items.push(item);
|
|
221
|
+
}
|
|
222
|
+
// Stop paging this channel when the page was not full (no older
|
|
223
|
+
// messages exist), the item budget is spent, or the oldest message on
|
|
224
|
+
// this page is already at/older than the `since` floor.
|
|
225
|
+
if (messages.length < perChannel || items.length >= opts.limit) break;
|
|
226
|
+
if (opts.since && oldestMs <= opts.since) break;
|
|
227
|
+
if (!oldestId) break;
|
|
228
|
+
before = oldestId;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { items, state: items.length > 0 ? 'ready' : 'empty' };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return unavailable(errMsg(error));
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Build a synthetic snowflake from a Unix-ms timestamp for the `after` cursor. */
|
|
240
|
+
function msToSnowflake(ms: number): string {
|
|
241
|
+
const delta = Math.max(0, Math.floor(ms) - DISCORD_EPOCH_MS);
|
|
242
|
+
return (BigInt(delta) << 22n).toString();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function unavailable(error: string): ProviderPollResult {
|
|
246
|
+
return { items: [], state: 'unavailable', error };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function errMsg(error: unknown): string {
|
|
250
|
+
return error instanceof Error ? error.message : String(error);
|
|
251
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Email (IMAP) inbound adapter.
|
|
3
|
+
//
|
|
4
|
+
// Uses the dependency-free ImapClient (node:tls) to pull recent INBOX messages.
|
|
5
|
+
// Connection params resolve through the daemon credential store (env/secrets
|
|
6
|
+
// backend) so nothing sensitive is read from plaintext config:
|
|
7
|
+
// surfaces.email.imapHost (e.g. imap.fastmail.com)
|
|
8
|
+
// surfaces.email.imapPort (default 993)
|
|
9
|
+
// surfaces.email.imapUser
|
|
10
|
+
// surfaces.email.imapPassword (app password / token)
|
|
11
|
+
//
|
|
12
|
+
// Any missing required field => state 'unavailable' WITH an explanatory error.
|
|
13
|
+
// Cadence: 60s (email tier).
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
AdapterContext,
|
|
18
|
+
InboundChannelItem,
|
|
19
|
+
InboundProviderAdapter,
|
|
20
|
+
ProviderPollOptions,
|
|
21
|
+
ProviderPollResult,
|
|
22
|
+
} from '../provider-adapter.ts';
|
|
23
|
+
import { POLL_CADENCE_MS } from '../provider-adapter.ts';
|
|
24
|
+
import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
|
|
25
|
+
import { resolveRouteId } from './route-util.ts';
|
|
26
|
+
import { ImapClient } from './imap-client.ts';
|
|
27
|
+
import type { ImapConfig, ImapEnvelope } from './imap-client.ts';
|
|
28
|
+
|
|
29
|
+
export const EMAIL_PROVIDER_ID = 'email';
|
|
30
|
+
export const EMAIL_HOST_KEY = 'surfaces.email.imapHost';
|
|
31
|
+
export const EMAIL_PORT_KEY = 'surfaces.email.imapPort';
|
|
32
|
+
export const EMAIL_USER_KEY = 'surfaces.email.imapUser';
|
|
33
|
+
export const EMAIL_PASSWORD_KEY = 'surfaces.email.imapPassword';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Injectable client factory so tests can substitute a fake IMAP client without
|
|
37
|
+
* opening a TLS socket. Production default constructs the real ImapClient.
|
|
38
|
+
*/
|
|
39
|
+
export interface ImapLike {
|
|
40
|
+
connect(): Promise<void>;
|
|
41
|
+
login(): Promise<void>;
|
|
42
|
+
select(mailbox?: string): Promise<void>;
|
|
43
|
+
searchUids(since?: number): Promise<number[]>;
|
|
44
|
+
fetchEnvelopes(uids: readonly number[]): Promise<ImapEnvelope[]>;
|
|
45
|
+
logout(): Promise<void>;
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ImapClientFactory = (cfg: ImapConfig) => ImapLike;
|
|
50
|
+
|
|
51
|
+
const defaultFactory: ImapClientFactory = (cfg) => new ImapClient(cfg);
|
|
52
|
+
|
|
53
|
+
export function createEmailAdapter(
|
|
54
|
+
ctx: AdapterContext,
|
|
55
|
+
factory: ImapClientFactory = defaultFactory,
|
|
56
|
+
): InboundProviderAdapter {
|
|
57
|
+
return {
|
|
58
|
+
id: EMAIL_PROVIDER_ID,
|
|
59
|
+
pollIntervalMs: POLL_CADENCE_MS.email,
|
|
60
|
+
async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
|
|
61
|
+
let host: string | null;
|
|
62
|
+
let user: string | null;
|
|
63
|
+
let password: string | null;
|
|
64
|
+
let portRaw: string | null;
|
|
65
|
+
try {
|
|
66
|
+
[host, portRaw, user, password] = await Promise.all([
|
|
67
|
+
ctx.credentials.resolveConfigSecret(EMAIL_HOST_KEY),
|
|
68
|
+
ctx.credentials.resolveConfigSecret(EMAIL_PORT_KEY),
|
|
69
|
+
ctx.credentials.resolveConfigSecret(EMAIL_USER_KEY),
|
|
70
|
+
ctx.credentials.resolveConfigSecret(EMAIL_PASSWORD_KEY),
|
|
71
|
+
]);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return unavailable(`credential lookup failed: ${errMsg(error)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const missing: string[] = [];
|
|
77
|
+
if (!host) missing.push('imapHost');
|
|
78
|
+
if (!user) missing.push('imapUser');
|
|
79
|
+
if (!password) missing.push('imapPassword');
|
|
80
|
+
if (missing.length > 0) {
|
|
81
|
+
return unavailable(`missing email IMAP credentials: ${missing.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
const port = portRaw ? Number.parseInt(portRaw, 10) : 993;
|
|
84
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
85
|
+
return unavailable(`invalid surfaces.email.imapPort: ${portRaw}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const client = factory({
|
|
89
|
+
host: host!,
|
|
90
|
+
port,
|
|
91
|
+
user: user!,
|
|
92
|
+
password: password!,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await client.connect();
|
|
97
|
+
await client.login();
|
|
98
|
+
await client.select('INBOX');
|
|
99
|
+
const uids = await client.searchUids(opts.since);
|
|
100
|
+
// Newest UIDs first, capped at limit.
|
|
101
|
+
const selected = uids.sort((a, b) => b - a).slice(0, opts.limit);
|
|
102
|
+
const envelopes = await client.fetchEnvelopes(selected);
|
|
103
|
+
const items: InboundChannelItem[] = [];
|
|
104
|
+
for (const env of envelopes) {
|
|
105
|
+
const receivedAt = env.date > 0 ? env.date : Date.now();
|
|
106
|
+
if (opts.since && receivedAt <= opts.since) continue;
|
|
107
|
+
const fromDigest = digestSender(`email:${normalizeAddress(env.from)}`);
|
|
108
|
+
const kind = 'dm';
|
|
109
|
+
const item: InboundChannelItem = {
|
|
110
|
+
id: `email:${user}:${env.uid}`,
|
|
111
|
+
provider: EMAIL_PROVIDER_ID,
|
|
112
|
+
kind,
|
|
113
|
+
fromDigest,
|
|
114
|
+
subjectPreview: toSubjectPreview(env.subject),
|
|
115
|
+
bodyPreview: toBodyPreview(env.bodyPreview),
|
|
116
|
+
receivedAt,
|
|
117
|
+
unread: !env.seen,
|
|
118
|
+
};
|
|
119
|
+
const routeId = await resolveRouteId(ctx, EMAIL_PROVIDER_ID, fromDigest, kind);
|
|
120
|
+
if (routeId) item.routeId = routeId;
|
|
121
|
+
items.push(item);
|
|
122
|
+
}
|
|
123
|
+
return { items, state: items.length > 0 ? 'ready' : 'empty' };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return unavailable(errMsg(error));
|
|
126
|
+
} finally {
|
|
127
|
+
try {
|
|
128
|
+
await client.logout();
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
client.close();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Extract a bare address from a `Name <addr@host>` From header for digesting. */
|
|
139
|
+
function normalizeAddress(from: string): string {
|
|
140
|
+
const angle = /<([^>]+)>/.exec(from);
|
|
141
|
+
const addr = (angle ? angle[1]! : from).trim().toLowerCase();
|
|
142
|
+
return addr.length > 0 ? addr : from.trim().toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unavailable(error: string): ProviderPollResult {
|
|
146
|
+
return { items: [], state: 'unavailable', error };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function errMsg(error: unknown): string {
|
|
150
|
+
return error instanceof Error ? error.message : String(error);
|
|
151
|
+
}
|