@pellux/goodvibes-tui 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/daemon/calendar/caldav-client.ts +657 -0
- package/src/daemon/calendar/ics.ts +556 -0
- package/src/daemon/calendar/index.ts +52 -0
- package/src/daemon/calendar/register.ts +527 -0
- package/src/daemon/channels/drafts/draft-store.ts +363 -0
- package/src/daemon/channels/drafts/index.ts +22 -0
- package/src/daemon/channels/drafts/register.ts +449 -0
- package/src/daemon/channels/inbox/cursor-store.ts +298 -0
- package/src/daemon/channels/inbox/index.ts +58 -0
- package/src/daemon/channels/inbox/mapping.ts +190 -0
- package/src/daemon/channels/inbox/poller.ts +155 -0
- package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
- package/src/daemon/channels/inbox/providers/discord.ts +253 -0
- package/src/daemon/channels/inbox/providers/email.ts +151 -0
- package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
- package/src/daemon/channels/inbox/providers/slack.ts +264 -0
- package/src/daemon/channels/inbox/register.ts +247 -0
- package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
- package/src/daemon/channels/routing/index.ts +39 -0
- package/src/daemon/channels/routing/register.ts +296 -0
- package/src/daemon/channels/routing/route-store.ts +278 -0
- package/src/daemon/channels/routing/routing-resolver.ts +75 -0
- package/src/daemon/email/imap-connector.ts +441 -0
- package/src/daemon/email/imap-parsing.ts +499 -0
- package/src/daemon/email/index.ts +68 -0
- package/src/daemon/email/register.ts +715 -0
- package/src/daemon/email/smtp-connector.ts +557 -0
- package/src/daemon/operator/credential-store.ts +129 -0
- package/src/daemon/operator/index.ts +43 -0
- package/src/daemon/operator/register-helper.ts +150 -0
- package/src/daemon/operator/sqlite-store.ts +124 -0
- package/src/daemon/operator/surfaces.ts +137 -0
- package/src/daemon/operator/types.ts +207 -0
- package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
- package/src/daemon/remote/backends/docker.ts +80 -0
- package/src/daemon/remote/backends/index.ts +34 -0
- package/src/daemon/remote/backends/local-process.ts +113 -0
- package/src/daemon/remote/backends/process-runner.ts +151 -0
- package/src/daemon/remote/backends/ssh.ts +120 -0
- package/src/daemon/remote/backends/types.ts +71 -0
- package/src/daemon/remote/dispatcher.ts +160 -0
- package/src/daemon/remote/index.ts +74 -0
- package/src/daemon/remote/peer-registry.ts +321 -0
- package/src/daemon/remote/register.ts +411 -0
- package/src/daemon/triage/index.ts +59 -0
- package/src/daemon/triage/integration.ts +179 -0
- package/src/daemon/triage/pipeline.ts +285 -0
- package/src/daemon/triage/register.ts +231 -0
- package/src/daemon/triage/scorer.ts +287 -0
- package/src/daemon/triage/tagger.ts +777 -0
- package/src/runtime/services.ts +35 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Slack inbound adapter.
|
|
3
|
+
//
|
|
4
|
+
// Transport note (contract fidelity): the daemon-handoff checklist names the
|
|
5
|
+
// Slack Events API / RTM for DM polling. Those are push transports (HTTP event
|
|
6
|
+
// callbacks / a persistent websocket) and cannot be driven by the inbound
|
|
7
|
+
// poller, whose contract is a stateless, cadence-driven adapter.poll() that
|
|
8
|
+
// MUST resolve each call (see provider-adapter.ts). A websocket/event-callback
|
|
9
|
+
// would require an inbound HTTP endpoint or a long-lived socket the poller
|
|
10
|
+
// neither owns nor supervises. We therefore satisfy the DM-polling goal over
|
|
11
|
+
// the supported request/response surface (the Slack Web API), which the Events
|
|
12
|
+
// API itself documents as the canonical pull-based equivalent for reading DM
|
|
13
|
+
// history on an interval:
|
|
14
|
+
// conversations.list (types=im) -> open DM channels (cursor-paginated)
|
|
15
|
+
// conversations.history -> recent messages per DM
|
|
16
|
+
// auth.test -> our own user id (for @-mention class.);
|
|
17
|
+
// id is digested, never emitted raw.
|
|
18
|
+
//
|
|
19
|
+
// Credential: surfaces.slack.botToken (xoxb-...). Missing => 'unavailable'.
|
|
20
|
+
// Cadence: 30s (realtime tier).
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
AdapterContext,
|
|
25
|
+
InboundChannelItem,
|
|
26
|
+
InboundProviderAdapter,
|
|
27
|
+
ProviderPollOptions,
|
|
28
|
+
ProviderPollResult,
|
|
29
|
+
} from '../provider-adapter.ts';
|
|
30
|
+
import { POLL_CADENCE_MS } from '../provider-adapter.ts';
|
|
31
|
+
import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
|
|
32
|
+
import { resolveRouteId } from './route-util.ts';
|
|
33
|
+
|
|
34
|
+
const SLACK_API = 'https://slack.com/api';
|
|
35
|
+
export const SLACK_PROVIDER_ID = 'slack';
|
|
36
|
+
export const SLACK_CREDENTIAL_KEY = 'surfaces.slack.botToken';
|
|
37
|
+
|
|
38
|
+
interface SlackConversationsListResponse {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
error?: string;
|
|
41
|
+
channels?: Array<{ id: string; user?: string }>;
|
|
42
|
+
response_metadata?: { next_cursor?: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SlackAuthTestResponse {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
error?: string;
|
|
48
|
+
user_id?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SlackReaction {
|
|
52
|
+
name?: string;
|
|
53
|
+
count?: number;
|
|
54
|
+
users?: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SlackMessage {
|
|
58
|
+
type?: string;
|
|
59
|
+
subtype?: string;
|
|
60
|
+
user?: string;
|
|
61
|
+
bot_id?: string;
|
|
62
|
+
text?: string;
|
|
63
|
+
ts?: string; // "1700000000.000200"
|
|
64
|
+
thread_ts?: string;
|
|
65
|
+
reactions?: SlackReaction[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SlackHistoryResponse {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
error?: string;
|
|
71
|
+
messages?: SlackMessage[];
|
|
72
|
+
has_more?: boolean;
|
|
73
|
+
response_metadata?: { next_cursor?: string };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Classify a Slack message into the InboundChannelItem `kind`.
|
|
78
|
+
* - reaction: someone reacted to OUR OWN message — a genuine inbound reaction
|
|
79
|
+
* event. This requires the message to be authored by us (its
|
|
80
|
+
* user id is selfUserId) AND to carry a non-empty reactions[].
|
|
81
|
+
* A message authored by someone else that merely carries
|
|
82
|
+
* reactions[] is a normal DM, not a reaction event.
|
|
83
|
+
* - mention: the message @-mentions us (text contains `<@SELF>`)
|
|
84
|
+
* - thread: a threaded reply (thread_ts present and not the root ts)
|
|
85
|
+
* - dm: a plain direct message
|
|
86
|
+
* Reaction takes precedence over mention/thread because a reaction is the most
|
|
87
|
+
* specific inbound signal; mention outranks thread/dm.
|
|
88
|
+
*/
|
|
89
|
+
function classifySlackKind(
|
|
90
|
+
msg: SlackMessage,
|
|
91
|
+
selfUserId: string | undefined,
|
|
92
|
+
): InboundChannelItem['kind'] {
|
|
93
|
+
if (
|
|
94
|
+
selfUserId &&
|
|
95
|
+
msg.user === selfUserId &&
|
|
96
|
+
Array.isArray(msg.reactions) &&
|
|
97
|
+
msg.reactions.length > 0
|
|
98
|
+
) {
|
|
99
|
+
return 'reaction';
|
|
100
|
+
}
|
|
101
|
+
if (selfUserId && typeof msg.text === 'string' && msg.text.includes(`<@${selfUserId}>`)) {
|
|
102
|
+
return 'mention';
|
|
103
|
+
}
|
|
104
|
+
if (msg.thread_ts && msg.thread_ts !== msg.ts) return 'thread';
|
|
105
|
+
return 'dm';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Slack ts ("1700000000.000200") -> Unix ms. */
|
|
109
|
+
function tsToMs(ts: string | undefined): number {
|
|
110
|
+
if (!ts) return 0;
|
|
111
|
+
const seconds = Number.parseFloat(ts);
|
|
112
|
+
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function slackGet<T>(
|
|
116
|
+
token: string,
|
|
117
|
+
method: string,
|
|
118
|
+
params: Record<string, string>,
|
|
119
|
+
): Promise<T> {
|
|
120
|
+
const url = new URL(`${SLACK_API}/${method}`);
|
|
121
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
122
|
+
const res = await fetch(url, {
|
|
123
|
+
method: 'GET',
|
|
124
|
+
headers: {
|
|
125
|
+
Authorization: `Bearer ${token}`,
|
|
126
|
+
Accept: 'application/json',
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
throw new Error(`Slack ${method} HTTP ${res.status}`);
|
|
131
|
+
}
|
|
132
|
+
return (await res.json()) as T;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createSlackAdapter(ctx: AdapterContext): InboundProviderAdapter {
|
|
136
|
+
return {
|
|
137
|
+
id: SLACK_PROVIDER_ID,
|
|
138
|
+
pollIntervalMs: POLL_CADENCE_MS.realtime,
|
|
139
|
+
async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
|
|
140
|
+
let token: string | null;
|
|
141
|
+
try {
|
|
142
|
+
token = await ctx.credentials.resolveConfigSecret(SLACK_CREDENTIAL_KEY);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return unavailable(`credential lookup failed: ${errMsg(error)}`);
|
|
145
|
+
}
|
|
146
|
+
if (!token || token.trim().length === 0) {
|
|
147
|
+
return unavailable('missing surfaces.slack.botToken');
|
|
148
|
+
}
|
|
149
|
+
if (!token.startsWith('xoxb-') && !token.startsWith('xoxp-')) {
|
|
150
|
+
return unavailable('surfaces.slack.botToken is not a valid Slack bot/user token');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Resolve our own user id so we can distinguish @-mentions of us from
|
|
154
|
+
// plain DMs. Best-effort: a failure here only means mentions are not
|
|
155
|
+
// separately classified, it never fails the provider.
|
|
156
|
+
let selfUserId: string | undefined;
|
|
157
|
+
try {
|
|
158
|
+
const auth = await slackGet<SlackAuthTestResponse>(token, 'auth.test', {});
|
|
159
|
+
if (auth.ok && auth.user_id) selfUserId = auth.user_id;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
ctx.logger.warn('slack auth.test failed; mentions will not be classified', {
|
|
162
|
+
error: errMsg(error),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
// Page through ALL open DM channels. conversations.list returns at most
|
|
168
|
+
// `limit` channels per page and a response_metadata.next_cursor when more
|
|
169
|
+
// remain; without following the cursor, accounts with >100 open DMs would
|
|
170
|
+
// silently lose every channel past the first page. MAX_LIST_PAGES bounds
|
|
171
|
+
// the loop so a misbehaving cursor can never spin forever.
|
|
172
|
+
const MAX_LIST_PAGES = 50;
|
|
173
|
+
const channels: Array<{ id: string; user?: string }> = [];
|
|
174
|
+
let cursor: string | undefined;
|
|
175
|
+
for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
|
|
176
|
+
const list = await slackGet<SlackConversationsListResponse>(token, 'conversations.list', {
|
|
177
|
+
types: 'im',
|
|
178
|
+
limit: '100',
|
|
179
|
+
...(cursor ? { cursor } : {}),
|
|
180
|
+
});
|
|
181
|
+
if (!list.ok) {
|
|
182
|
+
return unavailable(`conversations.list: ${list.error ?? 'unknown_error'}`);
|
|
183
|
+
}
|
|
184
|
+
if (list.channels) channels.push(...list.channels);
|
|
185
|
+
const next = list.response_metadata?.next_cursor;
|
|
186
|
+
if (!next || next.length === 0) break;
|
|
187
|
+
cursor = next;
|
|
188
|
+
}
|
|
189
|
+
const oldest = opts.since ? (opts.since / 1000).toFixed(6) : undefined;
|
|
190
|
+
// Page through conversations.history per channel: a single call returns at
|
|
191
|
+
// most `limit` (<=50) messages and sets has_more + a next_cursor when a DM
|
|
192
|
+
// accrued more new messages than fit in one page. Without following the
|
|
193
|
+
// cursor a busy DM would silently drop everything past the first page.
|
|
194
|
+
// MAX_HISTORY_PAGES bounds the loop so a misbehaving cursor cannot spin.
|
|
195
|
+
const MAX_HISTORY_PAGES = 20;
|
|
196
|
+
const items: InboundChannelItem[] = [];
|
|
197
|
+
channelLoop: for (const channel of channels) {
|
|
198
|
+
if (items.length >= opts.limit) break;
|
|
199
|
+
let historyCursor: string | undefined;
|
|
200
|
+
for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
|
|
201
|
+
const history = await slackGet<SlackHistoryResponse>(token, 'conversations.history', {
|
|
202
|
+
channel: channel.id,
|
|
203
|
+
limit: String(Math.min(opts.limit, 50)),
|
|
204
|
+
...(oldest ? { oldest } : {}),
|
|
205
|
+
...(historyCursor ? { cursor: historyCursor } : {}),
|
|
206
|
+
});
|
|
207
|
+
if (!history.ok) {
|
|
208
|
+
// Skip this DM but keep going; do not fail the whole provider.
|
|
209
|
+
ctx.logger.warn('slack conversations.history failed', {
|
|
210
|
+
channel: channel.id,
|
|
211
|
+
error: history.error,
|
|
212
|
+
});
|
|
213
|
+
continue channelLoop;
|
|
214
|
+
}
|
|
215
|
+
for (const msg of history.messages ?? []) {
|
|
216
|
+
if (items.length >= opts.limit) break;
|
|
217
|
+
if (msg.subtype === 'bot_message' || msg.bot_id) continue;
|
|
218
|
+
const senderId = msg.user ?? channel.user ?? channel.id;
|
|
219
|
+
const receivedAt = tsToMs(msg.ts);
|
|
220
|
+
if (opts.since && receivedAt <= opts.since) continue;
|
|
221
|
+
// Contract: fromDigest is SHA-256 (first 16 hex == 8 bytes) of the
|
|
222
|
+
// provider user id (see provider-adapter.ts InboundChannelItem and
|
|
223
|
+
// handoff acceptance checklist 'first 16 hex chars'). Slack user ids
|
|
224
|
+
// (U...) are unique within a workspace, and the item.id / provider
|
|
225
|
+
// fields already namespace by provider.
|
|
226
|
+
const fromDigest = digestSender(senderId);
|
|
227
|
+
const kind = classifySlackKind(msg, selfUserId);
|
|
228
|
+
const item: InboundChannelItem = {
|
|
229
|
+
id: `slack:${channel.id}:${msg.ts ?? String(receivedAt)}`,
|
|
230
|
+
provider: SLACK_PROVIDER_ID,
|
|
231
|
+
kind,
|
|
232
|
+
fromDigest,
|
|
233
|
+
subjectPreview: toSubjectPreview(`Direct message`),
|
|
234
|
+
bodyPreview: toBodyPreview(msg.text),
|
|
235
|
+
receivedAt,
|
|
236
|
+
unread: true,
|
|
237
|
+
};
|
|
238
|
+
const routeId = await resolveRouteId(ctx, SLACK_PROVIDER_ID, fromDigest, kind);
|
|
239
|
+
if (routeId) item.routeId = routeId;
|
|
240
|
+
items.push(item);
|
|
241
|
+
}
|
|
242
|
+
// Advance to the next history page only while the channel reported
|
|
243
|
+
// more messages AND we still have item budget left.
|
|
244
|
+
const nextHistory = history.response_metadata?.next_cursor;
|
|
245
|
+
if (!history.has_more || !nextHistory || nextHistory.length === 0) break;
|
|
246
|
+
if (items.length >= opts.limit) break;
|
|
247
|
+
historyCursor = nextHistory;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { items, state: items.length > 0 ? 'ready' : 'empty' };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return unavailable(errMsg(error));
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function unavailable(error: string): ProviderPollResult {
|
|
259
|
+
return { items: [], state: 'unavailable', error };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function errMsg(error: unknown): string {
|
|
263
|
+
return error instanceof Error ? error.message : String(error);
|
|
264
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Surface register: `channels.inbox.list`.
|
|
3
|
+
//
|
|
4
|
+
// register(ctx) =>
|
|
5
|
+
// 1. registers built-in provider adapter factories (slack/discord/email)
|
|
6
|
+
// 2. constructs adapters with a daemon credential store
|
|
7
|
+
// 3. opens the inbox cursor store and seeds an initial poll
|
|
8
|
+
// 4. starts the per-provider polling loops
|
|
9
|
+
// 5. publishes the read-only `channels.inbox.list` operator method
|
|
10
|
+
// 6. returns an Unregister that stops the poller, closes the store, and
|
|
11
|
+
// removes the method.
|
|
12
|
+
//
|
|
13
|
+
// `channels.inbox.list` is READ-ONLY (no confirm). It reads the persisted feed
|
|
14
|
+
// (filtered by providers/limit/since) and reports per-provider state from the
|
|
15
|
+
// live poller status. nextSince advances monotonically (max receivedAt in the
|
|
16
|
+
// returned window, never below the requested `since`).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createDaemonCredentialStore,
|
|
21
|
+
declareOperatorMethod,
|
|
22
|
+
} from '../../operator/index.ts';
|
|
23
|
+
import type {
|
|
24
|
+
OperatorContext,
|
|
25
|
+
OperatorInvocation,
|
|
26
|
+
SurfaceRegister,
|
|
27
|
+
Unregister,
|
|
28
|
+
} from '../../operator/index.ts';
|
|
29
|
+
import {
|
|
30
|
+
buildAdapters,
|
|
31
|
+
registerAdapterFactory,
|
|
32
|
+
type AdapterContext,
|
|
33
|
+
type InboundChannelItem,
|
|
34
|
+
type RouteResolver,
|
|
35
|
+
} from './provider-adapter.ts';
|
|
36
|
+
import { InboxCursorStore } from './cursor-store.ts';
|
|
37
|
+
import { InboundPoller, type ProviderStatus } from './poller.ts';
|
|
38
|
+
import { createSlackAdapter, SLACK_PROVIDER_ID } from './providers/slack.ts';
|
|
39
|
+
import { createDiscordAdapter, DISCORD_PROVIDER_ID } from './providers/discord.ts';
|
|
40
|
+
import { createEmailAdapter, EMAIL_PROVIDER_ID } from './providers/email.ts';
|
|
41
|
+
|
|
42
|
+
export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
|
|
43
|
+
export const INBOX_LIST_SCOPES = ['channels:inbox:read'];
|
|
44
|
+
const DEFAULT_LIMIT = 50;
|
|
45
|
+
const MAX_LIMIT = 500;
|
|
46
|
+
|
|
47
|
+
export interface InboxListInput {
|
|
48
|
+
providers?: string[];
|
|
49
|
+
limit?: number;
|
|
50
|
+
since?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InboxProviderReport {
|
|
54
|
+
id: string;
|
|
55
|
+
state: ProviderStatus['state'];
|
|
56
|
+
itemCount: number;
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface InboxListOutput {
|
|
61
|
+
items: InboundChannelItem[];
|
|
62
|
+
nextSince: number;
|
|
63
|
+
providers: InboxProviderReport[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RegisterInboxOptions {
|
|
67
|
+
/** Route resolver injected by the routing surface (optional, best-effort). */
|
|
68
|
+
resolveRouteId?: RouteResolver;
|
|
69
|
+
/** Override the cursor-store filename (tests). */
|
|
70
|
+
storeFileName?: string;
|
|
71
|
+
/** Skip the initial seed poll (tests that drive polling manually). */
|
|
72
|
+
skipInitialPoll?: boolean;
|
|
73
|
+
/** Replace the default built-in adapter set (tests). */
|
|
74
|
+
registerBuiltins?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function registerBuiltinAdapters(): void {
|
|
78
|
+
registerAdapterFactory(SLACK_PROVIDER_ID, (ctx) => createSlackAdapter(ctx));
|
|
79
|
+
registerAdapterFactory(DISCORD_PROVIDER_ID, (ctx) => createDiscordAdapter(ctx));
|
|
80
|
+
registerAdapterFactory(EMAIL_PROVIDER_ID, (ctx) => createEmailAdapter(ctx));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const INBOX_LIST_OUTPUT_SCHEMA: Record<string, unknown> = {
|
|
84
|
+
type: 'object',
|
|
85
|
+
required: ['items', 'nextSince', 'providers'],
|
|
86
|
+
properties: {
|
|
87
|
+
items: {
|
|
88
|
+
type: 'array',
|
|
89
|
+
items: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
required: [
|
|
92
|
+
'id', 'provider', 'kind', 'fromDigest',
|
|
93
|
+
'subjectPreview', 'bodyPreview', 'receivedAt', 'unread',
|
|
94
|
+
],
|
|
95
|
+
properties: {
|
|
96
|
+
id: { type: 'string' },
|
|
97
|
+
provider: { type: 'string' },
|
|
98
|
+
kind: { type: 'string', enum: ['dm', 'thread', 'mention', 'reaction'] },
|
|
99
|
+
fromDigest: { type: 'string' },
|
|
100
|
+
subjectPreview: { type: 'string', maxLength: 200 },
|
|
101
|
+
bodyPreview: { type: 'string', maxLength: 500 },
|
|
102
|
+
routeId: { type: 'string' },
|
|
103
|
+
receivedAt: { type: 'number' },
|
|
104
|
+
unread: { type: 'boolean' },
|
|
105
|
+
triageScore: { type: 'number' },
|
|
106
|
+
triageTags: { type: 'array', items: { type: 'string' } },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
nextSince: { type: 'number' },
|
|
111
|
+
providers: {
|
|
112
|
+
type: 'array',
|
|
113
|
+
items: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
required: ['id', 'state', 'itemCount'],
|
|
116
|
+
properties: {
|
|
117
|
+
id: { type: 'string' },
|
|
118
|
+
state: { type: 'string', enum: ['ready', 'unavailable', 'empty'] },
|
|
119
|
+
itemCount: { type: 'number' },
|
|
120
|
+
error: { type: 'string' },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const INBOX_LIST_INPUT_SCHEMA: Record<string, unknown> = {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
providers: { type: 'array', items: { type: 'string' } },
|
|
131
|
+
limit: { type: 'number', minimum: 1, maximum: MAX_LIMIT },
|
|
132
|
+
since: { type: 'number', minimum: 0 },
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function normalizeInput(body: unknown): { providers?: string[]; limit: number; since?: number } {
|
|
137
|
+
const input = (body ?? {}) as InboxListInput;
|
|
138
|
+
const providers = Array.isArray(input.providers)
|
|
139
|
+
? input.providers.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
|
140
|
+
: undefined;
|
|
141
|
+
let limit = typeof input.limit === 'number' && Number.isFinite(input.limit)
|
|
142
|
+
? Math.floor(input.limit)
|
|
143
|
+
: DEFAULT_LIMIT;
|
|
144
|
+
limit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
|
145
|
+
const since = typeof input.since === 'number' && Number.isFinite(input.since) && input.since >= 0
|
|
146
|
+
? Math.floor(input.since)
|
|
147
|
+
: undefined;
|
|
148
|
+
return {
|
|
149
|
+
...(providers && providers.length > 0 ? { providers } : {}),
|
|
150
|
+
limit,
|
|
151
|
+
...(since !== undefined ? { since } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Register the inbox surface. Returns an Unregister that tears down the poller,
|
|
157
|
+
* closes the store, and removes the operator method.
|
|
158
|
+
*/
|
|
159
|
+
export function registerInboxMethods(
|
|
160
|
+
ctx: OperatorContext,
|
|
161
|
+
options: RegisterInboxOptions = {},
|
|
162
|
+
): Unregister {
|
|
163
|
+
if (options.registerBuiltins !== false) {
|
|
164
|
+
registerBuiltinAdapters();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const credentials = createDaemonCredentialStore(ctx.secrets);
|
|
168
|
+
const adapterContext: AdapterContext = {
|
|
169
|
+
credentials,
|
|
170
|
+
logger: ctx.logger,
|
|
171
|
+
...(options.resolveRouteId ? { resolveRouteId: options.resolveRouteId } : {}),
|
|
172
|
+
};
|
|
173
|
+
const adapters = buildAdapters(adapterContext);
|
|
174
|
+
const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName);
|
|
175
|
+
const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
|
|
176
|
+
|
|
177
|
+
// Async bootstrap: init store, seed one poll, start loops. Failures are
|
|
178
|
+
// logged but never thrown out of register() — the method still serves the
|
|
179
|
+
// (possibly empty) persisted feed and reports provider states.
|
|
180
|
+
const ready: Promise<void> = (async () => {
|
|
181
|
+
await store.init();
|
|
182
|
+
if (!options.skipInitialPoll) {
|
|
183
|
+
await poller.pollOnce();
|
|
184
|
+
}
|
|
185
|
+
poller.start();
|
|
186
|
+
})().catch((error: unknown) => {
|
|
187
|
+
ctx.logger.error('inbox surface bootstrap failed', {
|
|
188
|
+
error: error instanceof Error ? error.message : String(error),
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const handler = async (
|
|
193
|
+
invocation: OperatorInvocation<InboxListInput>,
|
|
194
|
+
): Promise<InboxListOutput> => {
|
|
195
|
+
await ready;
|
|
196
|
+
const { providers, limit, since } = normalizeInput(invocation.body);
|
|
197
|
+
const items = store.listItems({ ...(providers ? { providers } : {}), ...(since !== undefined ? { since } : {}), limit });
|
|
198
|
+
const maxReceived = store.maxReceivedAt(providers);
|
|
199
|
+
const nextSince = Math.max(since ?? 0, maxReceived);
|
|
200
|
+
const statuses = poller.snapshotStatuses(providers);
|
|
201
|
+
const reports: InboxProviderReport[] = statuses.map((status) => ({
|
|
202
|
+
id: status.id,
|
|
203
|
+
state: status.state,
|
|
204
|
+
itemCount: status.itemCount,
|
|
205
|
+
...(status.error ? { error: status.error } : {}),
|
|
206
|
+
}));
|
|
207
|
+
return { items, nextSince, providers: reports };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const unregisterMethod = declareOperatorMethod<InboxListInput, InboxListOutput>(
|
|
211
|
+
ctx,
|
|
212
|
+
{
|
|
213
|
+
id: INBOX_LIST_METHOD_ID,
|
|
214
|
+
title: 'List inbound channel items',
|
|
215
|
+
description:
|
|
216
|
+
'Aggregated, deduplicated inbound feed (DMs/threads/mentions) across '
|
|
217
|
+
+ 'configured providers. Read-only; advances a monotonic nextSince cursor.',
|
|
218
|
+
category: 'channels',
|
|
219
|
+
source: 'daemon',
|
|
220
|
+
access: 'operator',
|
|
221
|
+
transport: ['ws', 'internal'],
|
|
222
|
+
scopes: INBOX_LIST_SCOPES,
|
|
223
|
+
effect: 'read-only',
|
|
224
|
+
confirm: false,
|
|
225
|
+
inputSchema: INBOX_LIST_INPUT_SCHEMA,
|
|
226
|
+
outputSchema: INBOX_LIST_OUTPUT_SCHEMA,
|
|
227
|
+
},
|
|
228
|
+
handler,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return () => {
|
|
232
|
+
try {
|
|
233
|
+
unregisterMethod();
|
|
234
|
+
} finally {
|
|
235
|
+
poller.stop();
|
|
236
|
+
void store.close();
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* SurfaceRegister contract entry point. Integration wires the inbox surface by
|
|
243
|
+
* calling this from its surface bootstrap (the same mechanism every other
|
|
244
|
+
* daemon surface uses, e.g. routing/triage), retaining the returned Unregister
|
|
245
|
+
* for teardown. This is the production caller of `registerInboxMethods`.
|
|
246
|
+
*/
|
|
247
|
+
export const register: SurfaceRegister = (ctx) => registerInboxMethods(ctx);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Inbox <-> Routing resolver bridge.
|
|
3
|
+
//
|
|
4
|
+
// The inbox provider adapters expose a best-effort `RouteResolver` seam
|
|
5
|
+
// (provider-adapter.ts): given an inbound item's `{ provider, fromDigest, kind }`
|
|
6
|
+
// they ask "which daemon route binding handles this item?" and stamp the answer
|
|
7
|
+
// onto `item.routeId` when one resolves. The routing control plane owns the
|
|
8
|
+
// channel<->profile bindings and exposes a `RoutingResolver` whose
|
|
9
|
+
// `getProfileForChannel(surfaceKind, routeId?)` applies the canonical resolution
|
|
10
|
+
// order:
|
|
11
|
+
//
|
|
12
|
+
// 1. exact — surfaceKind AND routeId both match
|
|
13
|
+
// 2. surface — surfaceKind matches, route has no routeId
|
|
14
|
+
// 3. wildcard — a route with surfaceKind === 'any' (no routeId)
|
|
15
|
+
//
|
|
16
|
+
// This module bridges the two shapes. The bridge maps an inbound item's
|
|
17
|
+
// `provider` to the routing `surfaceKind` and forwards an optional `routeId`
|
|
18
|
+
// refinement; `fromDigest`/`kind` are NOT used as routing keys (the routing
|
|
19
|
+
// store is keyed by surfaceKind + optional routeId only, never by sender digest
|
|
20
|
+
// or message kind — see RoutingChannelRoute). When no binding matches the
|
|
21
|
+
// resolver returns null and the bridge yields `undefined`, so the adapter falls
|
|
22
|
+
// back to leaving the item unrouted (offline/default behaviour intact). The
|
|
23
|
+
// bridge NEVER throws: route-util.ts already swallows resolver errors, and the
|
|
24
|
+
// underlying resolver is a pure in-memory lookup.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import type { RouteResolver } from '../inbox/provider-adapter.ts';
|
|
28
|
+
import type { RoutingResolver } from './routing-resolver.ts';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Optional input fields the bridge consults for routeId refinement. The
|
|
32
|
+
* published {@link RouteResolver} input does not currently carry a routeId, so
|
|
33
|
+
* surface-only / wildcard resolution is the effective path today; should the
|
|
34
|
+
* inbox seam grow a routeId (e.g. a Slack channel id or email mailbox tag) the
|
|
35
|
+
* bridge will pass it through to enable exact (surfaceKind+routeId) matches
|
|
36
|
+
* without any further change here.
|
|
37
|
+
*/
|
|
38
|
+
type RouteResolverInput = Parameters<RouteResolver>[0] & { routeId?: unknown };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a {@link RouteResolver} backed by the routing surface's
|
|
42
|
+
* {@link RoutingResolver}. Maps `provider -> surfaceKind` and forwards an
|
|
43
|
+
* optional `routeId` refinement, then applies the canonical
|
|
44
|
+
* exact > surface-only > wildcard resolution order via
|
|
45
|
+
* `getProfileForChannel`. Returns the resolved profileId (the daemon route
|
|
46
|
+
* binding) or `undefined` when nothing matches.
|
|
47
|
+
*/
|
|
48
|
+
export function createInboxRouteResolver(resolver: RoutingResolver): RouteResolver {
|
|
49
|
+
return (input: RouteResolverInput): string | undefined => {
|
|
50
|
+
const surfaceKind = typeof input.provider === 'string' ? input.provider : '';
|
|
51
|
+
if (surfaceKind.length === 0) return undefined;
|
|
52
|
+
const routeId = typeof input.routeId === 'string' && input.routeId.length > 0 ? input.routeId : undefined;
|
|
53
|
+
// getProfileForChannel applies exact (surfaceKind+routeId) > surface-only >
|
|
54
|
+
// wildcard ('any') and returns null when no assignment matches.
|
|
55
|
+
const profileId = resolver.getProfileForChannel(surfaceKind, routeId);
|
|
56
|
+
return profileId ?? undefined;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Channel-to-Profile Routing Control Plane — surface barrel.
|
|
2
|
+
//
|
|
3
|
+
// Integration wires this surface by calling `register(ctx)` (or
|
|
4
|
+
// `registerRoutingMethods(ctx)` when it needs the returned store/resolver).
|
|
5
|
+
// The inbox surface reuses `resolveProfile` / `createRoutingResolver` for
|
|
6
|
+
// identical online/offline routing resolution.
|
|
7
|
+
|
|
8
|
+
export { register, registerRoutingMethods } from './register.ts';
|
|
9
|
+
export type {
|
|
10
|
+
RoutingRegistration,
|
|
11
|
+
RoutingListInput,
|
|
12
|
+
RoutingListOutput,
|
|
13
|
+
RoutingAssignInput,
|
|
14
|
+
RoutingAssignOutput,
|
|
15
|
+
RoutingDeleteInput,
|
|
16
|
+
RoutingDeleteOutput,
|
|
17
|
+
} from './register.ts';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
RouteStore,
|
|
21
|
+
parseChannelId,
|
|
22
|
+
buildChannelId,
|
|
23
|
+
} from './route-store.ts';
|
|
24
|
+
export type {
|
|
25
|
+
RoutingChannelRoute,
|
|
26
|
+
ParsedChannelId,
|
|
27
|
+
RouteUpsertInput,
|
|
28
|
+
RouteUpsertResult,
|
|
29
|
+
RouteListFilter,
|
|
30
|
+
} from './route-store.ts';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
resolveProfile,
|
|
34
|
+
createRoutingResolver,
|
|
35
|
+
WILDCARD_SURFACE,
|
|
36
|
+
} from './routing-resolver.ts';
|
|
37
|
+
export type { RoutingResolver } from './routing-resolver.ts';
|
|
38
|
+
|
|
39
|
+
export { createInboxRouteResolver } from './inbox-bridge.ts';
|