fix: preserve Telegram compatibility and stabilize Office

This commit is contained in:
xmanrui 2026-08-21 01:37:21 +08:00
parent 7f114e1c36
commit 24e690498b
23 changed files with 1679 additions and 314 deletions

View file

@ -95,6 +95,7 @@ export class OfficeJobExecutor {
#transport;
#createHarness;
#logger;
#sleep;
#active = new Map();
#queued = new Set();
#completed = new Set();
@ -107,7 +108,13 @@ export class OfficeJobExecutor {
lastJobAt: null,
};
constructor({ config, transport, createHarness, logger = console }) {
constructor({
config,
transport,
createHarness,
logger = console,
sleepImpl = sleep,
}) {
if (!config || !transport || typeof createHarness !== 'function') {
throw new TypeError('OfficeJobExecutor requires config, transport, and createHarness');
}
@ -115,6 +122,7 @@ export class OfficeJobExecutor {
this.#transport = transport;
this.#createHarness = createHarness;
this.#logger = logger;
this.#sleep = sleepImpl;
}
get status() { return structuredClone(this.#status); }
@ -256,18 +264,10 @@ export class OfficeJobExecutor {
async #renew(jobId, entry) {
while (!entry.controller.signal.aborted) {
try { await sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
try { await this.#sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
catch { return; }
try {
await this.#transport.renewJob(jobId, entry.leaseToken, { signal: entry.controller.signal });
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
const approval = snapshot?.job?.approval;
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
entry.approvals.get(approval.id)?.resolve({
decision: approval.status,
answer: clean(approval.answer),
});
}
} catch (error) {
if (entry.controller.signal.aborted) return;
entry.cancelled = true;
@ -280,6 +280,22 @@ export class OfficeJobExecutor {
}
return;
}
try {
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
const approval = snapshot?.job?.approval;
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
entry.approvals.get(approval.id)?.resolve({
decision: approval.status,
answer: clean(approval.answer),
});
}
} catch (error) {
if (entry.controller.signal.aborted) return;
this.#logger.warn?.(
`[dsh-im:office] Job ${jobId} approval poll failed; will retry after the next renewal:`,
error.message,
);
}
}
}

View file

@ -22,15 +22,25 @@ export class OfficeRuntime {
#token;
#logger;
#transport;
#sleep;
#controller = null;
#task = null;
#status;
#jobs;
constructor({ config, token, logger = console, transport, createHarness, jobExecutor }) {
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,
});
@ -91,7 +101,10 @@ export class OfficeRuntime {
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
this.#offerJobs(heartbeat?.jobs);
this.#status.lastHeartbeatAt = new Date().toISOString();
const heartbeatTask = this.#heartbeatLoop(attemptSignal);
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,
@ -99,7 +112,7 @@ export class OfficeRuntime {
this.#status.connected = true;
this.#status.state = 'connected';
this.#status.error = null;
attempt = 0;
streamOpened = true;
},
onEvent: async (event) => {
this.#status.lastEventAt = new Date().toISOString();
@ -112,13 +125,14 @@ export class OfficeRuntime {
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 sleep(delay, undefined, { signal }); } catch { break; }
try { await this.#sleep(delay, undefined, { signal }); } catch { break; }
} finally {
attemptController.abort();
}
@ -127,12 +141,13 @@ export class OfficeRuntime {
this.#status.state = 'idle';
}
async #heartbeatLoop(signal) {
async #heartbeatLoop(signal, onSuccess) {
while (!signal.aborted) {
await sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
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?.();
}
}