@xmanrui/dsh-im 0.12.0 → 0.14.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 +64 -43
- package/README.md +66 -45
- 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 +805 -173
- package/lib/index.js +142 -129
- 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 +69 -1
- package/plugin-src/client/index.js +22 -12
- package/plugin-src/client/styles.js +5 -3
- 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/scripts/verify-package.mjs +34 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +3 -2
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +3 -2
- 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/qq/qq-bridge.mjs +3 -2
- package/src/channels/shared/model-command.mjs +44 -6
- package/src/channels/shared/text-harness-bridge.mjs +3 -2
- 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/wecom/wecom-bridge.mjs +3 -2
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +3 -2
|
@@ -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
|
+
}
|
|
@@ -46,8 +46,9 @@ const HELP_TEXT = [
|
|
|
46
46
|
'/workspacelist 列出工作区绝对路径',
|
|
47
47
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
48
48
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
49
|
-
'/models
|
|
50
|
-
'/model [
|
|
49
|
+
'/models 按序号列出所有可用模型',
|
|
50
|
+
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
|
51
|
+
'示例:先发 /models,再发 /model 2',
|
|
51
52
|
'/stop 停止当前任务',
|
|
52
53
|
'/steer 补充指令 纠偏当前任务',
|
|
53
54
|
'/status 检查连接状态',
|
|
@@ -4,7 +4,7 @@ import { withSessionBindingLock } from './session-binding-lock.mjs';
|
|
|
4
4
|
|
|
5
5
|
const MODEL_COMMAND = /^\/model(?=$|\s)/i;
|
|
6
6
|
const MODELS_COMMAND = /^\/models(?=$|\s)/i;
|
|
7
|
-
const MODEL_USAGE = '用法:/model <provider>/<model>';
|
|
7
|
+
const MODEL_USAGE = '用法:/model <序号> 或 /model <provider>/<model>';
|
|
8
8
|
const MODELS_USAGE = '用法:/models(不带参数)';
|
|
9
9
|
const SESSION_BINDING_CHANGED = 'session-binding-changed';
|
|
10
10
|
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
|
@@ -88,17 +88,46 @@ function matchingModel(catalog, requested) {
|
|
|
88
88
|
return null;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function modelAt(catalog, requestedIndex) {
|
|
92
|
+
let index = 0;
|
|
93
|
+
for (const group of catalog.groups) {
|
|
94
|
+
for (const model of group.models) {
|
|
95
|
+
index += 1;
|
|
96
|
+
if (index === requestedIndex) {
|
|
97
|
+
return { provider: group.id, model: model.id };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function modelNumberRequest(requested) {
|
|
105
|
+
if (!/^\d+$/u.test(requested)) return null;
|
|
106
|
+
const index = Number(requested);
|
|
107
|
+
return { index: Number.isSafeInteger(index) && index > 0 ? index : null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function invalidModelNumberMessage(requested) {
|
|
111
|
+
return [
|
|
112
|
+
`模型序号无效:${safeDisplayText(requested)}`,
|
|
113
|
+
'',
|
|
114
|
+
'请发送 /models 查看并输入有效的正整数序号。',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
91
118
|
function formatCatalog(catalog) {
|
|
92
119
|
const currentId = catalog.current
|
|
93
120
|
? modelId(catalog.current.provider, catalog.current.model)
|
|
94
121
|
: null;
|
|
95
122
|
const lines = ['可用模型:'];
|
|
123
|
+
let index = 0;
|
|
96
124
|
if (catalog.groups.length === 0) lines.push('', '当前没有可用模型。');
|
|
97
125
|
for (const group of catalog.groups) {
|
|
98
126
|
lines.push('', safeDisplayText(group.name) || safeDisplayText(group.id));
|
|
99
127
|
for (const model of group.models) {
|
|
128
|
+
index += 1;
|
|
100
129
|
const id = modelId(group.id, model.id);
|
|
101
|
-
lines.push(
|
|
130
|
+
lines.push(`${index}. ${safeDisplayText(id)}${id === currentId ? '(当前)' : ''}`);
|
|
102
131
|
}
|
|
103
132
|
}
|
|
104
133
|
if (catalog.failures.length > 0) {
|
|
@@ -107,6 +136,7 @@ function formatCatalog(catalog) {
|
|
|
107
136
|
lines.push(`- ${safeDisplayText(failure.name) || safeDisplayText(failure.id)}`);
|
|
108
137
|
}
|
|
109
138
|
}
|
|
139
|
+
if (index > 0) lines.push('', '切换模型:/model <序号>');
|
|
110
140
|
return lines.join('\n');
|
|
111
141
|
}
|
|
112
142
|
|
|
@@ -116,7 +146,7 @@ function currentModelMessage(current) {
|
|
|
116
146
|
modelId(current.provider, current.model),
|
|
117
147
|
'',
|
|
118
148
|
'查看全部模型:/models',
|
|
119
|
-
'切换模型:/model
|
|
149
|
+
'切换模型:/model <序号>',
|
|
120
150
|
].join('\n');
|
|
121
151
|
}
|
|
122
152
|
|
|
@@ -125,7 +155,7 @@ function noSessionMessage() {
|
|
|
125
155
|
'当前聊天还没有会话。',
|
|
126
156
|
'',
|
|
127
157
|
'查看模型:/models',
|
|
128
|
-
'选择模型:/model
|
|
158
|
+
'选择模型:/model <序号>',
|
|
129
159
|
].join('\n');
|
|
130
160
|
}
|
|
131
161
|
|
|
@@ -243,7 +273,12 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|
|
243
273
|
return commandResult(modelErrorMessage(error, 'select'));
|
|
244
274
|
}
|
|
245
275
|
}
|
|
246
|
-
|
|
276
|
+
const numberRequest = modelNumberRequest(requested);
|
|
277
|
+
if (numberRequest?.index === null) {
|
|
278
|
+
return commandResult(invalidModelNumberMessage(requested));
|
|
279
|
+
}
|
|
280
|
+
if (!numberRequest
|
|
281
|
+
&& (!requested.includes('/') || requested.startsWith('/') || requested.endsWith('/'))) {
|
|
247
282
|
return commandResult(MODEL_USAGE);
|
|
248
283
|
}
|
|
249
284
|
if (options.pendingInteraction) {
|
|
@@ -264,8 +299,11 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|
|
264
299
|
const catalog = bound
|
|
265
300
|
? await sessionCatalog(bound.session, requestOptions)
|
|
266
301
|
: await listCatalog(harness, requestOptions);
|
|
267
|
-
const selection =
|
|
302
|
+
const selection = numberRequest
|
|
303
|
+
? modelAt(catalog, numberRequest.index)
|
|
304
|
+
: matchingModel(catalog, requested);
|
|
268
305
|
if (!selection) {
|
|
306
|
+
if (numberRequest) return commandResult(invalidModelNumberMessage(requested));
|
|
269
307
|
return commandResult([
|
|
270
308
|
`没有找到模型:${safeDisplayText(requested)}`,
|
|
271
309
|
'',
|
|
@@ -316,8 +316,9 @@ export class TextHarnessBridge {
|
|
|
316
316
|
'/workspacelist 列出工作区绝对路径',
|
|
317
317
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
318
318
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
319
|
-
'/models
|
|
320
|
-
'/model [
|
|
319
|
+
'/models 按序号列出所有可用模型',
|
|
320
|
+
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
|
321
|
+
'示例:先发 /models,再发 /model 2',
|
|
321
322
|
'/stop 停止当前任务',
|
|
322
323
|
'/steer 补充指令 纠偏当前任务',
|
|
323
324
|
'/status 检查连接状态',
|
|
@@ -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
|
}
|