@pellux/goodvibes-tui 0.25.0 → 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 +5 -3
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
- 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/{channels → handlers}/drafts/draft-store.ts +114 -50
- 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/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/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/{channels → handlers}/inbox/cursor-store.ts +58 -66
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
- package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
- package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
- package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
- package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
- package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
- package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
- package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
- package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
- package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
- package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
- 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 -35
- package/src/version.ts +1 -1
- package/src/daemon/calendar/index.ts +0 -52
- package/src/daemon/calendar/register.ts +0 -527
- package/src/daemon/channels/drafts/index.ts +0 -22
- package/src/daemon/channels/drafts/register.ts +0 -449
- package/src/daemon/channels/inbox/index.ts +0 -58
- package/src/daemon/channels/inbox/register.ts +0 -247
- package/src/daemon/channels/routing/index.ts +0 -39
- package/src/daemon/channels/routing/register.ts +0 -296
- package/src/daemon/email/index.ts +0 -68
- package/src/daemon/email/register.ts +0 -715
- package/src/daemon/operator/index.ts +0 -43
- package/src/daemon/operator/register-helper.ts +0 -150
- package/src/daemon/operator/surfaces.ts +0 -137
- package/src/daemon/operator/types.ts +0 -207
- package/src/daemon/remote/backends/index.ts +0 -34
- package/src/daemon/remote/index.ts +0 -74
- package/src/daemon/remote/register.ts +0 -411
- package/src/daemon/triage/index.ts +0 -59
- package/src/daemon/triage/integration.ts +0 -179
- package/src/daemon/triage/register.ts +0 -231
- package/src/daemon/triage/tagger.ts +0 -777
- /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
- /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
- /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
- /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
- /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
- /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
- /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -3,23 +3,25 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Rules (from the handoff contract):
|
|
5
5
|
// - fromDigest = sha256First(senderExternalId, 16) (never the raw id)
|
|
6
|
-
// (16 hex chars == the first 8 bytes of the SHA-256 digest;
|
|
7
|
-
// handoff acceptance checklist: 'first 16 hex chars')
|
|
8
6
|
// - bodyPreview = plain-text, PII-stripped, truncated to 500 chars
|
|
9
7
|
// - subjectPreview <= 200 chars
|
|
10
8
|
// These are pure and deterministic so they are unit-testable in isolation.
|
|
11
9
|
// ---------------------------------------------------------------------------
|
|
12
10
|
|
|
13
|
-
import {
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
14
12
|
|
|
15
13
|
export const SUBJECT_PREVIEW_MAX = 200;
|
|
16
14
|
export const BODY_PREVIEW_MAX = 500;
|
|
17
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
|
+
|
|
18
22
|
/**
|
|
19
23
|
* Digest a sender's external id to a stable 16-hex-char token (the first 8
|
|
20
|
-
* bytes of the SHA-256 digest). The
|
|
21
|
-
* refers to those 8 bytes; the acceptance checklist states it as 'first 16 hex
|
|
22
|
-
* chars'. Both describe the same value emitted here.
|
|
24
|
+
* bytes of the SHA-256 digest). The raw id is never emitted.
|
|
23
25
|
*/
|
|
24
26
|
export function digestSender(senderExternalId: string): string {
|
|
25
27
|
return sha256First(senderExternalId, 16);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Discord 30s, email 60s, others 120s). Each tick:
|
|
6
6
|
// 1. resolves the provider's persisted cursor (nextSince)
|
|
7
7
|
// 2. calls adapter.poll({ since, limit })
|
|
8
|
-
// 3. dedups + persists items into the cursor store (
|
|
8
|
+
// 3. dedups + persists items into the cursor store (upsert)
|
|
9
9
|
// 4. advances the cursor monotonically to max(receivedAt)
|
|
10
10
|
// 5. records the last per-provider state for channels.inbox.list to report
|
|
11
11
|
//
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { InboundProviderAdapter, ProviderState } from './provider-adapter.ts';
|
|
18
18
|
import type { InboxCursorStore } from './cursor-store.ts';
|
|
19
|
-
import type {
|
|
19
|
+
import type { HandlerLogger } from '../context.ts';
|
|
20
20
|
|
|
21
21
|
export interface ProviderStatus {
|
|
22
22
|
id: string;
|
|
@@ -29,7 +29,7 @@ export interface ProviderStatus {
|
|
|
29
29
|
export interface PollerOptions {
|
|
30
30
|
adapters: Map<string, InboundProviderAdapter>;
|
|
31
31
|
store: InboxCursorStore;
|
|
32
|
-
logger:
|
|
32
|
+
logger: HandlerLogger;
|
|
33
33
|
/** Max items fetched per provider per tick. */
|
|
34
34
|
perProviderLimit?: number;
|
|
35
35
|
/** Inject a timer factory for tests (defaults to global setInterval). */
|
|
@@ -42,7 +42,7 @@ const DEFAULT_PER_PROVIDER_LIMIT = 50;
|
|
|
42
42
|
export class InboundPoller {
|
|
43
43
|
private readonly adapters: Map<string, InboundProviderAdapter>;
|
|
44
44
|
private readonly store: InboxCursorStore;
|
|
45
|
-
private readonly logger:
|
|
45
|
+
private readonly logger: HandlerLogger;
|
|
46
46
|
private readonly perProviderLimit: number;
|
|
47
47
|
private readonly setIntervalImpl: typeof setInterval;
|
|
48
48
|
private readonly clearIntervalImpl: typeof clearInterval;
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
// caller must be able to distinguish "configured-but-empty" from "not wired".
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
import type { DaemonCredentialStore } from '
|
|
16
|
-
import type {
|
|
15
|
+
import type { DaemonCredentialStore } from '../credentials.ts';
|
|
16
|
+
import type { HandlerLogger } from '../context.ts';
|
|
17
17
|
|
|
18
|
-
/**
|
|
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
|
+
*/
|
|
19
24
|
export interface InboundChannelItem {
|
|
20
25
|
/** Stable, provider-scoped dedup key. Idempotent across polls. */
|
|
21
26
|
id: string;
|
|
@@ -23,8 +28,7 @@ export interface InboundChannelItem {
|
|
|
23
28
|
kind: 'dm' | 'thread' | 'mention' | 'reaction';
|
|
24
29
|
/**
|
|
25
30
|
* sha256First(senderExternalId, 16) — NEVER the raw id. 16 hex chars == the
|
|
26
|
-
* first 8 bytes of the SHA-256 digest
|
|
27
|
-
* checklist: 'first 16 hex chars' — the same value).
|
|
31
|
+
* first 8 bytes of the SHA-256 digest.
|
|
28
32
|
*/
|
|
29
33
|
fromDigest: string;
|
|
30
34
|
/** <= 200 chars, safe for display. */
|
|
@@ -60,7 +64,7 @@ export interface ProviderPollOptions {
|
|
|
60
64
|
/** Context handed to every adapter at construction time. */
|
|
61
65
|
export interface AdapterContext {
|
|
62
66
|
readonly credentials: DaemonCredentialStore;
|
|
63
|
-
readonly logger:
|
|
67
|
+
readonly logger: HandlerLogger;
|
|
64
68
|
/**
|
|
65
69
|
* Optional route resolver supplied by the routing surface. Returns the route
|
|
66
70
|
* binding id for a given inbound item, or undefined when no route matches /
|
|
@@ -70,8 +74,8 @@ export interface AdapterContext {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/**
|
|
73
|
-
* Best-effort route resolution seam. The routing surface
|
|
74
|
-
*
|
|
77
|
+
* Best-effort route resolution seam. The routing surface injects its
|
|
78
|
+
* profile-backed implementation via the inbox bridge. Until then it is
|
|
75
79
|
* undefined and items carry no routeId.
|
|
76
80
|
*/
|
|
77
81
|
export type RouteResolver = (input: {
|
|
@@ -127,8 +131,8 @@ export function registeredProviderIds(): string[] {
|
|
|
127
131
|
|
|
128
132
|
/**
|
|
129
133
|
* Construct adapters for the requested provider ids (or all registered ids when
|
|
130
|
-
* `requested` is undefined/empty). Unknown ids are
|
|
131
|
-
*
|
|
134
|
+
* `requested` is undefined/empty). Unknown ids are dropped: the returned map
|
|
135
|
+
* only contains known providers.
|
|
132
136
|
*/
|
|
133
137
|
export function buildAdapters(
|
|
134
138
|
ctx: AdapterContext,
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// stateless, cadence-driven adapter.poll() that MUST resolve each call (see
|
|
8
8
|
// provider-adapter.ts) — a long-lived socket the poller neither owns nor
|
|
9
9
|
// supervises is out of scope for that contract. We therefore satisfy the
|
|
10
|
-
// DM-polling goal
|
|
11
|
-
// Discord Gateway") over Discord's supported request/response surface, the REST
|
|
10
|
+
// DM-polling goal over Discord's supported request/response surface, the REST
|
|
12
11
|
// API — the same DM data the Gateway streams, fetched on the poll cadence and
|
|
13
12
|
// paged so a busy DM is never truncated:
|
|
14
13
|
// GET /users/@me/channels -> list DM channels the bot participates in
|
|
@@ -67,10 +66,10 @@ interface DiscordMessage {
|
|
|
67
66
|
/**
|
|
68
67
|
* Classify a Discord message into the InboundChannelItem `kind`.
|
|
69
68
|
* - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
|
|
70
|
-
* event.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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.
|
|
74
73
|
* - mention: the message mentions us (our id is in mentions[])
|
|
75
74
|
* - thread: the message is a reply (referenced_message present)
|
|
76
75
|
* - dm: a plain direct message
|
|
@@ -201,10 +200,9 @@ export function createDiscordAdapter(ctx: AdapterContext): InboundProviderAdapte
|
|
|
201
200
|
if (opts.since && receivedAt <= opts.since) continue;
|
|
202
201
|
const senderId = msg.author?.id ?? channel.id;
|
|
203
202
|
// Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
|
|
204
|
-
// provider user id (
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// fields already namespace by provider.
|
|
203
|
+
// provider user id. Discord user ids (snowflakes) are globally
|
|
204
|
+
// unique, and the item.id / provider fields already namespace by
|
|
205
|
+
// provider.
|
|
208
206
|
const fromDigest = digestSender(senderId);
|
|
209
207
|
const kind = classifyDiscordKind(msg, selfId);
|
|
210
208
|
const item: InboundChannelItem = {
|
|
@@ -72,7 +72,7 @@ export class ImapClient {
|
|
|
72
72
|
await this.command(`LOGIN ${user} ${pass}`);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** SELECT a mailbox (default INBOX).
|
|
75
|
+
/** SELECT a mailbox (default INBOX). */
|
|
76
76
|
async select(mailbox = 'INBOX'): Promise<void> {
|
|
77
77
|
await this.command(`SELECT ${quote(mailbox)}`);
|
|
78
78
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shared best-effort route resolution wrapper used by all adapters.
|
|
2
|
-
// Swallows resolver failures (routing is an optional,
|
|
3
|
-
// route lookup can never crash a provider poll.
|
|
2
|
+
// Swallows resolver failures (routing is an optional, concurrently-wired
|
|
3
|
+
// surface) so a route lookup can never crash a provider poll.
|
|
4
4
|
|
|
5
5
|
import type { AdapterContext, InboundChannelItem } from '../provider-adapter.ts';
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// auth.test -> our own user id (for @-mention class.);
|
|
17
17
|
// id is digested, never emitted raw.
|
|
18
18
|
//
|
|
19
|
-
// Credential: surfaces.slack.botToken
|
|
19
|
+
// Credential: surfaces.slack.botToken. Missing => 'unavailable'.
|
|
20
20
|
// Cadence: 30s (realtime tier).
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
|
|
@@ -76,15 +76,14 @@ interface SlackHistoryResponse {
|
|
|
76
76
|
/**
|
|
77
77
|
* Classify a Slack message into the InboundChannelItem `kind`.
|
|
78
78
|
* - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
|
|
79
|
-
* event.
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
79
|
+
* event. Requires the message to be authored by us (its user id
|
|
80
|
+
* is selfUserId) AND to carry a non-empty reactions[]. A message
|
|
81
|
+
* authored by someone else that merely carries reactions[] is a
|
|
82
|
+
* normal DM, not a reaction event.
|
|
83
83
|
* - mention: the message @-mentions us (text contains `<@SELF>`)
|
|
84
84
|
* - thread: a threaded reply (thread_ts present and not the root ts)
|
|
85
85
|
* - dm: a plain direct message
|
|
86
|
-
* Reaction
|
|
87
|
-
* specific inbound signal; mention outranks thread/dm.
|
|
86
|
+
* Reaction outranks mention which outranks thread/dm (most-specific first).
|
|
88
87
|
*/
|
|
89
88
|
function classifySlackKind(
|
|
90
89
|
msg: SlackMessage,
|
|
@@ -219,10 +218,9 @@ export function createSlackAdapter(ctx: AdapterContext): InboundProviderAdapter
|
|
|
219
218
|
const receivedAt = tsToMs(msg.ts);
|
|
220
219
|
if (opts.since && receivedAt <= opts.since) continue;
|
|
221
220
|
// Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
|
|
222
|
-
// provider user id (
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// fields already namespace by provider.
|
|
221
|
+
// provider user id. Slack user ids (U...) are unique within a
|
|
222
|
+
// workspace, and the item.id / provider fields already namespace
|
|
223
|
+
// by provider.
|
|
226
224
|
const fromDigest = digestSender(senderId);
|
|
227
225
|
const kind = classifySlackKind(msg, selfUserId);
|
|
228
226
|
const item: InboundChannelItem = {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root for the daemon handler layer — the only module that
|
|
3
|
+
* `src/runtime/services.ts` imports. It assembles every surface (routing,
|
|
4
|
+
* inbox+triage, drafts, calendar, email, remote) onto the SDK gateway catalog
|
|
5
|
+
* and returns a single teardown plus the cross-surface handles the runtime
|
|
6
|
+
* needs (routing resolver, the remote `DistributedRuntimeRouteService` the SDK
|
|
7
|
+
* facade injects, and the `remote.peers.invoke` dispatch adapter).
|
|
8
|
+
*
|
|
9
|
+
* Surfaces are provided by the caller as `SurfaceRegister` functions so this
|
|
10
|
+
* foundation module stays free of import cycles and does not author any SDK
|
|
11
|
+
* descriptor or schema. Registration order is fixed; teardown runs in reverse.
|
|
12
|
+
*/
|
|
13
|
+
import type { DistributedRuntimeRouteService } from './contracts.ts';
|
|
14
|
+
import type { HandlerContext, SurfaceRegister } from './context.ts';
|
|
15
|
+
import type { Unregister } from './register.ts';
|
|
16
|
+
|
|
17
|
+
/** Routing surface handle: teardown plus the resolver other surfaces consume. */
|
|
18
|
+
export interface RoutingRegistration {
|
|
19
|
+
readonly unregister: Unregister;
|
|
20
|
+
/** Resolve an inbound channel target to a profile id (exact → wildcard → null). */
|
|
21
|
+
readonly resolveProfileId: (surfaceKind: string, routeId?: string) => string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Adapter the runtime wires into the published `remote.peers.invoke` route. */
|
|
25
|
+
export interface RemoteInvokeAdapter {
|
|
26
|
+
invoke(input: Record<string, unknown>): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Remote surface handle: the host service the SDK facade injects + its teardown. */
|
|
30
|
+
export interface RemoteSurfaceRegistration {
|
|
31
|
+
readonly unregister: Unregister;
|
|
32
|
+
readonly service: DistributedRuntimeRouteService;
|
|
33
|
+
readonly dispatch: RemoteInvokeAdapter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aggregate result returned to the runtime for teardown and cross-surface wiring. */
|
|
37
|
+
export interface DaemonHandlerSurfaces {
|
|
38
|
+
readonly unregister: Unregister;
|
|
39
|
+
readonly routing: RoutingRegistration;
|
|
40
|
+
readonly remoteSurface: { readonly service: DistributedRuntimeRouteService };
|
|
41
|
+
readonly remoteDispatch: RemoteInvokeAdapter;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The surface implementations the runtime supplies. Each builds its stores and
|
|
46
|
+
* attaches handlers to the SDK catalog via `registerCatalogHandler(s)`.
|
|
47
|
+
*
|
|
48
|
+
* Order of composition (and reverse teardown) is enforced by
|
|
49
|
+
* `registerDaemonHandlers`: routing → inbox(+triage) → drafts → calendar →
|
|
50
|
+
* email → remote.
|
|
51
|
+
*/
|
|
52
|
+
export interface DaemonHandlerSurfaceProviders {
|
|
53
|
+
/** channels.routing.* — returns the resolver consumed by the inbox surface. */
|
|
54
|
+
readonly registerRouting: (ctx: HandlerContext) => RoutingRegistration;
|
|
55
|
+
/** channels.inbox.list (triage-decorated) + inbox.triage.* (daemon-internal). */
|
|
56
|
+
readonly registerInbox: (ctx: HandlerContext, routing: RoutingRegistration) => Unregister;
|
|
57
|
+
/** channels.drafts.* */
|
|
58
|
+
readonly registerDrafts: SurfaceRegister;
|
|
59
|
+
/** calendar.* */
|
|
60
|
+
readonly registerCalendar: SurfaceRegister;
|
|
61
|
+
/** email.* */
|
|
62
|
+
readonly registerEmail: SurfaceRegister;
|
|
63
|
+
/** remote.peers.* — supplies the host DistributedRuntimeRouteService + dispatch adapter. */
|
|
64
|
+
readonly registerRemote: (ctx: HandlerContext) => RemoteSurfaceRegistration;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compose all daemon handler surfaces onto the gateway catalog held by `ctx`.
|
|
69
|
+
* Returns a single teardown (reverse order, best-effort) and the handles the
|
|
70
|
+
* runtime must expose: the routing registration, the remote service the SDK
|
|
71
|
+
* facade injects into `DaemonRemoteRouteContext.distributedRuntime`, and the
|
|
72
|
+
* remote invoke dispatch adapter.
|
|
73
|
+
*/
|
|
74
|
+
export function registerDaemonHandlers(
|
|
75
|
+
ctx: HandlerContext,
|
|
76
|
+
providers: DaemonHandlerSurfaceProviders,
|
|
77
|
+
): DaemonHandlerSurfaces {
|
|
78
|
+
const teardowns: Unregister[] = [];
|
|
79
|
+
|
|
80
|
+
const routing = providers.registerRouting(ctx);
|
|
81
|
+
teardowns.push(routing.unregister);
|
|
82
|
+
|
|
83
|
+
teardowns.push(providers.registerInbox(ctx, routing));
|
|
84
|
+
teardowns.push(providers.registerDrafts(ctx));
|
|
85
|
+
teardowns.push(providers.registerCalendar(ctx));
|
|
86
|
+
teardowns.push(providers.registerEmail(ctx));
|
|
87
|
+
|
|
88
|
+
const remote = providers.registerRemote(ctx);
|
|
89
|
+
teardowns.push(remote.unregister);
|
|
90
|
+
|
|
91
|
+
const unregister: Unregister = () => {
|
|
92
|
+
for (let i = teardowns.length - 1; i >= 0; i -= 1) {
|
|
93
|
+
try {
|
|
94
|
+
teardowns[i]!();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
ctx.logger.warn('daemon handler surface teardown failed', { error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
unregister,
|
|
103
|
+
routing,
|
|
104
|
+
remoteSurface: { service: remote.service },
|
|
105
|
+
remoteDispatch: remote.dispatch,
|
|
106
|
+
};
|
|
107
|
+
}
|