@ixo/oracle-runtime-workers 0.12.0 → 0.15.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/README.md +32 -10
- package/package.json +14 -9
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +198 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +523 -111
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
package/src/matrix/gateway-do.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
SIGNING_MNEMONIC_STATE_TYPE,
|
|
58
58
|
} from '../secrets/signing-mnemonic';
|
|
59
59
|
import {
|
|
60
|
+
attributeUser,
|
|
60
61
|
IngestPipeline,
|
|
61
62
|
userDidFromRoomAlias,
|
|
62
63
|
type InboundAttachment,
|
|
@@ -75,6 +76,7 @@ import {
|
|
|
75
76
|
MAX_TURN_REPLAYS,
|
|
76
77
|
planInboxReplay,
|
|
77
78
|
replyTxnId,
|
|
79
|
+
settleOfferedRow,
|
|
78
80
|
updateInboxThread,
|
|
79
81
|
} from './inbox-store';
|
|
80
82
|
import {
|
|
@@ -90,14 +92,48 @@ import {
|
|
|
90
92
|
resolveReplyChainRoot,
|
|
91
93
|
ThreadRootCache,
|
|
92
94
|
} from './reply-chain';
|
|
93
|
-
import {
|
|
95
|
+
import {
|
|
96
|
+
type CachedUserServerName,
|
|
97
|
+
lookupUserServerName,
|
|
98
|
+
type UserServerNameLookup,
|
|
99
|
+
} from './user-homeserver';
|
|
100
|
+
import { replyPartTxnId, roomReplyMessages } from './reply-parts';
|
|
101
|
+
import { VerifiedRoomAliases } from './room-alias';
|
|
102
|
+
import {
|
|
103
|
+
boundedSignal,
|
|
104
|
+
raceSignal,
|
|
105
|
+
streamWithDeadline,
|
|
106
|
+
} from './media-deadline';
|
|
94
107
|
|
|
95
108
|
const TYPING_REFRESH_MS = 20_000;
|
|
96
109
|
const TYPING_TIMEOUT_MS = 30_000;
|
|
97
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* User DID → room id memo (aliases never move; a miss re-resolves), and the
|
|
112
|
+
* lifetime of a room's verified canonical alias (`room-alias.ts`).
|
|
113
|
+
*/
|
|
98
114
|
const ALIAS_CACHE_TTL_MS = 30 * 60_000;
|
|
99
115
|
/** User DID → Matrix server name (from the DID document via Blocksync). */
|
|
100
116
|
const HOMESERVER_CACHE_TTL_MS = 6 * 60 * 60_000;
|
|
117
|
+
/** Entries each in-memory memo in front of the `hs:` / `alias:` storage keys holds. */
|
|
118
|
+
const MEMO_MAX_ENTRIES = 5_000;
|
|
119
|
+
/**
|
|
120
|
+
* The full `status()` is served from memory for this long: it scans several
|
|
121
|
+
* tables, and the route that asks for it is public.
|
|
122
|
+
*/
|
|
123
|
+
const STATUS_CACHE_MS = 5_000;
|
|
124
|
+
/**
|
|
125
|
+
* Upper bound on one media download (response and body), unless the caller
|
|
126
|
+
* asks for less. A stalled homeserver stream must not hold the reader — and
|
|
127
|
+
* with it the user's turn — open indefinitely.
|
|
128
|
+
*/
|
|
129
|
+
export const MEDIA_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
130
|
+
/**
|
|
131
|
+
* A live message whose sender's homeserver could not be looked up stays in
|
|
132
|
+
* the inbox and is replayed on its own after this delay (each replay is
|
|
133
|
+
* charged to the row, so a long Blocksync outage ends in the "try again"
|
|
134
|
+
* notice, not a silent drop).
|
|
135
|
+
*/
|
|
136
|
+
const SENDER_CHECK_RETRY_MS = 60_000;
|
|
101
137
|
/**
|
|
102
138
|
* A room created by a user seconds ago may not have delivered its invite
|
|
103
139
|
* through sync yet when the user's object asks for its state; within this
|
|
@@ -161,9 +197,37 @@ interface AliasCacheEntry {
|
|
|
161
197
|
at: number;
|
|
162
198
|
}
|
|
163
199
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
/** A media download's bound. `signal` only works for a caller in this isolate: an `AbortSignal` cannot cross an RPC boundary. */
|
|
201
|
+
export interface MediaDownloadOptions {
|
|
202
|
+
/** Upper bound on the whole transfer (default `MEDIA_DOWNLOAD_TIMEOUT_MS`). */
|
|
203
|
+
timeoutMs?: number;
|
|
204
|
+
signal?: AbortSignal;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** What `GET /health` on the gateway answers: from memory only, no storage read. */
|
|
208
|
+
export interface GatewayHealth {
|
|
209
|
+
running: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A small bounded memo with insertion-order eviction, in front of the
|
|
214
|
+
* gateway's storage keys (the values carry their own timestamps; freshness
|
|
215
|
+
* is the caller's rule, the same one the stored copy is judged by).
|
|
216
|
+
*/
|
|
217
|
+
class BoundedMemo<V> {
|
|
218
|
+
private readonly map = new Map<string, V>();
|
|
219
|
+
get(key: string): V | undefined {
|
|
220
|
+
return this.map.get(key);
|
|
221
|
+
}
|
|
222
|
+
set(key: string, value: V): void {
|
|
223
|
+
this.map.delete(key);
|
|
224
|
+
while (this.map.size >= MEMO_MAX_ENTRIES) {
|
|
225
|
+
const oldest = this.map.keys().next().value;
|
|
226
|
+
if (oldest === undefined) break;
|
|
227
|
+
this.map.delete(oldest);
|
|
228
|
+
}
|
|
229
|
+
this.map.set(key, value);
|
|
230
|
+
}
|
|
167
231
|
}
|
|
168
232
|
|
|
169
233
|
function gateSizeFromEnv(raw: string | undefined, fallback: number): number {
|
|
@@ -240,8 +304,33 @@ export class MatrixGatewayDO
|
|
|
240
304
|
{
|
|
241
305
|
private config: OracleGatewayConfig | null = null;
|
|
242
306
|
private ingest: IngestPipeline | null = null;
|
|
243
|
-
/**
|
|
244
|
-
|
|
307
|
+
/** How long a row without a verdict waits before it is offered again. */
|
|
308
|
+
protected readonly senderCheckRetryMs: number = SENDER_CHECK_RETRY_MS;
|
|
309
|
+
/**
|
|
310
|
+
* The verified user↔oracle alias per room (`room-alias.ts`): a canonical
|
|
311
|
+
* alias counts only when its homeserver resolves it to that same room.
|
|
312
|
+
*/
|
|
313
|
+
private readonly roomAliases = new VerifiedRoomAliases({
|
|
314
|
+
readCanonicalAlias: (roomId) => this.readOracleRoomAlias(roomId),
|
|
315
|
+
resolveAlias: (alias) => this.resolveAlias(alias),
|
|
316
|
+
ttlMs: ALIAS_CACHE_TTL_MS,
|
|
317
|
+
});
|
|
318
|
+
/** In front of `hs:<did>` in storage (same entries, same TTL). */
|
|
319
|
+
private readonly homeserverMemo = new BoundedMemo<CachedUserServerName>();
|
|
320
|
+
/** In front of `alias:<did>` in storage (same entries, same TTL). */
|
|
321
|
+
private readonly roomMemo = new BoundedMemo<AliasCacheEntry>();
|
|
322
|
+
/** The storage-scanning half of `status()`, served for `STATUS_CACHE_MS`. */
|
|
323
|
+
private statusCache: {
|
|
324
|
+
at: number;
|
|
325
|
+
value: Promise<Omit<GatewayStatus, 'turns' | 'ingestPending'>>;
|
|
326
|
+
} | null = null;
|
|
327
|
+
/** Whether the bot was last seen started (set by a start, cleared by `stop`); read by `GET /health`. */
|
|
328
|
+
private botRunning = false;
|
|
329
|
+
/**
|
|
330
|
+
* User DID → registered Matrix server name (lowercased), refreshed before
|
|
331
|
+
* every offer from `userServerName` so the pipeline can check it synchronously.
|
|
332
|
+
*/
|
|
333
|
+
private readonly userServers = new Map<string, string>();
|
|
245
334
|
/** Quote-reply chain → thread root memo (see reply-chain.ts). */
|
|
246
335
|
private readonly threadRoots = new ThreadRootCache();
|
|
247
336
|
private readonly turnGate = new Semaphore(
|
|
@@ -254,7 +343,16 @@ export class MatrixGatewayDO
|
|
|
254
343
|
private inboxTableReady = false;
|
|
255
344
|
/** Event ids of turns running in this instance (a graceful restart must not replay them). */
|
|
256
345
|
private readonly inFlightEvents = new Set<string>();
|
|
346
|
+
/**
|
|
347
|
+
* Event ids waiting in the ingest debounce buffer: their turn exists in
|
|
348
|
+
* this instance already, so a start in place (a device rotation) must not
|
|
349
|
+
* replay them. Cleared with the buffers on `stop`, which drops them.
|
|
350
|
+
*/
|
|
351
|
+
private readonly debouncing = new Set<string>();
|
|
257
352
|
private inboxReplayRunning = false;
|
|
353
|
+
/** Inbox rows whose sender check had no verdict, waiting for `unverifiedRetry`. */
|
|
354
|
+
private readonly unverifiedEvents = new Set<string>();
|
|
355
|
+
private unverifiedRetry: ReturnType<typeof setTimeout> | null = null;
|
|
258
356
|
/** Group rooms: the gate and the per-room channel memory (see group-chat.ts). */
|
|
259
357
|
private groupChatService: GroupChatService | null = null;
|
|
260
358
|
|
|
@@ -310,6 +408,23 @@ export class MatrixGatewayDO
|
|
|
310
408
|
env.MATRIX_RECYCLE_AFTER_SENDS ?? String(DEFAULT_RECYCLE_AFTER_SENDS),
|
|
311
409
|
MATRIX_HOT_ROOMS: env.MATRIX_HOT_ROOMS,
|
|
312
410
|
MATRIX_BACKFILL_MAX_EVENTS: env.MATRIX_BACKFILL_MAX_EVENTS,
|
|
411
|
+
// Memory guards (SDK 0.5+): byte-capped /sync and catch-up pages and
|
|
412
|
+
// an optional ciphertext cap on decryption. Unset = the SDK defaults.
|
|
413
|
+
MATRIX_SYNC_TIMELINE_LIMIT: env.MATRIX_SYNC_TIMELINE_LIMIT,
|
|
414
|
+
MATRIX_SYNC_BYTES_CAP: env.MATRIX_SYNC_BYTES_CAP,
|
|
415
|
+
MATRIX_CATCHUP_PAGE_SIZE: env.MATRIX_CATCHUP_PAGE_SIZE,
|
|
416
|
+
MATRIX_PAGE_BYTES_CAP: env.MATRIX_PAGE_BYTES_CAP,
|
|
417
|
+
MATRIX_MAX_DECRYPT_BYTES: env.MATRIX_MAX_DECRYPT_BYTES,
|
|
418
|
+
// Opt-in bulk restore of the key backup on a device's first start
|
|
419
|
+
// (SDK 0.6+); off by default — the gateway fetches room keys from the
|
|
420
|
+
// backup on demand, one session at a time.
|
|
421
|
+
MATRIX_BACKUP_BULK_RESTORE: env.MATRIX_BACKUP_BULK_RESTORE,
|
|
422
|
+
MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS:
|
|
423
|
+
env.MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS,
|
|
424
|
+
// Keep-alive fuse (SDK 0.7+): the alarm is re-armed a few seconds
|
|
425
|
+
// ahead while the object works, so a host drain brings it back within
|
|
426
|
+
// the fuse instead of at the next cycle.
|
|
427
|
+
MATRIX_KEEPALIVE_FUSE_MS: env.MATRIX_KEEPALIVE_FUSE_MS,
|
|
313
428
|
LOG_LEVEL: env.LOG_LEVEL,
|
|
314
429
|
});
|
|
315
430
|
return {
|
|
@@ -330,7 +445,8 @@ export class MatrixGatewayDO
|
|
|
330
445
|
this.ingest = new IngestPipeline({
|
|
331
446
|
oracleDid: cfg.oracleRoomDid,
|
|
332
447
|
botUserId: cfg.userId,
|
|
333
|
-
canonicalAlias: (roomId) => this.roomAliases.
|
|
448
|
+
canonicalAlias: (roomId) => this.roomAliases.known(roomId),
|
|
449
|
+
userServerName: (userDid) => this.userServers.get(userDid) ?? null,
|
|
334
450
|
dispatch: (turn) => this.dispatchTurn(turn),
|
|
335
451
|
onError: (err, context) =>
|
|
336
452
|
this.log('error', `ingest failed (${context})`, err),
|
|
@@ -370,38 +486,131 @@ export class MatrixGatewayDO
|
|
|
370
486
|
// message back if the instance dies before its reply is in the outbox.
|
|
371
487
|
const sql = this.inboxSql();
|
|
372
488
|
insertInboxRow(sql, inbound, Date.now());
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
489
|
+
try {
|
|
490
|
+
// The thread (a quote-reply chain walk) and the sender's identity (the
|
|
491
|
+
// verified alias, then the user's registered homeserver) are
|
|
492
|
+
// independent lookups: they run side by side.
|
|
493
|
+
const [alias, threadRootId] = await Promise.all([
|
|
494
|
+
this.attributionFor(inbound),
|
|
495
|
+
this.threadRootFor(
|
|
496
|
+
eventId,
|
|
497
|
+
message.roomId,
|
|
498
|
+
readRelatesTo(message.content),
|
|
499
|
+
message.threadRootId,
|
|
500
|
+
),
|
|
501
|
+
]);
|
|
502
|
+
if (threadRootId !== inbound.threadRootId) {
|
|
503
|
+
inbound.threadRootId = threadRootId;
|
|
504
|
+
updateInboxThread(sql, eventId, threadRootId);
|
|
505
|
+
}
|
|
506
|
+
this.offerInbound(inbound, alias);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
// A homeserver error while preparing the offer (the alias lookup, a
|
|
509
|
+
// rate limit) is not a verdict on the message. The SDK has already
|
|
510
|
+
// marked the event processed, so the row comes back on its own timer.
|
|
511
|
+
this.log(
|
|
512
|
+
'warn',
|
|
513
|
+
`ingest: could not prepare ${eventId} in ${message.roomId}; keeping it in the inbox for a replay`,
|
|
514
|
+
err,
|
|
515
|
+
);
|
|
516
|
+
this.retryUnverifiedLater(eventId);
|
|
385
517
|
}
|
|
386
|
-
this.offerInbound(inbound);
|
|
387
518
|
}
|
|
388
519
|
|
|
389
|
-
/**
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
520
|
+
/**
|
|
521
|
+
* Feed a message into the ingest pipeline. A message it will not turn into
|
|
522
|
+
* a turn leaves the inbox at once; one whose sender could not be checked
|
|
523
|
+
* stays there and is replayed after `SENDER_CHECK_RETRY_MS`.
|
|
524
|
+
*/
|
|
525
|
+
private offerInbound(inbound: InboundMessage, alias: string | null): void {
|
|
526
|
+
const outcome = this.ingestPipeline().offer(inbound, alias);
|
|
527
|
+
const row = settleOfferedRow(this.inboxSql(), inbound.eventId, outcome);
|
|
528
|
+
if (outcome === 'queued') this.debouncing.add(inbound.eventId);
|
|
529
|
+
else if (outcome === 'unverified') {
|
|
394
530
|
this.log(
|
|
395
|
-
'
|
|
396
|
-
`ingest
|
|
531
|
+
'warn',
|
|
532
|
+
`ingest: no homeserver verdict for ${inbound.sender}; keeping ${inbound.eventId} in ${inbound.roomId} for a replay`,
|
|
533
|
+
);
|
|
534
|
+
this.retryUnverifiedLater(inbound.eventId);
|
|
535
|
+
} else if (row === 'deleted') {
|
|
536
|
+
this.log(
|
|
537
|
+
// A DID-shaped identity from a server that is not the DID's own is
|
|
538
|
+
// someone presenting a DID they do not control.
|
|
539
|
+
outcome === 'foreign' ? 'warn' : 'debug',
|
|
540
|
+
`ingest dropped ${inbound.eventId} in ${inbound.roomId} from ${inbound.sender}: ${outcome}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Replay the unverified rows once `SENDER_CHECK_RETRY_MS` has passed (one timer for all of them). */
|
|
546
|
+
private retryUnverifiedLater(eventId: string): void {
|
|
547
|
+
this.unverifiedEvents.add(eventId);
|
|
548
|
+
if (this.unverifiedRetry) return;
|
|
549
|
+
this.unverifiedRetry = setTimeout(() => {
|
|
550
|
+
this.unverifiedRetry = null;
|
|
551
|
+
const only = new Set(this.unverifiedEvents);
|
|
552
|
+
this.unverifiedEvents.clear();
|
|
553
|
+
this.scheduleInboxReplay(only);
|
|
554
|
+
}, this.senderCheckRetryMs);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The room's verified alias, with the homeserver of the user the message
|
|
559
|
+
* is then attributed to looked up for the sender check. The alias is
|
|
560
|
+
* handed on as checked here: re-reading the memo later could find it
|
|
561
|
+
* expired or invalidated meanwhile, and a legacy sender would read as
|
|
562
|
+
* unmapped. Throws when the alias could not be checked.
|
|
563
|
+
*/
|
|
564
|
+
private async attributionFor(
|
|
565
|
+
inbound: InboundMessage,
|
|
566
|
+
): Promise<string | null> {
|
|
567
|
+
const alias = await this.canonicalAliasOf(inbound.roomId);
|
|
568
|
+
await this.resolveSenderServer(inbound, alias);
|
|
569
|
+
return alias;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Look up the registered homeserver of the user a message is attributed
|
|
574
|
+
* to, for the pipeline's sender check. A DID document that names no
|
|
575
|
+
* homeserver answers the oracle's own server. A Blocksync failure answers
|
|
576
|
+
* the last cached server even when it has expired; with nothing cached the
|
|
577
|
+
* memo is cleared, so the pipeline gives no verdict (`'unverified'`) and
|
|
578
|
+
* the message stays in the inbox instead of being dropped as foreign.
|
|
579
|
+
*/
|
|
580
|
+
private async resolveSenderServer(
|
|
581
|
+
inbound: InboundMessage,
|
|
582
|
+
alias: string | null,
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
const attribution = attributeUser({
|
|
585
|
+
alias,
|
|
586
|
+
sender: inbound.sender,
|
|
587
|
+
oracleDid: this.cfg().oracleRoomDid,
|
|
588
|
+
});
|
|
589
|
+
if (!attribution) return;
|
|
590
|
+
try {
|
|
591
|
+
const { serverName } = await this.lookupUserServer(attribution.userDid);
|
|
592
|
+
this.userServers.set(attribution.userDid, serverName.toLowerCase());
|
|
593
|
+
} catch (err) {
|
|
594
|
+
this.userServers.delete(attribution.userDid);
|
|
595
|
+
this.log(
|
|
596
|
+
'warn',
|
|
597
|
+
`could not resolve ${attribution.userDid}'s homeserver from Blocksync and nothing is cached; ${inbound.eventId} waits for a replay`,
|
|
598
|
+
err,
|
|
397
599
|
);
|
|
398
600
|
}
|
|
399
601
|
}
|
|
400
602
|
|
|
401
|
-
/**
|
|
603
|
+
/**
|
|
604
|
+
* A membership change invalidates what the group-chat gate knows about the
|
|
605
|
+
* room; an alias change also invalidates the room's verified alias (the
|
|
606
|
+
* gate's "direct" verdict rests on it too).
|
|
607
|
+
*/
|
|
402
608
|
protected override async onEvent(event: BotTimelineEvent): Promise<void> {
|
|
403
609
|
await super.onEvent(event);
|
|
404
|
-
if (event.type === 'm.room.
|
|
610
|
+
if (event.type === 'm.room.canonical_alias') {
|
|
611
|
+
this.roomAliases.invalidate(event.roomId);
|
|
612
|
+
this.groupChat().invalidateRoom(event.roomId);
|
|
613
|
+
} else if (event.type === 'm.room.member')
|
|
405
614
|
this.groupChat().invalidateRoom(event.roomId);
|
|
406
615
|
}
|
|
407
616
|
|
|
@@ -498,10 +707,18 @@ export class MatrixGatewayDO
|
|
|
498
707
|
return this.threadRootFor(row.eventId, row.roomId, relatesTo);
|
|
499
708
|
}
|
|
500
709
|
|
|
501
|
-
/**
|
|
710
|
+
/**
|
|
711
|
+
* The room's verified user↔oracle alias (null = none): its canonical alias,
|
|
712
|
+
* when that is an alias of this oracle AND its homeserver resolves it to
|
|
713
|
+
* this room. Memoised (`room-alias.ts`). Throws when the homeserver could
|
|
714
|
+
* not be asked — no verdict, never "accept".
|
|
715
|
+
*/
|
|
502
716
|
private async canonicalAliasOf(roomId: string): Promise<string | null> {
|
|
503
|
-
|
|
504
|
-
|
|
717
|
+
return this.roomAliases.verify(roomId);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** `m.room.canonical_alias` of a room when it is a user↔oracle alias of this oracle. */
|
|
721
|
+
private async readOracleRoomAlias(roomId: string): Promise<string | null> {
|
|
505
722
|
const json = await this.getRoomStateEvent(roomId, 'm.room.canonical_alias');
|
|
506
723
|
const parsed: unknown = json ? JSON.parse(json) : null;
|
|
507
724
|
const alias =
|
|
@@ -512,12 +729,25 @@ export class MatrixGatewayDO
|
|
|
512
729
|
parsed.alias
|
|
513
730
|
? parsed.alias
|
|
514
731
|
: null;
|
|
515
|
-
|
|
516
|
-
|
|
732
|
+
return alias !== null &&
|
|
733
|
+
userDidFromRoomAlias(alias, this.cfg().oracleRoomDid) !== null
|
|
734
|
+
? alias
|
|
735
|
+
: null;
|
|
517
736
|
}
|
|
518
737
|
|
|
519
738
|
private async dispatchTurn(turn: IngestTurn): Promise<void> {
|
|
520
|
-
|
|
739
|
+
// In flight from the moment the turn exists, not only once it holds a
|
|
740
|
+
// gate slot: an in-place restart replays the inbox, and a row still
|
|
741
|
+
// queued behind the gate must not be offered (and charged) again.
|
|
742
|
+
for (const id of turn.eventIds) {
|
|
743
|
+
this.debouncing.delete(id);
|
|
744
|
+
this.inFlightEvents.add(id);
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
await this.turnGate.run(() => this.dispatchTurnNow(turn));
|
|
748
|
+
} finally {
|
|
749
|
+
for (const id of turn.eventIds) this.inFlightEvents.delete(id);
|
|
750
|
+
}
|
|
521
751
|
}
|
|
522
752
|
|
|
523
753
|
private async dispatchTurnNow(turn: IngestTurn): Promise<void> {
|
|
@@ -563,10 +793,14 @@ export class MatrixGatewayDO
|
|
|
563
793
|
'info',
|
|
564
794
|
`turn ${requestId}: ${turn.userDid} in ${turn.roomId} (thread ${turn.threadId}${decision.roomKind === 'group' ? `, group room: ${decision.reason}` : ''})`,
|
|
565
795
|
);
|
|
566
|
-
for (const id of turn.eventIds) this.inFlightEvents.add(id);
|
|
567
796
|
let typing: ReturnType<typeof setInterval> | null = null;
|
|
568
797
|
try {
|
|
569
|
-
|
|
798
|
+
// Cosmetic: a homeserver hiccup on the typing notice must not cost the
|
|
799
|
+
// user their turn.
|
|
800
|
+
await this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS).catch(
|
|
801
|
+
(err: unknown) =>
|
|
802
|
+
this.log('debug', `turn ${requestId}: typing notice failed`, err),
|
|
803
|
+
);
|
|
570
804
|
typing = setInterval(() => {
|
|
571
805
|
this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS).catch(
|
|
572
806
|
() => undefined,
|
|
@@ -578,19 +812,30 @@ export class MatrixGatewayDO
|
|
|
578
812
|
'info',
|
|
579
813
|
`turn ${requestId}: reply served from the user object's ledger (turn had already finished)`,
|
|
580
814
|
);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
815
|
+
const messages = roomReplyMessages(result);
|
|
816
|
+
if (messages.length === 0)
|
|
817
|
+
this.log('info', `turn ${requestId}: empty reply, nothing sent`);
|
|
818
|
+
// Transaction ids are derived from the event id (one per plan part), so
|
|
819
|
+
// a reply is deduplicated by the homeserver if a previous incarnation
|
|
820
|
+
// already sent it before it died (see inbox-store.ts). Always inside the
|
|
821
|
+
// thread: the reply to a bare message opens the thread on it (Node's
|
|
822
|
+
// listener bridge does the same).
|
|
823
|
+
const eventId = turn.eventIds[0];
|
|
824
|
+
for (const message of messages)
|
|
825
|
+
await this.sendText(turn.roomId, message.body, {
|
|
588
826
|
threadId: turn.threadId,
|
|
589
|
-
...(
|
|
827
|
+
...(message.formattedBody
|
|
828
|
+
? { formattedBody: message.formattedBody }
|
|
829
|
+
: {}),
|
|
830
|
+
...(eventId
|
|
831
|
+
? {
|
|
832
|
+
txnId:
|
|
833
|
+
message.partId !== undefined
|
|
834
|
+
? replyPartTxnId(eventId, message.partId)
|
|
835
|
+
: replyTxnId(eventId),
|
|
836
|
+
}
|
|
837
|
+
: {}),
|
|
590
838
|
});
|
|
591
|
-
} else {
|
|
592
|
-
this.log('info', `turn ${requestId}: empty reply, nothing sent`);
|
|
593
|
-
}
|
|
594
839
|
// The reply is in the durable outbox (or there is none): the turn
|
|
595
840
|
// has ended, the inbox row has done its job.
|
|
596
841
|
deleteInboxRows(this.inboxSql(), turn.eventIds);
|
|
@@ -625,23 +870,29 @@ export class MatrixGatewayDO
|
|
|
625
870
|
// Told the user (or could not): either way this turn is over.
|
|
626
871
|
deleteInboxRows(this.inboxSql(), turn.eventIds);
|
|
627
872
|
} finally {
|
|
628
|
-
for (const id of turn.eventIds) this.inFlightEvents.delete(id);
|
|
629
873
|
if (typing) clearInterval(typing);
|
|
630
874
|
await this.setTyping(turn.roomId, false).catch(() => undefined);
|
|
631
875
|
// Close the card on every exit — reply posted, empty reply, thrown
|
|
632
876
|
// error — so no turn can leave a spinner running in the room.
|
|
633
|
-
await this.
|
|
634
|
-
|
|
635
|
-
.catch((err: unknown) =>
|
|
877
|
+
await this.finishWorkStatus(turn.userDid, requestId).catch(
|
|
878
|
+
(err: unknown) =>
|
|
636
879
|
this.log(
|
|
637
880
|
'warn',
|
|
638
881
|
`turn ${requestId}: could not close the status card`,
|
|
639
882
|
err,
|
|
640
883
|
),
|
|
641
|
-
|
|
884
|
+
);
|
|
642
885
|
}
|
|
643
886
|
}
|
|
644
887
|
|
|
888
|
+
/** Close the turn's work-status card in the user's object. */
|
|
889
|
+
protected async finishWorkStatus(
|
|
890
|
+
userDid: string,
|
|
891
|
+
requestId: string,
|
|
892
|
+
): Promise<void> {
|
|
893
|
+
await this.userStub(userDid).finishWorkStatus(requestId, 'done');
|
|
894
|
+
}
|
|
895
|
+
|
|
645
896
|
private userStub(userDid: string): DurableObjectStub<UserOracleObject> {
|
|
646
897
|
return this.env.USER_ORACLE.get(
|
|
647
898
|
this.env.USER_ORACLE.idFromName(
|
|
@@ -685,20 +936,38 @@ export class MatrixGatewayDO
|
|
|
685
936
|
}
|
|
686
937
|
|
|
687
938
|
/**
|
|
688
|
-
*
|
|
689
|
-
*
|
|
939
|
+
* After every successful start of this instance — a request, the
|
|
940
|
+
* keep-alive alarm bringing it back after a reset, a device rotation
|
|
941
|
+
* (the SDK's `onStarted` hook, detached from the caller). That is the
|
|
690
942
|
* moment to re-dispatch the turns a previous incarnation left unfinished.
|
|
691
943
|
*/
|
|
944
|
+
protected override async onStarted(_result: StartResult): Promise<void> {
|
|
945
|
+
this.noteRunning(true);
|
|
946
|
+
this.scheduleInboxReplay();
|
|
947
|
+
}
|
|
948
|
+
|
|
692
949
|
override async ensureStarted(): Promise<StartResult> {
|
|
693
950
|
const result = await super.ensureStarted();
|
|
694
|
-
|
|
951
|
+
this.noteRunning(true);
|
|
695
952
|
return result;
|
|
696
953
|
}
|
|
697
954
|
|
|
698
|
-
|
|
699
|
-
|
|
955
|
+
/** A lifecycle change: `GET /health` follows it, the cached status is stale. */
|
|
956
|
+
private noteRunning(running: boolean): void {
|
|
957
|
+
if (this.botRunning !== running) this.statusCache = null;
|
|
958
|
+
this.botRunning = running;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** `only`: replay just these rows (the unverified-sender retry); omitted, every row. */
|
|
962
|
+
private scheduleInboxReplay(only?: ReadonlySet<string>): void {
|
|
963
|
+
if (this.inboxReplayRunning) {
|
|
964
|
+
// A full replay already listed every row; a targeted one may not have,
|
|
965
|
+
// so the rows asked for here wait for the next retry.
|
|
966
|
+
if (only) for (const id of only) this.retryUnverifiedLater(id);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
700
969
|
this.inboxReplayRunning = true;
|
|
701
|
-
void this.replayInbox()
|
|
970
|
+
void this.replayInbox(only)
|
|
702
971
|
.catch((err: unknown) => this.log('error', 'inbox replay failed', err))
|
|
703
972
|
.finally(() => {
|
|
704
973
|
this.inboxReplayRunning = false;
|
|
@@ -712,12 +981,14 @@ export class MatrixGatewayDO
|
|
|
712
981
|
* object decides what a replay means for it (`matrix-turn-ledger.ts`):
|
|
713
982
|
* stored reply, attach to the running turn, or refuse — never a second run.
|
|
714
983
|
*/
|
|
715
|
-
private async replayInbox(): Promise<void> {
|
|
984
|
+
private async replayInbox(only?: ReadonlySet<string>): Promise<void> {
|
|
716
985
|
const sql = this.inboxSql();
|
|
717
|
-
const rows = listInboxRows(sql)
|
|
986
|
+
const rows = listInboxRows(sql).filter(
|
|
987
|
+
(row) => !only || only.has(row.eventId),
|
|
988
|
+
);
|
|
718
989
|
if (rows.length === 0) return;
|
|
719
990
|
const plan = planInboxReplay(rows, {
|
|
720
|
-
inFlight: this.inFlightEvents,
|
|
991
|
+
inFlight: new Set([...this.inFlightEvents, ...this.debouncing]),
|
|
721
992
|
maxReplays: MAX_TURN_REPLAYS,
|
|
722
993
|
});
|
|
723
994
|
for (const row of plan.exhausted) {
|
|
@@ -746,17 +1017,30 @@ export class MatrixGatewayDO
|
|
|
746
1017
|
);
|
|
747
1018
|
for (const row of plan.replay) {
|
|
748
1019
|
try {
|
|
749
|
-
|
|
1020
|
+
// Without an alias verdict a legacy sender would read as unmapped
|
|
1021
|
+
// and the row would be dropped: the row waits for the next retry
|
|
1022
|
+
// instead (its attempts were bumped above, so an outage still ends
|
|
1023
|
+
// in the "try again" notice).
|
|
1024
|
+
// A failed homeserver lookup leaves no verdict: the offer answers
|
|
1025
|
+
// 'unverified' and the row stays for the next replay.
|
|
1026
|
+
const inbound = inboundOfRow(row);
|
|
1027
|
+
const [alias, threadRootId] = await Promise.all([
|
|
1028
|
+
this.attributionFor(inbound),
|
|
1029
|
+
this.threadRootForRow(row),
|
|
1030
|
+
]);
|
|
1031
|
+
if (threadRootId !== inbound.threadRootId) {
|
|
1032
|
+
inbound.threadRootId = threadRootId;
|
|
1033
|
+
updateInboxThread(sql, row.eventId, threadRootId);
|
|
1034
|
+
}
|
|
1035
|
+
this.offerInbound(inbound, alias);
|
|
750
1036
|
} catch (err) {
|
|
751
|
-
this.log(
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
updateInboxThread(sql, row.eventId, threadRootId);
|
|
1037
|
+
this.log(
|
|
1038
|
+
'warn',
|
|
1039
|
+
`inbox: could not prepare ${row.eventId} in ${row.roomId}; retrying later`,
|
|
1040
|
+
err,
|
|
1041
|
+
);
|
|
1042
|
+
this.retryUnverifiedLater(row.eventId);
|
|
758
1043
|
}
|
|
759
|
-
this.offerInbound(inbound);
|
|
760
1044
|
}
|
|
761
1045
|
this.log(
|
|
762
1046
|
'info',
|
|
@@ -780,6 +1064,11 @@ export class MatrixGatewayDO
|
|
|
780
1064
|
// A debounce buffer firing after the stop would send through
|
|
781
1065
|
// `startedClient()`, which restarts the bot.
|
|
782
1066
|
this.ingest?.clear();
|
|
1067
|
+
this.debouncing.clear();
|
|
1068
|
+
if (this.unverifiedRetry) clearTimeout(this.unverifiedRetry);
|
|
1069
|
+
this.unverifiedRetry = null;
|
|
1070
|
+
this.unverifiedEvents.clear();
|
|
1071
|
+
this.noteRunning(false);
|
|
783
1072
|
await super.stop();
|
|
784
1073
|
}
|
|
785
1074
|
|
|
@@ -787,36 +1076,66 @@ export class MatrixGatewayDO
|
|
|
787
1076
|
// Rooms
|
|
788
1077
|
// -------------------------------------------------------------------------
|
|
789
1078
|
|
|
790
|
-
|
|
1079
|
+
/**
|
|
1080
|
+
* The user's registered homeserver (`lookupUserServerName`: a 6 h cache
|
|
1081
|
+
* over Blocksync, an expired entry when Blocksync fails). Throws when
|
|
1082
|
+
* Blocksync fails and nothing was ever cached.
|
|
1083
|
+
*/
|
|
1084
|
+
private async lookupUserServer(
|
|
1085
|
+
userDid: string,
|
|
1086
|
+
): Promise<UserServerNameLookup> {
|
|
791
1087
|
const cfg = this.cfg();
|
|
792
1088
|
const cacheKey = `hs:${userDid}`;
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1089
|
+
const lookup = await lookupUserServerName(userDid, {
|
|
1090
|
+
blocksyncGraphqlUrl: this.env.BLOCKSYNC_GRAPHQL_URL,
|
|
1091
|
+
defaultServerName: cfg.serverName,
|
|
1092
|
+
ttlMs: HOMESERVER_CACHE_TTL_MS,
|
|
1093
|
+
readCache: async () => {
|
|
1094
|
+
const memo = this.homeserverMemo.get(userDid);
|
|
1095
|
+
if (memo) return memo;
|
|
1096
|
+
const stored =
|
|
1097
|
+
await this.ctx.storage.get<CachedUserServerName>(cacheKey);
|
|
1098
|
+
if (stored) this.homeserverMemo.set(userDid, stored);
|
|
1099
|
+
return stored;
|
|
1100
|
+
},
|
|
1101
|
+
writeCache: async (_did, entry) => {
|
|
1102
|
+
this.homeserverMemo.set(userDid, entry);
|
|
1103
|
+
await this.ctx.storage.put(cacheKey, entry);
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
if (lookup.source === 'stale')
|
|
1107
|
+
this.log(
|
|
1108
|
+
'warn',
|
|
1109
|
+
`could not refresh ${userDid}'s homeserver from Blocksync; using the expired cached ${lookup.serverName}`,
|
|
1110
|
+
lookup.error,
|
|
1111
|
+
);
|
|
1112
|
+
else if (
|
|
1113
|
+
lookup.source === 'blocksync' &&
|
|
1114
|
+
lookup.serverName !== cfg.serverName
|
|
1115
|
+
)
|
|
1116
|
+
this.log(
|
|
1117
|
+
'info',
|
|
1118
|
+
`user ${userDid} lives on ${lookup.serverName} (oracle is on ${cfg.serverName}); room alias uses the user's server`,
|
|
1119
|
+
);
|
|
1120
|
+
return lookup;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* The server half of the user's room alias. When the lookup has no answer
|
|
1125
|
+
* the alias is tried on the oracle's own server — a wrong guess only
|
|
1126
|
+
* misses the room — unlike the sender check, which gives no verdict then.
|
|
1127
|
+
*/
|
|
1128
|
+
private async userServerName(userDid: string): Promise<string> {
|
|
799
1129
|
try {
|
|
800
|
-
|
|
801
|
-
if (!resolved) return cfg.serverName;
|
|
802
|
-
await this.ctx.storage.put(cacheKey, {
|
|
803
|
-
serverName: resolved,
|
|
804
|
-
at: Date.now(),
|
|
805
|
-
} satisfies HomeserverCacheEntry);
|
|
806
|
-
if (resolved !== cfg.serverName) {
|
|
807
|
-
this.log(
|
|
808
|
-
'info',
|
|
809
|
-
`user ${userDid} lives on ${resolved} (oracle is on ${cfg.serverName}); room alias uses the user's server`,
|
|
810
|
-
);
|
|
811
|
-
}
|
|
812
|
-
return resolved;
|
|
1130
|
+
return (await this.lookupUserServer(userDid)).serverName;
|
|
813
1131
|
} catch (err) {
|
|
1132
|
+
const fallback = this.cfg().serverName;
|
|
814
1133
|
this.log(
|
|
815
1134
|
'warn',
|
|
816
|
-
`could not resolve ${userDid}'s homeserver from Blocksync; using ${
|
|
1135
|
+
`could not resolve ${userDid}'s homeserver from Blocksync; using ${fallback} for the room alias`,
|
|
817
1136
|
err,
|
|
818
1137
|
);
|
|
819
|
-
return
|
|
1138
|
+
return fallback;
|
|
820
1139
|
}
|
|
821
1140
|
}
|
|
822
1141
|
|
|
@@ -829,21 +1148,24 @@ export class MatrixGatewayDO
|
|
|
829
1148
|
await this.userServerName(userDid),
|
|
830
1149
|
);
|
|
831
1150
|
const cacheKey = `alias:${userDid}`;
|
|
1151
|
+
const fresh = (
|
|
1152
|
+
entry: AliasCacheEntry | undefined,
|
|
1153
|
+
): entry is AliasCacheEntry =>
|
|
1154
|
+
entry !== undefined &&
|
|
1155
|
+
entry.alias === alias &&
|
|
1156
|
+
Date.now() - entry.at < ALIAS_CACHE_TTL_MS;
|
|
1157
|
+
const memo = this.roomMemo.get(userDid);
|
|
1158
|
+
if (fresh(memo)) return { roomId: memo.roomId, alias };
|
|
832
1159
|
const cached = await this.ctx.storage.get<AliasCacheEntry>(cacheKey);
|
|
833
|
-
if (
|
|
834
|
-
cached
|
|
835
|
-
cached.alias === alias &&
|
|
836
|
-
Date.now() - cached.at < ALIAS_CACHE_TTL_MS
|
|
837
|
-
) {
|
|
1160
|
+
if (fresh(cached)) {
|
|
1161
|
+
this.roomMemo.set(userDid, cached);
|
|
838
1162
|
return { roomId: cached.roomId, alias };
|
|
839
1163
|
}
|
|
840
1164
|
const roomId = await this.resolveAlias(alias);
|
|
841
1165
|
if (!roomId) return null;
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
at: Date.now(),
|
|
846
|
-
} satisfies AliasCacheEntry);
|
|
1166
|
+
const entry: AliasCacheEntry = { roomId, alias, at: Date.now() };
|
|
1167
|
+
this.roomMemo.set(userDid, entry);
|
|
1168
|
+
await this.ctx.storage.put(cacheKey, entry);
|
|
847
1169
|
return { roomId, alias };
|
|
848
1170
|
}
|
|
849
1171
|
|
|
@@ -1156,13 +1478,58 @@ export class MatrixGatewayDO
|
|
|
1156
1478
|
async downloadEventMediaStream(
|
|
1157
1479
|
roomId: string,
|
|
1158
1480
|
eventId: string,
|
|
1481
|
+
opts: MediaDownloadOptions = {},
|
|
1159
1482
|
): Promise<MediaStream | null> {
|
|
1160
|
-
|
|
1483
|
+
const bound = boundedSignal(
|
|
1484
|
+
opts.timeoutMs ?? MEDIA_DOWNLOAD_TIMEOUT_MS,
|
|
1485
|
+
opts.signal,
|
|
1486
|
+
);
|
|
1487
|
+
let media: MediaStream | null;
|
|
1488
|
+
try {
|
|
1489
|
+
// The SDK opens the download without a signal: race it, and release a
|
|
1490
|
+
// stream that arrives after the bound.
|
|
1491
|
+
media = await raceSignal(
|
|
1492
|
+
this.mediaStream(roomId, eventId),
|
|
1493
|
+
bound.signal,
|
|
1494
|
+
(late) => void late?.stream.cancel().catch(() => undefined),
|
|
1495
|
+
);
|
|
1496
|
+
} catch (err) {
|
|
1497
|
+
bound.clear();
|
|
1498
|
+
throw err;
|
|
1499
|
+
}
|
|
1500
|
+
if (!media) {
|
|
1501
|
+
bound.clear();
|
|
1502
|
+
return null;
|
|
1503
|
+
}
|
|
1504
|
+
return { ...media, stream: streamWithDeadline(media.stream, bound) };
|
|
1161
1505
|
}
|
|
1162
1506
|
|
|
1163
|
-
/**
|
|
1507
|
+
/**
|
|
1508
|
+
* Authenticated download of an `mxc://` URI as a stream
|
|
1509
|
+
* (`/_matrix/client/v1/media/download`, legacy fallback on 404), bounded
|
|
1510
|
+
* by `opts.timeoutMs` (default `MEDIA_DOWNLOAD_TIMEOUT_MS`) from the
|
|
1511
|
+
* request to the last byte.
|
|
1512
|
+
*/
|
|
1164
1513
|
async downloadMxcMediaStream(
|
|
1165
1514
|
mxc: string,
|
|
1515
|
+
opts: MediaDownloadOptions = {},
|
|
1516
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
1517
|
+
const bound = boundedSignal(
|
|
1518
|
+
opts.timeoutMs ?? MEDIA_DOWNLOAD_TIMEOUT_MS,
|
|
1519
|
+
opts.signal,
|
|
1520
|
+
);
|
|
1521
|
+
try {
|
|
1522
|
+
const body = await this.fetchMxc(mxc, bound.signal);
|
|
1523
|
+
return streamWithDeadline(body, bound);
|
|
1524
|
+
} catch (err) {
|
|
1525
|
+
bound.clear();
|
|
1526
|
+
throw err;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
private async fetchMxc(
|
|
1531
|
+
mxc: string,
|
|
1532
|
+
signal: AbortSignal,
|
|
1166
1533
|
): Promise<ReadableStream<Uint8Array>> {
|
|
1167
1534
|
const client = await this.startedClient();
|
|
1168
1535
|
const token = client.getAccessToken();
|
|
@@ -1179,7 +1546,7 @@ export class MatrixGatewayDO
|
|
|
1179
1546
|
true,
|
|
1180
1547
|
);
|
|
1181
1548
|
if (!authed) throw new Error(`MatrixGatewayDO: invalid mxc url ${mxc}`);
|
|
1182
|
-
let res = await fetch(authed, { headers });
|
|
1549
|
+
let res = await fetch(authed, { headers, signal });
|
|
1183
1550
|
if (res.status === 404) {
|
|
1184
1551
|
const legacy = client.mxcUrlToHttp(
|
|
1185
1552
|
mxc,
|
|
@@ -1190,12 +1557,17 @@ export class MatrixGatewayDO
|
|
|
1190
1557
|
true,
|
|
1191
1558
|
false,
|
|
1192
1559
|
);
|
|
1193
|
-
if (legacy)
|
|
1560
|
+
if (legacy) {
|
|
1561
|
+
await res.body?.cancel().catch(() => undefined);
|
|
1562
|
+
res = await fetch(legacy, { headers, signal });
|
|
1563
|
+
}
|
|
1194
1564
|
}
|
|
1195
|
-
if (!res.ok)
|
|
1565
|
+
if (!res.ok) {
|
|
1566
|
+
await res.body?.cancel().catch(() => undefined);
|
|
1196
1567
|
throw new Error(
|
|
1197
1568
|
`MatrixGatewayDO: media download failed: ${res.status} ${res.statusText}`,
|
|
1198
1569
|
);
|
|
1570
|
+
}
|
|
1199
1571
|
if (!res.body)
|
|
1200
1572
|
throw new Error('MatrixGatewayDO: media download has no body');
|
|
1201
1573
|
return res.body;
|
|
@@ -1358,15 +1730,55 @@ export class MatrixGatewayDO
|
|
|
1358
1730
|
return this.groupChat().unpinFact(roomId, factId);
|
|
1359
1731
|
}
|
|
1360
1732
|
|
|
1733
|
+
/**
|
|
1734
|
+
* The bot's status. The half that scans storage (the SDK's room, outbox
|
|
1735
|
+
* and catch-up counts, crypto key counts, channel memory, the inbox) is
|
|
1736
|
+
* computed at most once per `STATUS_CACHE_MS` and whenever the bot starts
|
|
1737
|
+
* or stops; the in-memory turn counters are always live.
|
|
1738
|
+
*/
|
|
1361
1739
|
override async status(): Promise<GatewayStatus> {
|
|
1740
|
+
const now = Date.now();
|
|
1741
|
+
if (!this.statusCache || now - this.statusCache.at >= STATUS_CACHE_MS) {
|
|
1742
|
+
const value = this.scanStatus();
|
|
1743
|
+
this.statusCache = { at: now, value };
|
|
1744
|
+
// A failed scan is not served to the next caller.
|
|
1745
|
+
value.catch(() => {
|
|
1746
|
+
if (this.statusCache?.value === value) this.statusCache = null;
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1362
1749
|
return {
|
|
1363
|
-
...(await
|
|
1750
|
+
...(await this.statusCache.value),
|
|
1364
1751
|
turns: { inFlight: this.turnGate.inUse, waiting: this.turnGate.queued },
|
|
1365
|
-
groupChat: this.groupChat().stats(),
|
|
1366
1752
|
ingestPending: this.ingest?.pendingCount ?? 0,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
private async scanStatus(): Promise<
|
|
1757
|
+
Omit<GatewayStatus, 'turns' | 'ingestPending'>
|
|
1758
|
+
> {
|
|
1759
|
+
return {
|
|
1760
|
+
...(await super.status()),
|
|
1761
|
+
groupChat: this.groupChat().stats(),
|
|
1367
1762
|
inbox: countInboxRows(this.inboxSql()),
|
|
1368
1763
|
};
|
|
1369
1764
|
}
|
|
1765
|
+
|
|
1766
|
+
/** Whether the bot is running, from memory only (no storage read, no start). */
|
|
1767
|
+
health(): GatewayHealth {
|
|
1768
|
+
return { running: this.botRunning };
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* The gateway's HTTP surface: `GET /health` answers `health()` — what the
|
|
1773
|
+
* shell's public `GET /health/matrix` asks, at the price of a request to
|
|
1774
|
+
* this object and nothing more.
|
|
1775
|
+
*/
|
|
1776
|
+
async fetch(request: Request): Promise<Response> {
|
|
1777
|
+
const { pathname } = new URL(request.url);
|
|
1778
|
+
if (request.method === 'GET' && pathname === '/health')
|
|
1779
|
+
return Response.json(this.health());
|
|
1780
|
+
return new Response('Not found', { status: 404 });
|
|
1781
|
+
}
|
|
1370
1782
|
}
|
|
1371
1783
|
|
|
1372
1784
|
/** Attachment descriptor of a media message for the ingest (Node bridge shape). */
|