@xmanrui/dsh-im 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.en.md +25 -10
- package/README.md +26 -11
- package/assets/logo-dsh-im-connecting-readme-3x2.png +0 -0
- package/assets/logo-dsh-im-connecting-square-v4.png +0 -0
- package/assets/logo-plugin-phone.png +0 -0
- package/lib/client.js +815 -173
- package/lib/index.js +142 -130
- package/package.json +4 -3
- package/plugin-src/client/build.mjs +1 -6
- package/plugin-src/client/channel-logos.js +12 -0
- package/plugin-src/client/channels/office/api.js +44 -0
- package/plugin-src/client/channels/office/index.js +152 -0
- package/plugin-src/client/channels/office/styles.js +50 -0
- package/plugin-src/client/channels/shared/token-api.js +5 -1
- package/plugin-src/client/channels/shared/token-channel.js +16 -1
- package/plugin-src/client/channels/telegram/api.js +17 -2
- package/plugin-src/client/channels/telegram/index.js +112 -0
- package/plugin-src/client/channels/telegram/styles.js +26 -0
- package/plugin-src/client/i18n.js +76 -0
- package/plugin-src/client/index.js +22 -11
- package/plugin-src/client/styles.js +7 -4
- package/plugin-src/host/channels/office/index.mjs +10 -0
- package/plugin-src/host/channels/office/production.mjs +45 -0
- package/plugin-src/host/channels/office/rpc.mjs +48 -0
- package/plugin-src/host/channels/shared/production.mjs +9 -1
- package/plugin-src/host/channels/telegram/production.mjs +2 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +58 -8
- package/plugin-src/host/index.mjs +3 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/office/config-store.mjs +107 -0
- package/src/channels/office/office-controller.mjs +167 -0
- package/src/channels/office/office-job-executor.mjs +390 -0
- package/src/channels/office/office-runtime.mjs +171 -0
- package/src/channels/office/office-transport.mjs +170 -0
- package/src/channels/office/protocol.mjs +43 -0
- package/src/channels/shared/token-bot-controller.mjs +28 -0
- package/src/channels/shared/token-config-store.mjs +14 -1
- package/src/channels/telegram/config-store.mjs +62 -1
- package/src/channels/telegram/telegram-controller.mjs +25 -1
- package/src/channels/telegram/telegram-runtime.mjs +26 -1
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-controller.mjs +56 -12
- package/src/channels/weixin/weixin-runtime.mjs +12 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
import { OfficeTransport } from './office-transport.mjs';
|
|
4
|
+
import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
|
|
5
|
+
import { OfficeJobExecutor } from './office-job-executor.mjs';
|
|
6
|
+
|
|
7
|
+
const RETRY_DELAYS = Object.freeze([1_000, 3_000, 10_000, 30_000]);
|
|
8
|
+
|
|
9
|
+
function safeConnectionError(error) {
|
|
10
|
+
const code = typeof error?.code === 'string' ? error.code : 'office-connection-failed';
|
|
11
|
+
const messages = {
|
|
12
|
+
'invalid-device-token': 'AI Office 拒绝了 Device Token。',
|
|
13
|
+
'office-hook-unavailable': 'AI Office Connector Hook 尚未就绪。',
|
|
14
|
+
'office-protocol-mismatch': 'AI Office Connector 协议版本不兼容。',
|
|
15
|
+
'office-transport-failed': '本机暂时无法访问 AI Office。',
|
|
16
|
+
};
|
|
17
|
+
return { code, message: messages[code] ?? 'AI Office 连接已中断。' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class OfficeRuntime {
|
|
21
|
+
#config;
|
|
22
|
+
#token;
|
|
23
|
+
#logger;
|
|
24
|
+
#transport;
|
|
25
|
+
#sleep;
|
|
26
|
+
#controller = null;
|
|
27
|
+
#task = null;
|
|
28
|
+
#status;
|
|
29
|
+
#jobs;
|
|
30
|
+
|
|
31
|
+
constructor({
|
|
32
|
+
config,
|
|
33
|
+
token,
|
|
34
|
+
logger = console,
|
|
35
|
+
transport,
|
|
36
|
+
createHarness,
|
|
37
|
+
jobExecutor,
|
|
38
|
+
sleepImpl = sleep,
|
|
39
|
+
}) {
|
|
40
|
+
this.#config = config;
|
|
41
|
+
this.#token = token;
|
|
42
|
+
this.#logger = logger;
|
|
43
|
+
this.#sleep = sleepImpl;
|
|
44
|
+
this.#transport = transport ?? new OfficeTransport({
|
|
45
|
+
baseUrl: config.baseUrl, deviceId: config.deviceId, token,
|
|
46
|
+
});
|
|
47
|
+
this.#status = {
|
|
48
|
+
state: 'idle', connected: false, startedAt: null, lastHeartbeatAt: null,
|
|
49
|
+
lastEventAt: null, lastEventId: null, lastEventType: null, reconnects: 0,
|
|
50
|
+
jobsOffered: 0, error: null,
|
|
51
|
+
};
|
|
52
|
+
this.#jobs = jobExecutor ?? (createHarness ? new OfficeJobExecutor({
|
|
53
|
+
config,
|
|
54
|
+
transport: this.#transport,
|
|
55
|
+
createHarness,
|
|
56
|
+
logger,
|
|
57
|
+
}) : null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get status() {
|
|
61
|
+
return structuredClone({
|
|
62
|
+
...this.#status,
|
|
63
|
+
...(this.#jobs ? { jobs: this.#jobs.status } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
capabilities() {
|
|
68
|
+
return {
|
|
69
|
+
protocolVersion: OFFICE_PROTOCOL_VERSION,
|
|
70
|
+
deviceId: this.#config.deviceId,
|
|
71
|
+
workspaces: Object.keys(this.#config.workspaces),
|
|
72
|
+
instructionPresets: Object.keys(this.#config.instructionPresets),
|
|
73
|
+
maxConcurrency: this.#config.maxConcurrency,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async testConnection(signal) {
|
|
78
|
+
await this.#transport.heartbeat({ ...this.capabilities(), probe: true }, { signal });
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
start() {
|
|
83
|
+
if (this.#task) return this.status;
|
|
84
|
+
this.#controller = new AbortController();
|
|
85
|
+
this.#status.startedAt = new Date().toISOString();
|
|
86
|
+
this.#status.state = 'connecting';
|
|
87
|
+
this.#task = this.#run(this.#controller.signal).finally(() => { this.#task = null; });
|
|
88
|
+
this.#task.catch((error) => {
|
|
89
|
+
if (this.#controller?.signal.aborted) return;
|
|
90
|
+
this.#logger.error?.('[dsh-im:office] connector stopped:', error);
|
|
91
|
+
});
|
|
92
|
+
return this.status;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async #run(signal) {
|
|
96
|
+
let attempt = 0;
|
|
97
|
+
while (!signal.aborted) {
|
|
98
|
+
const attemptController = new AbortController();
|
|
99
|
+
const attemptSignal = AbortSignal.any([signal, attemptController.signal]);
|
|
100
|
+
try {
|
|
101
|
+
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
|
|
102
|
+
this.#offerJobs(heartbeat?.jobs);
|
|
103
|
+
this.#status.lastHeartbeatAt = new Date().toISOString();
|
|
104
|
+
let streamOpened = false;
|
|
105
|
+
const heartbeatTask = this.#heartbeatLoop(attemptSignal, () => {
|
|
106
|
+
if (streamOpened && !attemptSignal.aborted) attempt = 0;
|
|
107
|
+
});
|
|
108
|
+
const stream = this.#transport.stream({
|
|
109
|
+
signal: attemptSignal,
|
|
110
|
+
lastEventId: this.#status.lastEventId,
|
|
111
|
+
onOpen: () => {
|
|
112
|
+
this.#status.connected = true;
|
|
113
|
+
this.#status.state = 'connected';
|
|
114
|
+
this.#status.error = null;
|
|
115
|
+
streamOpened = true;
|
|
116
|
+
},
|
|
117
|
+
onEvent: async (event) => {
|
|
118
|
+
this.#status.lastEventAt = new Date().toISOString();
|
|
119
|
+
this.#status.lastEventId = event.id ?? this.#status.lastEventId;
|
|
120
|
+
this.#status.lastEventType = event.type;
|
|
121
|
+
if (event.type === 'job.available') this.#status.jobsOffered += 1;
|
|
122
|
+
this.#jobs?.handleEvent(event);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
await Promise.race([stream, heartbeatTask]);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (signal.aborted) break;
|
|
128
|
+
attemptController.abort();
|
|
129
|
+
this.#status.connected = false;
|
|
130
|
+
this.#status.state = 'reconnecting';
|
|
131
|
+
this.#status.error = safeConnectionError(error);
|
|
132
|
+
this.#status.reconnects += 1;
|
|
133
|
+
const delay = RETRY_DELAYS[Math.min(attempt, RETRY_DELAYS.length - 1)];
|
|
134
|
+
attempt += 1;
|
|
135
|
+
try { await this.#sleep(delay, undefined, { signal }); } catch { break; }
|
|
136
|
+
} finally {
|
|
137
|
+
attemptController.abort();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.#status.connected = false;
|
|
141
|
+
this.#status.state = 'idle';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async #heartbeatLoop(signal, onSuccess) {
|
|
145
|
+
while (!signal.aborted) {
|
|
146
|
+
await this.#sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
|
|
147
|
+
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal });
|
|
148
|
+
this.#offerJobs(heartbeat?.jobs);
|
|
149
|
+
this.#status.lastHeartbeatAt = new Date().toISOString();
|
|
150
|
+
onSuccess?.();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#offerJobs(jobs) {
|
|
155
|
+
if (!Array.isArray(jobs)) return;
|
|
156
|
+
for (const job of jobs) {
|
|
157
|
+
if (typeof job?.id === 'string' && this.#jobs?.offer(job.id)) this.#status.jobsOffered += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async stop() {
|
|
162
|
+
const task = this.#task;
|
|
163
|
+
this.#controller?.abort();
|
|
164
|
+
this.#controller = null;
|
|
165
|
+
if (task) await task.catch(() => undefined);
|
|
166
|
+
await this.#jobs?.close();
|
|
167
|
+
this.#status.connected = false;
|
|
168
|
+
this.#status.state = 'idle';
|
|
169
|
+
return this.status;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { OFFICE_HOOK_PATHS, OFFICE_PROTOCOL_VERSION, officeHookUrls } from './protocol.mjs';
|
|
2
|
+
|
|
3
|
+
function safeTransportError(operation, response) {
|
|
4
|
+
const error = new Error(`AI Office ${operation} failed: HTTP ${response.status}`);
|
|
5
|
+
error.code = response.status === 401 ? 'invalid-device-token'
|
|
6
|
+
: response.status === 404 ? 'office-hook-unavailable'
|
|
7
|
+
: response.status === 409 ? 'office-job-conflict' : 'office-transport-failed';
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function transportFailure(message, code = 'office-transport-failed', cause) {
|
|
12
|
+
const error = new Error(message, cause === undefined ? undefined : { cause });
|
|
13
|
+
error.code = code;
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isAbort(error, signal) {
|
|
18
|
+
return signal?.aborted || error?.name === 'AbortError';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseFrame(frame) {
|
|
22
|
+
let type = 'message';
|
|
23
|
+
let id;
|
|
24
|
+
const data = [];
|
|
25
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
26
|
+
if (line.startsWith('event:')) type = line.slice(6).trim() || 'message';
|
|
27
|
+
else if (line.startsWith('id:')) id = line.slice(3).trim() || undefined;
|
|
28
|
+
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
|
29
|
+
}
|
|
30
|
+
if (data.length === 0) return null;
|
|
31
|
+
let value;
|
|
32
|
+
try { value = JSON.parse(data.join('\n')); } catch { throw new Error('AI Office SSE returned invalid JSON'); }
|
|
33
|
+
return { id, type: typeof value?.type === 'string' ? value.type : type, data: value };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class OfficeTransport {
|
|
37
|
+
#baseUrl;
|
|
38
|
+
#deviceId;
|
|
39
|
+
#token;
|
|
40
|
+
#fetch;
|
|
41
|
+
|
|
42
|
+
constructor({ baseUrl, deviceId, token, fetchImpl = fetch }) {
|
|
43
|
+
this.#baseUrl = baseUrl;
|
|
44
|
+
this.#deviceId = deviceId;
|
|
45
|
+
this.#token = token;
|
|
46
|
+
this.#fetch = fetchImpl;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
hooks() { return officeHookUrls(this.#baseUrl); }
|
|
50
|
+
|
|
51
|
+
#headers(extra = {}) {
|
|
52
|
+
return {
|
|
53
|
+
authorization: `Bearer ${this.#token}`,
|
|
54
|
+
'x-harness-device-id': this.#deviceId,
|
|
55
|
+
...extra,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async heartbeat(payload, { signal } = {}) {
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await this.#fetch(new URL(OFFICE_HOOK_PATHS.heartbeat, this.#baseUrl), {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: this.#headers({ accept: 'application/json', 'content-type': 'application/json' }),
|
|
65
|
+
body: JSON.stringify(payload),
|
|
66
|
+
signal,
|
|
67
|
+
redirect: 'error',
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (isAbort(error, signal)) throw error;
|
|
71
|
+
throw transportFailure('AI Office heartbeat request could not be completed', undefined, error);
|
|
72
|
+
}
|
|
73
|
+
if (!response.ok) throw safeTransportError('heartbeat', response);
|
|
74
|
+
let value;
|
|
75
|
+
try { value = await response.json(); }
|
|
76
|
+
catch (error) { throw transportFailure('AI Office heartbeat returned invalid JSON', 'office-protocol-mismatch', error); }
|
|
77
|
+
if (!value || typeof value !== 'object' || value.ok !== true
|
|
78
|
+
|| value.protocolVersion !== OFFICE_PROTOCOL_VERSION) {
|
|
79
|
+
throw transportFailure('AI Office heartbeat protocol does not match', 'office-protocol-mismatch');
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async #jobRequest(path, { method = 'POST', body, leaseToken, signal } = {}) {
|
|
85
|
+
const headers = this.#headers({ accept: 'application/json' });
|
|
86
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
87
|
+
if (leaseToken) headers['x-harness-lease-token'] = leaseToken;
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await this.#fetch(new URL(path, this.#baseUrl), {
|
|
91
|
+
method,
|
|
92
|
+
headers,
|
|
93
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
94
|
+
signal,
|
|
95
|
+
redirect: 'error',
|
|
96
|
+
cache: 'no-store',
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (isAbort(error, signal)) throw error;
|
|
100
|
+
throw transportFailure('AI Office Job request could not be completed', undefined, error);
|
|
101
|
+
}
|
|
102
|
+
if (!response.ok) throw safeTransportError('Job request', response);
|
|
103
|
+
try { return await response.json(); }
|
|
104
|
+
catch (error) { throw transportFailure('AI Office Job returned invalid JSON', 'office-protocol-mismatch', error); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getJob(jobId, options = {}) {
|
|
108
|
+
return this.#jobRequest(OFFICE_HOOK_PATHS.job.replace(':id', jobId), { ...options, method: 'GET' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async acceptJob(jobId, options = {}) {
|
|
112
|
+
return this.#jobRequest(OFFICE_HOOK_PATHS.accept.replace(':id', jobId), options);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async renewJob(jobId, leaseToken, options = {}) {
|
|
116
|
+
return this.#jobRequest(OFFICE_HOOK_PATHS.renew.replace(':id', jobId), { ...options, leaseToken });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async progressJob(jobId, leaseToken, body, options = {}) {
|
|
120
|
+
return this.#jobRequest(OFFICE_HOOK_PATHS.progress.replace(':id', jobId), { ...options, leaseToken, body });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async requestApproval(jobId, leaseToken, body, options = {}) {
|
|
124
|
+
return this.#jobRequest(OFFICE_HOOK_PATHS.approval.replace(':id', jobId), { ...options, leaseToken, body });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async completeJob(jobId, leaseToken, body, options = {}) {
|
|
128
|
+
return this.#jobRequest(OFFICE_HOOK_PATHS.result.replace(':id', jobId), { ...options, leaseToken, body });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async failJob(jobId, leaseToken, body, options = {}) {
|
|
132
|
+
return this.#jobRequest(OFFICE_HOOK_PATHS.fail.replace(':id', jobId), { ...options, leaseToken, body });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async stream({ signal, lastEventId, onOpen, onEvent }) {
|
|
136
|
+
const headers = this.#headers({ accept: 'text/event-stream' });
|
|
137
|
+
if (lastEventId) headers['last-event-id'] = lastEventId;
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await this.#fetch(new URL(OFFICE_HOOK_PATHS.stream, this.#baseUrl), {
|
|
141
|
+
method: 'GET', headers, signal, redirect: 'error', cache: 'no-store',
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (isAbort(error, signal)) throw error;
|
|
145
|
+
throw transportFailure('AI Office SSE request could not be completed', undefined, error);
|
|
146
|
+
}
|
|
147
|
+
if (!response.ok) throw safeTransportError('stream', response);
|
|
148
|
+
if (!response.headers.get('content-type')?.toLowerCase().includes('text/event-stream')) {
|
|
149
|
+
throw new Error('AI Office stream did not return text/event-stream');
|
|
150
|
+
}
|
|
151
|
+
if (!response.body) throw new Error('AI Office stream returned no body');
|
|
152
|
+
onOpen?.();
|
|
153
|
+
const reader = response.body.getReader();
|
|
154
|
+
const decoder = new TextDecoder();
|
|
155
|
+
let buffer = '';
|
|
156
|
+
while (true) {
|
|
157
|
+
const { value, done } = await reader.read();
|
|
158
|
+
if (done) break;
|
|
159
|
+
buffer = `${buffer}${decoder.decode(value, { stream: true })}`.replaceAll('\r\n', '\n');
|
|
160
|
+
let boundary;
|
|
161
|
+
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
|
162
|
+
const raw = buffer.slice(0, boundary);
|
|
163
|
+
buffer = buffer.slice(boundary + 2);
|
|
164
|
+
const event = parseFrame(raw);
|
|
165
|
+
if (event) await onEvent?.(event);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new Error('AI Office SSE stream ended');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const OFFICE_PROTOCOL_VERSION = 'office-harness.v1';
|
|
2
|
+
export const OFFICE_RPC_CHANNEL = '/office';
|
|
3
|
+
|
|
4
|
+
export const OFFICE_RPC_ENDPOINTS = Object.freeze({
|
|
5
|
+
status: 'connection.status',
|
|
6
|
+
configure: 'connector.configure',
|
|
7
|
+
reconnect: 'connector.reconnect',
|
|
8
|
+
test: 'connector.test',
|
|
9
|
+
remove: 'connector.remove',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const OFFICE_HOOK_PATHS = Object.freeze({
|
|
13
|
+
stream: '/api/harness/connector/stream',
|
|
14
|
+
heartbeat: '/api/harness/connector/heartbeat',
|
|
15
|
+
job: '/api/harness/connector/jobs/:id',
|
|
16
|
+
accept: '/api/harness/connector/jobs/:id/accept',
|
|
17
|
+
renew: '/api/harness/connector/jobs/:id/renew',
|
|
18
|
+
progress: '/api/harness/connector/jobs/:id/progress',
|
|
19
|
+
approval: '/api/harness/connector/jobs/:id/approval',
|
|
20
|
+
result: '/api/harness/connector/jobs/:id/result',
|
|
21
|
+
fail: '/api/harness/connector/jobs/:id/fail',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function normalizeOfficeBaseUrl(value) {
|
|
25
|
+
const url = new URL(typeof value === 'string' ? value.trim() : '');
|
|
26
|
+
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
27
|
+
if (url.protocol !== 'https:' && !localHttp) {
|
|
28
|
+
throw new TypeError('AI Office URL must use HTTPS (HTTP is allowed only for loopback testing)');
|
|
29
|
+
}
|
|
30
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
31
|
+
throw new TypeError('AI Office URL must be a bare origin');
|
|
32
|
+
}
|
|
33
|
+
url.pathname = '/';
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function officeHookUrls(baseUrl) {
|
|
38
|
+
const origin = normalizeOfficeBaseUrl(baseUrl);
|
|
39
|
+
return Object.fromEntries(Object.entries(OFFICE_HOOK_PATHS).map(([name, path]) => [
|
|
40
|
+
name,
|
|
41
|
+
new URL(path, origin).toString(),
|
|
42
|
+
]));
|
|
43
|
+
}
|
|
@@ -163,6 +163,34 @@ export class TokenBotController {
|
|
|
163
163
|
return this.status();
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
async updateBotConfig(botId, update) {
|
|
167
|
+
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
|
168
|
+
if (typeof update !== 'function') throw new TypeError('Bot config update must be a function');
|
|
169
|
+
await this.#withBotTransition(botId, async () => {
|
|
170
|
+
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
|
171
|
+
const config = this.#configStore.get(botId);
|
|
172
|
+
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
|
173
|
+
const token = await this.#resolveToken(config.tokenRef);
|
|
174
|
+
if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`);
|
|
175
|
+
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
|
176
|
+
const nextConfig = update(config);
|
|
177
|
+
const savedConfig = await this.#configStore.save(nextConfig);
|
|
178
|
+
try {
|
|
179
|
+
await this.#startRuntime(savedConfig, token);
|
|
180
|
+
this.#errors.delete(botId);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
this.#errors.set(botId, safeError(
|
|
183
|
+
'connection-failed',
|
|
184
|
+
`${this.#descriptor.label}连接仍未就绪,请稍后重试。`,
|
|
185
|
+
));
|
|
186
|
+
throw error;
|
|
187
|
+
} finally {
|
|
188
|
+
this.#touch();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return this.status();
|
|
192
|
+
}
|
|
193
|
+
|
|
166
194
|
async sendConnectionTest(botId) {
|
|
167
195
|
const config = this.#configStore.get(botId);
|
|
168
196
|
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
|
@@ -33,16 +33,26 @@ export class TokenBotConfigStore {
|
|
|
33
33
|
#channel;
|
|
34
34
|
#botPrefix;
|
|
35
35
|
#tokenRefPrefix;
|
|
36
|
+
#normalizeBotExtension;
|
|
36
37
|
#botIdPattern;
|
|
37
38
|
#tokenRefPattern;
|
|
38
39
|
#value = EMPTY_DOCUMENT;
|
|
39
40
|
#writeQueue = Promise.resolve();
|
|
40
41
|
|
|
41
|
-
constructor(path, {
|
|
42
|
+
constructor(path, {
|
|
43
|
+
channel,
|
|
44
|
+
botPrefix,
|
|
45
|
+
tokenRefPrefix,
|
|
46
|
+
normalizeBotExtension = () => ({}),
|
|
47
|
+
}) {
|
|
48
|
+
if (typeof normalizeBotExtension !== 'function') {
|
|
49
|
+
throw new TypeError('normalizeBotExtension must be a function');
|
|
50
|
+
}
|
|
42
51
|
this.#path = path;
|
|
43
52
|
this.#channel = channel;
|
|
44
53
|
this.#botPrefix = botPrefix;
|
|
45
54
|
this.#tokenRefPrefix = tokenRefPrefix;
|
|
55
|
+
this.#normalizeBotExtension = normalizeBotExtension;
|
|
46
56
|
this.#botIdPattern = new RegExp(`^${escapePattern(botPrefix)}_[a-f0-9]{24}$`);
|
|
47
57
|
this.#tokenRefPattern = new RegExp(`^${escapePattern(tokenRefPrefix)}_[A-F0-9]{24}$`);
|
|
48
58
|
}
|
|
@@ -127,6 +137,8 @@ export class TokenBotConfigStore {
|
|
|
127
137
|
tokenRefPrefix: this.#tokenRefPrefix,
|
|
128
138
|
});
|
|
129
139
|
if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null;
|
|
140
|
+
const extension = this.#normalizeBotExtension(value);
|
|
141
|
+
if (!extension || typeof extension !== 'object' || Array.isArray(extension)) return null;
|
|
130
142
|
return Object.freeze({
|
|
131
143
|
botId,
|
|
132
144
|
platformId,
|
|
@@ -135,6 +147,7 @@ export class TokenBotConfigStore {
|
|
|
135
147
|
username: cleanString(value.username),
|
|
136
148
|
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
|
137
149
|
connectedAt: cleanString(value.connectedAt),
|
|
150
|
+
...extension,
|
|
138
151
|
});
|
|
139
152
|
}
|
|
140
153
|
|
|
@@ -9,6 +9,58 @@ const IDENTITY_OPTIONS = Object.freeze({
|
|
|
9
9
|
tokenRefPrefix: 'DSH_TELEGRAM_BOT_TOKEN',
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
export const TELEGRAM_ACCESS_MODES = Object.freeze({
|
|
13
|
+
compatible: 'compatible',
|
|
14
|
+
privateAllowlist: 'private-allowlist',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const TELEGRAM_USER_ID = /^[1-9]\d{0,15}$/;
|
|
18
|
+
|
|
19
|
+
export function normalizeTelegramAllowedUsers(value) {
|
|
20
|
+
if (value === undefined) return Object.freeze([]);
|
|
21
|
+
if (!Array.isArray(value)) {
|
|
22
|
+
throw new TypeError('allowedUsers must be an array of numeric Telegram User IDs');
|
|
23
|
+
}
|
|
24
|
+
const normalized = value.map((entry) => {
|
|
25
|
+
const userId = typeof entry === 'number' && Number.isSafeInteger(entry)
|
|
26
|
+
? String(entry) : typeof entry === 'string' ? entry.trim() : '';
|
|
27
|
+
if (!TELEGRAM_USER_ID.test(userId)) {
|
|
28
|
+
throw new TypeError('allowedUsers contains an invalid Telegram User ID');
|
|
29
|
+
}
|
|
30
|
+
return userId;
|
|
31
|
+
});
|
|
32
|
+
return Object.freeze([...new Set(normalized)]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeTelegramAccessPolicy(value = {}) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
37
|
+
throw new TypeError('Telegram access policy must be an object');
|
|
38
|
+
}
|
|
39
|
+
const accessMode = value.accessMode ?? TELEGRAM_ACCESS_MODES.compatible;
|
|
40
|
+
if (!Object.values(TELEGRAM_ACCESS_MODES).includes(accessMode)) {
|
|
41
|
+
throw new TypeError('Telegram accessMode must be compatible or private-allowlist');
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
accessMode,
|
|
45
|
+
allowedUsers: normalizeTelegramAllowedUsers(value.allowedUsers),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeTelegramBotExtension(value) {
|
|
50
|
+
const hasAccessMode = Object.hasOwn(value, 'accessMode');
|
|
51
|
+
const hasAllowedUsers = Object.hasOwn(value, 'allowedUsers');
|
|
52
|
+
if (!hasAccessMode && !hasAllowedUsers) return {};
|
|
53
|
+
try {
|
|
54
|
+
const policy = normalizeTelegramAccessPolicy(value);
|
|
55
|
+
return {
|
|
56
|
+
...(hasAccessMode ? { accessMode: policy.accessMode } : {}),
|
|
57
|
+
...(hasAllowedUsers || hasAccessMode ? { allowedUsers: policy.allowedUsers } : {}),
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
12
64
|
export function deriveTelegramBotIdentity(platformId) {
|
|
13
65
|
return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
|
|
14
66
|
}
|
|
@@ -19,6 +71,15 @@ export function maskTelegramBotId(platformId) {
|
|
|
19
71
|
|
|
20
72
|
export class TelegramConfigStore extends TokenBotConfigStore {
|
|
21
73
|
constructor(path) {
|
|
22
|
-
super(path, {
|
|
74
|
+
super(path, {
|
|
75
|
+
channel: 'Telegram',
|
|
76
|
+
...IDENTITY_OPTIONS,
|
|
77
|
+
normalizeBotExtension: normalizeTelegramBotExtension,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async save(value) {
|
|
82
|
+
const previous = value?.platformId ? this.getByPlatformId(String(value.platformId)) : null;
|
|
83
|
+
return super.save({ ...previous, ...value });
|
|
23
84
|
}
|
|
24
85
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { TokenBotController } from '../shared/token-bot-controller.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
deriveTelegramBotIdentity,
|
|
4
|
+
maskTelegramBotId,
|
|
5
|
+
normalizeTelegramAccessPolicy,
|
|
6
|
+
} from './config-store.mjs';
|
|
3
7
|
import { inspectTelegramToken } from './telegram-api.mjs';
|
|
4
8
|
import { TELEGRAM_DESCRIPTOR } from './telegram-bridge.mjs';
|
|
5
9
|
|
|
6
10
|
export class TelegramController extends TokenBotController {
|
|
11
|
+
#configStore;
|
|
12
|
+
|
|
7
13
|
constructor(options) {
|
|
8
14
|
super({
|
|
9
15
|
...options,
|
|
@@ -12,5 +18,23 @@ export class TelegramController extends TokenBotController {
|
|
|
12
18
|
deriveIdentity: deriveTelegramBotIdentity,
|
|
13
19
|
maskPlatformId: maskTelegramBotId,
|
|
14
20
|
});
|
|
21
|
+
this.#configStore = options.configStore;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
status() {
|
|
25
|
+
const snapshot = super.status();
|
|
26
|
+
return {
|
|
27
|
+
...snapshot,
|
|
28
|
+
bots: snapshot.bots.map((bot) => {
|
|
29
|
+
const config = this.#configStore.get(bot.botId);
|
|
30
|
+
const accessPolicy = normalizeTelegramAccessPolicy(config ?? {});
|
|
31
|
+
return { ...bot, accessPolicy };
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async setAccessPolicy(botId, value) {
|
|
37
|
+
const accessPolicy = normalizeTelegramAccessPolicy(value);
|
|
38
|
+
return this.updateBotConfig(botId, (config) => ({ ...config, ...accessPolicy }));
|
|
15
39
|
}
|
|
16
40
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
2
2
|
import { TelegramApi } from './telegram-api.mjs';
|
|
3
3
|
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
|
4
|
+
import {
|
|
5
|
+
TELEGRAM_ACCESS_MODES,
|
|
6
|
+
normalizeTelegramAccessPolicy,
|
|
7
|
+
} from './config-store.mjs';
|
|
4
8
|
|
|
5
9
|
function escaped(value) {
|
|
6
10
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -115,6 +119,16 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
|
|
|
115
119
|
};
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
export function telegramInboundAllowed(message, {
|
|
123
|
+
accessMode = TELEGRAM_ACCESS_MODES.compatible,
|
|
124
|
+
allowedPrivateUserIds = new Set(),
|
|
125
|
+
} = {}) {
|
|
126
|
+
if (accessMode !== TELEGRAM_ACCESS_MODES.privateAllowlist) return true;
|
|
127
|
+
return message?.kind === 'direct'
|
|
128
|
+
&& allowedPrivateUserIds instanceof Set
|
|
129
|
+
&& allowedPrivateUserIds.has(String(message.senderId));
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
class TelegramBotClient {
|
|
119
133
|
#api;
|
|
120
134
|
#signal;
|
|
@@ -198,6 +212,8 @@ export class TelegramRuntime {
|
|
|
198
212
|
#logger;
|
|
199
213
|
#replyTimeoutMs;
|
|
200
214
|
#createApi;
|
|
215
|
+
#accessMode;
|
|
216
|
+
#allowedPrivateUserIds;
|
|
201
217
|
#status = createTelegramRuntimeStatus();
|
|
202
218
|
#api = null;
|
|
203
219
|
#bridge = null;
|
|
@@ -224,6 +240,9 @@ export class TelegramRuntime {
|
|
|
224
240
|
this.#logger = logger;
|
|
225
241
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
226
242
|
this.#createApi = createApi;
|
|
243
|
+
const accessPolicy = normalizeTelegramAccessPolicy(config);
|
|
244
|
+
this.#accessMode = accessPolicy.accessMode;
|
|
245
|
+
this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
|
|
227
246
|
}
|
|
228
247
|
|
|
229
248
|
get status() {
|
|
@@ -323,7 +342,10 @@ export class TelegramRuntime {
|
|
|
323
342
|
username: this.#config.username,
|
|
324
343
|
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
|
325
344
|
});
|
|
326
|
-
if (message
|
|
345
|
+
if (message && telegramInboundAllowed(message, {
|
|
346
|
+
accessMode: this.#accessMode,
|
|
347
|
+
allowedPrivateUserIds: this.#allowedPrivateUserIds,
|
|
348
|
+
})) {
|
|
327
349
|
void this.#bridge.accept(message).catch((error) => {
|
|
328
350
|
if (signal.aborted) return;
|
|
329
351
|
this.#logger.error?.(
|
|
@@ -331,6 +353,9 @@ export class TelegramRuntime {
|
|
|
331
353
|
error,
|
|
332
354
|
);
|
|
333
355
|
});
|
|
356
|
+
} else if (message) {
|
|
357
|
+
this.#status.messagesRejected += 1;
|
|
358
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
334
359
|
}
|
|
335
360
|
cursor = update.update_id + 1;
|
|
336
361
|
await this.#state.setCursor(cursor);
|