@ixo/oracle-runtime-workers 0.2.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 +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- package/src/tasks/store.ts +351 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matrix gateway Durable Object — one per oracle, home of the bot identity.
|
|
3
|
+
*
|
|
4
|
+
* Built on `@ixo/matrix-bot-workers-sdk`'s `MatrixBotDO`, which owns
|
|
5
|
+
* everything generic about running an E2EE Matrix bot on Workers: password
|
|
6
|
+
* login and the device identity, the crypto store persisted to DO storage,
|
|
7
|
+
* the sync loop and its keep-alive alarm, paced durable sends (per-room
|
|
8
|
+
* FIFOs, token bucket, outbox with poison-row eviction), resumable catch-up
|
|
9
|
+
* after a restart, invite handling, the idle recycle and device rotation.
|
|
10
|
+
*
|
|
11
|
+
* This subclass adds only what is specific to the oracle:
|
|
12
|
+
*
|
|
13
|
+
* - inbound room messages → user-object turns (`IngestPipeline` for the
|
|
14
|
+
* per-session debounce, thread / quote-reply resolution, attachments,
|
|
15
|
+
* typing, the work-status card);
|
|
16
|
+
* - user↔oracle room resolution from DIDs (alias on the user's homeserver);
|
|
17
|
+
* - dedicated task rooms;
|
|
18
|
+
* - user SQLite snapshots as encrypted room media (`m.ixo.media_*`);
|
|
19
|
+
* - the oracle's P-256 secrets key from its account room;
|
|
20
|
+
* - a second, crypto-less password device for plugins that need a raw
|
|
21
|
+
* matrix-js-sdk client (editor, flows).
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
listPendingSends,
|
|
25
|
+
MatrixBotDO,
|
|
26
|
+
optionsFromEnv,
|
|
27
|
+
Semaphore,
|
|
28
|
+
withRateLimitRetry,
|
|
29
|
+
type BotMessage,
|
|
30
|
+
type MatrixBotOptions,
|
|
31
|
+
} from '@ixo/matrix-bot-workers-sdk';
|
|
32
|
+
import { createClient, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
|
|
33
|
+
import {
|
|
34
|
+
userObjectName,
|
|
35
|
+
userOracleRoomAlias,
|
|
36
|
+
type BotCredentials,
|
|
37
|
+
type CreateRoomOptions,
|
|
38
|
+
type GatewayStatus,
|
|
39
|
+
type JsonString,
|
|
40
|
+
type MatrixGatewayObject,
|
|
41
|
+
type OracleWorkerEnv,
|
|
42
|
+
type OutboxRow,
|
|
43
|
+
type TurnResult,
|
|
44
|
+
type UserOracleObject,
|
|
45
|
+
} from '../do/contracts';
|
|
46
|
+
import { decryptWithPin } from '../secrets/pin-cipher';
|
|
47
|
+
import {
|
|
48
|
+
IngestPipeline,
|
|
49
|
+
type InboundAttachment,
|
|
50
|
+
type IngestTurn,
|
|
51
|
+
} from './ingest';
|
|
52
|
+
import {
|
|
53
|
+
readRelatesTo,
|
|
54
|
+
resolveReplyChainRoot,
|
|
55
|
+
ThreadRootCache,
|
|
56
|
+
} from './reply-chain';
|
|
57
|
+
import { fetchUserMatrixServerName } from './user-homeserver';
|
|
58
|
+
|
|
59
|
+
const TYPING_REFRESH_MS = 20_000;
|
|
60
|
+
const TYPING_TIMEOUT_MS = 30_000;
|
|
61
|
+
/** User DID → room id memo (aliases never move; a miss re-resolves). */
|
|
62
|
+
const ALIAS_CACHE_TTL_MS = 30 * 60_000;
|
|
63
|
+
/** User DID → Matrix server name (from the DID document via Blocksync). */
|
|
64
|
+
const HOMESERVER_CACHE_TTL_MS = 6 * 60 * 60_000;
|
|
65
|
+
/**
|
|
66
|
+
* A room created by a user seconds ago may not have delivered its invite
|
|
67
|
+
* through sync yet when the user's object asks for its state; within this
|
|
68
|
+
* window a 403 is answered by accepting the invite and reading again.
|
|
69
|
+
*/
|
|
70
|
+
const PENDING_INVITE_GRACE_MS = 5_000;
|
|
71
|
+
/** Room turns in flight at once (each ends in an encrypted reply). */
|
|
72
|
+
const DEFAULT_TURN_CONCURRENCY = 4;
|
|
73
|
+
/** Idle recycle of the object after this many sends (0 disables). */
|
|
74
|
+
const DEFAULT_RECYCLE_AFTER_SENDS = 300;
|
|
75
|
+
const LOGIN_RETRY_ATTEMPTS = 3;
|
|
76
|
+
const LOGIN_RETRY_MAX_MS = 30_000;
|
|
77
|
+
|
|
78
|
+
/** State event (keyed by storageKey) naming the live media event of a user snapshot. */
|
|
79
|
+
const MEDIA_STATE_TYPE = 'm.ixo.media_state';
|
|
80
|
+
/** Timeline event carrying the snapshot bytes (encrypted in E2EE rooms). */
|
|
81
|
+
const MEDIA_UPLOAD_TYPE = 'm.ixo.media_upload';
|
|
82
|
+
const SNAPSHOT_MIMETYPE = 'application/x-sqlite3';
|
|
83
|
+
|
|
84
|
+
/** Media msgtypes the ingest turns into attachments (the Node bridge's set). */
|
|
85
|
+
const FILE_MSGTYPES = new Set<string>([
|
|
86
|
+
'm.file',
|
|
87
|
+
'm.image',
|
|
88
|
+
'm.video',
|
|
89
|
+
'm.audio',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** Storage key of the plugins' (crypto-less) device — see `botCredentials`. */
|
|
93
|
+
const BOT_CLIENT_IDENTITY_KEY = 'identity:bot-client';
|
|
94
|
+
|
|
95
|
+
interface OracleGatewayConfig {
|
|
96
|
+
baseUrl: string;
|
|
97
|
+
userId: string;
|
|
98
|
+
password: string;
|
|
99
|
+
recoveryPassphrase?: string;
|
|
100
|
+
/** Server name used for user↔oracle room aliases when the user's DID names none. */
|
|
101
|
+
serverName: string;
|
|
102
|
+
/** The oracle's chain (account) DID — Durable-Object routing of user objects. */
|
|
103
|
+
oracleDid: string;
|
|
104
|
+
/**
|
|
105
|
+
* The DID that forms the oracle half of the user↔oracle room alias
|
|
106
|
+
* (`#<userDidDashed>_<oracleRoomDidDashed>:server`). The Node runtime builds
|
|
107
|
+
* this alias from the oracle's ENTITY DID, not its account DID, so a Workers
|
|
108
|
+
* oracle whose `ORACLE_ENTITY_DID` differs from `ORACLE_DID` must resolve the
|
|
109
|
+
* SAME room to read a migrating user's media. Falls back to `oracleDid` when
|
|
110
|
+
* no entity DID is configured (e.g. the local harness, where they are equal).
|
|
111
|
+
*/
|
|
112
|
+
oracleRoomDid: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The plugins' device credentials as stored (`identity:bot-client`). */
|
|
116
|
+
interface StoredDevice {
|
|
117
|
+
userId: string;
|
|
118
|
+
deviceId: string;
|
|
119
|
+
accessToken: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface AliasCacheEntry {
|
|
123
|
+
roomId: string;
|
|
124
|
+
alias: string;
|
|
125
|
+
at: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface HomeserverCacheEntry {
|
|
129
|
+
serverName: string;
|
|
130
|
+
at: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function gateSizeFromEnv(raw: string | undefined, fallback: number): number {
|
|
134
|
+
const n = Number(raw);
|
|
135
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isAbortError(err: unknown): boolean {
|
|
139
|
+
return (
|
|
140
|
+
err instanceof Error &&
|
|
141
|
+
(err.name === 'AbortError' || /\babort(ed)?\b/i.test(err.message))
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isForbidden(err: unknown): boolean {
|
|
146
|
+
return (
|
|
147
|
+
err instanceof MatrixError &&
|
|
148
|
+
(err.errcode === 'M_FORBIDDEN' || err.httpStatus === 403)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sleep(ms: number): Promise<void> {
|
|
153
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `info.mimetype` / `info.size` of a media message when well-formed. */
|
|
157
|
+
function readMediaInfo(content: Record<string, unknown>): {
|
|
158
|
+
mimetype?: string;
|
|
159
|
+
size?: number;
|
|
160
|
+
} {
|
|
161
|
+
const info: unknown = content['info'];
|
|
162
|
+
if (typeof info !== 'object' || info === null) return {};
|
|
163
|
+
const rec: Record<string, unknown> = { ...info };
|
|
164
|
+
return {
|
|
165
|
+
...(typeof rec['mimetype'] === 'string'
|
|
166
|
+
? { mimetype: rec['mimetype'] }
|
|
167
|
+
: {}),
|
|
168
|
+
...(typeof rec['size'] === 'number' ? { size: rec['size'] } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The `eventId` a `m.ixo.media_state` event points at, when set. */
|
|
173
|
+
function stateEventId(json: JsonString | null): string | undefined {
|
|
174
|
+
if (!json) return undefined;
|
|
175
|
+
const parsed: unknown = JSON.parse(json);
|
|
176
|
+
return typeof parsed === 'object' &&
|
|
177
|
+
parsed !== null &&
|
|
178
|
+
'eventId' in parsed &&
|
|
179
|
+
typeof parsed.eventId === 'string' &&
|
|
180
|
+
parsed.eventId
|
|
181
|
+
? parsed.eventId
|
|
182
|
+
: undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Durable Object
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export class MatrixGatewayDO
|
|
190
|
+
extends MatrixBotDO<OracleWorkerEnv>
|
|
191
|
+
implements MatrixGatewayObject
|
|
192
|
+
{
|
|
193
|
+
private config: OracleGatewayConfig | null = null;
|
|
194
|
+
private ingest: IngestPipeline | null = null;
|
|
195
|
+
/** `m.room.canonical_alias` per room (null = none), read once per instance. */
|
|
196
|
+
private readonly roomAliases = new Map<string, string | null>();
|
|
197
|
+
/** Quote-reply chain → thread root memo (see reply-chain.ts). */
|
|
198
|
+
private readonly threadRoots = new ThreadRootCache();
|
|
199
|
+
private readonly turnGate = new Semaphore(
|
|
200
|
+
gateSizeFromEnv(this.env.MATRIX_TURN_CONCURRENCY, DEFAULT_TURN_CONCURRENCY),
|
|
201
|
+
);
|
|
202
|
+
private pluginDevice: BotCredentials | null = null;
|
|
203
|
+
/** Memoised `getOracleSecretsKey` result (private JWK JSON). */
|
|
204
|
+
private oracleSecretsKeyJson: string | null = null;
|
|
205
|
+
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
// Configuration
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
private cfg(): OracleGatewayConfig {
|
|
211
|
+
if (this.config) return this.config;
|
|
212
|
+
const env = this.env;
|
|
213
|
+
const baseUrl = env.MATRIX_BASE_URL;
|
|
214
|
+
const userId = env.MATRIX_ORACLE_ADMIN_USER_ID;
|
|
215
|
+
if (!baseUrl || !userId)
|
|
216
|
+
throw new Error(
|
|
217
|
+
'MatrixGatewayDO: MATRIX_BASE_URL and MATRIX_ORACLE_ADMIN_USER_ID are required',
|
|
218
|
+
);
|
|
219
|
+
if (!env.ORACLE_DID)
|
|
220
|
+
throw new Error('MatrixGatewayDO: ORACLE_DID is required');
|
|
221
|
+
const password = env.MATRIX_ORACLE_ADMIN_PASSWORD;
|
|
222
|
+
if (!password)
|
|
223
|
+
throw new Error(
|
|
224
|
+
'MatrixGatewayDO: MATRIX_ORACLE_ADMIN_PASSWORD is required — the gateway logs its own device in with it',
|
|
225
|
+
);
|
|
226
|
+
const phrase = env.MATRIX_RECOVERY_PHRASE;
|
|
227
|
+
this.config = {
|
|
228
|
+
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
229
|
+
userId,
|
|
230
|
+
password,
|
|
231
|
+
// The infra chart ships the literal placeholder `secret` when no
|
|
232
|
+
// passphrase was provisioned.
|
|
233
|
+
...(phrase && phrase !== 'secret' ? { recoveryPassphrase: phrase } : {}),
|
|
234
|
+
serverName:
|
|
235
|
+
env.MATRIX_HOMESERVER_NAME || userId.slice(userId.indexOf(':') + 1),
|
|
236
|
+
oracleDid: env.ORACLE_DID,
|
|
237
|
+
oracleRoomDid: env.ORACLE_ENTITY_DID || env.ORACLE_DID,
|
|
238
|
+
};
|
|
239
|
+
return this.config;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
protected override async resolveOptions(): Promise<MatrixBotOptions> {
|
|
243
|
+
const cfg = this.cfg();
|
|
244
|
+
const env = this.env;
|
|
245
|
+
const options = optionsFromEnv({
|
|
246
|
+
MATRIX_HOMESERVER_URL: cfg.baseUrl,
|
|
247
|
+
MATRIX_USER_ID: cfg.userId,
|
|
248
|
+
MATRIX_PASSWORD: cfg.password,
|
|
249
|
+
MATRIX_DEVICE_DISPLAY_NAME: `QiForge oracle (${env.ORACLE_NAME ?? 'workers'})`,
|
|
250
|
+
MATRIX_RECOVERY_PASSPHRASE: cfg.recoveryPassphrase,
|
|
251
|
+
MATRIX_SEND_RATE_PER_SECOND: env.MATRIX_SEND_RATE_PER_SECOND,
|
|
252
|
+
MATRIX_SEND_BURST: env.MATRIX_SEND_BURST,
|
|
253
|
+
MATRIX_SEND_CONCURRENCY: env.MATRIX_SEND_CONCURRENCY,
|
|
254
|
+
MATRIX_RECYCLE_AFTER_SENDS:
|
|
255
|
+
env.MATRIX_RECYCLE_AFTER_SENDS ?? String(DEFAULT_RECYCLE_AFTER_SENDS),
|
|
256
|
+
MATRIX_HOT_ROOMS: env.MATRIX_HOT_ROOMS,
|
|
257
|
+
MATRIX_BACKFILL_MAX_EVENTS: env.MATRIX_BACKFILL_MAX_EVENTS,
|
|
258
|
+
LOG_LEVEL: env.LOG_LEVEL,
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
...options,
|
|
262
|
+
// The SDK delivers every message at once; the per-session debounce that
|
|
263
|
+
// merges a burst into one turn is the ingest pipeline's (Node parity).
|
|
264
|
+
messageDebounceMs: 0,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// -------------------------------------------------------------------------
|
|
269
|
+
// Inbound: room messages → user-object turns
|
|
270
|
+
// -------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
private ingestPipeline(): IngestPipeline {
|
|
273
|
+
if (this.ingest) return this.ingest;
|
|
274
|
+
const cfg = this.cfg();
|
|
275
|
+
this.ingest = new IngestPipeline({
|
|
276
|
+
oracleDid: cfg.oracleRoomDid,
|
|
277
|
+
canonicalAlias: (roomId) => this.roomAliases.get(roomId) ?? null,
|
|
278
|
+
dispatch: (turn) => this.dispatchTurn(turn),
|
|
279
|
+
onError: (err, context) =>
|
|
280
|
+
this.log('error', `ingest failed (${context})`, err),
|
|
281
|
+
});
|
|
282
|
+
return this.ingest;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
protected override async onMessage(message: BotMessage): Promise<void> {
|
|
286
|
+
// `messageDebounceMs` is 0, so every delivery carries exactly one event.
|
|
287
|
+
const eventId = message.eventIds[0];
|
|
288
|
+
if (!eventId) return;
|
|
289
|
+
const content = message.content;
|
|
290
|
+
if ('INTERNAL' in content) return; // Node-runtime bookkeeping events
|
|
291
|
+
const isText = message.msgtype === 'm.text';
|
|
292
|
+
const isFile = FILE_MSGTYPES.has(message.msgtype);
|
|
293
|
+
if (!isText && !isFile) return;
|
|
294
|
+
// Resolved before the offer so the pipeline's synchronous alias lookup
|
|
295
|
+
// (room alias → user DID) can answer from the memo.
|
|
296
|
+
await this.canonicalAliasOf(message.roomId);
|
|
297
|
+
const threadRootId = await this.threadRootFor(message, eventId);
|
|
298
|
+
const outcome = this.ingestPipeline().offer({
|
|
299
|
+
eventId,
|
|
300
|
+
roomId: message.roomId,
|
|
301
|
+
sender: message.sender,
|
|
302
|
+
ts: message.ts,
|
|
303
|
+
body: isText ? message.body : '',
|
|
304
|
+
...(threadRootId ? { threadRootId } : {}),
|
|
305
|
+
...(isFile
|
|
306
|
+
? { attachment: attachmentOf(content, eventId, message.body) }
|
|
307
|
+
: {}),
|
|
308
|
+
});
|
|
309
|
+
if (outcome !== 'queued')
|
|
310
|
+
this.log(
|
|
311
|
+
'debug',
|
|
312
|
+
`ingest dropped ${eventId} in ${message.roomId}: ${outcome}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Thread for an inbound message: the SDK's `threadRootId` (an explicit
|
|
318
|
+
* `m.thread` relation) or, for a quote-reply, the thread of the message
|
|
319
|
+
* chain it replies to (see `reply-chain.ts`).
|
|
320
|
+
*/
|
|
321
|
+
private async threadRootFor(
|
|
322
|
+
message: BotMessage,
|
|
323
|
+
eventId: string,
|
|
324
|
+
): Promise<string | undefined> {
|
|
325
|
+
if (message.threadRootId) return message.threadRootId;
|
|
326
|
+
const relatesTo = readRelatesTo(message.content);
|
|
327
|
+
if (!relatesTo) return undefined;
|
|
328
|
+
const root = await resolveReplyChainRoot({
|
|
329
|
+
eventId,
|
|
330
|
+
relatesTo,
|
|
331
|
+
cache: this.threadRoots,
|
|
332
|
+
fetchRelatesTo: async (id) => {
|
|
333
|
+
const json = await this.getEvent(message.roomId, id);
|
|
334
|
+
if (!json) return null;
|
|
335
|
+
const parsed: unknown = JSON.parse(json);
|
|
336
|
+
const content =
|
|
337
|
+
typeof parsed === 'object' && parsed !== null && 'content' in parsed
|
|
338
|
+
? parsed.content
|
|
339
|
+
: undefined;
|
|
340
|
+
return readRelatesTo(content) ?? {};
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
return root ?? undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** `m.room.canonical_alias` of a room, memoised per instance (null = none). */
|
|
347
|
+
private async canonicalAliasOf(roomId: string): Promise<string | null> {
|
|
348
|
+
const memo = this.roomAliases.get(roomId);
|
|
349
|
+
if (memo !== undefined) return memo;
|
|
350
|
+
const json = await this.getRoomStateEvent(roomId, 'm.room.canonical_alias');
|
|
351
|
+
const parsed: unknown = json ? JSON.parse(json) : null;
|
|
352
|
+
const alias =
|
|
353
|
+
typeof parsed === 'object' &&
|
|
354
|
+
parsed !== null &&
|
|
355
|
+
'alias' in parsed &&
|
|
356
|
+
typeof parsed.alias === 'string' &&
|
|
357
|
+
parsed.alias
|
|
358
|
+
? parsed.alias
|
|
359
|
+
: null;
|
|
360
|
+
this.roomAliases.set(roomId, alias);
|
|
361
|
+
return alias;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async dispatchTurn(turn: IngestTurn): Promise<void> {
|
|
365
|
+
return this.turnGate.run(() => this.dispatchTurnNow(turn));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async dispatchTurnNow(turn: IngestTurn): Promise<void> {
|
|
369
|
+
const requestId = crypto.randomUUID();
|
|
370
|
+
this.log(
|
|
371
|
+
'info',
|
|
372
|
+
`turn ${requestId}: ${turn.userDid} in ${turn.roomId}${turn.threadId ? ` (thread ${turn.threadId})` : ''}`,
|
|
373
|
+
);
|
|
374
|
+
let typing: ReturnType<typeof setInterval> | null = null;
|
|
375
|
+
try {
|
|
376
|
+
await this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS);
|
|
377
|
+
typing = setInterval(() => {
|
|
378
|
+
this.setTyping(turn.roomId, true, TYPING_TIMEOUT_MS).catch(
|
|
379
|
+
() => undefined,
|
|
380
|
+
);
|
|
381
|
+
}, TYPING_REFRESH_MS);
|
|
382
|
+
const result = await this.runTurn(turn, requestId);
|
|
383
|
+
if (result.text.trim()) {
|
|
384
|
+
await this.sendText(
|
|
385
|
+
turn.roomId,
|
|
386
|
+
result.text,
|
|
387
|
+
turn.threadId ? { threadId: turn.threadId } : undefined,
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
this.log('info', `turn ${requestId}: empty reply, nothing sent`);
|
|
391
|
+
}
|
|
392
|
+
} catch (err) {
|
|
393
|
+
// A turn aborted because a newer message on the same thread superseded
|
|
394
|
+
// it is not a failure: the new turn answers, the old card says so.
|
|
395
|
+
if (isAbortError(err)) {
|
|
396
|
+
this.log('info', `turn ${requestId}: superseded by a newer message`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
this.log('error', `turn ${requestId} failed`, err);
|
|
400
|
+
try {
|
|
401
|
+
await this.sendNotice(
|
|
402
|
+
turn.roomId,
|
|
403
|
+
'Sorry — something went wrong while handling your message. Please try again.',
|
|
404
|
+
turn.threadId ? { threadId: turn.threadId } : undefined,
|
|
405
|
+
);
|
|
406
|
+
} catch (noticeErr) {
|
|
407
|
+
this.log(
|
|
408
|
+
'error',
|
|
409
|
+
`turn ${requestId}: could not post error notice`,
|
|
410
|
+
noticeErr,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
if (typing) clearInterval(typing);
|
|
415
|
+
await this.setTyping(turn.roomId, false).catch(() => undefined);
|
|
416
|
+
// Close the card on every exit — reply posted, empty reply, thrown
|
|
417
|
+
// error — so no turn can leave a spinner running in the room.
|
|
418
|
+
await this.userStub(turn.userDid)
|
|
419
|
+
.finishWorkStatus(requestId, 'done')
|
|
420
|
+
.catch((err: unknown) =>
|
|
421
|
+
this.log(
|
|
422
|
+
'warn',
|
|
423
|
+
`turn ${requestId}: could not close the status card`,
|
|
424
|
+
err,
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private userStub(userDid: string): DurableObjectStub<UserOracleObject> {
|
|
431
|
+
return this.env.USER_ORACLE.get(
|
|
432
|
+
this.env.USER_ORACLE.idFromName(
|
|
433
|
+
userObjectName(userDid, this.cfg().oracleDid),
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Run the turn in the user's object. */
|
|
439
|
+
protected async runTurn(
|
|
440
|
+
turn: IngestTurn,
|
|
441
|
+
requestId: string,
|
|
442
|
+
): Promise<TurnResult> {
|
|
443
|
+
return this.userStub(turn.userDid).runTurn({
|
|
444
|
+
identity: { userDid: turn.userDid, matrixUserId: turn.matrixUserId },
|
|
445
|
+
sessionId: turn.sessionId,
|
|
446
|
+
message: turn.message,
|
|
447
|
+
client: 'matrix',
|
|
448
|
+
roomId: turn.roomId,
|
|
449
|
+
...(turn.threadId ? { threadId: turn.threadId } : {}),
|
|
450
|
+
...(turn.eventIds[0] ? { eventId: turn.eventIds[0] } : {}),
|
|
451
|
+
...(turn.attachments?.length ? { attachments: turn.attachments } : {}),
|
|
452
|
+
requestId,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
override async stop(): Promise<void> {
|
|
457
|
+
// A debounce buffer firing after the stop would send through
|
|
458
|
+
// `startedClient()`, which restarts the bot.
|
|
459
|
+
this.ingest?.clear();
|
|
460
|
+
await super.stop();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// -------------------------------------------------------------------------
|
|
464
|
+
// Rooms
|
|
465
|
+
// -------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
private async userServerName(userDid: string): Promise<string> {
|
|
468
|
+
const cfg = this.cfg();
|
|
469
|
+
const cacheKey = `hs:${userDid}`;
|
|
470
|
+
const cached = await this.ctx.storage.get<HomeserverCacheEntry>(cacheKey);
|
|
471
|
+
if (cached && Date.now() - cached.at < HOMESERVER_CACHE_TTL_MS) {
|
|
472
|
+
return cached.serverName;
|
|
473
|
+
}
|
|
474
|
+
const blocksync = this.env.BLOCKSYNC_GRAPHQL_URL;
|
|
475
|
+
if (!blocksync) return cfg.serverName;
|
|
476
|
+
try {
|
|
477
|
+
const resolved = await fetchUserMatrixServerName(blocksync, userDid);
|
|
478
|
+
if (!resolved) return cfg.serverName;
|
|
479
|
+
await this.ctx.storage.put(cacheKey, {
|
|
480
|
+
serverName: resolved,
|
|
481
|
+
at: Date.now(),
|
|
482
|
+
} satisfies HomeserverCacheEntry);
|
|
483
|
+
if (resolved !== cfg.serverName) {
|
|
484
|
+
this.log(
|
|
485
|
+
'info',
|
|
486
|
+
`user ${userDid} lives on ${resolved} (oracle is on ${cfg.serverName}); room alias uses the user's server`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return resolved;
|
|
490
|
+
} catch (err) {
|
|
491
|
+
this.log(
|
|
492
|
+
'warn',
|
|
493
|
+
`could not resolve ${userDid}'s homeserver from Blocksync; using ${cfg.serverName}`,
|
|
494
|
+
err,
|
|
495
|
+
);
|
|
496
|
+
return cfg.serverName;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async resolveUserRoom(
|
|
501
|
+
userDid: string,
|
|
502
|
+
): Promise<{ roomId: string; alias: string } | null> {
|
|
503
|
+
const alias = userOracleRoomAlias(
|
|
504
|
+
userDid,
|
|
505
|
+
this.cfg().oracleRoomDid,
|
|
506
|
+
await this.userServerName(userDid),
|
|
507
|
+
);
|
|
508
|
+
const cacheKey = `alias:${userDid}`;
|
|
509
|
+
const cached = await this.ctx.storage.get<AliasCacheEntry>(cacheKey);
|
|
510
|
+
if (
|
|
511
|
+
cached &&
|
|
512
|
+
cached.alias === alias &&
|
|
513
|
+
Date.now() - cached.at < ALIAS_CACHE_TTL_MS
|
|
514
|
+
) {
|
|
515
|
+
return { roomId: cached.roomId, alias };
|
|
516
|
+
}
|
|
517
|
+
const roomId = await this.resolveAlias(alias);
|
|
518
|
+
if (!roomId) return null;
|
|
519
|
+
await this.ctx.storage.put(cacheKey, {
|
|
520
|
+
roomId,
|
|
521
|
+
alias,
|
|
522
|
+
at: Date.now(),
|
|
523
|
+
} satisfies AliasCacheEntry);
|
|
524
|
+
return { roomId, alias };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Create a private room the bot owns (dedicated `[Task] <title>` rooms) and
|
|
529
|
+
* invite the given users. Like the Node runtime's `createDedicatedRoom`:
|
|
530
|
+
* `private_chat` preset, no alias. Replies in it route to the sender's
|
|
531
|
+
* object (no alias → the sender's own DID), so nothing else is recorded.
|
|
532
|
+
*/
|
|
533
|
+
async createDedicatedRoom(
|
|
534
|
+
opts: CreateRoomOptions,
|
|
535
|
+
): Promise<{ roomId: string }> {
|
|
536
|
+
const roomId = await this.createRoom(
|
|
537
|
+
JSON.stringify({
|
|
538
|
+
name: opts.name,
|
|
539
|
+
...(opts.topic ? { topic: opts.topic } : {}),
|
|
540
|
+
visibility: Visibility.Private,
|
|
541
|
+
preset: Preset.PrivateChat,
|
|
542
|
+
invite: opts.invite,
|
|
543
|
+
}),
|
|
544
|
+
);
|
|
545
|
+
this.log(
|
|
546
|
+
'info',
|
|
547
|
+
`created room ${roomId} (${opts.name}) for ${opts.userDid}`,
|
|
548
|
+
);
|
|
549
|
+
return { roomId };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
override async getRoomState(roomId: string): Promise<JsonString> {
|
|
553
|
+
const deadline = Date.now() + PENDING_INVITE_GRACE_MS;
|
|
554
|
+
for (;;) {
|
|
555
|
+
try {
|
|
556
|
+
return await super.getRoomState(roomId);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
// 403 = not a member. If the oracle was only just invited (a page
|
|
559
|
+
// room created by the user seconds ago), accept the invite here
|
|
560
|
+
// instead of waiting for the sync loop, then read again. The join
|
|
561
|
+
// fails quietly for a room we are not invited to.
|
|
562
|
+
if (!isForbidden(err) || Date.now() > deadline) throw err;
|
|
563
|
+
await this.joinRoom(roomId).catch(() => undefined);
|
|
564
|
+
await sleep(250);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// -------------------------------------------------------------------------
|
|
570
|
+
// Oracle secrets key (P-256 private JWK from the account room)
|
|
571
|
+
// -------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Load the oracle's P-256 secrets key from its Matrix *account* room —
|
|
575
|
+
* the port of the chain-client's `loadEncryptionKey`
|
|
576
|
+
* (`matrix-bot/setup-encryption-key.ts`): the state event
|
|
577
|
+
* `ixo.room.encryption_key.index` / `p256_encryption` names the active
|
|
578
|
+
* key's timeline event, whose `encrypted_private_key` is AES-256-CBC
|
|
579
|
+
* encrypted with `MATRIX_VALUE_PIN` (`ivHex:cipherHex`, key =
|
|
580
|
+
* `pin.padEnd(32)` UTF-8 — the exact scheme of the chain-client's
|
|
581
|
+
* `decrypt` in `setup-claim-signing-mnemonics.ts`). Returns the private
|
|
582
|
+
* JWK JSON, or null when the key is not provisioned / env is unset.
|
|
583
|
+
* Memoised for the life of this object (the Node runtime loads it once at
|
|
584
|
+
* boot).
|
|
585
|
+
*/
|
|
586
|
+
async getOracleSecretsKey(): Promise<JsonString | null> {
|
|
587
|
+
if (this.oracleSecretsKeyJson !== null) return this.oracleSecretsKeyJson;
|
|
588
|
+
const roomId = this.env.MATRIX_ACCOUNT_ROOM_ID;
|
|
589
|
+
const pin = this.env.MATRIX_VALUE_PIN;
|
|
590
|
+
if (!roomId || !pin) {
|
|
591
|
+
this.log(
|
|
592
|
+
'debug',
|
|
593
|
+
'secrets key unavailable: MATRIX_ACCOUNT_ROOM_ID / MATRIX_VALUE_PIN not set',
|
|
594
|
+
);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
await this.ensureStarted();
|
|
598
|
+
const indexJson = await this.getRoomStateEvent(
|
|
599
|
+
roomId,
|
|
600
|
+
'ixo.room.encryption_key.index',
|
|
601
|
+
'p256_encryption',
|
|
602
|
+
);
|
|
603
|
+
if (!indexJson) {
|
|
604
|
+
this.log(
|
|
605
|
+
'warn',
|
|
606
|
+
'no encryption key index in the account room — run "oracles-cli setup-encryption-key" to provision one',
|
|
607
|
+
);
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
const indexContent = JSON.parse(indexJson) as {
|
|
611
|
+
keys?: Record<string, { eventId?: string; active?: boolean }>;
|
|
612
|
+
};
|
|
613
|
+
const entries = Object.entries(indexContent.keys ?? {});
|
|
614
|
+
const active = entries.find(([, entry]) => entry.active === true);
|
|
615
|
+
if (!active || typeof active[1].eventId !== 'string') {
|
|
616
|
+
this.log('warn', 'encryption key index exists but has no active key');
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
const eventJson = await this.getEvent(roomId, active[1].eventId);
|
|
620
|
+
if (!eventJson) {
|
|
621
|
+
this.log(
|
|
622
|
+
'warn',
|
|
623
|
+
`encryption key event ${active[1].eventId} not found in the account room`,
|
|
624
|
+
);
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
const event = JSON.parse(eventJson) as {
|
|
628
|
+
content?: { encrypted_private_key?: unknown };
|
|
629
|
+
};
|
|
630
|
+
const encrypted = event.content?.encrypted_private_key;
|
|
631
|
+
if (typeof encrypted !== 'string' || encrypted.length === 0) {
|
|
632
|
+
this.log(
|
|
633
|
+
'warn',
|
|
634
|
+
`encryption key event ${active[1].eventId} has no encrypted_private_key`,
|
|
635
|
+
);
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
const jwkJson = await decryptWithPin(encrypted, pin);
|
|
639
|
+
// Round-trip through JSON.parse to fail fast on a wrong PIN (garbage
|
|
640
|
+
// plaintext) before any user object seats it.
|
|
641
|
+
JSON.parse(jwkJson);
|
|
642
|
+
this.oracleSecretsKeyJson = jwkJson;
|
|
643
|
+
this.log('info', 'oracle secrets key loaded from the account room');
|
|
644
|
+
return jwkJson;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// -------------------------------------------------------------------------
|
|
648
|
+
// User snapshots (SQLite files as room media) and media downloads
|
|
649
|
+
// -------------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
async uploadUserSnapshot(
|
|
652
|
+
userDid: string,
|
|
653
|
+
storageKey: string,
|
|
654
|
+
bytes: Uint8Array,
|
|
655
|
+
filename: string,
|
|
656
|
+
): Promise<{ eventId: string }> {
|
|
657
|
+
const target = await this.resolveUserRoom(userDid);
|
|
658
|
+
if (!target)
|
|
659
|
+
throw new Error(`MatrixGatewayDO: no user↔oracle room for ${userDid}`);
|
|
660
|
+
const roomId = target.roomId;
|
|
661
|
+
// Look up the previous media event FIRST so it is redacted only after the
|
|
662
|
+
// replacement is live (a failed upload must not destroy the old copy).
|
|
663
|
+
const oldEventId = stateEventId(
|
|
664
|
+
await this.getRoomStateEvent(roomId, MEDIA_STATE_TYPE, storageKey),
|
|
665
|
+
);
|
|
666
|
+
// `{"file":{…}}` (encrypted) or `{"url":"mxc://…"}`, per the room.
|
|
667
|
+
const source: unknown = JSON.parse(
|
|
668
|
+
await this.uploadFile(roomId, bytes, {
|
|
669
|
+
filename,
|
|
670
|
+
mimetype: SNAPSHOT_MIMETYPE,
|
|
671
|
+
}),
|
|
672
|
+
);
|
|
673
|
+
const content = {
|
|
674
|
+
msgtype: 'm.file',
|
|
675
|
+
body: storageKey,
|
|
676
|
+
filename: storageKey,
|
|
677
|
+
cid: storageKey,
|
|
678
|
+
sender: this.cfg().userId,
|
|
679
|
+
info: { mimetype: SNAPSHOT_MIMETYPE, size: bytes.byteLength },
|
|
680
|
+
...(typeof source === 'object' && source !== null ? source : {}),
|
|
681
|
+
};
|
|
682
|
+
const eventId = await this.sendEvent(
|
|
683
|
+
roomId,
|
|
684
|
+
MEDIA_UPLOAD_TYPE,
|
|
685
|
+
JSON.stringify(content),
|
|
686
|
+
);
|
|
687
|
+
await this.sendStateEvent(
|
|
688
|
+
roomId,
|
|
689
|
+
MEDIA_STATE_TYPE,
|
|
690
|
+
JSON.stringify({ eventId }),
|
|
691
|
+
storageKey,
|
|
692
|
+
);
|
|
693
|
+
if (oldEventId && oldEventId !== eventId) {
|
|
694
|
+
try {
|
|
695
|
+
await this.sendEvent(
|
|
696
|
+
roomId,
|
|
697
|
+
'm.room.redaction',
|
|
698
|
+
JSON.stringify({
|
|
699
|
+
redacts: oldEventId,
|
|
700
|
+
reason: 'Replacing with updated file',
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
this.log(
|
|
705
|
+
'warn',
|
|
706
|
+
`could not redact previous snapshot ${oldEventId}`,
|
|
707
|
+
err,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
this.log(
|
|
712
|
+
'info',
|
|
713
|
+
`uploaded snapshot ${storageKey} (${bytes.byteLength} bytes) for ${userDid} → ${eventId}`,
|
|
714
|
+
);
|
|
715
|
+
return { eventId };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async downloadUserSnapshot(
|
|
719
|
+
userDid: string,
|
|
720
|
+
storageKey: string,
|
|
721
|
+
): Promise<{ bytes: Uint8Array; eventId: string } | null> {
|
|
722
|
+
const target = await this.resolveUserRoom(userDid);
|
|
723
|
+
if (!target) return null;
|
|
724
|
+
const eventId = stateEventId(
|
|
725
|
+
await this.getRoomStateEvent(target.roomId, MEDIA_STATE_TYPE, storageKey),
|
|
726
|
+
);
|
|
727
|
+
if (!eventId) return null;
|
|
728
|
+
const file = await this.downloadFile(target.roomId, eventId);
|
|
729
|
+
return file ? { bytes: file.bytes, eventId } : null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Bytes of a media message (`m.image` / `m.file` / …) for the attachments
|
|
734
|
+
* pipeline — decrypted when the event carries an encrypted `file`. Null
|
|
735
|
+
* when the event is unknown or redacted.
|
|
736
|
+
*/
|
|
737
|
+
async downloadEventMedia(
|
|
738
|
+
roomId: string,
|
|
739
|
+
eventId: string,
|
|
740
|
+
): Promise<{
|
|
741
|
+
bytes: Uint8Array;
|
|
742
|
+
mimetype?: string;
|
|
743
|
+
filename?: string;
|
|
744
|
+
} | null> {
|
|
745
|
+
return this.downloadFile(roomId, eventId);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Authenticated download of an `mxc://` URI (`/_matrix/client/v1/media/download`, legacy fallback on 404). */
|
|
749
|
+
async downloadMxcMedia(mxc: string): Promise<Uint8Array> {
|
|
750
|
+
const client = await this.startedClient();
|
|
751
|
+
const token = client.getAccessToken();
|
|
752
|
+
const headers: Record<string, string> = token
|
|
753
|
+
? { Authorization: `Bearer ${token}` }
|
|
754
|
+
: {};
|
|
755
|
+
const authed = client.mxcUrlToHttp(
|
|
756
|
+
mxc,
|
|
757
|
+
undefined,
|
|
758
|
+
undefined,
|
|
759
|
+
undefined,
|
|
760
|
+
false,
|
|
761
|
+
true,
|
|
762
|
+
true,
|
|
763
|
+
);
|
|
764
|
+
if (!authed) throw new Error(`MatrixGatewayDO: invalid mxc url ${mxc}`);
|
|
765
|
+
let res = await fetch(authed, { headers });
|
|
766
|
+
if (res.status === 404) {
|
|
767
|
+
const legacy = client.mxcUrlToHttp(
|
|
768
|
+
mxc,
|
|
769
|
+
undefined,
|
|
770
|
+
undefined,
|
|
771
|
+
undefined,
|
|
772
|
+
false,
|
|
773
|
+
true,
|
|
774
|
+
false,
|
|
775
|
+
);
|
|
776
|
+
if (legacy) res = await fetch(legacy, { headers });
|
|
777
|
+
}
|
|
778
|
+
if (!res.ok)
|
|
779
|
+
throw new Error(
|
|
780
|
+
`MatrixGatewayDO: media download failed: ${res.status} ${res.statusText}`,
|
|
781
|
+
);
|
|
782
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// -------------------------------------------------------------------------
|
|
786
|
+
// Plugins' device
|
|
787
|
+
// -------------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Password login with Synapse's per-address login rate limit handled: every
|
|
791
|
+
* object shares the Worker's egress addresses, so parallel boots can see
|
|
792
|
+
* 429 with `retry_after_ms`, which the SDK's own login call does not retry.
|
|
793
|
+
*/
|
|
794
|
+
private async passwordLogin(opts: {
|
|
795
|
+
displayName: string;
|
|
796
|
+
deviceId?: string;
|
|
797
|
+
}): Promise<StoredDevice> {
|
|
798
|
+
const cfg = this.cfg();
|
|
799
|
+
const login = createClient({ baseUrl: cfg.baseUrl });
|
|
800
|
+
const localpart = cfg.userId.slice(1, cfg.userId.indexOf(':'));
|
|
801
|
+
const res = await withRateLimitRetry(
|
|
802
|
+
() =>
|
|
803
|
+
login.login('m.login.password', {
|
|
804
|
+
identifier: { type: 'm.id.user', user: localpart },
|
|
805
|
+
password: cfg.password,
|
|
806
|
+
initial_device_display_name: opts.displayName,
|
|
807
|
+
...(opts.deviceId ? { device_id: opts.deviceId } : {}),
|
|
808
|
+
}),
|
|
809
|
+
{
|
|
810
|
+
retries: LOGIN_RETRY_ATTEMPTS,
|
|
811
|
+
maxWaitMs: LOGIN_RETRY_MAX_MS,
|
|
812
|
+
onWait: (waitMs, attempt) =>
|
|
813
|
+
this.log(
|
|
814
|
+
'warn',
|
|
815
|
+
`login rate-limited (attempt ${attempt}); retrying in ${waitMs} ms`,
|
|
816
|
+
),
|
|
817
|
+
},
|
|
818
|
+
);
|
|
819
|
+
if (!res.access_token || !res.device_id)
|
|
820
|
+
throw new Error(
|
|
821
|
+
'MatrixGatewayDO: login returned no access_token/device_id',
|
|
822
|
+
);
|
|
823
|
+
if (res.user_id !== cfg.userId)
|
|
824
|
+
throw new Error(
|
|
825
|
+
`MatrixGatewayDO: logged in as ${res.user_id}, expected ${cfg.userId}`,
|
|
826
|
+
);
|
|
827
|
+
return {
|
|
828
|
+
userId: res.user_id,
|
|
829
|
+
deviceId: res.device_id,
|
|
830
|
+
accessToken: res.access_token,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* See `MatrixGatewayObject.botCredentials`: a second password device with
|
|
836
|
+
* NO crypto store, for plugins that drive a raw matrix-js-sdk client
|
|
837
|
+
* (editor, flows) — it must never be the gateway's own E2EE device, whose
|
|
838
|
+
* one-time keys and store are the SDK's. Its id is pinned in storage so a
|
|
839
|
+
* re-login (token rejected) keeps the same device. Cached per instance
|
|
840
|
+
* after one `whoami`.
|
|
841
|
+
*/
|
|
842
|
+
async botCredentials(): Promise<BotCredentials> {
|
|
843
|
+
if (this.pluginDevice) return this.pluginDevice;
|
|
844
|
+
const cfg = this.cfg();
|
|
845
|
+
const stored = await this.ctx.storage.get<StoredDevice>(
|
|
846
|
+
BOT_CLIENT_IDENTITY_KEY,
|
|
847
|
+
);
|
|
848
|
+
if (stored?.accessToken && stored.userId === cfg.userId) {
|
|
849
|
+
try {
|
|
850
|
+
const who = await createClient({
|
|
851
|
+
baseUrl: cfg.baseUrl,
|
|
852
|
+
accessToken: stored.accessToken,
|
|
853
|
+
useAuthorizationHeader: true,
|
|
854
|
+
}).whoami();
|
|
855
|
+
if (who.device_id === stored.deviceId) {
|
|
856
|
+
this.pluginDevice = { baseUrl: cfg.baseUrl, ...stored };
|
|
857
|
+
return this.pluginDevice;
|
|
858
|
+
}
|
|
859
|
+
} catch (err) {
|
|
860
|
+
this.log(
|
|
861
|
+
'warn',
|
|
862
|
+
'plugin device token rejected; re-logging in with the same device id',
|
|
863
|
+
err,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
const device = await this.passwordLogin({
|
|
868
|
+
displayName: `QiForge oracle plugins (${this.env.ORACLE_NAME ?? 'workers'})`,
|
|
869
|
+
...(stored?.deviceId ? { deviceId: stored.deviceId } : {}),
|
|
870
|
+
});
|
|
871
|
+
await this.ctx.storage.put(BOT_CLIENT_IDENTITY_KEY, device);
|
|
872
|
+
this.log('info', `plugin device ${device.deviceId} logged in`);
|
|
873
|
+
this.pluginDevice = { baseUrl: cfg.baseUrl, ...device };
|
|
874
|
+
return this.pluginDevice;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// -------------------------------------------------------------------------
|
|
878
|
+
// Operator surface
|
|
879
|
+
// -------------------------------------------------------------------------
|
|
880
|
+
|
|
881
|
+
/** The durable outbox (pending sends), without message bodies. */
|
|
882
|
+
async listOutbox(): Promise<OutboxRow[]> {
|
|
883
|
+
return listPendingSends(this.ctx.storage.sql).map((row) => ({
|
|
884
|
+
txnId: row.txnId,
|
|
885
|
+
roomId: row.roomId,
|
|
886
|
+
kind: row.kind,
|
|
887
|
+
...(row.threadId ? { threadId: row.threadId } : {}),
|
|
888
|
+
bodyChars: row.body.length,
|
|
889
|
+
htmlChars: row.formattedBody?.length ?? 0,
|
|
890
|
+
attempts: row.attempts ?? 0,
|
|
891
|
+
enqueuedAt: row.enqueuedAt,
|
|
892
|
+
}));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
override async status(): Promise<GatewayStatus> {
|
|
896
|
+
return {
|
|
897
|
+
...(await super.status()),
|
|
898
|
+
turns: { inFlight: this.turnGate.inUse, waiting: this.turnGate.queued },
|
|
899
|
+
ingestPending: this.ingest?.pendingCount ?? 0,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Attachment descriptor of a media message for the ingest (Node bridge shape). */
|
|
905
|
+
function attachmentOf(
|
|
906
|
+
content: Record<string, unknown>,
|
|
907
|
+
eventId: string,
|
|
908
|
+
body: string,
|
|
909
|
+
): InboundAttachment {
|
|
910
|
+
const info = readMediaInfo(content);
|
|
911
|
+
const filename: unknown = content['filename'];
|
|
912
|
+
return {
|
|
913
|
+
eventId,
|
|
914
|
+
filename: typeof filename === 'string' ? filename : body || 'file',
|
|
915
|
+
mimetype: info.mimetype ?? 'application/octet-stream',
|
|
916
|
+
...(info.size !== undefined ? { size: info.size } : {}),
|
|
917
|
+
};
|
|
918
|
+
}
|