@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,449 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OperatorError,
|
|
3
|
+
createAtRestCipher,
|
|
4
|
+
createDaemonCredentialStore,
|
|
5
|
+
declareOperatorMethods,
|
|
6
|
+
} from '../../operator/index.ts';
|
|
7
|
+
import type {
|
|
8
|
+
OperatorContext,
|
|
9
|
+
OperatorHandler,
|
|
10
|
+
OperatorMethodDescriptor,
|
|
11
|
+
Unregister,
|
|
12
|
+
} from '../../operator/index.ts';
|
|
13
|
+
import {
|
|
14
|
+
ALL_DRAFT_STATUSES,
|
|
15
|
+
DEFAULT_DRAFT_LIST_LIMIT,
|
|
16
|
+
DraftSyncStore,
|
|
17
|
+
MAX_DRAFT_LIST_LIMIT,
|
|
18
|
+
WRITABLE_DRAFT_STATUSES,
|
|
19
|
+
} from './draft-store.ts';
|
|
20
|
+
import type {
|
|
21
|
+
DraftListQuery,
|
|
22
|
+
DraftRecord,
|
|
23
|
+
DraftSaveInput,
|
|
24
|
+
DraftSaveResult,
|
|
25
|
+
DraftStatus,
|
|
26
|
+
} from './draft-store.ts';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// channels.drafts.* operator methods (Draft Sync Backend).
|
|
30
|
+
//
|
|
31
|
+
// channels.drafts.list read-only scopes ['channels:drafts:read']
|
|
32
|
+
// channels.drafts.get read-only scopes ['channels:drafts:read']
|
|
33
|
+
// channels.drafts.save local mutation scopes ['channels:drafts:write']
|
|
34
|
+
// channels.drafts.delete local mutation scopes ['channels:drafts:write']
|
|
35
|
+
//
|
|
36
|
+
// save/delete are LOCAL state mutations (no external provider effect), so per
|
|
37
|
+
// the handoff ("Confirmation / Effect Semantics") they do NOT require
|
|
38
|
+
// confirm:true. The daemon still enforces encrypt-at-rest for the body and
|
|
39
|
+
// redaction of the webhook in every response (handled by DraftSyncStore).
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const CATEGORY = 'channels';
|
|
43
|
+
const READ_SCOPES = ['channels:drafts:read'];
|
|
44
|
+
const WRITE_SCOPES = ['channels:drafts:write'];
|
|
45
|
+
const BAD_INPUT = 'OPERATOR_INVALID_INPUT';
|
|
46
|
+
|
|
47
|
+
const writableStatusSet = new Set<string>(WRITABLE_DRAFT_STATUSES);
|
|
48
|
+
const allStatusSet = new Set<string>(ALL_DRAFT_STATUSES);
|
|
49
|
+
|
|
50
|
+
// --- Input validation helpers ----------------------------------------------
|
|
51
|
+
|
|
52
|
+
function asRecord(body: unknown): Record<string, unknown> {
|
|
53
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
54
|
+
throw new OperatorError('Request body must be an object.', BAD_INPUT, 400);
|
|
55
|
+
}
|
|
56
|
+
return body as Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function optionalString(
|
|
60
|
+
value: unknown,
|
|
61
|
+
field: string,
|
|
62
|
+
): string | undefined {
|
|
63
|
+
if (value === undefined || value === null) return undefined;
|
|
64
|
+
if (typeof value !== 'string') {
|
|
65
|
+
throw new OperatorError(`Field '${field}' must be a string.`, BAD_INPUT, 400);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function requiredString(value: unknown, field: string): string {
|
|
71
|
+
const str = optionalString(value, field);
|
|
72
|
+
if (str === undefined || str.length === 0) {
|
|
73
|
+
throw new OperatorError(`Field '${field}' is required.`, BAD_INPUT, 400);
|
|
74
|
+
}
|
|
75
|
+
return str;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function optionalTags(value: unknown): string[] | undefined {
|
|
79
|
+
if (value === undefined || value === null) return undefined;
|
|
80
|
+
if (!Array.isArray(value)) {
|
|
81
|
+
throw new OperatorError("Field 'tags' must be an array of strings.", BAD_INPUT, 400);
|
|
82
|
+
}
|
|
83
|
+
const tags: string[] = [];
|
|
84
|
+
for (const entry of value) {
|
|
85
|
+
if (typeof entry !== 'string') {
|
|
86
|
+
throw new OperatorError("Field 'tags' must contain only strings.", BAD_INPUT, 400);
|
|
87
|
+
}
|
|
88
|
+
tags.push(entry);
|
|
89
|
+
}
|
|
90
|
+
return tags;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function optionalLimit(value: unknown): number | undefined {
|
|
94
|
+
if (value === undefined || value === null) return undefined;
|
|
95
|
+
// Mirror the declared schema exactly: { type:'integer', minimum:1,
|
|
96
|
+
// maximum:MAX_DRAFT_LIST_LIMIT }. A float (e.g. 1.5) or out-of-range value
|
|
97
|
+
// must be rejected with OPERATOR_INVALID_INPUT rather than silently
|
|
98
|
+
// floored/clamped downstream by clampLimit — closing the contract-fidelity
|
|
99
|
+
// gap between the integer schema and the validator.
|
|
100
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
101
|
+
throw new OperatorError("Field 'limit' must be an integer.", BAD_INPUT, 400);
|
|
102
|
+
}
|
|
103
|
+
if (value < 1 || value > MAX_DRAFT_LIST_LIMIT) {
|
|
104
|
+
throw new OperatorError(
|
|
105
|
+
`Field 'limit' must be between 1 and ${MAX_DRAFT_LIST_LIMIT}.`,
|
|
106
|
+
BAD_INPUT,
|
|
107
|
+
400,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Strict ISO-8601 date-time: 'YYYY-MM-DDTHH:mm:ss' with optional fractional
|
|
114
|
+
// seconds and a 'Z' or '+/-HH:mm' offset. Anchored so loosely-formatted but
|
|
115
|
+
// Date.parse-able values (e.g. '2020', 'Jan 1 2020', '2020-1-1') are rejected.
|
|
116
|
+
const ISO_8601_DATE_TIME =
|
|
117
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
118
|
+
|
|
119
|
+
function optionalIso8601(value: unknown, field: string): string | undefined {
|
|
120
|
+
const str = optionalString(value, field);
|
|
121
|
+
if (str === undefined) return undefined;
|
|
122
|
+
// Lets an integrator push the agent's authoritative updatedAt so the sync
|
|
123
|
+
// contract's 'most recent updatedAt wins' conflict model is expressible
|
|
124
|
+
// end-to-end. The stored value is surfaced as DraftRecord.updatedAt, which
|
|
125
|
+
// the contract documents as ISO-8601 (format:'date-time'), so the input must
|
|
126
|
+
// be a strict ISO-8601 date-time — not merely Date.parse-able. We then
|
|
127
|
+
// normalize via Date#toISOString() to guarantee the persisted/returned value
|
|
128
|
+
// is always canonical ISO-8601, honoring the declared date-time format.
|
|
129
|
+
const time = ISO_8601_DATE_TIME.test(str) ? Date.parse(str) : Number.NaN;
|
|
130
|
+
if (Number.isNaN(time)) {
|
|
131
|
+
throw new OperatorError(
|
|
132
|
+
`Field '${field}' must be an ISO-8601 timestamp.`,
|
|
133
|
+
BAD_INPUT,
|
|
134
|
+
400,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return new Date(time).toISOString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseListStatus(value: unknown): DraftStatus | undefined {
|
|
141
|
+
const str = optionalString(value, 'status');
|
|
142
|
+
if (str === undefined) return undefined;
|
|
143
|
+
if (!allStatusSet.has(str)) {
|
|
144
|
+
throw new OperatorError(
|
|
145
|
+
`Field 'status' must be one of: ${ALL_DRAFT_STATUSES.join(', ')}.`,
|
|
146
|
+
BAD_INPUT,
|
|
147
|
+
400,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return str as DraftStatus;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseSaveStatus(value: unknown): DraftStatus | undefined {
|
|
154
|
+
const str = optionalString(value, 'status');
|
|
155
|
+
if (str === undefined) return undefined;
|
|
156
|
+
if (!writableStatusSet.has(str)) {
|
|
157
|
+
throw new OperatorError(
|
|
158
|
+
`Field 'status' must be one of: ${WRITABLE_DRAFT_STATUSES.join(', ')}.`,
|
|
159
|
+
BAD_INPUT,
|
|
160
|
+
400,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return str as DraftStatus;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseListQuery(body: unknown): DraftListQuery {
|
|
167
|
+
const record = asRecord(body ?? {});
|
|
168
|
+
const query: DraftListQuery = {};
|
|
169
|
+
const status = parseListStatus(record.status);
|
|
170
|
+
if (status !== undefined) query.status = status;
|
|
171
|
+
const limit = optionalLimit(record.limit);
|
|
172
|
+
if (limit !== undefined) query.limit = limit;
|
|
173
|
+
return query;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseSaveInput(body: unknown): DraftSaveInput {
|
|
177
|
+
const record = asRecord(body);
|
|
178
|
+
const input: DraftSaveInput = {
|
|
179
|
+
message: requiredString(record.message, 'message'),
|
|
180
|
+
};
|
|
181
|
+
const id = optionalString(record.id, 'id');
|
|
182
|
+
if (id !== undefined) input.id = id;
|
|
183
|
+
const title = optionalString(record.title, 'title');
|
|
184
|
+
if (title !== undefined) input.title = title;
|
|
185
|
+
const channel = optionalString(record.channel, 'channel');
|
|
186
|
+
if (channel !== undefined) input.channel = channel;
|
|
187
|
+
const route = optionalString(record.route, 'route');
|
|
188
|
+
if (route !== undefined) input.route = route;
|
|
189
|
+
const webhook = optionalString(record.webhook, 'webhook');
|
|
190
|
+
if (webhook !== undefined) input.webhook = webhook;
|
|
191
|
+
const link = optionalString(record.link, 'link');
|
|
192
|
+
if (link !== undefined) input.link = link;
|
|
193
|
+
const tags = optionalTags(record.tags);
|
|
194
|
+
if (tags !== undefined) input.tags = tags;
|
|
195
|
+
const status = parseSaveStatus(record.status);
|
|
196
|
+
if (status !== undefined) input.status = status;
|
|
197
|
+
const updatedAt = optionalIso8601(record.updatedAt, 'updatedAt');
|
|
198
|
+
if (updatedAt !== undefined) input.updatedAt = updatedAt;
|
|
199
|
+
return input;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseIdInput(body: unknown): string {
|
|
203
|
+
const record = asRecord(body);
|
|
204
|
+
return requiredString(record.id, 'id');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- JSON Schemas (catalog metadata) ---------------------------------------
|
|
208
|
+
|
|
209
|
+
const draftRecordSchema: Record<string, unknown> = {
|
|
210
|
+
type: 'object',
|
|
211
|
+
required: ['id', 'createdAt', 'updatedAt', 'status', 'messageDigest'],
|
|
212
|
+
properties: {
|
|
213
|
+
id: { type: 'string' },
|
|
214
|
+
createdAt: { type: 'string' },
|
|
215
|
+
updatedAt: { type: 'string' },
|
|
216
|
+
status: { type: 'string', enum: [...ALL_DRAFT_STATUSES] },
|
|
217
|
+
title: { type: 'string' },
|
|
218
|
+
messageDigest: { type: 'string' },
|
|
219
|
+
channel: { type: 'string' },
|
|
220
|
+
route: { type: 'string' },
|
|
221
|
+
webhook: { type: 'string', description: "Always '[redacted]' when present." },
|
|
222
|
+
link: { type: 'string' },
|
|
223
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
224
|
+
sentResponseId: { type: 'string' },
|
|
225
|
+
sendError: { type: 'string' },
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const listInputSchema: Record<string, unknown> = {
|
|
230
|
+
type: 'object',
|
|
231
|
+
properties: {
|
|
232
|
+
status: { type: 'string', enum: [...ALL_DRAFT_STATUSES] },
|
|
233
|
+
limit: {
|
|
234
|
+
type: 'integer',
|
|
235
|
+
minimum: 1,
|
|
236
|
+
maximum: MAX_DRAFT_LIST_LIMIT,
|
|
237
|
+
default: DEFAULT_DRAFT_LIST_LIMIT,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const listOutputSchema: Record<string, unknown> = {
|
|
243
|
+
type: 'object',
|
|
244
|
+
required: ['drafts'],
|
|
245
|
+
properties: { drafts: { type: 'array', items: draftRecordSchema } },
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const getInputSchema: Record<string, unknown> = {
|
|
249
|
+
type: 'object',
|
|
250
|
+
required: ['id'],
|
|
251
|
+
properties: { id: { type: 'string' } },
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const getOutputSchema: Record<string, unknown> = {
|
|
255
|
+
oneOf: [
|
|
256
|
+
draftRecordSchema,
|
|
257
|
+
{
|
|
258
|
+
type: 'object',
|
|
259
|
+
required: ['notFound', 'id'],
|
|
260
|
+
properties: { notFound: { const: true }, id: { type: 'string' } },
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const saveInputSchema: Record<string, unknown> = {
|
|
266
|
+
type: 'object',
|
|
267
|
+
required: ['message'],
|
|
268
|
+
properties: {
|
|
269
|
+
id: { type: 'string' },
|
|
270
|
+
title: { type: 'string' },
|
|
271
|
+
message: { type: 'string' },
|
|
272
|
+
channel: { type: 'string' },
|
|
273
|
+
route: { type: 'string' },
|
|
274
|
+
webhook: { type: 'string' },
|
|
275
|
+
link: { type: 'string' },
|
|
276
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
277
|
+
status: { type: 'string', enum: [...WRITABLE_DRAFT_STATUSES] },
|
|
278
|
+
updatedAt: {
|
|
279
|
+
type: 'string',
|
|
280
|
+
format: 'date-time',
|
|
281
|
+
description:
|
|
282
|
+
"Optional caller-supplied last-modified timestamp (ISO-8601). When "
|
|
283
|
+
+ "provided it is persisted verbatim, enabling the 'most recent updatedAt "
|
|
284
|
+
+ "wins' conflict model; when omitted the daemon stamps server now().",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const saveOutputSchema: Record<string, unknown> = {
|
|
290
|
+
type: 'object',
|
|
291
|
+
required: ['id', 'created'],
|
|
292
|
+
properties: { id: { type: 'string' }, created: { type: 'boolean' } },
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const deleteInputSchema = getInputSchema;
|
|
296
|
+
|
|
297
|
+
const deleteOutputSchema: Record<string, unknown> = {
|
|
298
|
+
type: 'object',
|
|
299
|
+
required: ['deleted'],
|
|
300
|
+
properties: { deleted: { type: 'boolean' } },
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// --- Response types ---------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
interface ListResponse {
|
|
306
|
+
drafts: DraftRecord[];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
type GetResponse = DraftRecord | { notFound: true; id: string };
|
|
310
|
+
|
|
311
|
+
// --- Registration -----------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
export interface RegisterDraftsOptions {
|
|
314
|
+
/** Override the sqlite filename (tests). */
|
|
315
|
+
fileName?: string;
|
|
316
|
+
/** Inject a pre-built store (tests). When provided, no credential store is built. */
|
|
317
|
+
store?: DraftSyncStore;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Register the channels.drafts.* operator methods against the catalog in ctx.
|
|
322
|
+
* Returns an Unregister that removes all four methods.
|
|
323
|
+
*
|
|
324
|
+
* The store is created eagerly but initialized lazily on first invocation
|
|
325
|
+
* (SQLite WASM load is deferred until a draft method is actually called). Every
|
|
326
|
+
* mutating handler persists the store via save() after the mutation so the
|
|
327
|
+
* mirror survives daemon restarts.
|
|
328
|
+
*/
|
|
329
|
+
export function registerDraftsMethods(
|
|
330
|
+
ctx: OperatorContext,
|
|
331
|
+
options: RegisterDraftsOptions = {},
|
|
332
|
+
): Unregister {
|
|
333
|
+
const store =
|
|
334
|
+
options.store
|
|
335
|
+
?? new DraftSyncStore({
|
|
336
|
+
workingDirectory: ctx.workingDirectory,
|
|
337
|
+
cipher: createAtRestCipher(createDaemonCredentialStore(ctx.secrets)),
|
|
338
|
+
...(options.fileName !== undefined ? { fileName: options.fileName } : {}),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
let initPromise: Promise<void> | null = null;
|
|
342
|
+
const ensureInit = (): Promise<void> => {
|
|
343
|
+
if (!initPromise) initPromise = store.init();
|
|
344
|
+
return initPromise;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const listHandler: OperatorHandler<unknown, ListResponse> = async ({ body }) => {
|
|
348
|
+
await ensureInit();
|
|
349
|
+
const query = parseListQuery(body);
|
|
350
|
+
return { drafts: store.list(query) };
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const getHandler: OperatorHandler<unknown, GetResponse> = async ({ body }) => {
|
|
354
|
+
await ensureInit();
|
|
355
|
+
const id = parseIdInput(body);
|
|
356
|
+
const record = store.get(id);
|
|
357
|
+
return record ?? { notFound: true, id };
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const saveHandler: OperatorHandler<unknown, DraftSaveResult> = async ({ body }) => {
|
|
361
|
+
await ensureInit();
|
|
362
|
+
const input = parseSaveInput(body);
|
|
363
|
+
const result = await store.upsert(input);
|
|
364
|
+
await store.save();
|
|
365
|
+
return result;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const deleteHandler: OperatorHandler<unknown, { deleted: boolean }> = async ({ body }) => {
|
|
369
|
+
await ensureInit();
|
|
370
|
+
const id = parseIdInput(body);
|
|
371
|
+
const deleted = store.delete(id);
|
|
372
|
+
if (deleted) await store.save();
|
|
373
|
+
return { deleted };
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const listDescriptor: OperatorMethodDescriptor = {
|
|
377
|
+
id: 'channels.drafts.list',
|
|
378
|
+
title: 'List drafts',
|
|
379
|
+
description:
|
|
380
|
+
'List server-mirrored channel drafts. Bodies are never returned — only a '
|
|
381
|
+
+ 'messageDigest. Webhooks are redacted.',
|
|
382
|
+
category: CATEGORY,
|
|
383
|
+
access: 'operator',
|
|
384
|
+
scopes: READ_SCOPES,
|
|
385
|
+
effect: 'read-only',
|
|
386
|
+
inputSchema: listInputSchema,
|
|
387
|
+
outputSchema: listOutputSchema,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const getDescriptor: OperatorMethodDescriptor = {
|
|
391
|
+
id: 'channels.drafts.get',
|
|
392
|
+
title: 'Get draft',
|
|
393
|
+
description:
|
|
394
|
+
'Fetch a single server-mirrored draft by id. The body is never returned — '
|
|
395
|
+
+ 'only a messageDigest. Webhook is redacted. Returns { notFound, id } when absent.',
|
|
396
|
+
category: CATEGORY,
|
|
397
|
+
access: 'operator',
|
|
398
|
+
scopes: READ_SCOPES,
|
|
399
|
+
effect: 'read-only',
|
|
400
|
+
inputSchema: getInputSchema,
|
|
401
|
+
outputSchema: getOutputSchema,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const saveDescriptor: OperatorMethodDescriptor = {
|
|
405
|
+
id: 'channels.drafts.save',
|
|
406
|
+
title: 'Save draft',
|
|
407
|
+
description:
|
|
408
|
+
'Create or update a server-mirrored draft. The message body is encrypted at '
|
|
409
|
+
+ 'rest; the webhook is encrypted and redacted on read. Local state mutation, '
|
|
410
|
+
+ 'no confirmation required.',
|
|
411
|
+
category: CATEGORY,
|
|
412
|
+
access: 'operator',
|
|
413
|
+
scopes: WRITE_SCOPES,
|
|
414
|
+
effect: 'local-state-mutation',
|
|
415
|
+
confirm: false,
|
|
416
|
+
inputSchema: saveInputSchema,
|
|
417
|
+
outputSchema: saveOutputSchema,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const deleteDescriptor: OperatorMethodDescriptor = {
|
|
421
|
+
id: 'channels.drafts.delete',
|
|
422
|
+
title: 'Delete draft',
|
|
423
|
+
description:
|
|
424
|
+
'Delete a server-mirrored draft by id. Local state mutation, no confirmation '
|
|
425
|
+
+ 'required.',
|
|
426
|
+
category: CATEGORY,
|
|
427
|
+
access: 'operator',
|
|
428
|
+
scopes: WRITE_SCOPES,
|
|
429
|
+
effect: 'local-state-mutation',
|
|
430
|
+
confirm: false,
|
|
431
|
+
inputSchema: deleteInputSchema,
|
|
432
|
+
outputSchema: deleteOutputSchema,
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const unregisterMethods = declareOperatorMethods(ctx, [
|
|
436
|
+
{ descriptor: listDescriptor, handler: listHandler as OperatorHandler<unknown, unknown> },
|
|
437
|
+
{ descriptor: getDescriptor, handler: getHandler as OperatorHandler<unknown, unknown> },
|
|
438
|
+
{ descriptor: saveDescriptor, handler: saveHandler as OperatorHandler<unknown, unknown> },
|
|
439
|
+
{ descriptor: deleteDescriptor, handler: deleteHandler as OperatorHandler<unknown, unknown> },
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
// Teardown unregisters the methods and — only when this function owns the
|
|
443
|
+
// store — closes it. An injected store is owned by the caller.
|
|
444
|
+
const ownsStore = options.store === undefined;
|
|
445
|
+
return () => {
|
|
446
|
+
unregisterMethods();
|
|
447
|
+
if (ownsStore) store.close();
|
|
448
|
+
};
|
|
449
|
+
}
|