@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,147 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Input validation, PII-safe digesting, and response shaping for the email
|
|
3
|
+
// handler surface. Pure functions only (no I/O, no secrets).
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { HandlerError } from '../errors.ts';
|
|
8
|
+
import type { ImapEnvelopeSummary, ImapFullMessage } from './imap-connector.ts';
|
|
9
|
+
|
|
10
|
+
export function asRecord(body: unknown): Record<string, unknown> {
|
|
11
|
+
if (typeof body !== 'object' || body === null) {
|
|
12
|
+
throw new HandlerError('Request body must be an object', 'EMAIL_BAD_INPUT', 400);
|
|
13
|
+
}
|
|
14
|
+
return body as Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function requireString(value: unknown, field: string): string {
|
|
18
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
19
|
+
throw new HandlerError(`Field '${field}' is required`, 'EMAIL_BAD_INPUT', 400);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function optionalString(value: unknown, field: string): string | undefined {
|
|
25
|
+
if (value === undefined || value === null) return undefined;
|
|
26
|
+
if (typeof value !== 'string') {
|
|
27
|
+
throw new HandlerError(`Field '${field}' must be a string`, 'EMAIL_BAD_INPUT', 400);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract the addr-spec from an RFC 5322 name-addr ("Display <a@b>"). Reads the
|
|
34
|
+
* contents of the LAST <...> pair so a stray '<' inside a quoted display name
|
|
35
|
+
* does not corrupt the result.
|
|
36
|
+
*/
|
|
37
|
+
export function extractAddrSpec(entry: string): string {
|
|
38
|
+
const open = entry.lastIndexOf('<');
|
|
39
|
+
if (open !== -1) {
|
|
40
|
+
const close = entry.indexOf('>', open + 1);
|
|
41
|
+
if (close !== -1) return entry.slice(open + 1, close).trim();
|
|
42
|
+
}
|
|
43
|
+
return entry.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateEmailAddress(value: string, field: string): string {
|
|
47
|
+
const entries = value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
if (entries.length === 0 || entries.some((e) => !/.+@.+\..+/.test(extractAddrSpec(e)))) {
|
|
49
|
+
throw new HandlerError(`Field '${field}' must be a valid email address`, 'EMAIL_BAD_INPUT', 400);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clampLimit(value: unknown): number {
|
|
55
|
+
if (value === undefined || value === null) return 10;
|
|
56
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
57
|
+
if (!Number.isFinite(n)) {
|
|
58
|
+
throw new HandlerError("Field 'limit' must be a number", 'EMAIL_BAD_INPUT', 400);
|
|
59
|
+
}
|
|
60
|
+
return Math.min(100, Math.max(1, Math.floor(n)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validateIsoDate(value: unknown): string | undefined {
|
|
64
|
+
const str = optionalString(value, 'since');
|
|
65
|
+
if (str === undefined) return undefined;
|
|
66
|
+
if (Number.isNaN(new Date(str).getTime())) {
|
|
67
|
+
throw new HandlerError("Field 'since' must be an ISO-8601 date", 'EMAIL_BAD_INPUT', 400);
|
|
68
|
+
}
|
|
69
|
+
return str;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function requireUid(value: unknown): number {
|
|
73
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
74
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
75
|
+
throw new HandlerError("Field 'uid' must be a positive integer", 'EMAIL_BAD_INPUT', 400);
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reduce an address to a stable, non-reversible digest for PII-safe logging. */
|
|
81
|
+
export function addressDigest(address: string): string {
|
|
82
|
+
return createHash('sha256').update(address.toLowerCase().trim(), 'utf-8').digest('hex').slice(0, 16);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Response contracts (must match EMAIL_INBOX_MESSAGE_SCHEMA /
|
|
87
|
+
// EMAIL_MESSAGE_DETAIL_SCHEMA / {uid,draftId} / {messageId,sentAt}).
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export interface InboxListResponse {
|
|
91
|
+
messages: Array<{
|
|
92
|
+
uid: number;
|
|
93
|
+
from: string;
|
|
94
|
+
subject: string;
|
|
95
|
+
date: string;
|
|
96
|
+
unread: boolean;
|
|
97
|
+
bodyPreview: string;
|
|
98
|
+
messageId: string;
|
|
99
|
+
}>;
|
|
100
|
+
total: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface InboxReadResponse {
|
|
104
|
+
uid: number;
|
|
105
|
+
from: string;
|
|
106
|
+
subject: string;
|
|
107
|
+
date: string;
|
|
108
|
+
messageId: string;
|
|
109
|
+
bodyText: string;
|
|
110
|
+
bodyHtml?: string;
|
|
111
|
+
attachments?: Array<{ filename: string; contentType: string; sizeBytes: number }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DraftCreateResponse {
|
|
115
|
+
uid: number;
|
|
116
|
+
draftId: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SendResponse {
|
|
120
|
+
messageId: string;
|
|
121
|
+
sentAt: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function toListMessage(m: ImapEnvelopeSummary): InboxListResponse['messages'][number] {
|
|
125
|
+
return {
|
|
126
|
+
uid: m.uid,
|
|
127
|
+
from: m.from,
|
|
128
|
+
subject: m.subject,
|
|
129
|
+
date: m.date,
|
|
130
|
+
unread: m.unread,
|
|
131
|
+
bodyPreview: m.bodyPreview,
|
|
132
|
+
messageId: m.messageId,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function toReadMessage(m: ImapFullMessage): InboxReadResponse {
|
|
137
|
+
return {
|
|
138
|
+
uid: m.uid,
|
|
139
|
+
from: m.from,
|
|
140
|
+
subject: m.subject,
|
|
141
|
+
date: m.date,
|
|
142
|
+
messageId: m.messageId,
|
|
143
|
+
bodyText: m.bodyText,
|
|
144
|
+
...(m.bodyHtml ? { bodyHtml: m.bodyHtml } : {}),
|
|
145
|
+
...(m.attachments && m.attachments.length > 0 ? { attachments: m.attachments } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Write handlers: email.draft.create + email.send.
|
|
3
|
+
//
|
|
4
|
+
// email.draft.create is access:admin (no body.confirm in the SDK contract);
|
|
5
|
+
// email.send is dangerous:true and confirmation-gated — index.ts registers it
|
|
6
|
+
// with { confirm: true }, so the register wrapper enforces body.confirm===true
|
|
7
|
+
// AND explicitUserRequest before this handler runs.
|
|
8
|
+
//
|
|
9
|
+
// Recipients are validated, sender/recipient addresses are reduced to a digest
|
|
10
|
+
// before logging (PII strip), and draft bodies are encrypted at rest by the
|
|
11
|
+
// runtime before persistence. No secret or raw address is echoed into a
|
|
12
|
+
// response. No descriptor or schema is authored here.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import type { CatalogHandlerEntry, TypedHandler } from '../register.ts';
|
|
16
|
+
import type { EmailRuntime } from './runtime.ts';
|
|
17
|
+
import { buildRfc5322Message, generateMessageId } from './smtp-connector.ts';
|
|
18
|
+
import {
|
|
19
|
+
asRecord,
|
|
20
|
+
requireString,
|
|
21
|
+
optionalString,
|
|
22
|
+
validateEmailAddress,
|
|
23
|
+
addressDigest,
|
|
24
|
+
type DraftCreateResponse,
|
|
25
|
+
type SendResponse,
|
|
26
|
+
} from './validation.ts';
|
|
27
|
+
|
|
28
|
+
interface DraftCreateBody {
|
|
29
|
+
to?: unknown;
|
|
30
|
+
subject?: unknown;
|
|
31
|
+
body?: unknown;
|
|
32
|
+
inReplyTo?: unknown;
|
|
33
|
+
references?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SendBody {
|
|
37
|
+
to?: unknown;
|
|
38
|
+
subject?: unknown;
|
|
39
|
+
body?: unknown;
|
|
40
|
+
inReplyTo?: unknown;
|
|
41
|
+
confirm?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function draftCreateHandler(runtime: EmailRuntime): TypedHandler<DraftCreateBody, DraftCreateResponse> {
|
|
45
|
+
return async ({ body }) => {
|
|
46
|
+
const input = asRecord(body);
|
|
47
|
+
const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
|
|
48
|
+
const subject = requireString(input.subject, 'subject');
|
|
49
|
+
const draftBody = requireString(input.body, 'body');
|
|
50
|
+
const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
|
|
51
|
+
const references = optionalString(input.references, 'references');
|
|
52
|
+
|
|
53
|
+
const from = await runtime.smtpFrom();
|
|
54
|
+
const messageId = generateMessageId(from);
|
|
55
|
+
const raw = buildRfc5322Message({
|
|
56
|
+
from,
|
|
57
|
+
to,
|
|
58
|
+
subject,
|
|
59
|
+
body: draftBody,
|
|
60
|
+
messageId,
|
|
61
|
+
date: new Date(),
|
|
62
|
+
...(inReplyTo ? { inReplyTo } : {}),
|
|
63
|
+
...(references ? { references } : {}),
|
|
64
|
+
});
|
|
65
|
+
const appended = await runtime.withImap((imap) => imap.appendDraft(raw));
|
|
66
|
+
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
const draftId = messageId.replace(/[<>]/g, '');
|
|
69
|
+
await runtime.persistDraft({
|
|
70
|
+
id: draftId,
|
|
71
|
+
to,
|
|
72
|
+
subject,
|
|
73
|
+
plaintextBody: draftBody,
|
|
74
|
+
status: 'draft',
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
metadata: { uid: appended.uid, mailbox: appended.mailbox, messageId },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
runtime.logger.info('email.draft.create', {
|
|
81
|
+
uid: appended.uid,
|
|
82
|
+
draftId,
|
|
83
|
+
recipient: addressDigest(to),
|
|
84
|
+
});
|
|
85
|
+
return { uid: appended.uid, draftId };
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sendHandler(runtime: EmailRuntime): TypedHandler<SendBody, SendResponse> {
|
|
90
|
+
return async ({ body }) => {
|
|
91
|
+
const input = asRecord(body);
|
|
92
|
+
const to = validateEmailAddress(requireString(input.to, 'to'), 'to');
|
|
93
|
+
const subject = requireString(input.subject, 'subject');
|
|
94
|
+
const sendBody = requireString(input.body, 'body');
|
|
95
|
+
const inReplyTo = optionalString(input.inReplyTo, 'inReplyTo');
|
|
96
|
+
|
|
97
|
+
const smtp = await runtime.openSmtp();
|
|
98
|
+
let result: SendResponse;
|
|
99
|
+
try {
|
|
100
|
+
result = await smtp.send({
|
|
101
|
+
to,
|
|
102
|
+
subject,
|
|
103
|
+
body: sendBody,
|
|
104
|
+
...(inReplyTo ? { inReplyTo } : {}),
|
|
105
|
+
});
|
|
106
|
+
} finally {
|
|
107
|
+
await smtp.close();
|
|
108
|
+
}
|
|
109
|
+
runtime.logger.info('email.send', {
|
|
110
|
+
messageId: result.messageId,
|
|
111
|
+
sentAt: result.sentAt,
|
|
112
|
+
recipient: addressDigest(to),
|
|
113
|
+
});
|
|
114
|
+
return result;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Build the write-handler catalog entries bound to the shared runtime. */
|
|
119
|
+
export function buildWriteHandlerEntries(runtime: EmailRuntime): CatalogHandlerEntry[] {
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
id: 'email.draft.create',
|
|
123
|
+
handler: draftCreateHandler(runtime) as TypedHandler<unknown, unknown>,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'email.send',
|
|
127
|
+
handler: sendHandler(runtime) as TypedHandler<unknown, unknown>,
|
|
128
|
+
// dangerous:true SDK method — require explicit confirmation (body.confirm
|
|
129
|
+
// === true AND explicitUserRequest) via the register wrapper.
|
|
130
|
+
options: { confirm: true },
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error type for daemon handler execution. Carries a stable machine `code`
|
|
3
|
+
* and an HTTP `status` so the control-plane dispatcher can map failures to
|
|
4
|
+
* wire responses without leaking implementation detail.
|
|
5
|
+
*/
|
|
6
|
+
export class HandlerError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly code: string,
|
|
10
|
+
public readonly status: number,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'HandlerError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Code emitted when a confirmation-gated method is invoked without explicit user confirmation. */
|
|
18
|
+
export const REQUIRE_CONFIRM = 'REQUIRE_CONFIRM';
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Persistent cursor + item store for the inbound feed.
|
|
3
|
+
//
|
|
4
|
+
// Backed by HandlerSqliteStore (sql.js WASM) at
|
|
5
|
+
// {wd}/.goodvibes/tui/operator/inbox.sqlite
|
|
6
|
+
//
|
|
7
|
+
// Two tables:
|
|
8
|
+
// items(id PK, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
9
|
+
// routeId, receivedAt INT, unread INT)
|
|
10
|
+
// cursors(provider PK, nextSince INT)
|
|
11
|
+
//
|
|
12
|
+
// Dedup is by items.id (upsert). nextSince advances monotonically per provider
|
|
13
|
+
// = max(receivedAt) ever seen. Triage metadata is NOT persisted here: it is
|
|
14
|
+
// applied downstream at the triage overlay layer (triage/integration.ts) over a
|
|
15
|
+
// separate store, so this feed store carries only the raw inbound fields.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
19
|
+
import type { InboundChannelItem } from './provider-adapter.ts';
|
|
20
|
+
|
|
21
|
+
const SCHEMA: string[] = [
|
|
22
|
+
`CREATE TABLE IF NOT EXISTS items (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
provider TEXT NOT NULL,
|
|
25
|
+
kind TEXT NOT NULL,
|
|
26
|
+
fromDigest TEXT NOT NULL,
|
|
27
|
+
subjectPreview TEXT NOT NULL,
|
|
28
|
+
bodyPreview TEXT NOT NULL,
|
|
29
|
+
routeId TEXT,
|
|
30
|
+
receivedAt INTEGER NOT NULL,
|
|
31
|
+
unread INTEGER NOT NULL
|
|
32
|
+
)`,
|
|
33
|
+
`CREATE INDEX IF NOT EXISTS idx_items_provider_received
|
|
34
|
+
ON items(provider, receivedAt)`,
|
|
35
|
+
`CREATE INDEX IF NOT EXISTS idx_items_received
|
|
36
|
+
ON items(receivedAt)`,
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS cursors (
|
|
38
|
+
provider TEXT PRIMARY KEY,
|
|
39
|
+
nextSince INTEGER NOT NULL
|
|
40
|
+
)`,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
interface ItemRow {
|
|
44
|
+
id: string;
|
|
45
|
+
provider: string;
|
|
46
|
+
kind: string;
|
|
47
|
+
fromDigest: string;
|
|
48
|
+
subjectPreview: string;
|
|
49
|
+
bodyPreview: string;
|
|
50
|
+
routeId: string | null;
|
|
51
|
+
receivedAt: number;
|
|
52
|
+
unread: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface InboxQuery {
|
|
56
|
+
providers?: readonly string[];
|
|
57
|
+
since?: number;
|
|
58
|
+
/** Max items returned across the whole query. */
|
|
59
|
+
limit: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class InboxCursorStore {
|
|
63
|
+
private readonly store: HandlerSqliteStore;
|
|
64
|
+
private dirty = false;
|
|
65
|
+
|
|
66
|
+
constructor(workingDirectory: string, fileName = 'inbox.sqlite') {
|
|
67
|
+
this.store = new HandlerSqliteStore({
|
|
68
|
+
workingDirectory,
|
|
69
|
+
fileName,
|
|
70
|
+
schema: SCHEMA,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get dbPath(): string {
|
|
75
|
+
return this.store.dbPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async init(): Promise<void> {
|
|
79
|
+
await this.store.init();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Insert/refresh items, deduping by id. On conflict the mutable feed fields
|
|
84
|
+
* (unread, previews, routeId, receivedAt) are updated in place.
|
|
85
|
+
* Returns the number of NEW (previously unseen) items.
|
|
86
|
+
*/
|
|
87
|
+
upsertItems(items: readonly InboundChannelItem[]): number {
|
|
88
|
+
if (items.length === 0) return 0;
|
|
89
|
+
let inserted = 0;
|
|
90
|
+
// Only the ids in THIS batch can collide, so probe for exactly those rather
|
|
91
|
+
// than loading the whole table — bounds the lookup to the poll size instead
|
|
92
|
+
// of growing O(n) with the (unbounded) feed.
|
|
93
|
+
const batchIds = [...new Set(items.map((i) => i.id))];
|
|
94
|
+
const placeholders = batchIds.map(() => '?').join(', ');
|
|
95
|
+
const existing = new Set(
|
|
96
|
+
this.store
|
|
97
|
+
.all<{ id: string }>(
|
|
98
|
+
`SELECT id FROM items WHERE id IN (${placeholders})`,
|
|
99
|
+
batchIds,
|
|
100
|
+
)
|
|
101
|
+
.map((r) => r.id),
|
|
102
|
+
);
|
|
103
|
+
// Track ids seen within this batch so a duplicate id in a single poll is
|
|
104
|
+
// counted (and inserted) once, not once per occurrence.
|
|
105
|
+
const seen = new Set<string>();
|
|
106
|
+
this.store.transaction(() => {
|
|
107
|
+
for (const item of items) {
|
|
108
|
+
const isNew = !existing.has(item.id) && !seen.has(item.id);
|
|
109
|
+
if (isNew) inserted += 1;
|
|
110
|
+
seen.add(item.id);
|
|
111
|
+
this.store.run(
|
|
112
|
+
`INSERT INTO items
|
|
113
|
+
(id, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
114
|
+
routeId, receivedAt, unread)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
116
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
117
|
+
provider = excluded.provider,
|
|
118
|
+
kind = excluded.kind,
|
|
119
|
+
fromDigest = excluded.fromDigest,
|
|
120
|
+
subjectPreview = excluded.subjectPreview,
|
|
121
|
+
bodyPreview = excluded.bodyPreview,
|
|
122
|
+
routeId = COALESCE(excluded.routeId, items.routeId),
|
|
123
|
+
receivedAt = excluded.receivedAt,
|
|
124
|
+
unread = excluded.unread`,
|
|
125
|
+
[
|
|
126
|
+
item.id,
|
|
127
|
+
item.provider,
|
|
128
|
+
item.kind,
|
|
129
|
+
item.fromDigest,
|
|
130
|
+
item.subjectPreview,
|
|
131
|
+
item.bodyPreview,
|
|
132
|
+
item.routeId ?? null,
|
|
133
|
+
item.receivedAt,
|
|
134
|
+
item.unread ? 1 : 0,
|
|
135
|
+
],
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
this.dirty = true;
|
|
140
|
+
return inserted;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Advance a provider's cursor monotonically. The stored value is always the
|
|
145
|
+
* max of the current value and the supplied candidate.
|
|
146
|
+
*/
|
|
147
|
+
advanceCursor(provider: string, candidate: number): void {
|
|
148
|
+
if (!Number.isFinite(candidate)) return;
|
|
149
|
+
const current = this.getCursor(provider);
|
|
150
|
+
const next = Math.max(current, Math.floor(candidate));
|
|
151
|
+
if (next === current && current !== 0) return;
|
|
152
|
+
this.store.run(
|
|
153
|
+
`INSERT INTO cursors (provider, nextSince) VALUES (?, ?)
|
|
154
|
+
ON CONFLICT(provider) DO UPDATE SET
|
|
155
|
+
nextSince = MAX(cursors.nextSince, excluded.nextSince)`,
|
|
156
|
+
[provider, next],
|
|
157
|
+
);
|
|
158
|
+
this.dirty = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Current cursor for a provider (0 when unset). */
|
|
162
|
+
getCursor(provider: string): number {
|
|
163
|
+
const row = this.store.get<{ nextSince: number }>(
|
|
164
|
+
'SELECT nextSince FROM cursors WHERE provider = ?',
|
|
165
|
+
[provider],
|
|
166
|
+
);
|
|
167
|
+
return row ? Number(row.nextSince) : 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read items for the feed, filtered by provider set + since, newest first,
|
|
172
|
+
* capped at limit. Maps SQLite rows back to the internal item shape.
|
|
173
|
+
*/
|
|
174
|
+
listItems(query: InboxQuery): InboundChannelItem[] {
|
|
175
|
+
const clauses: string[] = [];
|
|
176
|
+
const params: (string | number)[] = [];
|
|
177
|
+
if (query.providers && query.providers.length > 0) {
|
|
178
|
+
const placeholders = query.providers.map(() => '?').join(', ');
|
|
179
|
+
clauses.push(`provider IN (${placeholders})`);
|
|
180
|
+
params.push(...query.providers);
|
|
181
|
+
}
|
|
182
|
+
if (typeof query.since === 'number' && Number.isFinite(query.since)) {
|
|
183
|
+
clauses.push('receivedAt > ?');
|
|
184
|
+
params.push(Math.floor(query.since));
|
|
185
|
+
}
|
|
186
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
187
|
+
const limit = Math.max(0, Math.floor(query.limit));
|
|
188
|
+
params.push(limit);
|
|
189
|
+
const rows = this.store.all<ItemRow>(
|
|
190
|
+
`SELECT id, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
191
|
+
routeId, receivedAt, unread
|
|
192
|
+
FROM items ${where}
|
|
193
|
+
ORDER BY receivedAt DESC, id ASC
|
|
194
|
+
LIMIT ?`,
|
|
195
|
+
params,
|
|
196
|
+
);
|
|
197
|
+
return rows.map(rowToItem);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Highest receivedAt across the (optionally provider-filtered) feed, or 0. */
|
|
201
|
+
maxReceivedAt(providers?: readonly string[]): number {
|
|
202
|
+
let where = '';
|
|
203
|
+
const params: (string | number)[] = [];
|
|
204
|
+
if (providers && providers.length > 0) {
|
|
205
|
+
const placeholders = providers.map(() => '?').join(', ');
|
|
206
|
+
where = `WHERE provider IN (${placeholders})`;
|
|
207
|
+
params.push(...providers);
|
|
208
|
+
}
|
|
209
|
+
const row = this.store.get<{ maxReceived: number | null }>(
|
|
210
|
+
`SELECT MAX(receivedAt) AS maxReceived FROM items ${where}`,
|
|
211
|
+
params,
|
|
212
|
+
);
|
|
213
|
+
return row && row.maxReceived != null ? Number(row.maxReceived) : 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Total count of items across the (optional) provider set. */
|
|
217
|
+
countItems(providers?: readonly string[]): number {
|
|
218
|
+
let where = '';
|
|
219
|
+
const params: (string | number)[] = [];
|
|
220
|
+
if (providers && providers.length > 0) {
|
|
221
|
+
const placeholders = providers.map(() => '?').join(', ');
|
|
222
|
+
where = `WHERE provider IN (${placeholders})`;
|
|
223
|
+
params.push(...providers);
|
|
224
|
+
}
|
|
225
|
+
const row = this.store.get<{ n: number }>(
|
|
226
|
+
`SELECT COUNT(*) AS n FROM items ${where}`,
|
|
227
|
+
params,
|
|
228
|
+
);
|
|
229
|
+
return row ? Number(row.n) : 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Retention: delete items strictly older than `cutoff` (Unix ms), optionally
|
|
234
|
+
* scoped to a provider set. Returns the number of rows removed. Cursors are
|
|
235
|
+
* left untouched so already-consumed watermarks never regress. A non-finite
|
|
236
|
+
* cutoff is a no-op (prevents an accidental whole-table wipe).
|
|
237
|
+
*/
|
|
238
|
+
pruneOlderThan(cutoff: number, providers?: readonly string[]): number {
|
|
239
|
+
if (!Number.isFinite(cutoff)) return 0;
|
|
240
|
+
const clauses = ['receivedAt < ?'];
|
|
241
|
+
const params: (string | number)[] = [Math.floor(cutoff)];
|
|
242
|
+
if (providers && providers.length > 0) {
|
|
243
|
+
const placeholders = providers.map(() => '?').join(', ');
|
|
244
|
+
clauses.push(`provider IN (${placeholders})`);
|
|
245
|
+
params.push(...providers);
|
|
246
|
+
}
|
|
247
|
+
const before = this.countItems(providers);
|
|
248
|
+
this.store.run(`DELETE FROM items WHERE ${clauses.join(' AND ')}`, params);
|
|
249
|
+
const removed = before - this.countItems(providers);
|
|
250
|
+
if (removed > 0) this.dirty = true;
|
|
251
|
+
return removed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Persist to disk if anything changed since the last flush. */
|
|
255
|
+
async flush(): Promise<void> {
|
|
256
|
+
if (!this.dirty) return;
|
|
257
|
+
await this.store.save();
|
|
258
|
+
this.dirty = false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Flush (best-effort) then close the underlying database. */
|
|
262
|
+
async close(): Promise<void> {
|
|
263
|
+
try {
|
|
264
|
+
await this.flush();
|
|
265
|
+
} finally {
|
|
266
|
+
this.store.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function rowToItem(row: ItemRow): InboundChannelItem {
|
|
272
|
+
const item: InboundChannelItem = {
|
|
273
|
+
id: row.id,
|
|
274
|
+
provider: row.provider,
|
|
275
|
+
kind: normalizeKind(row.kind),
|
|
276
|
+
fromDigest: row.fromDigest,
|
|
277
|
+
subjectPreview: row.subjectPreview,
|
|
278
|
+
bodyPreview: row.bodyPreview,
|
|
279
|
+
receivedAt: Number(row.receivedAt),
|
|
280
|
+
unread: Number(row.unread) !== 0,
|
|
281
|
+
};
|
|
282
|
+
if (row.routeId != null) item.routeId = row.routeId;
|
|
283
|
+
return item;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeKind(value: string): InboundChannelItem['kind'] {
|
|
287
|
+
return value === 'dm' || value === 'thread' || value === 'mention' || value === 'reaction'
|
|
288
|
+
? value
|
|
289
|
+
: 'dm';
|
|
290
|
+
}
|