dsh-scheduled-message 0.1.1
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/LICENSE +21 -0
- package/README.md +113 -0
- package/client.js +1739 -0
- package/cordis.patch.yml +3 -0
- package/docs/DETAIL.md +440 -0
- package/docs/preview.png +0 -0
- package/icon.svg +13 -0
- package/index.js +694 -0
- package/lib/core.js +466 -0
- package/locale/en.json +91 -0
- package/locale/zh.json +91 -0
- package/package.json +65 -0
- package/scripts/build-client.mjs +99 -0
- package/scripts/check.mjs +101 -0
- package/scripts/host-smoke.mjs +433 -0
package/index.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-scheduled-message.
|
|
3
|
+
*
|
|
4
|
+
* Owns one durable record per scheduled message and one `ctx.timer.timeout`
|
|
5
|
+
* armed for the earliest pending record, so a message still sends when the
|
|
6
|
+
* browser tab is closed and DSH keeps running; a message whose instant passed
|
|
7
|
+
* while DSH was closed is sent at the next start and marked late.
|
|
8
|
+
*
|
|
9
|
+
* Everything sits behind `ctx.inject`, so a profile without the Automation
|
|
10
|
+
* service still loads: Repeat is then reported as unavailable instead of
|
|
11
|
+
* failing the plugin.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-scheduled-message
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
18
|
+
import {
|
|
19
|
+
DOMAIN_NAME,
|
|
20
|
+
MAX_PENDING,
|
|
21
|
+
NS,
|
|
22
|
+
buildRrule,
|
|
23
|
+
earliestPending,
|
|
24
|
+
isLate,
|
|
25
|
+
pruneTargets,
|
|
26
|
+
summariseTitle,
|
|
27
|
+
timerDelay,
|
|
28
|
+
validateCreateRequest,
|
|
29
|
+
} from './lib/core.js'
|
|
30
|
+
|
|
31
|
+
/** Largest accepted create body; ten images plus text fit comfortably. */
|
|
32
|
+
const MAX_CREATE_BODY_BYTES = 64 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
/** Delivery is abandoned after this long instead of holding the serialized queue. */
|
|
35
|
+
const DELIVERY_TIMEOUT_MS = 120_000
|
|
36
|
+
|
|
37
|
+
/** Records surfaced to a session's composer strip. */
|
|
38
|
+
const SENT_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
/** Longest stored diagnostic. */
|
|
41
|
+
const MAX_ERROR_CHARS = 500
|
|
42
|
+
|
|
43
|
+
const STATUSES = new Set(['pending', 'sending', 'sent', 'failed', 'cancelled'])
|
|
44
|
+
|
|
45
|
+
const ROUTE_PREFIX = '/api/dsh-scheduled-message'
|
|
46
|
+
|
|
47
|
+
/** Stable machine codes the browser half maps onto localized copy. */
|
|
48
|
+
const CODES = {
|
|
49
|
+
invalidRequest: 'invalid-request',
|
|
50
|
+
tooSoon: 'too-soon',
|
|
51
|
+
unknownSession: 'unknown-session',
|
|
52
|
+
unknownReceipt: 'unknown-receipt',
|
|
53
|
+
repeatUnavailable: 'repeat-unavailable',
|
|
54
|
+
notPending: 'not-pending',
|
|
55
|
+
notFound: 'not-found',
|
|
56
|
+
tooManyPending: 'too-many-pending',
|
|
57
|
+
internal: 'internal',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isRecord(value) {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requireString(record, key) {
|
|
65
|
+
const value = record[key]
|
|
66
|
+
if (typeof value !== 'string' || value.length === 0) throw new Error(`${key} must be a non-empty string`)
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function requireInstant(record, key) {
|
|
71
|
+
const value = requireString(record, key)
|
|
72
|
+
if (!Number.isFinite(Date.parse(value))) throw new Error(`${key} must be an absolute instant`)
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requireAttachmentRefs(record, key) {
|
|
77
|
+
const value = record[key]
|
|
78
|
+
if (!Array.isArray(value)) throw new Error(`${key} must be an array`)
|
|
79
|
+
return value.map((ref) => {
|
|
80
|
+
if (!isRecord(ref)) throw new Error(`${key} entries must be objects`)
|
|
81
|
+
if (typeof ref.attachmentId !== 'string' || ref.attachmentId.length === 0) {
|
|
82
|
+
throw new Error(`${key} entries need an attachmentId`)
|
|
83
|
+
}
|
|
84
|
+
return ref
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate one stored record.
|
|
90
|
+
*
|
|
91
|
+
* The storage layer only ever calls `parse`, so this plain object plays the
|
|
92
|
+
* role `domainTable` normally fills with a zod schema: it normalizes one
|
|
93
|
+
* detached record and rejects anything the Host did not write itself.
|
|
94
|
+
*/
|
|
95
|
+
const messageSchema = {
|
|
96
|
+
parse(value) {
|
|
97
|
+
if (!isRecord(value)) throw new Error('a scheduled message must be an object')
|
|
98
|
+
const status = requireString(value, 'status')
|
|
99
|
+
if (!STATUSES.has(status)) throw new Error(`unknown status ${status}`)
|
|
100
|
+
return {
|
|
101
|
+
id: requireString(value, 'id'),
|
|
102
|
+
sessionId: requireString(value, 'sessionId'),
|
|
103
|
+
dueAt: requireInstant(value, 'dueAt'),
|
|
104
|
+
timeZone: requireString(value, 'timeZone'),
|
|
105
|
+
text: typeof value.text === 'string' ? value.text : '',
|
|
106
|
+
images: requireAttachmentRefs(value, 'images'),
|
|
107
|
+
files: requireAttachmentRefs(value, 'files'),
|
|
108
|
+
createdAt: requireInstant(value, 'createdAt'),
|
|
109
|
+
updatedAt: requireInstant(value, 'updatedAt'),
|
|
110
|
+
status,
|
|
111
|
+
...(value.sentAt === undefined ? {} : { sentAt: requireInstant(value, 'sentAt') }),
|
|
112
|
+
...(value.late === undefined ? {} : { late: Boolean(value.late) }),
|
|
113
|
+
...(value.error === undefined ? {} : { error: String(value.error).slice(0, MAX_ERROR_CHARS) }),
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const domain = defineDomain({
|
|
119
|
+
name: DOMAIN_NAME,
|
|
120
|
+
version: 1,
|
|
121
|
+
tables: { messages: domainTable(messageSchema) },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
function jsonResponse(status, body) {
|
|
125
|
+
return new Response(JSON.stringify(body), {
|
|
126
|
+
status,
|
|
127
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function jsonOk(value) {
|
|
132
|
+
return jsonResponse(200, { ok: true, value })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function jsonError(code, message) {
|
|
136
|
+
return jsonResponse(200, { ok: false, error: { code, message } })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function messageOf(error) {
|
|
140
|
+
if (error instanceof Error) return error.message
|
|
141
|
+
return String(error)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Render one thrown error as a short user-facing diagnostic. */
|
|
145
|
+
function describeError(error) {
|
|
146
|
+
const code = isRecord(error) && typeof error.code === 'string' ? error.code : undefined
|
|
147
|
+
const text = messageOf(error).replace(/\s+/gu, ' ').trim()
|
|
148
|
+
const composed = code === undefined ? text : text.length === 0 ? code : `${code}: ${text}`
|
|
149
|
+
return (composed.length === 0 ? 'delivery failed' : composed).slice(0, MAX_ERROR_CHARS)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Read one request body as JSON, refusing an oversized streaming body early. */
|
|
153
|
+
async function readJsonBody(request, limit) {
|
|
154
|
+
const declared = Number(request.headers?.get?.('content-length') ?? Number.NaN)
|
|
155
|
+
if (Number.isFinite(declared) && declared > limit) return { ok: false, code: 'body-too-large' }
|
|
156
|
+
const body = request.body
|
|
157
|
+
let buffer
|
|
158
|
+
if (body !== null && body !== undefined && typeof body.getReader === 'function') {
|
|
159
|
+
const reader = body.getReader()
|
|
160
|
+
const chunks = []
|
|
161
|
+
let total = 0
|
|
162
|
+
for (;;) {
|
|
163
|
+
const { done, value } = await reader.read()
|
|
164
|
+
if (done === true) break
|
|
165
|
+
if (value === undefined) continue
|
|
166
|
+
total += value.byteLength
|
|
167
|
+
if (total > limit) {
|
|
168
|
+
await reader.cancel().catch(() => undefined)
|
|
169
|
+
return { ok: false, code: 'body-too-large' }
|
|
170
|
+
}
|
|
171
|
+
chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength))
|
|
172
|
+
}
|
|
173
|
+
buffer = Buffer.concat(chunks)
|
|
174
|
+
} else {
|
|
175
|
+
const text = await request.text()
|
|
176
|
+
if (text.length > limit) return { ok: false, code: 'body-too-large' }
|
|
177
|
+
buffer = Buffer.from(text, 'utf8')
|
|
178
|
+
}
|
|
179
|
+
if (buffer.byteLength === 0) return { ok: false, code: 'empty-body' }
|
|
180
|
+
try {
|
|
181
|
+
return { ok: true, value: JSON.parse(buffer.toString('utf8')) }
|
|
182
|
+
} catch {
|
|
183
|
+
return { ok: false, code: 'invalid-json' }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Durable schedule store plus the single timer that delivers due messages.
|
|
189
|
+
*
|
|
190
|
+
* Records are read back from the domain rather than cached, so two tabs and a
|
|
191
|
+
* restart always act on the same durable state and a stale browser call is
|
|
192
|
+
* refused by status instead of being silently applied.
|
|
193
|
+
*/
|
|
194
|
+
class Scheduler {
|
|
195
|
+
/**
|
|
196
|
+
* @param ctx - Host context that owns the services this feature needs.
|
|
197
|
+
*/
|
|
198
|
+
constructor(ctx) {
|
|
199
|
+
this.ctx = ctx
|
|
200
|
+
/** @type {Promise<{ domain: any, table: any }> | undefined} */
|
|
201
|
+
this.opening = undefined
|
|
202
|
+
this.timer = undefined
|
|
203
|
+
this.armSeq = 0
|
|
204
|
+
this.chain = Promise.resolve()
|
|
205
|
+
this.stopping = false
|
|
206
|
+
/** @type {any} */
|
|
207
|
+
this.schedule = undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Open the durable domain once and prune expired records on first use. */
|
|
211
|
+
async opened() {
|
|
212
|
+
if (this.opening === undefined) {
|
|
213
|
+
this.opening = this.ctx.storageDomain.open(domain).then(async (openedDomain) => {
|
|
214
|
+
const handle = { domain: openedDomain, table: openedDomain.table('messages') }
|
|
215
|
+
try {
|
|
216
|
+
await this.prune(handle.table)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.ctx.logger?.warn?.(`dsh-scheduled-message: pruning stored records failed: ${messageOf(error)}`)
|
|
219
|
+
}
|
|
220
|
+
return handle
|
|
221
|
+
}).catch((error) => {
|
|
222
|
+
this.opening = undefined
|
|
223
|
+
throw error
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
return await this.opening
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The record table, opening the domain on first use. */
|
|
230
|
+
async table() {
|
|
231
|
+
return (await this.opened()).table
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Every stored record. */
|
|
235
|
+
async records() {
|
|
236
|
+
const table = await this.table()
|
|
237
|
+
return [...table.entries()].map(([, record]) => record)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Drop finished records past retention. */
|
|
241
|
+
async prune(table) {
|
|
242
|
+
const ids = pruneTargets([...table.entries()].map(([, record]) => record), Date.now())
|
|
243
|
+
for (const id of ids) await table.delete(id)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Stop the armed timer without touching durable state. */
|
|
247
|
+
disarm() {
|
|
248
|
+
if (this.timer !== undefined) {
|
|
249
|
+
this.timer()
|
|
250
|
+
this.timer = undefined
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Arm one timer for the earliest pending record.
|
|
256
|
+
*
|
|
257
|
+
* Long waits are chunked: a delay above what `setTimeout` can hold fires
|
|
258
|
+
* early and re-arms, and every mutation and delivery re-arms again.
|
|
259
|
+
*/
|
|
260
|
+
async rearm() {
|
|
261
|
+
const seq = ++this.armSeq
|
|
262
|
+
this.disarm()
|
|
263
|
+
if (this.stopping) return
|
|
264
|
+
// Arming is best-effort: a re-arm failure must never fail the request that
|
|
265
|
+
// triggered it, because the record it belongs to is already durable.
|
|
266
|
+
try {
|
|
267
|
+
const next = earliestPending(await this.records())
|
|
268
|
+
if (seq !== this.armSeq || this.stopping || next === undefined) return
|
|
269
|
+
const delay = timerDelay(Date.parse(next.dueAt), Date.now())
|
|
270
|
+
this.timer = this.ctx.timer.timeout(() => {
|
|
271
|
+
this.timer = undefined
|
|
272
|
+
void this.pump()
|
|
273
|
+
}, delay)
|
|
274
|
+
} catch (error) {
|
|
275
|
+
this.ctx.logger?.warn?.(`dsh-scheduled-message: arming the delivery timer failed: ${messageOf(error)}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Run one delivery task after every earlier one, keeping the chain settled. */
|
|
280
|
+
exclusive(task) {
|
|
281
|
+
const run = () => Promise.resolve().then(task)
|
|
282
|
+
const next = this.chain.then(run, run)
|
|
283
|
+
this.chain = next.then(() => undefined, () => undefined)
|
|
284
|
+
return next
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Deliver whatever is due now, then re-arm. */
|
|
288
|
+
pump() {
|
|
289
|
+
return this.exclusive(() => this.runDue()).catch((error) => {
|
|
290
|
+
this.ctx.logger?.warn?.(`dsh-scheduled-message: delivery pass failed: ${messageOf(error)}`)
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Deliver every record whose instant has passed, then re-arm. */
|
|
295
|
+
async runDue() {
|
|
296
|
+
if (this.stopping) return
|
|
297
|
+
const now = Date.now()
|
|
298
|
+
const due = (await this.records())
|
|
299
|
+
.filter((record) => (record.status === 'pending' || record.status === 'sending') && Date.parse(record.dueAt) <= now)
|
|
300
|
+
.sort((left, right) => Date.parse(left.dueAt) - Date.parse(right.dueAt))
|
|
301
|
+
for (const record of due) {
|
|
302
|
+
if (this.stopping) return
|
|
303
|
+
await this.deliver(record.id)
|
|
304
|
+
}
|
|
305
|
+
await this.prune(await this.table())
|
|
306
|
+
await this.rearm()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Build the wire content of one record, re-reading every stored attachment. */
|
|
310
|
+
async content(record, signal) {
|
|
311
|
+
const content = []
|
|
312
|
+
if (record.text.length > 0) content.push({ type: 'text', text: record.text })
|
|
313
|
+
for (const ref of record.images) {
|
|
314
|
+
const stored = await this.ctx.attachments.readImage(ref, signal)
|
|
315
|
+
content.push({
|
|
316
|
+
type: 'image',
|
|
317
|
+
mediaType: stored.ref.mediaType,
|
|
318
|
+
data: Buffer.from(stored.data).toString('base64'),
|
|
319
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
for (const ref of record.files) {
|
|
323
|
+
const uploaded = await this.ctx.fileUploads.uploadStream({
|
|
324
|
+
sessionId: record.sessionId,
|
|
325
|
+
data: this.ctx.attachments.readFileStream(ref, signal),
|
|
326
|
+
signal,
|
|
327
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
328
|
+
})
|
|
329
|
+
content.push({ type: 'file', receiptId: uploaded.receiptId })
|
|
330
|
+
}
|
|
331
|
+
return content
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Deliver one record.
|
|
336
|
+
*
|
|
337
|
+
* The request id is derived from the record id, so a retry after a crash
|
|
338
|
+
* cannot deliver the same message twice: the Session inbox refuses a
|
|
339
|
+
* repeated request id.
|
|
340
|
+
* @param id - record id.
|
|
341
|
+
* @returns the settled record, or undefined when it no longer exists.
|
|
342
|
+
*/
|
|
343
|
+
async deliver(id) {
|
|
344
|
+
const table = await this.table()
|
|
345
|
+
const record = table.get(id)
|
|
346
|
+
if (record === undefined) return undefined
|
|
347
|
+
if (record.status !== 'pending' && record.status !== 'sending') return record
|
|
348
|
+
const startedIso = new Date().toISOString()
|
|
349
|
+
const sending = messageSchema.parse({ ...record, status: 'sending', updatedAt: startedIso })
|
|
350
|
+
await table.put(id, sending)
|
|
351
|
+
const signal = AbortSignal.timeout(DELIVERY_TIMEOUT_MS)
|
|
352
|
+
try {
|
|
353
|
+
const content = await this.content(sending, signal)
|
|
354
|
+
await this.ctx.sessionController.prompt({
|
|
355
|
+
requestId: `dsm-${sending.id}`,
|
|
356
|
+
sessionId: sending.sessionId,
|
|
357
|
+
mode: 'queue',
|
|
358
|
+
content,
|
|
359
|
+
clientTimeZone: sending.timeZone,
|
|
360
|
+
}, signal)
|
|
361
|
+
const settledMs = Date.now()
|
|
362
|
+
const sent = messageSchema.parse({
|
|
363
|
+
...sending,
|
|
364
|
+
status: 'sent',
|
|
365
|
+
sentAt: new Date(settledMs).toISOString(),
|
|
366
|
+
updatedAt: new Date(settledMs).toISOString(),
|
|
367
|
+
late: isLate(Date.parse(sending.dueAt), settledMs),
|
|
368
|
+
})
|
|
369
|
+
await table.put(id, sent)
|
|
370
|
+
return sent
|
|
371
|
+
} catch (error) {
|
|
372
|
+
const failed = messageSchema.parse({
|
|
373
|
+
...sending,
|
|
374
|
+
status: 'failed',
|
|
375
|
+
updatedAt: new Date().toISOString(),
|
|
376
|
+
error: describeError(error),
|
|
377
|
+
})
|
|
378
|
+
await table.put(id, failed)
|
|
379
|
+
this.ctx.logger?.warn?.(`dsh-scheduled-message: delivery of ${id} failed: ${failed.error}`)
|
|
380
|
+
return failed
|
|
381
|
+
} finally {
|
|
382
|
+
void this.rearm()
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Create one durable record from an already validated request. */
|
|
387
|
+
async createRecord(validated, images, files) {
|
|
388
|
+
const table = await this.table()
|
|
389
|
+
const nowIso = new Date().toISOString()
|
|
390
|
+
const record = messageSchema.parse({
|
|
391
|
+
id: randomUUID(),
|
|
392
|
+
sessionId: validated.sessionId,
|
|
393
|
+
dueAt: validated.dueAt,
|
|
394
|
+
timeZone: validated.timeZone,
|
|
395
|
+
text: validated.text,
|
|
396
|
+
images,
|
|
397
|
+
files,
|
|
398
|
+
createdAt: nowIso,
|
|
399
|
+
updatedAt: nowIso,
|
|
400
|
+
status: 'pending',
|
|
401
|
+
})
|
|
402
|
+
await table.put(record.id, record)
|
|
403
|
+
return record
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Count the records that still hold a timer obligation. */
|
|
407
|
+
async pendingCount() {
|
|
408
|
+
return (await this.records()).filter((record) => record.status === 'pending').length
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Compose the JSON view of one record for the browser half. */
|
|
413
|
+
function publicRecord(record) {
|
|
414
|
+
return {
|
|
415
|
+
id: record.id,
|
|
416
|
+
sessionId: record.sessionId,
|
|
417
|
+
dueAt: record.dueAt,
|
|
418
|
+
timeZone: record.timeZone,
|
|
419
|
+
text: record.text,
|
|
420
|
+
imageCount: record.images.length,
|
|
421
|
+
fileCount: record.files.length,
|
|
422
|
+
status: record.status,
|
|
423
|
+
createdAt: record.createdAt,
|
|
424
|
+
updatedAt: record.updatedAt,
|
|
425
|
+
...(record.sentAt === undefined ? {} : { sentAt: record.sentAt }),
|
|
426
|
+
...(record.late === undefined ? {} : { late: record.late }),
|
|
427
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Apply one allowed status change under the serialized delivery chain.
|
|
433
|
+
* @param scheduler - owning store.
|
|
434
|
+
* @param id - record id.
|
|
435
|
+
* @param change - projection returning the patch, or null when the status forbids it.
|
|
436
|
+
*/
|
|
437
|
+
async function settleMutation(scheduler, id, change) {
|
|
438
|
+
return await scheduler.exclusive(async () => {
|
|
439
|
+
const table = await scheduler.table()
|
|
440
|
+
const record = table.get(id)
|
|
441
|
+
if (record === undefined) return jsonError(CODES.notFound, 'That scheduled message no longer exists.')
|
|
442
|
+
const patch = change(record)
|
|
443
|
+
if (patch === null) return jsonError(CODES.notPending, 'That message is no longer waiting to be sent.')
|
|
444
|
+
const next = messageSchema.parse({ ...record, ...patch, updatedAt: new Date().toISOString() })
|
|
445
|
+
await table.put(id, next)
|
|
446
|
+
await scheduler.rearm()
|
|
447
|
+
return jsonOk({ message: publicRecord(next) })
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Validate and commit one create request.
|
|
453
|
+
* @param scheduler - owning store.
|
|
454
|
+
* @param body - untrusted JSON body.
|
|
455
|
+
*/
|
|
456
|
+
async function handleCreate(scheduler, body) {
|
|
457
|
+
const validated = validateCreateRequest(body, Date.now())
|
|
458
|
+
if (validated.ok !== true) return jsonError(validated.code, validated.message)
|
|
459
|
+
const request = validated.value
|
|
460
|
+
|
|
461
|
+
let agent
|
|
462
|
+
try {
|
|
463
|
+
const resolved = await scheduler.ctx.sessionController.resolveAgent(request.sessionId)
|
|
464
|
+
if (resolved === null || typeof resolved !== 'object' || 'error' in resolved) {
|
|
465
|
+
return jsonError(CODES.unknownSession, 'That conversation is no longer available; reopen it and try again.')
|
|
466
|
+
}
|
|
467
|
+
agent = resolved.agent
|
|
468
|
+
if (agent === undefined) {
|
|
469
|
+
return jsonError(CODES.unknownSession, 'That conversation is no longer available; reopen it and try again.')
|
|
470
|
+
}
|
|
471
|
+
} catch (error) {
|
|
472
|
+
return jsonError(CODES.unknownSession, describeError(error))
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (request.repeat !== null) {
|
|
476
|
+
if (scheduler.schedule === undefined) {
|
|
477
|
+
return jsonError(CODES.repeatUnavailable, 'Repeating messages need the Automation service in this profile.')
|
|
478
|
+
}
|
|
479
|
+
let rrule
|
|
480
|
+
try {
|
|
481
|
+
rrule = buildRrule({ repeat: request.repeat, instantMs: request.dueMs, timeZone: request.timeZone })
|
|
482
|
+
} catch (error) {
|
|
483
|
+
return jsonError(CODES.invalidRequest, describeError(error))
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const task = await scheduler.schedule.create({
|
|
487
|
+
title: summariseTitle(request.text) || 'Scheduled message',
|
|
488
|
+
prompt: request.text,
|
|
489
|
+
timing: { kind: 'rrule', rrule },
|
|
490
|
+
target: { kind: 'session', sessionId: request.sessionId },
|
|
491
|
+
})
|
|
492
|
+
return jsonOk({ kind: 'automation', taskId: String(task?.id ?? ''), rrule })
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return jsonError(CODES.invalidRequest, describeError(error))
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (await scheduler.pendingCount() >= MAX_PENDING) {
|
|
499
|
+
return jsonError(CODES.tooManyPending, `At most ${MAX_PENDING} messages may wait to be sent.`)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let images = []
|
|
503
|
+
if (request.images.length > 0) {
|
|
504
|
+
try {
|
|
505
|
+
images = await scheduler.ctx.attachments.saveImages(request.images.map(image => ({
|
|
506
|
+
data: Buffer.from(image.data, 'base64'),
|
|
507
|
+
mediaType: image.mediaType,
|
|
508
|
+
...(image.name === undefined ? {} : { name: image.name }),
|
|
509
|
+
})))
|
|
510
|
+
} catch (error) {
|
|
511
|
+
return jsonError('invalid-images', describeError(error))
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const files = []
|
|
516
|
+
for (const receiptId of request.fileReceipts) {
|
|
517
|
+
const staged = scheduler.ctx.fileUploads.resolve(agent, receiptId)
|
|
518
|
+
if (staged === undefined || staged.type !== 'file') {
|
|
519
|
+
return jsonError(CODES.unknownReceipt, 'One of the attached files is no longer available; attach it again.')
|
|
520
|
+
}
|
|
521
|
+
files.push(staged.attachment)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const record = await scheduler.createRecord(request, images, files)
|
|
526
|
+
await scheduler.rearm()
|
|
527
|
+
return jsonOk({ kind: 'once', message: publicRecord(record) })
|
|
528
|
+
} catch (error) {
|
|
529
|
+
return jsonError(CODES.internal, describeError(error))
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Register the durable store, the six HTTP routes and the delivery timer.
|
|
535
|
+
* @param ctx - Host plugin context.
|
|
536
|
+
*/
|
|
537
|
+
export function apply(ctx) {
|
|
538
|
+
const scheduler = new Scheduler(ctx)
|
|
539
|
+
|
|
540
|
+
ctx.inject(['storageDomain', 'sessionController', 'connection', 'attachments', 'fileUploads', 'timer'], (scope) => {
|
|
541
|
+
scheduler.ctx = scope
|
|
542
|
+
|
|
543
|
+
scope.effect(() => () => {
|
|
544
|
+
scheduler.stopping = true
|
|
545
|
+
scheduler.armSeq += 1
|
|
546
|
+
scheduler.disarm()
|
|
547
|
+
const opening = scheduler.opening
|
|
548
|
+
scheduler.opening = undefined
|
|
549
|
+
if (opening !== undefined) {
|
|
550
|
+
void opening.then(handle => handle.domain.close()).catch(() => undefined)
|
|
551
|
+
}
|
|
552
|
+
}, 'dsh-scheduled-message: stop the timer and close the durable domain')
|
|
553
|
+
|
|
554
|
+
scope.inject(['schedule'], (scheduleScope) => {
|
|
555
|
+
scheduler.schedule = scheduleScope.schedule
|
|
556
|
+
return () => {
|
|
557
|
+
scheduler.schedule = undefined
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
const routes = []
|
|
562
|
+
|
|
563
|
+
routes.push(scope.connection.fetch.register({
|
|
564
|
+
path: `${ROUTE_PREFIX}/create`,
|
|
565
|
+
methods: ['POST'],
|
|
566
|
+
requestBody: 'streaming',
|
|
567
|
+
fetch: async (request) => {
|
|
568
|
+
const body = await readJsonBody(request, MAX_CREATE_BODY_BYTES)
|
|
569
|
+
if (body.ok !== true) {
|
|
570
|
+
return jsonError(CODES.invalidRequest, body.code === 'body-too-large'
|
|
571
|
+
? 'That message is too large to schedule.'
|
|
572
|
+
: 'The request body could not be read.')
|
|
573
|
+
}
|
|
574
|
+
return await handleCreate(scheduler, body.value)
|
|
575
|
+
},
|
|
576
|
+
}))
|
|
577
|
+
|
|
578
|
+
routes.push(scope.connection.fetch.register({
|
|
579
|
+
path: `${ROUTE_PREFIX}/list`,
|
|
580
|
+
methods: ['GET'],
|
|
581
|
+
requestBody: 'buffered',
|
|
582
|
+
fetch: async (request) => {
|
|
583
|
+
const url = new URL(request.url, 'http://localhost')
|
|
584
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
585
|
+
if (sessionId === null || sessionId.length === 0) {
|
|
586
|
+
return jsonError(CODES.invalidRequest, 'A sessionId query parameter is required.')
|
|
587
|
+
}
|
|
588
|
+
const now = Date.now()
|
|
589
|
+
const items = (await scheduler.records())
|
|
590
|
+
.filter((record) => record.sessionId === sessionId)
|
|
591
|
+
.filter((record) => record.status === 'pending' || record.status === 'failed' || record.status === 'sending'
|
|
592
|
+
|| (record.status === 'sent' && now - Date.parse(record.sentAt ?? record.updatedAt) <= SENT_WINDOW_MS))
|
|
593
|
+
.sort((left, right) => Date.parse(left.dueAt) - Date.parse(right.dueAt))
|
|
594
|
+
return jsonOk({ items: items.map(publicRecord), now })
|
|
595
|
+
},
|
|
596
|
+
}))
|
|
597
|
+
|
|
598
|
+
routes.push(scope.connection.fetch.register({
|
|
599
|
+
path: `${ROUTE_PREFIX}/cancel`,
|
|
600
|
+
methods: ['POST'],
|
|
601
|
+
requestBody: 'buffered',
|
|
602
|
+
fetch: async (request) => {
|
|
603
|
+
const body = await readJsonBody(request, 4096)
|
|
604
|
+
if (body.ok !== true || typeof body.value?.id !== 'string') {
|
|
605
|
+
return jsonError(CODES.invalidRequest, 'A record id is required.')
|
|
606
|
+
}
|
|
607
|
+
return await settleMutation(scheduler, body.value.id, (record) => (
|
|
608
|
+
record.status === 'pending' || record.status === 'failed' ? { status: 'cancelled' } : null
|
|
609
|
+
))
|
|
610
|
+
},
|
|
611
|
+
}))
|
|
612
|
+
|
|
613
|
+
routes.push(scope.connection.fetch.register({
|
|
614
|
+
path: `${ROUTE_PREFIX}/reschedule`,
|
|
615
|
+
methods: ['POST'],
|
|
616
|
+
requestBody: 'buffered',
|
|
617
|
+
fetch: async (request) => {
|
|
618
|
+
const body = await readJsonBody(request, 4096)
|
|
619
|
+
const id = body.ok === true ? body.value?.id : undefined
|
|
620
|
+
const dueAt = body.ok === true ? body.value?.dueAt : undefined
|
|
621
|
+
if (typeof id !== 'string' || typeof dueAt !== 'string') {
|
|
622
|
+
return jsonError(CODES.invalidRequest, 'A record id and an absolute dueAt are required.')
|
|
623
|
+
}
|
|
624
|
+
const dueMs = Date.parse(dueAt)
|
|
625
|
+
if (!Number.isFinite(dueMs)) return jsonError(CODES.invalidRequest, 'dueAt is not a real instant.')
|
|
626
|
+
if (dueMs < Date.now() + 30_000) return jsonError(CODES.tooSoon, 'Choose a time at least 30 seconds from now.')
|
|
627
|
+
return await settleMutation(scheduler, id, (record) => (
|
|
628
|
+
record.status === 'pending' ? { dueAt: new Date(dueMs).toISOString() } : null
|
|
629
|
+
))
|
|
630
|
+
},
|
|
631
|
+
}))
|
|
632
|
+
|
|
633
|
+
routes.push(scope.connection.fetch.register({
|
|
634
|
+
path: `${ROUTE_PREFIX}/send-now`,
|
|
635
|
+
methods: ['POST'],
|
|
636
|
+
requestBody: 'buffered',
|
|
637
|
+
fetch: async (request) => {
|
|
638
|
+
const body = await readJsonBody(request, 4096)
|
|
639
|
+
if (body.ok !== true || typeof body.value?.id !== 'string') {
|
|
640
|
+
return jsonError(CODES.invalidRequest, 'A record id is required.')
|
|
641
|
+
}
|
|
642
|
+
const id = body.value.id
|
|
643
|
+
return await scheduler.exclusive(async () => {
|
|
644
|
+
const table = await scheduler.table()
|
|
645
|
+
const record = table.get(id)
|
|
646
|
+
if (record === undefined) return jsonError(CODES.notFound, 'That scheduled message no longer exists.')
|
|
647
|
+
if (record.status !== 'pending' && record.status !== 'failed') {
|
|
648
|
+
return jsonError(CODES.notPending, 'That message is no longer waiting to be sent.')
|
|
649
|
+
}
|
|
650
|
+
const settled = await scheduler.deliver(id)
|
|
651
|
+
return jsonOk({ message: settled === undefined ? null : publicRecord(settled) })
|
|
652
|
+
})
|
|
653
|
+
},
|
|
654
|
+
}))
|
|
655
|
+
|
|
656
|
+
routes.push(scope.connection.fetch.register({
|
|
657
|
+
path: `${ROUTE_PREFIX}/retry`,
|
|
658
|
+
methods: ['POST'],
|
|
659
|
+
requestBody: 'buffered',
|
|
660
|
+
fetch: async (request) => {
|
|
661
|
+
const body = await readJsonBody(request, 4096)
|
|
662
|
+
if (body.ok !== true || typeof body.value?.id !== 'string') {
|
|
663
|
+
return jsonError(CODES.invalidRequest, 'A record id is required.')
|
|
664
|
+
}
|
|
665
|
+
const id = body.value.id
|
|
666
|
+
return await scheduler.exclusive(async () => {
|
|
667
|
+
const table = await scheduler.table()
|
|
668
|
+
const record = table.get(id)
|
|
669
|
+
if (record === undefined) return jsonError(CODES.notFound, 'That scheduled message no longer exists.')
|
|
670
|
+
if (record.status !== 'failed') return jsonError(CODES.notPending, 'Only a failed message can be retried.')
|
|
671
|
+
const nowIso = new Date().toISOString()
|
|
672
|
+
const { error: _error, ...rest } = record
|
|
673
|
+
const pending = messageSchema.parse({ ...rest, status: 'pending', dueAt: nowIso, updatedAt: nowIso })
|
|
674
|
+
await table.put(id, pending)
|
|
675
|
+
const settled = await scheduler.deliver(id)
|
|
676
|
+
await scheduler.rearm()
|
|
677
|
+
return jsonOk({ message: settled === undefined ? null : publicRecord(settled) })
|
|
678
|
+
})
|
|
679
|
+
},
|
|
680
|
+
}))
|
|
681
|
+
|
|
682
|
+
scope.effect(() => () => Promise.all(routes.map(remove => remove().catch(() => undefined))),
|
|
683
|
+
'dsh-scheduled-message: remove the HTTP routes')
|
|
684
|
+
|
|
685
|
+
// Deliver whatever came due while DSH was closed, then arm the timer.
|
|
686
|
+
void scheduler.rearm()
|
|
687
|
+
void scheduler.pump()
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export const name = NS
|
|
692
|
+
|
|
693
|
+
/** Services the one-shot path needs; `schedule` is injected separately. */
|
|
694
|
+
export const inject = ['storageDomain']
|