mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 11:43:19 +08:00
Wrap all user-facing chat text in src/channels with a host-side t() translator (Chinese is the identity default, so untranslated text is always sent verbatim in Chinese). English is selected via the host plugin config `language: en` or the DSH_IM_LANGUAGE environment variable, and the settings UI keeps following the DSH web locale. - add src/channels/shared/i18n.mjs with setImHostLanguage/t() and a Chinese-key -> English dictionary split by area (shared + channels) - complete the English dictionary for every t() key in src/channels, including the feishu channel (welcome/help, repair flow, interactive cards, watch flow) and the shared command/approval/stream text - wrap previously untranslated chat-facing strings in feishu bridge, feishu runtime, editable-message-stream, harness-client, connection-test and image-prompt - dedupe dictionary entries that existed in multiple channel files and canonicalize conflicting translations into the shared dictionaries - extend the i18n unit test to decode string-literal escapes so keys containing \n are checked correctly - document the language switch in README.md, README.en.md and CHANGELOG.md, and rebuild lib/index.js Added English support for bot chat messages across all nine IM channels and AI Office without changing any Chinese output.
172 lines
5.6 KiB
JavaScript
172 lines
5.6 KiB
JavaScript
import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
|
import { t } from '../shared/i18n.mjs';
|
|
import { OfficeTransport } from './office-transport.mjs';
|
|
import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
|
|
import { OfficeJobExecutor } from './office-job-executor.mjs';
|
|
|
|
const RETRY_DELAYS = Object.freeze([1_000, 3_000, 10_000, 30_000]);
|
|
|
|
function safeConnectionError(error) {
|
|
const code = typeof error?.code === 'string' ? error.code : 'office-connection-failed';
|
|
const messages = {
|
|
'invalid-device-token': t('AI Office 拒绝了 Device Token。'),
|
|
'office-hook-unavailable': t('AI Office Connector Hook 尚未就绪。'),
|
|
'office-protocol-mismatch': t('AI Office Connector 协议版本不兼容。'),
|
|
'office-transport-failed': t('本机暂时无法访问 AI Office。'),
|
|
};
|
|
return { code, message: messages[code] ?? t('AI Office 连接已中断。') };
|
|
}
|
|
|
|
export class OfficeRuntime {
|
|
#config;
|
|
#token;
|
|
#logger;
|
|
#transport;
|
|
#sleep;
|
|
#controller = null;
|
|
#task = null;
|
|
#status;
|
|
#jobs;
|
|
|
|
constructor({
|
|
config,
|
|
token,
|
|
logger = console,
|
|
transport,
|
|
createHarness,
|
|
jobExecutor,
|
|
sleepImpl = sleep,
|
|
}) {
|
|
this.#config = config;
|
|
this.#token = token;
|
|
this.#logger = logger;
|
|
this.#sleep = sleepImpl;
|
|
this.#transport = transport ?? new OfficeTransport({
|
|
baseUrl: config.baseUrl, deviceId: config.deviceId, token,
|
|
});
|
|
this.#status = {
|
|
state: 'idle', connected: false, startedAt: null, lastHeartbeatAt: null,
|
|
lastEventAt: null, lastEventId: null, lastEventType: null, reconnects: 0,
|
|
jobsOffered: 0, error: null,
|
|
};
|
|
this.#jobs = jobExecutor ?? (createHarness ? new OfficeJobExecutor({
|
|
config,
|
|
transport: this.#transport,
|
|
createHarness,
|
|
logger,
|
|
}) : null);
|
|
}
|
|
|
|
get status() {
|
|
return structuredClone({
|
|
...this.#status,
|
|
...(this.#jobs ? { jobs: this.#jobs.status } : {}),
|
|
});
|
|
}
|
|
|
|
capabilities() {
|
|
return {
|
|
protocolVersion: OFFICE_PROTOCOL_VERSION,
|
|
deviceId: this.#config.deviceId,
|
|
workspaces: Object.keys(this.#config.workspaces),
|
|
instructionPresets: Object.keys(this.#config.instructionPresets),
|
|
maxConcurrency: this.#config.maxConcurrency,
|
|
};
|
|
}
|
|
|
|
async testConnection(signal) {
|
|
await this.#transport.heartbeat({ ...this.capabilities(), probe: true }, { signal });
|
|
return { ok: true };
|
|
}
|
|
|
|
start() {
|
|
if (this.#task) return this.status;
|
|
this.#controller = new AbortController();
|
|
this.#status.startedAt = new Date().toISOString();
|
|
this.#status.state = 'connecting';
|
|
this.#task = this.#run(this.#controller.signal).finally(() => { this.#task = null; });
|
|
this.#task.catch((error) => {
|
|
if (this.#controller?.signal.aborted) return;
|
|
this.#logger.error?.('[dsh-im:office] connector stopped:', error);
|
|
});
|
|
return this.status;
|
|
}
|
|
|
|
async #run(signal) {
|
|
let attempt = 0;
|
|
while (!signal.aborted) {
|
|
const attemptController = new AbortController();
|
|
const attemptSignal = AbortSignal.any([signal, attemptController.signal]);
|
|
try {
|
|
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
|
|
this.#offerJobs(heartbeat?.jobs);
|
|
this.#status.lastHeartbeatAt = new Date().toISOString();
|
|
let streamOpened = false;
|
|
const heartbeatTask = this.#heartbeatLoop(attemptSignal, () => {
|
|
if (streamOpened && !attemptSignal.aborted) attempt = 0;
|
|
});
|
|
const stream = this.#transport.stream({
|
|
signal: attemptSignal,
|
|
lastEventId: this.#status.lastEventId,
|
|
onOpen: () => {
|
|
this.#status.connected = true;
|
|
this.#status.state = 'connected';
|
|
this.#status.error = null;
|
|
streamOpened = true;
|
|
},
|
|
onEvent: async (event) => {
|
|
this.#status.lastEventAt = new Date().toISOString();
|
|
this.#status.lastEventId = event.id ?? this.#status.lastEventId;
|
|
this.#status.lastEventType = event.type;
|
|
if (event.type === 'job.available') this.#status.jobsOffered += 1;
|
|
this.#jobs?.handleEvent(event);
|
|
},
|
|
});
|
|
await Promise.race([stream, heartbeatTask]);
|
|
} catch (error) {
|
|
if (signal.aborted) break;
|
|
attemptController.abort();
|
|
this.#status.connected = false;
|
|
this.#status.state = 'reconnecting';
|
|
this.#status.error = safeConnectionError(error);
|
|
this.#status.reconnects += 1;
|
|
const delay = RETRY_DELAYS[Math.min(attempt, RETRY_DELAYS.length - 1)];
|
|
attempt += 1;
|
|
try { await this.#sleep(delay, undefined, { signal }); } catch { break; }
|
|
} finally {
|
|
attemptController.abort();
|
|
}
|
|
}
|
|
this.#status.connected = false;
|
|
this.#status.state = 'idle';
|
|
}
|
|
|
|
async #heartbeatLoop(signal, onSuccess) {
|
|
while (!signal.aborted) {
|
|
await this.#sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
|
|
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal });
|
|
this.#offerJobs(heartbeat?.jobs);
|
|
this.#status.lastHeartbeatAt = new Date().toISOString();
|
|
onSuccess?.();
|
|
}
|
|
}
|
|
|
|
#offerJobs(jobs) {
|
|
if (!Array.isArray(jobs)) return;
|
|
for (const job of jobs) {
|
|
if (typeof job?.id === 'string' && this.#jobs?.offer(job.id)) this.#status.jobsOffered += 1;
|
|
}
|
|
}
|
|
|
|
async stop() {
|
|
const task = this.#task;
|
|
this.#controller?.abort();
|
|
this.#controller = null;
|
|
if (task) await task.catch(() => undefined);
|
|
await this.#jobs?.close();
|
|
this.#status.connected = false;
|
|
this.#status.state = 'idle';
|
|
return this.status;
|
|
}
|
|
}
|