@ixo/oracle-runtime-workers 0.13.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 +13 -8
- 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 +170 -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 +504 -108
- 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
|
|
|
@@ -347,7 +445,8 @@ export class MatrixGatewayDO
|
|
|
347
445
|
this.ingest = new IngestPipeline({
|
|
348
446
|
oracleDid: cfg.oracleRoomDid,
|
|
349
447
|
botUserId: cfg.userId,
|
|
350
|
-
canonicalAlias: (roomId) => this.roomAliases.
|
|
448
|
+
canonicalAlias: (roomId) => this.roomAliases.known(roomId),
|
|
449
|
+
userServerName: (userDid) => this.userServers.get(userDid) ?? null,
|
|
351
450
|
dispatch: (turn) => this.dispatchTurn(turn),
|
|
352
451
|
onError: (err, context) =>
|
|
353
452
|
this.log('error', `ingest failed (${context})`, err),
|
|
@@ -387,38 +486,131 @@ export class MatrixGatewayDO
|
|
|
387
486
|
// message back if the instance dies before its reply is in the outbox.
|
|
388
487
|
const sql = this.inboxSql();
|
|
389
488
|
insertInboxRow(sql, inbound, Date.now());
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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);
|
|
402
517
|
}
|
|
403
|
-
this.offerInbound(inbound);
|
|
404
518
|
}
|
|
405
519
|
|
|
406
|
-
/**
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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') {
|
|
411
530
|
this.log(
|
|
412
|
-
'
|
|
413
|
-
`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,
|
|
414
599
|
);
|
|
415
600
|
}
|
|
416
601
|
}
|
|
417
602
|
|
|
418
|
-
/**
|
|
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
|
+
*/
|
|
419
608
|
protected override async onEvent(event: BotTimelineEvent): Promise<void> {
|
|
420
609
|
await super.onEvent(event);
|
|
421
|
-
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')
|
|
422
614
|
this.groupChat().invalidateRoom(event.roomId);
|
|
423
615
|
}
|
|
424
616
|
|
|
@@ -515,10 +707,18 @@ export class MatrixGatewayDO
|
|
|
515
707
|
return this.threadRootFor(row.eventId, row.roomId, relatesTo);
|
|
516
708
|
}
|
|
517
709
|
|
|
518
|
-
/**
|
|
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
|
+
*/
|
|
519
716
|
private async canonicalAliasOf(roomId: string): Promise<string | null> {
|
|
520
|
-
|
|
521
|
-
|
|
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> {
|
|
522
722
|
const json = await this.getRoomStateEvent(roomId, 'm.room.canonical_alias');
|
|
523
723
|
const parsed: unknown = json ? JSON.parse(json) : null;
|
|
524
724
|
const alias =
|
|
@@ -529,12 +729,25 @@ export class MatrixGatewayDO
|
|
|
529
729
|
parsed.alias
|
|
530
730
|
? parsed.alias
|
|
531
731
|
: null;
|
|
532
|
-
|
|
533
|
-
|
|
732
|
+
return alias !== null &&
|
|
733
|
+
userDidFromRoomAlias(alias, this.cfg().oracleRoomDid) !== null
|
|
734
|
+
? alias
|
|
735
|
+
: null;
|
|
534
736
|
}
|
|
535
737
|
|
|
536
738
|
private async dispatchTurn(turn: IngestTurn): Promise<void> {
|
|
537
|
-
|
|
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
|
+
}
|
|
538
751
|
}
|
|
539
752
|
|
|
540
753
|
private async dispatchTurnNow(turn: IngestTurn): Promise<void> {
|
|
@@ -580,10 +793,14 @@ export class MatrixGatewayDO
|
|
|
580
793
|
'info',
|
|
581
794
|
`turn ${requestId}: ${turn.userDid} in ${turn.roomId} (thread ${turn.threadId}${decision.roomKind === 'group' ? `, group room: ${decision.reason}` : ''})`,
|
|
582
795
|
);
|
|
583
|
-
for (const id of turn.eventIds) this.inFlightEvents.add(id);
|
|
584
796
|
let typing: ReturnType<typeof setInterval> | null = null;
|
|
585
797
|
try {
|
|
586
|
-
|
|
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
|
+
);
|
|
587
804
|
typing = setInterval(() => {
|
|
588
805
|
this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS).catch(
|
|
589
806
|
() => undefined,
|
|
@@ -595,19 +812,30 @@ export class MatrixGatewayDO
|
|
|
595
812
|
'info',
|
|
596
813
|
`turn ${requestId}: reply served from the user object's ledger (turn had already finished)`,
|
|
597
814
|
);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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, {
|
|
605
826
|
threadId: turn.threadId,
|
|
606
|
-
...(
|
|
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
|
+
: {}),
|
|
607
838
|
});
|
|
608
|
-
} else {
|
|
609
|
-
this.log('info', `turn ${requestId}: empty reply, nothing sent`);
|
|
610
|
-
}
|
|
611
839
|
// The reply is in the durable outbox (or there is none): the turn
|
|
612
840
|
// has ended, the inbox row has done its job.
|
|
613
841
|
deleteInboxRows(this.inboxSql(), turn.eventIds);
|
|
@@ -642,23 +870,29 @@ export class MatrixGatewayDO
|
|
|
642
870
|
// Told the user (or could not): either way this turn is over.
|
|
643
871
|
deleteInboxRows(this.inboxSql(), turn.eventIds);
|
|
644
872
|
} finally {
|
|
645
|
-
for (const id of turn.eventIds) this.inFlightEvents.delete(id);
|
|
646
873
|
if (typing) clearInterval(typing);
|
|
647
874
|
await this.setTyping(turn.roomId, false).catch(() => undefined);
|
|
648
875
|
// Close the card on every exit — reply posted, empty reply, thrown
|
|
649
876
|
// error — so no turn can leave a spinner running in the room.
|
|
650
|
-
await this.
|
|
651
|
-
|
|
652
|
-
.catch((err: unknown) =>
|
|
877
|
+
await this.finishWorkStatus(turn.userDid, requestId).catch(
|
|
878
|
+
(err: unknown) =>
|
|
653
879
|
this.log(
|
|
654
880
|
'warn',
|
|
655
881
|
`turn ${requestId}: could not close the status card`,
|
|
656
882
|
err,
|
|
657
883
|
),
|
|
658
|
-
|
|
884
|
+
);
|
|
659
885
|
}
|
|
660
886
|
}
|
|
661
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
|
+
|
|
662
896
|
private userStub(userDid: string): DurableObjectStub<UserOracleObject> {
|
|
663
897
|
return this.env.USER_ORACLE.get(
|
|
664
898
|
this.env.USER_ORACLE.idFromName(
|
|
@@ -708,13 +942,32 @@ export class MatrixGatewayDO
|
|
|
708
942
|
* moment to re-dispatch the turns a previous incarnation left unfinished.
|
|
709
943
|
*/
|
|
710
944
|
protected override async onStarted(_result: StartResult): Promise<void> {
|
|
945
|
+
this.noteRunning(true);
|
|
711
946
|
this.scheduleInboxReplay();
|
|
712
947
|
}
|
|
713
948
|
|
|
714
|
-
|
|
715
|
-
|
|
949
|
+
override async ensureStarted(): Promise<StartResult> {
|
|
950
|
+
const result = await super.ensureStarted();
|
|
951
|
+
this.noteRunning(true);
|
|
952
|
+
return result;
|
|
953
|
+
}
|
|
954
|
+
|
|
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
|
+
}
|
|
716
969
|
this.inboxReplayRunning = true;
|
|
717
|
-
void this.replayInbox()
|
|
970
|
+
void this.replayInbox(only)
|
|
718
971
|
.catch((err: unknown) => this.log('error', 'inbox replay failed', err))
|
|
719
972
|
.finally(() => {
|
|
720
973
|
this.inboxReplayRunning = false;
|
|
@@ -728,12 +981,14 @@ export class MatrixGatewayDO
|
|
|
728
981
|
* object decides what a replay means for it (`matrix-turn-ledger.ts`):
|
|
729
982
|
* stored reply, attach to the running turn, or refuse — never a second run.
|
|
730
983
|
*/
|
|
731
|
-
private async replayInbox(): Promise<void> {
|
|
984
|
+
private async replayInbox(only?: ReadonlySet<string>): Promise<void> {
|
|
732
985
|
const sql = this.inboxSql();
|
|
733
|
-
const rows = listInboxRows(sql)
|
|
986
|
+
const rows = listInboxRows(sql).filter(
|
|
987
|
+
(row) => !only || only.has(row.eventId),
|
|
988
|
+
);
|
|
734
989
|
if (rows.length === 0) return;
|
|
735
990
|
const plan = planInboxReplay(rows, {
|
|
736
|
-
inFlight: this.inFlightEvents,
|
|
991
|
+
inFlight: new Set([...this.inFlightEvents, ...this.debouncing]),
|
|
737
992
|
maxReplays: MAX_TURN_REPLAYS,
|
|
738
993
|
});
|
|
739
994
|
for (const row of plan.exhausted) {
|
|
@@ -762,17 +1017,30 @@ export class MatrixGatewayDO
|
|
|
762
1017
|
);
|
|
763
1018
|
for (const row of plan.replay) {
|
|
764
1019
|
try {
|
|
765
|
-
|
|
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);
|
|
766
1036
|
} catch (err) {
|
|
767
|
-
this.log(
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
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);
|
|
774
1043
|
}
|
|
775
|
-
this.offerInbound(inbound);
|
|
776
1044
|
}
|
|
777
1045
|
this.log(
|
|
778
1046
|
'info',
|
|
@@ -796,6 +1064,11 @@ export class MatrixGatewayDO
|
|
|
796
1064
|
// A debounce buffer firing after the stop would send through
|
|
797
1065
|
// `startedClient()`, which restarts the bot.
|
|
798
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);
|
|
799
1072
|
await super.stop();
|
|
800
1073
|
}
|
|
801
1074
|
|
|
@@ -803,36 +1076,66 @@ export class MatrixGatewayDO
|
|
|
803
1076
|
// Rooms
|
|
804
1077
|
// -------------------------------------------------------------------------
|
|
805
1078
|
|
|
806
|
-
|
|
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> {
|
|
807
1087
|
const cfg = this.cfg();
|
|
808
1088
|
const cacheKey = `hs:${userDid}`;
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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> {
|
|
815
1129
|
try {
|
|
816
|
-
|
|
817
|
-
if (!resolved) return cfg.serverName;
|
|
818
|
-
await this.ctx.storage.put(cacheKey, {
|
|
819
|
-
serverName: resolved,
|
|
820
|
-
at: Date.now(),
|
|
821
|
-
} satisfies HomeserverCacheEntry);
|
|
822
|
-
if (resolved !== cfg.serverName) {
|
|
823
|
-
this.log(
|
|
824
|
-
'info',
|
|
825
|
-
`user ${userDid} lives on ${resolved} (oracle is on ${cfg.serverName}); room alias uses the user's server`,
|
|
826
|
-
);
|
|
827
|
-
}
|
|
828
|
-
return resolved;
|
|
1130
|
+
return (await this.lookupUserServer(userDid)).serverName;
|
|
829
1131
|
} catch (err) {
|
|
1132
|
+
const fallback = this.cfg().serverName;
|
|
830
1133
|
this.log(
|
|
831
1134
|
'warn',
|
|
832
|
-
`could not resolve ${userDid}'s homeserver from Blocksync; using ${
|
|
1135
|
+
`could not resolve ${userDid}'s homeserver from Blocksync; using ${fallback} for the room alias`,
|
|
833
1136
|
err,
|
|
834
1137
|
);
|
|
835
|
-
return
|
|
1138
|
+
return fallback;
|
|
836
1139
|
}
|
|
837
1140
|
}
|
|
838
1141
|
|
|
@@ -845,21 +1148,24 @@ export class MatrixGatewayDO
|
|
|
845
1148
|
await this.userServerName(userDid),
|
|
846
1149
|
);
|
|
847
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 };
|
|
848
1159
|
const cached = await this.ctx.storage.get<AliasCacheEntry>(cacheKey);
|
|
849
|
-
if (
|
|
850
|
-
cached
|
|
851
|
-
cached.alias === alias &&
|
|
852
|
-
Date.now() - cached.at < ALIAS_CACHE_TTL_MS
|
|
853
|
-
) {
|
|
1160
|
+
if (fresh(cached)) {
|
|
1161
|
+
this.roomMemo.set(userDid, cached);
|
|
854
1162
|
return { roomId: cached.roomId, alias };
|
|
855
1163
|
}
|
|
856
1164
|
const roomId = await this.resolveAlias(alias);
|
|
857
1165
|
if (!roomId) return null;
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
at: Date.now(),
|
|
862
|
-
} 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);
|
|
863
1169
|
return { roomId, alias };
|
|
864
1170
|
}
|
|
865
1171
|
|
|
@@ -1172,13 +1478,58 @@ export class MatrixGatewayDO
|
|
|
1172
1478
|
async downloadEventMediaStream(
|
|
1173
1479
|
roomId: string,
|
|
1174
1480
|
eventId: string,
|
|
1481
|
+
opts: MediaDownloadOptions = {},
|
|
1175
1482
|
): Promise<MediaStream | null> {
|
|
1176
|
-
|
|
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) };
|
|
1177
1505
|
}
|
|
1178
1506
|
|
|
1179
|
-
/**
|
|
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
|
+
*/
|
|
1180
1513
|
async downloadMxcMediaStream(
|
|
1181
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,
|
|
1182
1533
|
): Promise<ReadableStream<Uint8Array>> {
|
|
1183
1534
|
const client = await this.startedClient();
|
|
1184
1535
|
const token = client.getAccessToken();
|
|
@@ -1195,7 +1546,7 @@ export class MatrixGatewayDO
|
|
|
1195
1546
|
true,
|
|
1196
1547
|
);
|
|
1197
1548
|
if (!authed) throw new Error(`MatrixGatewayDO: invalid mxc url ${mxc}`);
|
|
1198
|
-
let res = await fetch(authed, { headers });
|
|
1549
|
+
let res = await fetch(authed, { headers, signal });
|
|
1199
1550
|
if (res.status === 404) {
|
|
1200
1551
|
const legacy = client.mxcUrlToHttp(
|
|
1201
1552
|
mxc,
|
|
@@ -1206,12 +1557,17 @@ export class MatrixGatewayDO
|
|
|
1206
1557
|
true,
|
|
1207
1558
|
false,
|
|
1208
1559
|
);
|
|
1209
|
-
if (legacy)
|
|
1560
|
+
if (legacy) {
|
|
1561
|
+
await res.body?.cancel().catch(() => undefined);
|
|
1562
|
+
res = await fetch(legacy, { headers, signal });
|
|
1563
|
+
}
|
|
1210
1564
|
}
|
|
1211
|
-
if (!res.ok)
|
|
1565
|
+
if (!res.ok) {
|
|
1566
|
+
await res.body?.cancel().catch(() => undefined);
|
|
1212
1567
|
throw new Error(
|
|
1213
1568
|
`MatrixGatewayDO: media download failed: ${res.status} ${res.statusText}`,
|
|
1214
1569
|
);
|
|
1570
|
+
}
|
|
1215
1571
|
if (!res.body)
|
|
1216
1572
|
throw new Error('MatrixGatewayDO: media download has no body');
|
|
1217
1573
|
return res.body;
|
|
@@ -1374,15 +1730,55 @@ export class MatrixGatewayDO
|
|
|
1374
1730
|
return this.groupChat().unpinFact(roomId, factId);
|
|
1375
1731
|
}
|
|
1376
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
|
+
*/
|
|
1377
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
|
+
}
|
|
1378
1749
|
return {
|
|
1379
|
-
...(await
|
|
1750
|
+
...(await this.statusCache.value),
|
|
1380
1751
|
turns: { inFlight: this.turnGate.inUse, waiting: this.turnGate.queued },
|
|
1381
|
-
groupChat: this.groupChat().stats(),
|
|
1382
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(),
|
|
1383
1762
|
inbox: countInboxRows(this.inboxSql()),
|
|
1384
1763
|
};
|
|
1385
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
|
+
}
|
|
1386
1782
|
}
|
|
1387
1783
|
|
|
1388
1784
|
/** Attachment descriptor of a media message for the ingest (Node bridge shape). */
|