@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,210 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Inbox surface: attaches the HOST handler to the SDK-registered
|
|
3
|
+
// `channels.inbox.list` gateway descriptor.
|
|
4
|
+
//
|
|
5
|
+
// The SDK already declares the method id, input schema, output schema, scopes
|
|
6
|
+
// (read:channels) and HTTP binding (GET /api/channels/inbox). This module does
|
|
7
|
+
// NOT re-declare any of that — it looks the descriptor up via the catalog and
|
|
8
|
+
// attaches an implementation with `registerCatalogHandler` ({ replace: true }).
|
|
9
|
+
//
|
|
10
|
+
// register(ctx, routing) =>
|
|
11
|
+
// 1. registers built-in provider adapter factories (slack/discord/email)
|
|
12
|
+
// 2. constructs adapters with the daemon credential store + a route resolver
|
|
13
|
+
// bridged from the routing surface (best-effort, never throws)
|
|
14
|
+
// 3. opens the inbox cursor store and seeds an initial poll
|
|
15
|
+
// 4. starts the per-provider polling loops
|
|
16
|
+
// 5. attaches the read-only `channels.inbox.list` handler (no confirm)
|
|
17
|
+
// 6. returns an Unregister that detaches the handler, stops the poller, and
|
|
18
|
+
// closes the store.
|
|
19
|
+
//
|
|
20
|
+
// The handler reads the persisted feed (filtered by provider/limit/since) and
|
|
21
|
+
// maps the daemon-internal item shape onto the SDK CHANNEL_INBOX_ITEM_SCHEMA
|
|
22
|
+
// wire shape. The redacted `fromDigest` is the only sender value emitted (as
|
|
23
|
+
// `from`); raw sender ids and unredacted bodies never leave the daemon. The
|
|
24
|
+
// monotonic cursor advances to max(receivedAt) in the returned window.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import type { HandlerContext } from '../context.ts';
|
|
28
|
+
import type { Unregister } from '../register.ts';
|
|
29
|
+
import { registerCatalogHandler } from '../register.ts';
|
|
30
|
+
import type { RoutingRegistration } from '../index.ts';
|
|
31
|
+
import {
|
|
32
|
+
buildAdapters,
|
|
33
|
+
registerAdapterFactory,
|
|
34
|
+
type AdapterContext,
|
|
35
|
+
type InboundChannelItem,
|
|
36
|
+
type RouteResolver,
|
|
37
|
+
} from './provider-adapter.ts';
|
|
38
|
+
import { InboxCursorStore } from './cursor-store.ts';
|
|
39
|
+
import { InboundPoller } from './poller.ts';
|
|
40
|
+
import { createSlackAdapter, SLACK_PROVIDER_ID } from './providers/slack.ts';
|
|
41
|
+
import { createDiscordAdapter, DISCORD_PROVIDER_ID } from './providers/discord.ts';
|
|
42
|
+
import { createEmailAdapter, EMAIL_PROVIDER_ID } from './providers/email.ts';
|
|
43
|
+
|
|
44
|
+
export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
|
|
45
|
+
const DEFAULT_LIMIT = 50;
|
|
46
|
+
const MAX_LIMIT = 500;
|
|
47
|
+
|
|
48
|
+
/** SDK `channels.inbox.list` input (single optional provider per the schema). */
|
|
49
|
+
export interface InboxListInput {
|
|
50
|
+
provider?: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
since?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One item in the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
|
|
56
|
+
export interface ChannelInboxItem {
|
|
57
|
+
id: string;
|
|
58
|
+
provider: string;
|
|
59
|
+
kind: string;
|
|
60
|
+
/** Redacted sender token (sha256First, 16 hex). Never the raw id. */
|
|
61
|
+
from: string;
|
|
62
|
+
subject?: string;
|
|
63
|
+
bodyPreview: string;
|
|
64
|
+
receivedAt: number;
|
|
65
|
+
unread: boolean;
|
|
66
|
+
routeId?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** SDK `channels.inbox.list` output (objectSchema: items/total/truncated/cursor?). */
|
|
70
|
+
export interface InboxListOutput {
|
|
71
|
+
items: ChannelInboxItem[];
|
|
72
|
+
total: number;
|
|
73
|
+
truncated: boolean;
|
|
74
|
+
cursor?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RegisterInboxOptions {
|
|
78
|
+
/** Override the cursor-store filename (tests). */
|
|
79
|
+
storeFileName?: string;
|
|
80
|
+
/** Skip the initial seed poll (tests that drive polling manually). */
|
|
81
|
+
skipInitialPoll?: boolean;
|
|
82
|
+
/** Register the built-in slack/discord/email adapters (default true). */
|
|
83
|
+
registerBuiltins?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function registerBuiltinAdapters(): void {
|
|
87
|
+
registerAdapterFactory(SLACK_PROVIDER_ID, (ctx) => createSlackAdapter(ctx));
|
|
88
|
+
registerAdapterFactory(DISCORD_PROVIDER_ID, (ctx) => createDiscordAdapter(ctx));
|
|
89
|
+
registerAdapterFactory(EMAIL_PROVIDER_ID, (ctx) => createEmailAdapter(ctx));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeInput(body: unknown): { providers?: string[]; limit: number; since?: number } {
|
|
93
|
+
const input = (body ?? {}) as InboxListInput;
|
|
94
|
+
const provider = typeof input.provider === 'string' && input.provider.length > 0
|
|
95
|
+
? input.provider
|
|
96
|
+
: undefined;
|
|
97
|
+
let limit = typeof input.limit === 'number' && Number.isFinite(input.limit)
|
|
98
|
+
? Math.floor(input.limit)
|
|
99
|
+
: DEFAULT_LIMIT;
|
|
100
|
+
limit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
|
101
|
+
const since = typeof input.since === 'number' && Number.isFinite(input.since) && input.since >= 0
|
|
102
|
+
? Math.floor(input.since)
|
|
103
|
+
: undefined;
|
|
104
|
+
return {
|
|
105
|
+
...(provider ? { providers: [provider] } : {}),
|
|
106
|
+
limit,
|
|
107
|
+
...(since !== undefined ? { since } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Map a daemon-internal item onto the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
|
|
112
|
+
function toWireItem(item: InboundChannelItem): ChannelInboxItem {
|
|
113
|
+
const wire: ChannelInboxItem = {
|
|
114
|
+
id: item.id,
|
|
115
|
+
provider: item.provider,
|
|
116
|
+
kind: item.kind,
|
|
117
|
+
from: item.fromDigest,
|
|
118
|
+
bodyPreview: item.bodyPreview,
|
|
119
|
+
receivedAt: item.receivedAt,
|
|
120
|
+
unread: item.unread,
|
|
121
|
+
};
|
|
122
|
+
if (item.subjectPreview.length > 0) wire.subject = item.subjectPreview;
|
|
123
|
+
if (item.routeId != null) wire.routeId = item.routeId;
|
|
124
|
+
return wire;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Bridge the routing surface's profile resolver into the adapter `RouteResolver`
|
|
129
|
+
* seam. Resolution is by provider surface (best-effort wildcard); a resolved
|
|
130
|
+
* profile id is surfaced as the item's routeId binding. Never throws.
|
|
131
|
+
*/
|
|
132
|
+
function routeResolverFromRouting(routing: RoutingRegistration): RouteResolver {
|
|
133
|
+
return ({ provider }) => {
|
|
134
|
+
try {
|
|
135
|
+
return routing.resolveProfileId(provider) ?? undefined;
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Register the inbox surface. Attaches the `channels.inbox.list` handler to the
|
|
144
|
+
* SDK catalog and returns an Unregister that detaches it, stops the poller, and
|
|
145
|
+
* closes the store.
|
|
146
|
+
*/
|
|
147
|
+
export function registerInboxMethods(
|
|
148
|
+
ctx: HandlerContext,
|
|
149
|
+
routing?: RoutingRegistration,
|
|
150
|
+
options: RegisterInboxOptions = {},
|
|
151
|
+
): Unregister {
|
|
152
|
+
if (options.registerBuiltins !== false) {
|
|
153
|
+
registerBuiltinAdapters();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const adapterContext: AdapterContext = {
|
|
157
|
+
credentials: ctx.credentials,
|
|
158
|
+
logger: ctx.logger,
|
|
159
|
+
...(routing ? { resolveRouteId: routeResolverFromRouting(routing) } : {}),
|
|
160
|
+
};
|
|
161
|
+
const adapters = buildAdapters(adapterContext);
|
|
162
|
+
const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName);
|
|
163
|
+
const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
|
|
164
|
+
|
|
165
|
+
// Async bootstrap: init store, seed one poll, start loops. Failures are
|
|
166
|
+
// logged but never thrown out of register() — the handler still serves the
|
|
167
|
+
// (possibly empty) persisted feed.
|
|
168
|
+
const ready: Promise<void> = (async () => {
|
|
169
|
+
await store.init();
|
|
170
|
+
if (!options.skipInitialPoll) {
|
|
171
|
+
await poller.pollOnce();
|
|
172
|
+
}
|
|
173
|
+
poller.start();
|
|
174
|
+
})().catch((error: unknown) => {
|
|
175
|
+
ctx.logger.error('inbox surface bootstrap failed', {
|
|
176
|
+
error: error instanceof Error ? error.message : String(error),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const unregisterMethod = registerCatalogHandler<InboxListInput, InboxListOutput>(
|
|
181
|
+
ctx.catalog,
|
|
182
|
+
INBOX_LIST_METHOD_ID,
|
|
183
|
+
async (invocation) => {
|
|
184
|
+
await ready;
|
|
185
|
+
const { providers, limit, since } = normalizeInput(invocation.body);
|
|
186
|
+
const internalItems = store.listItems({
|
|
187
|
+
...(providers ? { providers } : {}),
|
|
188
|
+
...(since !== undefined ? { since } : {}),
|
|
189
|
+
limit,
|
|
190
|
+
});
|
|
191
|
+
const items = internalItems.map(toWireItem);
|
|
192
|
+
const total = store.countItems(providers);
|
|
193
|
+
const truncated = internalItems.length >= limit && total > internalItems.length;
|
|
194
|
+
const maxReceived = store.maxReceivedAt(providers);
|
|
195
|
+
const nextSince = Math.max(since ?? 0, maxReceived);
|
|
196
|
+
const output: InboxListOutput = { items, total, truncated };
|
|
197
|
+
if (nextSince > 0) output.cursor = String(nextSince);
|
|
198
|
+
return output;
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
try {
|
|
204
|
+
unregisterMethod();
|
|
205
|
+
} finally {
|
|
206
|
+
poller.stop();
|
|
207
|
+
void store.close();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Pure mapping + redaction helpers shared by every adapter.
|
|
3
|
+
//
|
|
4
|
+
// Rules (from the handoff contract):
|
|
5
|
+
// - fromDigest = sha256First(senderExternalId, 16) (never the raw id)
|
|
6
|
+
// - bodyPreview = plain-text, PII-stripped, truncated to 500 chars
|
|
7
|
+
// - subjectPreview <= 200 chars
|
|
8
|
+
// These are pure and deterministic so they are unit-testable in isolation.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export const SUBJECT_PREVIEW_MAX = 200;
|
|
14
|
+
export const BODY_PREVIEW_MAX = 500;
|
|
15
|
+
|
|
16
|
+
/** First `hexChars` hex chars of the SHA-256 digest of `input` (utf-8). */
|
|
17
|
+
export function sha256First(input: string, hexChars: number): string {
|
|
18
|
+
const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
|
|
19
|
+
return digest.slice(0, Math.max(0, hexChars));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Digest a sender's external id to a stable 16-hex-char token (the first 8
|
|
24
|
+
* bytes of the SHA-256 digest). The raw id is never emitted.
|
|
25
|
+
*/
|
|
26
|
+
export function digestSender(senderExternalId: string): string {
|
|
27
|
+
return sha256First(senderExternalId, 16);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// PII patterns stripped from body previews before they ever leave the daemon.
|
|
31
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
32
|
+
// E.164-ish and common separated phone numbers (>=7 digits with separators).
|
|
33
|
+
const PHONE_RE = /(?:\+?\d[\d\s().-]{6,}\d)/g;
|
|
34
|
+
// Long digit runs that look like card / account numbers (13-19 digits).
|
|
35
|
+
const LONG_NUMBER_RE = /\b\d{13,19}\b/g;
|
|
36
|
+
// IPv4 addresses.
|
|
37
|
+
const IPV4_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
|
|
38
|
+
|
|
39
|
+
// --- Secret / token patterns -----------------------------------------------
|
|
40
|
+
// These must run BEFORE the generic numeric/email scrubbers and are ordered
|
|
41
|
+
// most-specific first so a long opaque secret is never partially redacted.
|
|
42
|
+
//
|
|
43
|
+
// `Authorization: Bearer <token>` / bare `Bearer <token>`.
|
|
44
|
+
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
45
|
+
// JSON Web Tokens: three base64url segments separated by dots (header.payload.sig).
|
|
46
|
+
const JWT_RE = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g;
|
|
47
|
+
// Slack tokens: xoxb-/xoxp-/xoxa-/xoxr-/xoxs-... and legacy xox*-.
|
|
48
|
+
const SLACK_TOKEN_RE = /\bxox[abeoprs]-[A-Za-z0-9-]{8,}/gi;
|
|
49
|
+
// Common prefixed provider keys: OpenAI sk-/sk-proj-, GitHub gh[poursa]_,
|
|
50
|
+
// Google AIza..., Stripe sk_live_/pk_live_/rk_live_, Slack-app xapp-, AWS AKIA...
|
|
51
|
+
const PREFIXED_KEY_RE =
|
|
52
|
+
/\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{16,}|gh[poursa]_[A-Za-z0-9]{16,}|AIza[A-Za-z0-9_-]{16,}|(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|xapp-[A-Za-z0-9-]{8,}|AKIA[A-Z0-9]{16})/g;
|
|
53
|
+
// `token=`, `api_key=`, `access_token: ...`, `secret = ...` style key/value pairs.
|
|
54
|
+
const KV_SECRET_RE =
|
|
55
|
+
/\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|pwd|authorization|auth)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[A-Za-z0-9._~+/=-]{6,})/gi;
|
|
56
|
+
// Generic high-entropy opaque blobs (>=24 chars of base64url/hex) not already
|
|
57
|
+
// caught above — catches raw API keys pasted without a recognizable prefix.
|
|
58
|
+
const OPAQUE_SECRET_RE = /\b[A-Za-z0-9_-]{24,}\b/g;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Replace PII and credentials with stable redaction tokens (does not alter the
|
|
62
|
+
* length budget). Secrets/tokens are scrubbed before the email/phone/number
|
|
63
|
+
* passes so an OAuth/bearer/API token can never leak into a preview.
|
|
64
|
+
*/
|
|
65
|
+
export function stripPii(input: string): string {
|
|
66
|
+
return input
|
|
67
|
+
.replace(BEARER_RE, '[token]')
|
|
68
|
+
.replace(JWT_RE, '[token]')
|
|
69
|
+
.replace(SLACK_TOKEN_RE, '[token]')
|
|
70
|
+
.replace(PREFIXED_KEY_RE, '[token]')
|
|
71
|
+
.replace(KV_SECRET_RE, (match) => {
|
|
72
|
+
const sep = match.includes('=') ? '=' : ':';
|
|
73
|
+
const key = match.slice(0, match.indexOf(sep)).trimEnd();
|
|
74
|
+
return `${key}${sep}[token]`;
|
|
75
|
+
})
|
|
76
|
+
.replace(EMAIL_RE, '[email]')
|
|
77
|
+
.replace(IPV4_RE, '[ip]')
|
|
78
|
+
.replace(LONG_NUMBER_RE, '[number]')
|
|
79
|
+
.replace(PHONE_RE, (match) => {
|
|
80
|
+
// Avoid eating short numeric tokens that survived LONG_NUMBER_RE; only
|
|
81
|
+
// redact when there are at least 7 digits.
|
|
82
|
+
const digits = match.replace(/\D/g, '');
|
|
83
|
+
return digits.length >= 7 ? '[phone]' : match;
|
|
84
|
+
})
|
|
85
|
+
// Sweep any remaining long opaque blob (e.g. a bare API key) last so we do
|
|
86
|
+
// not clobber the redaction tokens we just inserted.
|
|
87
|
+
.replace(OPAQUE_SECRET_RE, (match) => (/^[A-Za-z]+$/.test(match) ? match : '[token]'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Markup / MIME de-structuring ------------------------------------------
|
|
91
|
+
// Email BODY[TEXT] is frequently raw HTML and/or a multipart/MIME payload with
|
|
92
|
+
// boundary lines and Content-* headers. Previews must be human-readable plain
|
|
93
|
+
// text, so we de-MIME (prefer the text/plain part), strip tags, and decode the
|
|
94
|
+
// handful of HTML entities that survive into previews. This is a no-op on text
|
|
95
|
+
// that is already plain.
|
|
96
|
+
const HTML_ENTITIES: Record<string, string> = {
|
|
97
|
+
'&': '&', '<': '<', '>': '>', '"': '"',
|
|
98
|
+
''': "'", ''': "'", ' ': ' ',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** True when the text looks like a MIME multipart payload (has boundary lines). */
|
|
102
|
+
function isMultipart(text: string): boolean {
|
|
103
|
+
return /^--[^\r\n]+\r?\n/m.test(text) && /content-type:/i.test(text);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Given a multipart body, return the decoded text/plain part if present,
|
|
108
|
+
* otherwise the text/html part, otherwise the original input. Strips the
|
|
109
|
+
* per-part MIME headers so only the body bytes remain.
|
|
110
|
+
*/
|
|
111
|
+
function extractMimePart(text: string): string {
|
|
112
|
+
const boundaryMatch = /^--([^\r\n]+?)(?:--)?\r?$/m.exec(text);
|
|
113
|
+
if (!boundaryMatch) return text;
|
|
114
|
+
const boundary = boundaryMatch[1]!;
|
|
115
|
+
const parts = text.split(new RegExp(`--${boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?\r?\n?`));
|
|
116
|
+
let htmlPart: string | undefined;
|
|
117
|
+
for (const rawPart of parts) {
|
|
118
|
+
const split = /\r?\n\r?\n/.exec(rawPart);
|
|
119
|
+
if (!split) continue;
|
|
120
|
+
const headers = rawPart.slice(0, split.index);
|
|
121
|
+
const body = rawPart.slice(split.index + split[0].length);
|
|
122
|
+
const ctype = /content-type:\s*([^\r\n;]+)/i.exec(headers)?.[1]?.trim().toLowerCase();
|
|
123
|
+
if (!ctype) continue;
|
|
124
|
+
if (ctype === 'text/plain') return body.trim();
|
|
125
|
+
if (ctype === 'text/html' && htmlPart === undefined) htmlPart = body;
|
|
126
|
+
}
|
|
127
|
+
return (htmlPart ?? text).trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Strip stray top-level MIME/Content-* headers from a single-part body. */
|
|
131
|
+
function stripMimeHeaders(text: string): string {
|
|
132
|
+
return text.replace(
|
|
133
|
+
/^(?:content-type|content-transfer-encoding|content-disposition|content-id|mime-version|--[^\r\n]+)\b[^\r\n]*\r?\n/gim,
|
|
134
|
+
'',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Decode the small set of HTML entities that matter for plain-text previews. */
|
|
139
|
+
function decodeEntities(text: string): string {
|
|
140
|
+
return text
|
|
141
|
+
.replace(/&|<|>|"|'|'| /gi, (m) => HTML_ENTITIES[m.toLowerCase()] ?? m)
|
|
142
|
+
.replace(/&#(\d{1,7});/g, (_m, dec: string) => {
|
|
143
|
+
const code = Number.parseInt(dec, 10);
|
|
144
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : _m;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Convert HTML/MIME body text into readable plain text. Drops <script>/<style>
|
|
150
|
+
* blocks entirely, turns block-level tags into spaces, removes all remaining
|
|
151
|
+
* tags, decodes entities, and de-MIMEs multipart payloads. Plain text passes
|
|
152
|
+
* through unchanged (modulo entity decoding).
|
|
153
|
+
*/
|
|
154
|
+
export function stripMarkup(input: string): string {
|
|
155
|
+
let text = input;
|
|
156
|
+
if (isMultipart(text)) {
|
|
157
|
+
text = extractMimePart(text);
|
|
158
|
+
}
|
|
159
|
+
text = stripMimeHeaders(text);
|
|
160
|
+
// Drop script/style contents before tag removal so their bodies never leak.
|
|
161
|
+
text = text.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ');
|
|
162
|
+
// Only treat as HTML if there is an actual tag; avoids mangling plain text
|
|
163
|
+
// that merely contains a stray '<'.
|
|
164
|
+
if (/<[a-z!/][^>]*>/i.test(text)) {
|
|
165
|
+
text = text
|
|
166
|
+
.replace(/<\/?(?:br|p|div|tr|li|h[1-6]|table|ul|ol|blockquote|hr)\b[^>]*>/gi, ' ')
|
|
167
|
+
.replace(/<[^>]+>/g, '');
|
|
168
|
+
}
|
|
169
|
+
return decodeEntities(text);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Collapse whitespace and trim — keeps previews single-line and tidy. */
|
|
173
|
+
export function normalizeWhitespace(input: string): string {
|
|
174
|
+
return input.replace(/\s+/g, ' ').trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Build a display-safe subject preview (<=200 chars, no PII, single line). */
|
|
178
|
+
export function toSubjectPreview(raw: string | undefined | null): string {
|
|
179
|
+
const normalized = normalizeWhitespace(stripPii(raw ?? ''));
|
|
180
|
+
return normalized.slice(0, SUBJECT_PREVIEW_MAX);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build a display-safe body preview (<=500 chars, plain text, PII-stripped,
|
|
185
|
+
* single line). HTML/MIME is de-structured to readable text first so previews
|
|
186
|
+
* of real-world (HTML/multipart) emails never leak tags or MIME headers.
|
|
187
|
+
*/
|
|
188
|
+
export function toBodyPreview(raw: string | undefined | null): string {
|
|
189
|
+
const plain = stripMarkup(raw ?? '');
|
|
190
|
+
const normalized = normalizeWhitespace(stripPii(plain));
|
|
191
|
+
return normalized.slice(0, BODY_PREVIEW_MAX);
|
|
192
|
+
}
|
|
@@ -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 (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 { HandlerLogger } from '../context.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: HandlerLogger;
|
|
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: HandlerLogger;
|
|
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
|
+
}
|