@xmanrui/dsh-im 4.24.0 → 4.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +11 -3
- package/README.md +11 -3
- package/THIRD_PARTY_NOTICES.md +2 -0
- package/lib/client.js +965 -607
- package/lib/index.js +296 -287
- package/package.json +8 -2
- package/plugin-src/client/channel-card-meta.js +3 -9
- package/plugin-src/client/channel-logos.js +11 -0
- package/plugin-src/client/channels/dingtalk/index.js +9 -8
- package/plugin-src/client/channels/feishu/index.js +14 -13
- package/plugin-src/client/channels/matrix/api.js +11 -0
- package/plugin-src/client/channels/matrix/index.js +151 -0
- package/plugin-src/client/channels/matrix/styles.js +36 -0
- package/plugin-src/client/channels/qq/index.js +9 -8
- package/plugin-src/client/channels/shared/collapsible-account.js +49 -26
- package/plugin-src/client/channels/shared/token-channel.js +9 -8
- package/plugin-src/client/channels/wecom/index.js +9 -8
- package/plugin-src/client/channels/wecom-app/index.js +9 -8
- package/plugin-src/client/channels/weixin/index.js +9 -8
- package/plugin-src/client/channels/whatsapp/index.js +9 -8
- package/plugin-src/client/i18n.js +21 -0
- package/plugin-src/client/index.js +20 -0
- package/plugin-src/client/session-channel-logos.js +2 -1
- package/plugin-src/client/styles.js +15 -9
- package/plugin-src/client/update-panel.js +42 -18
- package/plugin-src/host/channels/matrix/index.mjs +31 -0
- package/plugin-src/host/channels/matrix/production.mjs +227 -0
- package/plugin-src/host/channels/matrix/rpc.mjs +228 -0
- package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
- package/plugin-src/host/channels/shared/startup-error.mjs +2 -1
- package/plugin-src/host/delivery-adapter.mjs +18 -0
- package/plugin-src/host/delivery-suggestions.mjs +13 -0
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/host/update-service.mjs +19 -14
- package/scripts/verify-lan-management.mjs +1 -1
- package/scripts/verify-package.mjs +5 -1
- package/src/channels/feishu/bridge.mjs +91 -42
- package/src/channels/matrix/matrix-api.mjs +696 -0
- package/src/channels/matrix/matrix-bridge.mjs +20 -0
- package/src/channels/matrix/matrix-config-store.mjs +356 -0
- package/src/channels/matrix/matrix-controller.mjs +404 -0
- package/src/channels/matrix/matrix-crypto-store.mjs +279 -0
- package/src/channels/matrix/matrix-crypto.mjs +1014 -0
- package/src/channels/matrix/matrix-harness-client.mjs +11 -0
- package/src/channels/matrix/matrix-normalize.mjs +357 -0
- package/src/channels/matrix/matrix-rich-text.mjs +313 -0
- package/src/channels/matrix/matrix-runtime.mjs +900 -0
- package/src/channels/shared/command-catalog.mjs +1 -1
- package/src/channels/shared/i18n-en/matrix.mjs +75 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/session-channel-labels.mjs +1 -0
- package/src/channels/shared/text-harness-bridge.mjs +3 -0
- package/src/channels/shared/workspace-session.mjs +7 -1
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { t } from '../shared/i18n.mjs';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
4
|
+
const UPLOAD_TIMEOUT_MS = 120_000;
|
|
5
|
+
const SYNC_TIMEOUT_MS = 45_000;
|
|
6
|
+
const MEDIA_TIMEOUT_MS = 60_000;
|
|
7
|
+
const MATRIX_FILE_HOST_MAX_REDIRECTS = 20;
|
|
8
|
+
|
|
9
|
+
const MATRIX_CLIENT_PREFIX = '/_matrix/client/v3';
|
|
10
|
+
const MATRIX_MEDIA_PREFIX = '/_matrix/media/v1';
|
|
11
|
+
|
|
12
|
+
function cleanString(value) {
|
|
13
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requestSignal(signal, timeoutMs) {
|
|
17
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
18
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function abortReason(signal) {
|
|
22
|
+
return signal?.reason instanceof Error
|
|
23
|
+
? signal.reason
|
|
24
|
+
: new DOMException('The operation was aborted', 'AbortError');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function delay(ms, signal) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const timer = setTimeout(resolve, Math.min(Math.max(ms, 0), 60_000));
|
|
30
|
+
timer.unref?.();
|
|
31
|
+
const rejectAbort = () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
reject(abortReason(signal));
|
|
34
|
+
};
|
|
35
|
+
if (signal?.aborted) rejectAbort();
|
|
36
|
+
else signal?.addEventListener?.('abort', rejectAbort, { once: true });
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateMatrixHomeserver(value) {
|
|
41
|
+
const raw = cleanString(value);
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
let url;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
50
|
+
if (url.username || url.password || url.hash) return null;
|
|
51
|
+
return url.href.replace(/\/+$/, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isMatrixUserId(value) {
|
|
55
|
+
return typeof value === 'string'
|
|
56
|
+
&& /^@[-.=_+/A-Za-z0-9]+:[-.A-Za-z0-9]+(?::\d{1,5})?$/u.test(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isMatrixRoomId(value) {
|
|
60
|
+
return typeof value === 'string'
|
|
61
|
+
&& /^[!#][^:\s]+:[A-Za-z0-9.-]+(?::\d{1,5})?$/u.test(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isMatrixEventId(value) {
|
|
65
|
+
return typeof value === 'string' && /^\$[^:\s]+(?::[^\s]+)?$/u.test(value) && value.length <= 512;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function mxcToMatrixMediaUrl(homeserver, contentUri) {
|
|
69
|
+
const base = validateMatrixHomeserver(homeserver);
|
|
70
|
+
const match = /^mxc:\/\/([^/:]+)\/([^/?]+)$/.exec(cleanString(contentUri) ?? '');
|
|
71
|
+
if (!base || !match) return null;
|
|
72
|
+
const [, host, path] = match;
|
|
73
|
+
if (!/^[A-Za-z0-9.-]+(?::\d{1,5})?$/u.test(host)) return null;
|
|
74
|
+
const url = new URL(base);
|
|
75
|
+
url.pathname = `${MATRIX_MEDIA_PREFIX}/download/${encodeURIComponent(host)}/${encodeURIComponent(path)}`;
|
|
76
|
+
url.search = '';
|
|
77
|
+
return url;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class MatrixApiError extends Error {
|
|
81
|
+
constructor(message, { code = 'matrix-api', status, providerCode, retryAfterMs, permanent = false } = {}) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'MatrixApiError';
|
|
84
|
+
this.code = code;
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.providerCode = providerCode;
|
|
87
|
+
this.retryAfterMs = retryAfterMs;
|
|
88
|
+
this.permanent = permanent;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PERMANENT_PROVIDER_CODES = new Set([
|
|
93
|
+
'm.unknown_token', 'm.missing_token', 'm.unauthorized', 'm.forbidden',
|
|
94
|
+
'm.unknown', 'm.bad_json', 'm.invalid_json',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function classifyMatrixError(status, providerCode, retryAfterMs) {
|
|
98
|
+
if (status === 401) return { code: 'matrix-auth', permanent: true };
|
|
99
|
+
if (status === 403) return { code: 'matrix-forbidden', permanent: true };
|
|
100
|
+
if (status === 404) return { code: 'matrix-not-found', permanent: false };
|
|
101
|
+
if (status === 429 || providerCode === 'm.limit_exceeded' || providerCode === 'm.too_many_requests') {
|
|
102
|
+
return { code: 'matrix-rate-limited', permanent: false, retryAfterMs };
|
|
103
|
+
}
|
|
104
|
+
if (status >= 400 && status < 500 && PERMANENT_PROVIDER_CODES.has(providerCode ?? '')) {
|
|
105
|
+
return { code: 'matrix-permanent', permanent: true };
|
|
106
|
+
}
|
|
107
|
+
if (status >= 400 && status < 500) return { code: 'matrix-rejected', permanent: false };
|
|
108
|
+
return { code: 'matrix-service', permanent: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parsedRetryAfterMs(retryAfterHeader, providerBody) {
|
|
112
|
+
const numeric = Number(providerBody?.retry_after_ms);
|
|
113
|
+
if (Number.isFinite(numeric) && numeric >= 0) return Math.min(numeric, 60_000);
|
|
114
|
+
const seconds = Number(retryAfterHeader);
|
|
115
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1_000, 60_000);
|
|
116
|
+
return 1_000;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function newTransactionId() {
|
|
120
|
+
return globalThis.crypto?.randomUUID?.() ?? `dsh-${Date.now()}-${Math.floor(Math.random() * 1e12)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Password login without an existing token; returns the minted session. The
|
|
125
|
+
* runtime keeps the server-assigned device id so a stable `deviceId` can be
|
|
126
|
+
* pinned afterwards for E2EE identity persistence.
|
|
127
|
+
*/
|
|
128
|
+
export async function performMatrixPasswordLogin(
|
|
129
|
+
{ homeserver, userId, password, deviceId, fetchImpl = globalThis.fetch } = {},
|
|
130
|
+
options = {},
|
|
131
|
+
) {
|
|
132
|
+
const base = validateMatrixHomeserver(homeserver);
|
|
133
|
+
if (!base || !isMatrixUserId(cleanString(userId) ?? '') || !cleanString(password)) {
|
|
134
|
+
throw new TypeError('Matrix password login requires a valid identity and password');
|
|
135
|
+
}
|
|
136
|
+
const url = new URL(`${base}${MATRIX_CLIENT_PREFIX}/login`);
|
|
137
|
+
const signal = requestSignal(options.signal, DEFAULT_TIMEOUT_MS);
|
|
138
|
+
const response = await fetchImpl(url.href, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
redirect: 'error',
|
|
141
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
type: 'm.login.password',
|
|
144
|
+
identifier: { type: 'm.id.user', user: userId },
|
|
145
|
+
password,
|
|
146
|
+
...(cleanString(deviceId) ? { device_id: deviceId } : {}),
|
|
147
|
+
initial_device_display_name: 'DeepSeek Harness',
|
|
148
|
+
}),
|
|
149
|
+
signal,
|
|
150
|
+
}).catch((cause) => {
|
|
151
|
+
throw new MatrixApiError('Matrix password login is unreachable', {
|
|
152
|
+
code: cause?.name === 'AbortError' || cause?.name === 'TimeoutError' ? 'timeout' : 'network',
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const body = await response.json().catch(() => ({}));
|
|
157
|
+
throw new MatrixApiError(`Matrix password login failed with HTTP ${response.status}`,
|
|
158
|
+
classifyMatrixError(response.status, cleanString(body?.errcode), 1_000));
|
|
159
|
+
}
|
|
160
|
+
const body = await response.json().catch(() => ({}));
|
|
161
|
+
const accessToken = cleanString(body?.access_token);
|
|
162
|
+
const resolvedUserId = cleanString(body?.user_id);
|
|
163
|
+
if (!accessToken || !resolvedUserId) {
|
|
164
|
+
throw new MatrixApiError('Matrix password login returned no session', { code: 'matrix-invalid' });
|
|
165
|
+
}
|
|
166
|
+
return { accessToken, userId: resolvedUserId, deviceId: cleanString(body?.device_id) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class MatrixApi {
|
|
170
|
+
#homeserver;
|
|
171
|
+
#accessToken;
|
|
172
|
+
#fetchImpl;
|
|
173
|
+
#userId;
|
|
174
|
+
#userAgent = 'dsh-im/1.0';
|
|
175
|
+
|
|
176
|
+
constructor({ homeserver, accessToken, userId, fetchImpl = globalThis.fetch, userAgent } = {}) {
|
|
177
|
+
const base = validateMatrixHomeserver(homeserver);
|
|
178
|
+
const token = cleanString(accessToken);
|
|
179
|
+
if (!base) throw new TypeError('Matrix homeserver URL is invalid');
|
|
180
|
+
if (!token) throw new TypeError('Matrix access token is required');
|
|
181
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('Matrix API requires a fetch implementation');
|
|
182
|
+
this.#homeserver = base;
|
|
183
|
+
this.#accessToken = token;
|
|
184
|
+
this.#fetchImpl = fetchImpl;
|
|
185
|
+
this.#userId = isMatrixUserId(cleanString(userId) ?? '') ? cleanString(userId) : null;
|
|
186
|
+
if (userAgent) this.#userAgent = String(userAgent);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get homeserver() {
|
|
190
|
+
return this.#homeserver;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#matrixUrl(path, query) {
|
|
194
|
+
const url = new URL(`${this.#homeserver}${MATRIX_CLIENT_PREFIX}${path}`);
|
|
195
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
196
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
197
|
+
}
|
|
198
|
+
return url;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async #request(label, {
|
|
202
|
+
path, method = 'GET', query, body, timeoutMs = DEFAULT_TIMEOUT_MS, signal, retry = true,
|
|
203
|
+
}) {
|
|
204
|
+
const url = this.#matrixUrl(path, query);
|
|
205
|
+
const requestSignalValue = requestSignal(signal, timeoutMs);
|
|
206
|
+
const attempt = async () => {
|
|
207
|
+
const response = await this.#fetchImpl(url.href, {
|
|
208
|
+
method,
|
|
209
|
+
redirect: 'error',
|
|
210
|
+
headers: {
|
|
211
|
+
authorization: `Bearer ${this.#accessToken}`,
|
|
212
|
+
'content-type': 'application/json',
|
|
213
|
+
accept: 'application/json',
|
|
214
|
+
'user-agent': this.#userAgent,
|
|
215
|
+
},
|
|
216
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
217
|
+
signal: requestSignalValue,
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const providerBody = await response.json().catch(() => ({}));
|
|
221
|
+
const providerCode = cleanString(providerBody?.errcode);
|
|
222
|
+
const providerMessage = cleanString(providerBody?.error);
|
|
223
|
+
const classified = classifyMatrixError(
|
|
224
|
+
response.status,
|
|
225
|
+
providerCode,
|
|
226
|
+
parsedRetryAfterMs(response.headers?.get?.('retry-after'), providerBody),
|
|
227
|
+
);
|
|
228
|
+
const detail = [providerCode, providerMessage].filter(Boolean).join(': ');
|
|
229
|
+
throw new MatrixApiError(
|
|
230
|
+
`Matrix ${label} failed with HTTP ${response.status}${detail ? ` (${detail})` : ''}`,
|
|
231
|
+
{ ...classified, status: response.status, providerCode },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return await response.json().catch(() => ({}));
|
|
235
|
+
};
|
|
236
|
+
try {
|
|
237
|
+
if (requestSignalValue?.aborted) throw abortReason(requestSignalValue);
|
|
238
|
+
return await attempt();
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!retry || !(error instanceof MatrixApiError) || error.code !== 'matrix-rate-limited') throw error;
|
|
241
|
+
if (requestSignalValue?.aborted) throw abortReason(requestSignalValue);
|
|
242
|
+
await delay(error.retryAfterMs ?? 1_000, requestSignalValue);
|
|
243
|
+
return await attempt();
|
|
244
|
+
} finally {
|
|
245
|
+
requestSignalValue?.throwIfAborted?.();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async whoami(options = {}) {
|
|
250
|
+
return await this.#request('whoami', { path: '/account/whoami', signal: options.signal });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async login({ identifier, password, deviceId, displayName }, options = {}) {
|
|
254
|
+
const body = {
|
|
255
|
+
type: 'm.login.password',
|
|
256
|
+
identifier: { type: 'm.id.user', user: identifier },
|
|
257
|
+
password: String(password ?? ''),
|
|
258
|
+
...(cleanString(deviceId) ? { device_id: deviceId } : {}),
|
|
259
|
+
initial_device_display_name: cleanString(displayName) ?? 'DeepSeek Harness',
|
|
260
|
+
};
|
|
261
|
+
return await this.#request('login', {
|
|
262
|
+
path: '/login', method: 'POST', body, signal: options.signal,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async sync({ since, timeout = 0, signal } = {}) {
|
|
267
|
+
return await this.#request('sync', {
|
|
268
|
+
path: '/sync',
|
|
269
|
+
query: { timeout, ...(cleanString(since) ? { since } : {}) },
|
|
270
|
+
timeoutMs: timeout > 0 ? SYNC_TIMEOUT_MS : DEFAULT_TIMEOUT_MS,
|
|
271
|
+
signal,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async joinRoom(roomId, options = {}) {
|
|
276
|
+
if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix room id is invalid');
|
|
277
|
+
return await this.#request('join', {
|
|
278
|
+
path: `/join/${encodeURIComponent(roomId)}`, method: 'POST', signal: options.signal,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async leaveRoom(roomId, options = {}) {
|
|
283
|
+
if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix room id is invalid');
|
|
284
|
+
return await this.#request('leave', {
|
|
285
|
+
path: `/leave/${encodeURIComponent(roomId)}`, method: 'POST', signal: options.signal,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async createRoom(body, options = {}) {
|
|
290
|
+
return await this.#request('create-room', {
|
|
291
|
+
path: '/create_room', method: 'POST', body, timeoutMs: 30_000, signal: options.signal,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async sendEvent(roomId, eventType, content, options = {}) {
|
|
296
|
+
if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix room id is invalid');
|
|
297
|
+
if (typeof eventType !== 'string' || !eventType) throw new TypeError('Matrix event type is invalid');
|
|
298
|
+
const transactionId = cleanString(options.transactionId) ?? newTransactionId();
|
|
299
|
+
return await this.#request('send', {
|
|
300
|
+
path: `/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(transactionId)}`,
|
|
301
|
+
method: 'PUT',
|
|
302
|
+
body: content ?? {},
|
|
303
|
+
timeoutMs: 30_000,
|
|
304
|
+
signal: options.signal,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async redactEvent(roomId, eventId, reason, options = {}) {
|
|
309
|
+
if (!isMatrixRoomId(roomId) || !isMatrixEventId(eventId)) throw new TypeError('Matrix redact target is invalid');
|
|
310
|
+
return await this.#request('redact', {
|
|
311
|
+
path: `/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(newTransactionId())}`,
|
|
312
|
+
method: 'PUT',
|
|
313
|
+
body: cleanString(reason) ? { reason } : {},
|
|
314
|
+
signal: options.signal,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async setTyping(roomId, { typing = true, timeoutMs = 20_000 } = {}, options = {}) {
|
|
319
|
+
if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix room id is invalid');
|
|
320
|
+
if (!this.#userId) return null;
|
|
321
|
+
return await this.#request('typing', {
|
|
322
|
+
path: `/rooms/${encodeURIComponent(roomId)}/typing/${encodeURIComponent(this.#userId)}`,
|
|
323
|
+
method: 'PUT',
|
|
324
|
+
body: { typing, ...(typing ? { timeout: timeoutMs } : {}) },
|
|
325
|
+
signal: options.signal,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async sendReceipt(roomId, eventId, options = {}) {
|
|
330
|
+
if (!isMatrixRoomId(roomId) || !isMatrixEventId(eventId)) throw new TypeError('Matrix receipt target is invalid');
|
|
331
|
+
return await this.#request('receipt', {
|
|
332
|
+
path: `/rooms/${encodeURIComponent(roomId)}/receipts/m.read/${encodeURIComponent(eventId)}`,
|
|
333
|
+
method: 'POST',
|
|
334
|
+
body: {},
|
|
335
|
+
signal: options.signal,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async listRelations(roomId, eventId, relationType, options = {}) {
|
|
340
|
+
if (!isMatrixRoomId(roomId) || !isMatrixEventId(eventId)) throw new TypeError('Matrix relation query target is invalid');
|
|
341
|
+
if (!/^[m]?[A-Za-z0-9._-]{1,128}$/.test(String(relationType ?? ''))) throw new TypeError('Matrix relation type is invalid');
|
|
342
|
+
return await this.#request('relations', {
|
|
343
|
+
path: `/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/${encodeURIComponent(relationType)}`,
|
|
344
|
+
query: { dir: 'b', limit: 50 },
|
|
345
|
+
timeoutMs: 10_000,
|
|
346
|
+
signal: options.signal,
|
|
347
|
+
}).catch(() => ({ events: [] }));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async getAccountData(type, options = {}) {
|
|
351
|
+
return await this.#request('account-data', {
|
|
352
|
+
path: `/account_data/${encodeURIComponent(type)}`,
|
|
353
|
+
signal: options.signal,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async setAccountData(type, body, options = {}) {
|
|
358
|
+
return await this.#request('set-account-data', {
|
|
359
|
+
path: `/account_data/${encodeURIComponent(type)}`,
|
|
360
|
+
method: 'PUT',
|
|
361
|
+
body: body ?? {},
|
|
362
|
+
signal: options.signal,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async getRoomStateEvent(roomId, type, stateKey = '', options = {}) {
|
|
367
|
+
return await this.#request('room-state', {
|
|
368
|
+
path: `/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(type)}/${encodeURIComponent(stateKey)}`,
|
|
369
|
+
signal: options.signal,
|
|
370
|
+
}).catch((error) => {
|
|
371
|
+
if (error instanceof MatrixApiError && error.status === 404) return null;
|
|
372
|
+
throw error;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async getJoinedMemberCount(roomId, options = {}) {
|
|
377
|
+
const members = await this.#joinedMembers(roomId, options);
|
|
378
|
+
return members === null ? null : members.length;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async getJoinedMembers(roomId, options = {}) {
|
|
382
|
+
return await this.#joinedMembers(roomId, options);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async #joinedMembers(roomId, options) {
|
|
386
|
+
if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix room id is invalid');
|
|
387
|
+
const body = await this.#request('members', {
|
|
388
|
+
path: `/rooms/${encodeURIComponent(roomId)}/joined_members`,
|
|
389
|
+
timeoutMs: 10_000,
|
|
390
|
+
signal: options.signal,
|
|
391
|
+
}).catch(() => null);
|
|
392
|
+
const joined = body?.joined;
|
|
393
|
+
// The CS API answers joined members as a user-id keyed map; tolerate arrays as well.
|
|
394
|
+
if (joined && typeof joined === 'object') return Object.freeze(Object.keys(joined));
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async queryKeys(userIds, options = {}) {
|
|
399
|
+
const ids = Array.isArray(userIds)
|
|
400
|
+
? [...new Set(userIds.filter((entry) => isMatrixUserId(entry)))].slice(0, 250)
|
|
401
|
+
: [];
|
|
402
|
+
if (ids.length === 0) return { device_keys: {} };
|
|
403
|
+
return await this.#request('keys-query', {
|
|
404
|
+
path: '/keys/query',
|
|
405
|
+
method: 'POST',
|
|
406
|
+
body: { user_ids: ids, timeout: options.timeoutMs ?? 10_000 },
|
|
407
|
+
timeoutMs: (options.timeoutMs ?? 10_000) + 5_000,
|
|
408
|
+
signal: options.signal,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async claimKeys(oneTimeKeys, options = {}) {
|
|
413
|
+
const claim = {};
|
|
414
|
+
for (const [userId, devices] of Object.entries(oneTimeKeys ?? {})) {
|
|
415
|
+
if (!isMatrixUserId(userId) || !devices || typeof devices !== 'object' || Array.isArray(devices)) continue;
|
|
416
|
+
const perUser = {};
|
|
417
|
+
for (const [deviceId, count] of Object.entries(devices)) {
|
|
418
|
+
if (!deviceId.trim()) continue;
|
|
419
|
+
const amount = Number.isSafeInteger(Number(count)) && Number(count) > 0 ? Math.min(Number(count), 10) : 1;
|
|
420
|
+
perUser[deviceId] = amount;
|
|
421
|
+
}
|
|
422
|
+
if (Object.keys(perUser).length > 0) claim[userId] = perUser;
|
|
423
|
+
}
|
|
424
|
+
if (Object.keys(claim).length === 0) return { one_time_keys: {} };
|
|
425
|
+
return await this.#request('keys-claim', {
|
|
426
|
+
path: '/keys/claim',
|
|
427
|
+
method: 'POST',
|
|
428
|
+
body: { one_time_keys: claim },
|
|
429
|
+
timeoutMs: 20_000,
|
|
430
|
+
signal: options.signal,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async uploadKeys(payload, options = {}) {
|
|
435
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
436
|
+
throw new TypeError('Matrix key upload payload is invalid');
|
|
437
|
+
}
|
|
438
|
+
return await this.#request('keys-upload', {
|
|
439
|
+
// POST per the Matrix CS API (POST /keys/upload). A PUT here is rejected by
|
|
440
|
+
// some homeservers as 405 M_UNRECOGNIZED, which aborts crypto bootstrap.
|
|
441
|
+
path: '/keys/upload',
|
|
442
|
+
method: 'POST',
|
|
443
|
+
body: payload,
|
|
444
|
+
timeoutMs: 20_000,
|
|
445
|
+
signal: options.signal,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async listKeyChanges(from, options = {}) {
|
|
450
|
+
return await this.#request('keys-changes', {
|
|
451
|
+
path: '/keys/changes',
|
|
452
|
+
query: { ...(cleanString(from) ? { from } : {}), timeout: options.timeoutMs ?? 10_000 },
|
|
453
|
+
timeoutMs: (options.timeoutMs ?? 10_000) + 5_000,
|
|
454
|
+
signal: options.signal,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async sendToDevice(eventType, messages, options = {}) {
|
|
459
|
+
if (!/^[A-Za-z][A-Za-z0-9._-]{0,127}$/.test(String(eventType ?? ''))) {
|
|
460
|
+
throw new TypeError('Matrix to-device event type is invalid');
|
|
461
|
+
}
|
|
462
|
+
if (!messages || typeof messages !== 'object' || Array.isArray(messages)) {
|
|
463
|
+
throw new TypeError('Matrix to-device messages are invalid');
|
|
464
|
+
}
|
|
465
|
+
const transactionId = cleanString(options.transactionId) ?? newTransactionId();
|
|
466
|
+
return await this.#request('send-to-device', {
|
|
467
|
+
path: '/sendToDevice',
|
|
468
|
+
method: 'PUT',
|
|
469
|
+
query: { type: eventType, txn: transactionId },
|
|
470
|
+
body: { messages },
|
|
471
|
+
timeoutMs: 30_000,
|
|
472
|
+
signal: options.signal,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async uploadMedia(bytes, { filename, mediaType } = {}, options = {}) {
|
|
477
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new TypeError('Matrix media upload requires a non-empty Uint8Array');
|
|
478
|
+
const type = cleanString(mediaType) ?? 'application/octet-stream';
|
|
479
|
+
const name = cleanString(filename);
|
|
480
|
+
if (!/^[A-Za-z0-9._/-]{1,512}$/.test(type)) throw new TypeError('Matrix media type is invalid');
|
|
481
|
+
const query = { filename: name ?? undefined };
|
|
482
|
+
const url = new URL(`${this.#homeserver}${MATRIX_MEDIA_PREFIX}/upload`);
|
|
483
|
+
for (const [key, value] of Object.entries(query)) {
|
|
484
|
+
if (value !== undefined) url.searchParams.set(key, String(value));
|
|
485
|
+
}
|
|
486
|
+
const signal = requestSignal(options.signal, UPLOAD_TIMEOUT_MS);
|
|
487
|
+
const response = await this.#fetchImpl(url.href, {
|
|
488
|
+
method: 'POST',
|
|
489
|
+
redirect: 'error',
|
|
490
|
+
headers: {
|
|
491
|
+
authorization: `Bearer ${this.#accessToken}`,
|
|
492
|
+
'content-type': type,
|
|
493
|
+
accept: 'application/json',
|
|
494
|
+
'user-agent': this.#userAgent,
|
|
495
|
+
},
|
|
496
|
+
body: bytes,
|
|
497
|
+
signal,
|
|
498
|
+
}).catch((error) => {
|
|
499
|
+
throw new MatrixApiError('Matrix media upload is unreachable', {
|
|
500
|
+
code: error?.name === 'AbortError' || error?.name === 'TimeoutError' ? 'timeout' : 'network',
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
if (!response.ok) {
|
|
504
|
+
const providerBody = await response.json().catch(() => ({}));
|
|
505
|
+
const classified = classifyMatrixError(
|
|
506
|
+
response.status,
|
|
507
|
+
cleanString(providerBody?.errcode),
|
|
508
|
+
parsedRetryAfterMs(response.headers?.get?.('retry-after'), providerBody),
|
|
509
|
+
);
|
|
510
|
+
throw new MatrixApiError(`Matrix media upload failed with HTTP ${response.status}`, {
|
|
511
|
+
...classified, status: response.status, providerCode: cleanString(providerBody?.errcode),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const body = await response.json().catch(() => ({}));
|
|
515
|
+
const contentUri = cleanString(body?.content_uri);
|
|
516
|
+
if (!contentUri) throw new MatrixApiError('Matrix media upload returned no content URI', { code: 'matrix-invalid' });
|
|
517
|
+
return contentUri;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Bounded MXC content download against the configured homeserver only. The
|
|
522
|
+
* media path derives from the `mxc://` server/path pair; foreign servers in
|
|
523
|
+
* the URI are rejected by the allowlist below, redirects are followed
|
|
524
|
+
* manually up to a fixed hop budget, and the byte cap is enforced while
|
|
525
|
+
* streaming so a lying `content-length` cannot inflate memory.
|
|
526
|
+
*/
|
|
527
|
+
async downloadContent(contentUri, { signal, maxBytes = 104_857_600 } = {}) {
|
|
528
|
+
const base = validateMatrixHomeserver(this.#homeserver);
|
|
529
|
+
const allowHost = base ? new URL(base).host : '';
|
|
530
|
+
let target = mxcToMatrixMediaUrl(this.#homeserver, contentUri);
|
|
531
|
+
if (!target || target.host !== allowHost) {
|
|
532
|
+
throw new MatrixApiError('Matrix media content URI is invalid or hosted by a foreign server',
|
|
533
|
+
{ code: 'matrix-invalid', permanent: true });
|
|
534
|
+
}
|
|
535
|
+
target.searchParams.set('access_token', this.#accessToken);
|
|
536
|
+
const requestSignalValue = requestSignal(signal, MEDIA_TIMEOUT_MS);
|
|
537
|
+
let hops = 0;
|
|
538
|
+
for (;;) {
|
|
539
|
+
const response = await this.#fetchImpl(target.href, {
|
|
540
|
+
method: 'GET',
|
|
541
|
+
redirect: 'error',
|
|
542
|
+
headers: {
|
|
543
|
+
authorization: `Bearer ${this.#accessToken}`,
|
|
544
|
+
accept: '*/*',
|
|
545
|
+
'user-agent': this.#userAgent,
|
|
546
|
+
},
|
|
547
|
+
signal: requestSignalValue,
|
|
548
|
+
}).catch((cause) => {
|
|
549
|
+
throw new MatrixApiError('Matrix media download is unreachable', {
|
|
550
|
+
code: cause?.name === 'AbortError' || cause?.name === 'TimeoutError' ? 'timeout' : 'network',
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
if (response.status >= 300 && response.status < 400 && hops < MATRIX_FILE_HOST_MAX_REDIRECTS) {
|
|
554
|
+
const location = response.headers?.get?.('location');
|
|
555
|
+
await response.body?.cancel?.().catch(() => undefined);
|
|
556
|
+
if (!location) throw new MatrixApiError('Matrix media redirect without a location', { code: 'matrix-invalid' });
|
|
557
|
+
const next = new URL(location, target);
|
|
558
|
+
if (next.host !== allowHost) {
|
|
559
|
+
throw new MatrixApiError('Matrix media redirect leaves the configured homeserver',
|
|
560
|
+
{ code: 'matrix-invalid', permanent: true });
|
|
561
|
+
}
|
|
562
|
+
target = next;
|
|
563
|
+
hops += 1;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (!response.ok) {
|
|
567
|
+
await response.body?.cancel?.().catch(() => undefined);
|
|
568
|
+
throw new MatrixApiError(`Matrix media download failed with HTTP ${response.status}`,
|
|
569
|
+
classifyMatrixError(response.status, undefined, 1_000));
|
|
570
|
+
}
|
|
571
|
+
const declared = Number(response.headers?.get?.('content-length'));
|
|
572
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
573
|
+
await response.body?.cancel?.().catch(() => undefined);
|
|
574
|
+
throw new MatrixApiError(`Matrix media exceeds the ${maxBytes} byte cap`, { code: 'too-large' });
|
|
575
|
+
}
|
|
576
|
+
const chunks = [];
|
|
577
|
+
let total = 0;
|
|
578
|
+
if (response.body?.[Symbol.asyncIterator]) {
|
|
579
|
+
for await (const chunk of response.body) {
|
|
580
|
+
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
581
|
+
total += bytes.byteLength;
|
|
582
|
+
if (total > maxBytes) {
|
|
583
|
+
await response.body.cancel?.().catch(() => undefined);
|
|
584
|
+
throw new MatrixApiError(`Matrix media exceeds the ${maxBytes} byte cap`, { code: 'too-large' });
|
|
585
|
+
}
|
|
586
|
+
chunks.push(bytes);
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
const buffer = await response.arrayBuffer();
|
|
590
|
+
total = buffer.byteLength;
|
|
591
|
+
if (total > maxBytes) throw new MatrixApiError(`Matrix media exceeds the ${maxBytes} byte cap`, { code: 'too-large' });
|
|
592
|
+
if (total) chunks.push(new Uint8Array(buffer));
|
|
593
|
+
}
|
|
594
|
+
if (!total) return null;
|
|
595
|
+
const merged = new Uint8Array(total);
|
|
596
|
+
let offset = 0;
|
|
597
|
+
for (const chunk of chunks) {
|
|
598
|
+
merged.set(chunk, offset);
|
|
599
|
+
offset += chunk.byteLength;
|
|
600
|
+
}
|
|
601
|
+
return merged;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Resolve one credential pair into the stable Matrix bot identity that the
|
|
608
|
+
* config store hashes into `botId` and credential references. Token logins are
|
|
609
|
+
* validated through `whoami`; password logins rotate a device session and keep
|
|
610
|
+
* the server-reported device id for E2EE stability.
|
|
611
|
+
*/
|
|
612
|
+
export async function inspectMatrixCredentials(
|
|
613
|
+
{ homeserver, accessToken, userId, password } = {},
|
|
614
|
+
{ fetchImpl } = {},
|
|
615
|
+
) {
|
|
616
|
+
const base = validateMatrixHomeserver(homeserver);
|
|
617
|
+
const token = cleanString(accessToken);
|
|
618
|
+
const identifier = cleanString(userId);
|
|
619
|
+
const secret = cleanString(password);
|
|
620
|
+
if (!base) {
|
|
621
|
+
const error = new Error(t('Matrix homeserver 地址无效,请填写 https:// 或 http:// 开头的完整地址。'));
|
|
622
|
+
error.code = 'invalid-config';
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
if (!token && !(identifier && secret)) {
|
|
626
|
+
const error = new Error(t('Matrix 凭据不完整:请提供访问令牌,或用户 ID 与密码的组合。'));
|
|
627
|
+
error.code = 'invalid-config';
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
if (identifier && !isMatrixUserId(identifier)) {
|
|
631
|
+
const error = new Error(t('Matrix 用户 ID 无效,请使用 @user:server 形式。'));
|
|
632
|
+
error.code = 'invalid-config';
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
let resolvedUserId = identifier;
|
|
636
|
+
let resolvedDeviceId = null;
|
|
637
|
+
if (token) {
|
|
638
|
+
const api = new MatrixApi({ homeserver: base, accessToken: token, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
639
|
+
let identity;
|
|
640
|
+
try {
|
|
641
|
+
identity = await api.whoami();
|
|
642
|
+
} catch (cause) {
|
|
643
|
+
if (cause instanceof MatrixApiError && cause.permanent) {
|
|
644
|
+
const error = new Error(t('Matrix 访问令牌无效或已失效,请在 homeserver 重新签发后重试。'));
|
|
645
|
+
error.code = 'auth-failed';
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
const error = new Error(t('Matrix homeserver 暂时无法访问,请确认网络与地址后重试。'));
|
|
649
|
+
error.code = 'network';
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
resolvedUserId = cleanString(identity?.user_id) ?? identifier;
|
|
653
|
+
resolvedDeviceId = cleanString(identity?.device_id);
|
|
654
|
+
if (!resolvedUserId) {
|
|
655
|
+
const error = new Error(t('Matrix whoami 未返回用户身份,请改用用户 ID 与密码接入。'));
|
|
656
|
+
error.code = 'auth-failed';
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
const url = new URL(`${base}${MATRIX_CLIENT_PREFIX}/login`);
|
|
661
|
+
const response = await (fetchImpl ?? globalThis.fetch)(url.href, {
|
|
662
|
+
method: 'POST',
|
|
663
|
+
redirect: 'error',
|
|
664
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
665
|
+
body: JSON.stringify({
|
|
666
|
+
type: 'm.login.password',
|
|
667
|
+
identifier: { type: 'm.id.user', user: identifier },
|
|
668
|
+
password: secret,
|
|
669
|
+
initial_device_display_name: 'DeepSeek Harness',
|
|
670
|
+
}),
|
|
671
|
+
}).catch(() => null);
|
|
672
|
+
if (!response?.ok) {
|
|
673
|
+
if (response && (response.status === 403 || response.status === 401)) {
|
|
674
|
+
const error = new Error(t('Matrix 用户名或密码不正确,请核对后重试。'));
|
|
675
|
+
error.code = 'auth-failed';
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
const error = new Error(t('Matrix homeserver 暂不支持密码登录,请改用访问令牌接入。'));
|
|
679
|
+
error.code = 'login-failed';
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
const body = await response.json().catch(() => ({}));
|
|
683
|
+
resolvedUserId = cleanString(body?.user_id) ?? identifier;
|
|
684
|
+
resolvedDeviceId = cleanString(body?.device_id);
|
|
685
|
+
}
|
|
686
|
+
const host = new URL(base).host.toLowerCase();
|
|
687
|
+
const localpart = resolvedUserId.slice(1, Math.max(1, resolvedUserId.indexOf(':')));
|
|
688
|
+
return {
|
|
689
|
+
platformId: `${host}|${resolvedUserId.toLowerCase()}`,
|
|
690
|
+
homeserver: base,
|
|
691
|
+
userId: resolvedUserId,
|
|
692
|
+
deviceId: resolvedDeviceId,
|
|
693
|
+
name: localpart || resolvedUserId,
|
|
694
|
+
username: localpart || null,
|
|
695
|
+
};
|
|
696
|
+
}
|