Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
18963f3
fix(telegram): 接入官方 IM 定时正文投递与持久回执
FicoHub Sep 10, 2026
2d454bf
fix(telegram): 清除迟到成功回执的未知状态标记
FicoHub Sep 10, 2026
ee204bf
fix(telegram): 显式协商直投能力并核验真实目标回执
FicoHub Sep 10, 2026
c6a2c1c
fix(telegram): 留出时钟余量并说明真实实体回执
FicoHub Sep 10, 2026
4ad8ff9
test(telegram): 覆盖重启晚到回执与发送收尾竞争
FicoHub Sep 11, 2026
858623e
chore(telegram): 清理主动投递文件末尾空行
FicoHub Sep 11, 2026
d7ec17b
fix(telegram): 守住发送授权并保留并发成功回执
FicoHub Sep 11, 2026
b324155
fix(telegram): 保留完整实体回执并清理未发送的失败认领
FicoHub Sep 11, 2026
bef4572
fix(telegram): 发送前同步认领目录并区分本地拒绝
FicoHub Sep 11, 2026
01a9a16
fix(telegram): 认领前限制单条正文并同步主干
FicoHub Sep 15, 2026
1fc7a99
fix(telegram): 认领前保守限制HTML正文长度
FicoHub Sep 15, 2026
a30abfd
fix(telegram): 发布投递回执后同步目录项
FicoHub Sep 15, 2026
9f83581
merge: 同步主干并保留 Telegram 发送与 Slack 通讯入口
FicoHub Sep 16, 2026
d1417c6
fix: 区分 Telegram 提交前断线与不确定写入
FicoHub Sep 16, 2026
7ee81b6
merge: 同步主干并保留 Telegram 投递桥接入口
FicoHub Sep 20, 2026
f14663d
merge: 同步主干,mcpToolApprovalPolicy 测试同时保留 Telegram 外发审批与主干 pre-run 审批用例
FicoHub Sep 23, 2026
8478f46
merge: 同步主干,SchedulerMcpDeps 同时保留 telegramDelivery 与主干 withAccountDat…
FicoHub Oct 1, 2026
dcc0dd1
merge: 同步主干,合并 #5550 的 Telegram 客户端发布能力
FicoHub Oct 8, 2026
655a39b
merge: 同步主干解决 mcp-providers 导入冲突(Telegram 投递桥与主干 i18n 导入)
FicoHub Oct 8, 2026
4b29d66
merge: 同步主干解决 hook-control bindings 冲突(保留 listKeys 与主干 findBySession)
FicoHub Oct 9, 2026
e9f288f
Merge remote-tracking branch 'origin/main' into fix/official-telegram…
FicoHub Oct 10, 2026
fbde3c9
fix(telegram): 删除账号时清除官方定时投递回执日志
FicoHub Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
import { once } from 'node:events';
import type { Duplex } from 'node:stream';

import { WebSocketServer, type WebSocket as ServerSocket } from 'ws';
import { WebSocket, WebSocketServer, type WebSocket as ServerSocket } from 'ws';
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
HOOK_FEATURE_GROUP_RELAY,
HOOK_FEATURE_MESSAGE_OPS,
makeMessageOpResult,
HOOK_FEATURE_GROUP_RELAY_RECIPIENT,
HOOK_FEATURE_LIFECYCLE_ANNOUNCEMENT,
HOOK_FEATURE_MULTI_TEAM,
Expand Down Expand Up @@ -4209,3 +4211,59 @@ describe('多 workspace 绑定(multi-team)', () => {
await expect(promise).resolves.toEqual({ bound: true, prefs: [] });
});
});


describe('official Telegram scheduled message transport', () => {
it('uses the negotiated Telegram connection, matches real op receipts and gates on disconnect', async () => {
const { wss, url } = await startServer();
const key = 'telegram:dm:bot-1:telegram-user-1:g1';
const manager = makeManager(memoryStore({ url, enabled: false, telegramEnabled: true }), {
listTelegramDeliveryKeys: () => [key], toolTimeoutMs: 200,
});
cleanups.push(() => manager.dispose());
const connection = once(wss, 'connection') as Promise<[ServerSocket]>;
manager.sync();
const [socket] = await connection;
const server = collectFrames(socket);
await server.waitFor('hello');
socket.send(serializeHookMessage(makeWelcome({ serverName: 'telegram-server', features: TELEGRAM_FEATURES })));
socket.send(serializeHookMessage(makeProviderBindState(TELEGRAM_CONFIRMED)));
await expect.poll(() => manager.telegramDeliveryStatus().connected).toBe(true);
expect(manager.telegramDeliveryStatus().supported).toBe(false);
socket.send(serializeHookMessage(makeWelcome({ serverName: 'telegram-server', features: [...TELEGRAM_FEATURES, HOOK_FEATURE_MESSAGE_OPS] })));
socket.send(serializeHookMessage(makeProviderBindState(TELEGRAM_CONFIRMED)));
await new Promise(resolve => setTimeout(resolve, 30));
expect(manager.telegramDeliveryStatus().supported).toBe(false); // msg-op-v1 is reaction-only on old servers
expect(await manager.sendTelegramDelivery({ opId: 'old-server', scope: { externalKey: key }, action: { kind: 'send', text: 'test' } })).toMatchObject({ deliveryState: 'not_sent' });
expect(server.frames.filter(f => f.type === 'msg.op')).toHaveLength(0);
socket.send(serializeHookMessage(makeWelcome({ serverName: 'telegram-server', features: [...TELEGRAM_FEATURES, HOOK_FEATURE_MESSAGE_OPS, 'telegram-dm-send-v1', 'telegram-send-epoch:test-epoch'] })));
socket.send(serializeHookMessage(makeProviderBindState(TELEGRAM_CONFIRMED)));
await expect.poll(() => manager.telegramDeliveryStatus().supported).toBe(true);
expect(manager.telegramDeliveryStatus().target).toMatchObject({ externalKey: key, botName: 'cindy_example_bot' });
const payload = { opId: 'telegram-delivery:test', scope: { externalKey: key }, action: { kind: 'send' as const, text: '<b>test</b>', tier: 'html' as const, delivery: { bindingId: TELEGRAM_CONFIRMED.bindingId!, epoch: 'test-epoch', expiresAt: Date.now() + 60000 } } };
// The manager's connected snapshot can outlive the socket's OPEN state.
const closedSocket = vi.spyOn(WebSocket.prototype, 'readyState', 'get').mockReturnValue(WebSocket.CLOSING);
const refused = manager.sendTelegramDelivery(payload);
closedSocket.mockRestore();
expect(await refused).toMatchObject({ opId: payload.opId, deliveryState: 'not_sent' });
expect(server.frames.filter(f => f.type === 'msg.op')).toHaveLength(0);

// send() can throw after attempting a write: that is not proof of non-delivery.
const failedWrite = vi.spyOn(WebSocket.prototype, 'send').mockImplementation(() => { throw new Error('uncertain write'); });
const uncertain = manager.sendTelegramDelivery({ ...payload, opId: 'telegram-delivery:write-error' });
failedWrite.mockRestore();
expect(await uncertain).toBeNull();
// A definite pre-write refusal must release the pending op for a same-key retry.
const sent = manager.sendTelegramDelivery(payload);
const frame = await server.waitFor('msg.op');
expect(frame.type === 'msg.op' ? frame.payload : null).toEqual(payload);
socket.send(serializeHookMessage(makeMessageOpResult({ opId: 'unrelated', ok: true, messageId: 'wrong' })));
socket.send(serializeHookMessage(makeMessageOpResult({ opId: payload.opId, ok: true, messageId: '42' })));
expect(await sent).toEqual({ opId: payload.opId, ok: true, messageId: '42' });
const interrupted = manager.sendTelegramDelivery({ ...payload, opId: 'telegram-delivery:interrupted' });
await manager.deactivateAccount();
expect(await interrupted).toBeNull();
expect(manager.telegramDeliveryStatus().connected).toBe(false);
expect(await manager.sendTelegramDelivery(payload)).toMatchObject({ deliveryState: 'not_sent' });
});
});
8 changes: 8 additions & 0 deletions apps/desktop/src/main/hook-control/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import fs from 'node:fs';
import path from 'node:path';

export interface HookBindingStore {
listKeys?(connectionId: string): string[];
findBySession?(sessionId: string): Array<{ connectionId: string; externalKey: string }>;
get(connectionId: string, externalKey: string): string | null;
/**
Expand Down Expand Up @@ -90,6 +91,13 @@ export function createHookBindingStore(deps: {
}

return {
listKeys(connectionId) {
const ns: unknown = readAll()[connectionId];
if (!ns || typeof ns !== 'object' || Array.isArray(ns)) return [];
return Object.entries(ns).filter(([, row]) =>
row && typeof row === 'object' && typeof row.sessionId === 'string'
).map(([key]) => key);
},
findBySession(sessionId) {
const found: Array<{ connectionId: string; externalKey: string }> = [];
for (const [connectionId, rows] of Object.entries(readAll())) {
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/hook-control/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
} from './manager.js';
import { createHookTransport } from './transport.js';
import { registerSlackToolBridge, unregisterSlackToolBridge } from './slackToolBridge.js';
import { createTelegramDeliveryBridge, purgeTelegramDeliveryJournals, registerTelegramDeliveryBridge, type TelegramDeliveryBridge } from './telegramDelivery.js';
import { createHookBindingStore } from './bindings.js';
import { createHookRequestLedger } from './requestLedger.js';
import {
Expand Down Expand Up @@ -125,6 +126,8 @@ let codexMcpRefreshPending = false;
let codexMcpRefreshRunning = false;
let codexMcpRefreshRetryTimer: NodeJS.Timeout | null = null;
let latestSlackToolProviderEnabled = false;
/** Owner-scoped journal directory of the active Telegram delivery bridge (sent text lives in `.sent`). */
let telegramDeliveryDirectory: string | null = null;

const CODEX_MCP_REFRESH_RETRY_MS = 2_000;

Expand Down Expand Up @@ -435,6 +438,10 @@ function ensureInstances(): { store: SlackHookStore; manager: HookControlManager
});
}
if (!manager) {
let telegramDelivery: TelegramDeliveryBridge | null = null;
const deliveryBindings = createHookBindingStore({
filePath: ownerScopedUserDataPath('hook-bindings.json'), log,
});
const dispatcher = createHookDispatcher({
// 两个 provider 复用 dispatcher,但连接身份和服务地址彼此隔离。
getConnection: (connectionId) => {
Expand Down Expand Up @@ -566,6 +573,8 @@ function ensureInstances(): { store: SlackHookStore; manager: HookControlManager
manager = createHookControlManager({
store,
isAvailable: hookControlAvailable,
onTelegramDeliveryResult: (result) => telegramDelivery?.onResult(result),
listTelegramDeliveryKeys: (connectionId) => deliveryBindings.listKeys?.(connectionId) ?? [],
createTransport: createHookTransport,
getTelegramUrl: () => getClientEndpoint('telegramHookWsUrl'),
getXUrl: () => getClientEndpoint('xHookWsUrl'),
Expand Down Expand Up @@ -659,6 +668,13 @@ function ensureInstances(): { store: SlackHookStore; manager: HookControlManager
// Slack 网关工具桥: lizi_slack provider 经叶子注册表取用(不直接 import
// 本模块, 避免 mcp-providers <-> ipc 的静态引用闭环)
const m = manager;
telegramDeliveryDirectory = ownerScopedUserDataPath('telegram-delivery-receipts');
telegramDelivery = createTelegramDeliveryBridge({
directory: telegramDeliveryDirectory,
status: () => m.telegramDeliveryStatus(),
send: (payload) => m.sendTelegramDelivery(payload),
});
registerTelegramDeliveryBridge(telegramDelivery);
registerSlackToolBridge({
availability: () =>
hookControlAvailable()
Expand Down Expand Up @@ -1215,6 +1231,16 @@ export async function stopHookControlAccount(): Promise<void> {
export function resetHookControlOwnerBoundary(options?: { clearPersisted?: boolean }): void {
mirrorWorkspacePrefs.invalidateOwnerBoundary();
unregisterSlackToolBridge();
registerTelegramDeliveryBridge(null);
Comment thread
FicoHub marked this conversation as resolved.
// Account deletion removes the owner's delivery journals (`.sent` keeps the actual Telegram
// text and entities). The bridge is unregistered first and journal writes are synchronous, so
// nothing recreates the directory afterwards; a late in-flight result write fails on the
// missing directory instead. Ordinary logout / quit keep the journals for idempotent replay.
const deliveryDirectory = telegramDeliveryDirectory;
telegramDeliveryDirectory = null;
void purgeTelegramDeliveryJournals(deliveryDirectory, options?.clearPersisted).catch((error: unknown) => {
log.warn(`telegram delivery journal purge failed: ${String(error)}`);
});
resetGroupContextCursorsSafely(options);
resetTelegramSpeakerRegistrationCache();
manager?.dispose();
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/src/main/hook-control/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
* 测试用内存 harness 直接驱动(规则 14); Electron 绑定在 ipc.ts 组装。
*/

import { selectTelegramDeliveryTarget, type TelegramDeliveryStatus } from './telegramDelivery.js';
import { randomUUID } from 'node:crypto';

import {
HOOK_FEATURE_GROUP_RELAY,
DEFAULT_TELEGRAM_BEHAVIOR,
HOOK_FEATURE_MESSAGE_OPS,
HOOK_FEATURE_TELEGRAM_DM_SEND,
HOOK_TELEGRAM_SEND_EPOCH_PREFIX,
HOOK_FEATURE_GROUP_RELAY_RECIPIENT,
HOOK_FEATURE_LIFECYCLE_ANNOUNCEMENT,
HOOK_FEATURE_MULTI_TEAM,
Expand Down Expand Up @@ -53,6 +56,9 @@ import {
makeQueryResponse,
makeTaskAck,
makeToolRequest,
makeMessageOp,
type MessageOpPayload,
type MessageOpResultPayload,
type BindUpdatePayload,
type GroupMessagePayload,
type HelloInput,
Expand Down Expand Up @@ -139,6 +145,8 @@ export function telegramGroupMessageOwner(
}

export interface HookControlManagerDeps {
onTelegramDeliveryResult?: (result: MessageOpResultPayload) => void;
listTelegramDeliveryKeys?: (connectionId: string) => string[];
store: SlackHookStore;
/** Runtime capability gate; false stops the transport without changing user prefs/bindings. */
isAvailable?: () => boolean;
Expand Down Expand Up @@ -218,6 +226,8 @@ export interface HookControlManagerDeps {
}

export interface HookControlManager {
telegramDeliveryStatus(): TelegramDeliveryStatus;
sendTelegramDelivery(payload: MessageOpPayload): Promise<MessageOpResultPayload | null>;
/** 按当前配置 + 登录态同步连接(启动 / 开关 / 配置变更 / 登录态变化后调用)。 */
sync(): void;
/**
Expand Down Expand Up @@ -651,6 +661,7 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont
// 只给 Telegram 声明: msg.op 目前只有 Telegram 的执行器, X 的渲染路径
// 不接入(#1855 的红线之一)。
HOOK_FEATURE_MESSAGE_OPS,
HOOK_FEATURE_TELEGRAM_DM_SEND,
// 进度消息、成功终稿、交互卡由客户端渲染并经 msg.op 发布, 命令菜单以客户端注册表
// 为准(dispatcher 的 telegramTurnCarrier / telegramCardOps / provider.commands.set)。
// 每项都要服务端同时宣告才启用; 否则那一项继续由服务端渲染。
Expand Down Expand Up @@ -903,6 +914,35 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont
return store.get().lifecycleAnnouncementOverride ?? DEFAULT_SLACK_LIFECYCLE_ANNOUNCEMENT;
}

const pendingDeliveries = new Map<string, {
resolve: (result: MessageOpResultPayload | null) => void;
timer: ReturnType<typeof setTimeout>;
}>();
function drainDeliveries(): void {
for (const pending of pendingDeliveries.values()) {
clearTimeout(pending.timer);
pending.resolve(null);
}
pendingDeliveries.clear();
}
function telegramDeliveryStatus(): TelegramDeliveryStatus {
const lane = telegramLane;
const connected = accountActive && !disposed && isAvailable() && store.get().telegramEnabled &&
lane.status === 'connected' && lane.serverWelcomeReceived && !lane.awaitingStateSnapshot &&
laneCapabilityReady(lane);
const epochs = lane.serverFeatures.filter(f => f.startsWith(HOOK_TELEGRAM_SEND_EPOCH_PREFIX));
const sendEpoch = epochs.length === 1 ? epochs[0]!.slice(HOOK_TELEGRAM_SEND_EPOCH_PREFIX.length) : undefined;
const supported = connected && lane.serverFeatures.includes(HOOK_FEATURE_MESSAGE_OPS) &&
lane.serverFeatures.includes(HOOK_FEATURE_TELEGRAM_DM_SEND) && !!sendEpoch;
const target = connected && lane.binding?.state === 'confirmed'
? selectTelegramDeliveryTarget(lane.binding, deps.listTelegramDeliveryKeys?.(dispatchId('telegram')) ?? [])
: null;
return { connected, supported, target, sendEpoch,
...(!connected ? { code: 'HOOK_NOT_CONNECTED' } : !supported ? { code: 'SERVER_TOO_OLD' }
: !target ? { code: 'OWNER_DM_TARGET_UNAVAILABLE' } : {}),
};
}

function dispatchId(provider: HookProvider): string {
const account = getAccountFingerprint?.() ?? 'no-account';
return `${id}:${account}:${provider}`;
Expand Down Expand Up @@ -2179,6 +2219,16 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont
return;
}
if (msg.type === 'msg.op.result') {
if (expectedProvider === 'telegram') {
deps.onTelegramDeliveryResult?.(msg.payload);
const pending = pendingDeliveries.get(msg.payload.opId);
if (pending) {
pendingDeliveries.delete(msg.payload.opId);
clearTimeout(pending.timer);
pending.resolve(msg.payload);
return;
}
}
// 表情回执: 纯装饰动作的结果, 失败只记一行 —— 不重试也不影响任务本身。
dispatcher?.onMessageOpResult(msg.payload);
return;
Expand Down Expand Up @@ -2630,6 +2680,7 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont
}

function stopLane(lane: NeutralProviderLane): void {
if (lane.config.provider === 'telegram') drainDeliveries();
clearLaneBindWatchdog(lane);
lane.openRequestId = null;
lane.bindRequest = null;
Expand Down Expand Up @@ -2796,6 +2847,7 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont
if (created === null || lane.transport !== created) return;
lane.status = s;
lane.lastError = err;
if (provider === 'telegram' && s !== 'connected') drainDeliveries();
if (s !== 'connected' || !laneCapabilityReady(lane)) {
dispatcher?.onDisconnected(dispatchId(provider));
drainLanePendingPrefs(lane);
Expand Down Expand Up @@ -2948,6 +3000,36 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont
notifyStatus(toView());
return true;
},
telegramDeliveryStatus,
sendTelegramDelivery(payload) {
if (pendingDeliveries.has(payload.opId)) return Promise.resolve(null);
const status = telegramDeliveryStatus();
if (!status.supported || !status.target ||
status.target.externalKey !== payload.scope.externalKey || payload.action.kind !== 'send' ||
payload.action.delivery?.epoch !== status.sendEpoch ||
payload.action.delivery?.bindingId !== status.target.bindingId) {
return Promise.resolve({ opId: payload.opId, ok: false, deliveryState: 'not_sent',
error: 'Telegram target or capability changed before transport submission' });
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
pendingDeliveries.delete(payload.opId);
resolve(null);
}, toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
pendingDeliveries.set(payload.opId, { resolve, timer });
try {
if (telegramLane.transport?.send(makeMessageOp(payload), { throwOnWriteFailure: true })) return;
clearTimeout(timer);
pendingDeliveries.delete(payload.opId);
resolve({ opId: payload.opId, ok: false, deliveryState: 'not_sent',
error: 'Telegram transport closed before submission' });
} catch { // A write was attempted: preserve unknown delivery rather than permit a retry.
clearTimeout(timer);
pendingDeliveries.delete(payload.opId);
resolve(null);
}
});
},
async setSlackCommunications(teamId, enabled) {
if (!serverFeatures.includes('slack-communications')) {
return { ok: false, error: { code: 'SERVER_TOO_OLD', message: 'Slack server does not support device communications' } };
Expand Down
Loading
Loading