feat: secure Telegram access and connect AI Office

This commit is contained in:
pompy 2026-08-20 11:56:53 +08:00
parent e3ae772106
commit b0606159e0
30 changed files with 2045 additions and 302 deletions

View file

@ -0,0 +1,10 @@
import { createProductionController } from './production.mjs';
import { installOfficeRpc } from './rpc.mjs';
export async function apply(ctx, config = {}) {
if (config.controller) return installOfficeRpc(ctx, config.controller, config.rpcAuthority);
const production = await createProductionController(ctx, config, config.internals ?? {});
const dispose = installOfficeRpc(ctx, production.controller, config.rpcAuthority);
ctx.effect(() => async () => production.close(), 'dsh-im: close AI Office connector');
return dispose;
}

View file

@ -0,0 +1,29 @@
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { OfficeConfigStore } from '../../../../src/channels/office/config-store.mjs';
import { OfficeController } from '../../../../src/channels/office/office-controller.mjs';
import { OfficeRuntime } from '../../../../src/channels/office/office-runtime.mjs';
export function officePaths(config = {}) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-office'));
return { root, config: resolve(config.configPath ?? join(root, 'config.json')) };
}
export async function createProductionController(ctx, config = {}, internals = {}) {
const Store = internals.ConfigStore ?? OfficeConfigStore;
const Controller = internals.Controller ?? OfficeController;
const Runtime = internals.Runtime ?? OfficeRuntime;
const paths = officePaths(config);
const configStore = await new Store(paths.config).load();
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:office') : (ctx.logger ?? console);
const controller = new Controller({
credentials: ctx.credentials,
configStore,
logger,
createRuntime: (options) => new Runtime({ ...options, ...(internals.transport ? { transport: internals.transport } : {}) }),
});
await controller.initialize();
return { controller, close: () => controller.close(), paths };
}

View file

@ -0,0 +1,48 @@
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS } from '../../../../src/channels/office/protocol.mjs';
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
function exact(value, keys) { return record(value) && Object.keys(value).every((key) => keys.includes(key)); }
function validConfigure(payload) {
return exact(payload, [
'baseUrl', 'deviceId', 'deviceToken', 'maxConcurrency', 'heartbeatSeconds',
'workspaces', 'instructionPresets',
]) && typeof payload.baseUrl === 'string' && typeof payload.deviceId === 'string'
&& (payload.deviceToken === undefined || typeof payload.deviceToken === 'string')
&& record(payload.workspaces) && record(payload.instructionPresets);
}
export function createOfficeRpcHandler(controller) {
for (const method of ['status', 'configure', 'reconnect', 'test', 'remove']) {
if (typeof controller?.[method] !== 'function') throw new TypeError(`AI Office controller requires ${method}()`);
}
return async (endpoint, payload, signal) => {
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
try {
let value;
if (endpoint === OFFICE_RPC_ENDPOINTS.status && exact(payload, [])) value = await controller.status();
else if (endpoint === OFFICE_RPC_ENDPOINTS.configure && validConfigure(payload)) value = await controller.configure(payload);
else if (endpoint === OFFICE_RPC_ENDPOINTS.reconnect && exact(payload, [])) value = await controller.reconnect();
else if (endpoint === OFFICE_RPC_ENDPOINTS.test && exact(payload, [])) value = await controller.test();
else if (endpoint === OFFICE_RPC_ENDPOINTS.remove && exact(payload, ['confirm']) && payload.confirm === true) value = await controller.remove();
else return { ok: false, error: { code: 'bad-request', message: 'Invalid AI Office connector request.' } };
return { ok: true, value };
} catch (error) {
const code = error?.code === 'invalid-device-token' ? 'invalid-device-token'
: error?.code === 'office-hook-unavailable' ? 'office-hook-unavailable' : 'office-operation-failed';
const message = code === 'invalid-device-token' ? 'AI Office Device Token 无效。'
: code === 'office-hook-unavailable' ? 'AI Office Hook 尚未上线或地址不正确。'
: error instanceof TypeError ? error.message : 'AI Office 连接操作失败,请稍后重试。';
return { ok: false, error: { code, message } };
}
};
}
export function installOfficeRpc(ctx, controller, authority) {
return ctx.connection.rpc.handle(
OFFICE_RPC_CHANNEL,
createOfficeRpcHandler(controller),
{ authority: resolveRpcAuthority(authority) },
);
}

View file

@ -32,7 +32,9 @@ export function pluginPaths(config, channel) {
}
export async function createTokenProductionController(ctx, config, internals, definitions) {
const { channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime } = definitions;
const {
channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
} = definitions;
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
@ -41,6 +43,11 @@ export async function createTokenProductionController(ctx, config, internals, de
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
const ResolvedController = internals.Controller ?? Controller;
const ResolvedRuntime = internals.Runtime ?? Runtime;
const channelRuntimeOptions = typeof runtimeOptions === 'function' ? runtimeOptions(config) : {};
if (!channelRuntimeOptions || typeof channelRuntimeOptions !== 'object'
|| Array.isArray(channelRuntimeOptions)) {
throw new TypeError(`dsh-im ${channel} runtimeOptions must return an object`);
}
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
const logger = typeof ctx.logger === 'function'
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
@ -91,6 +98,7 @@ export async function createTokenProductionController(ctx, config, internals, de
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new ResolvedRuntime({
...channelRuntimeOptions,
config: botConfig,
token,
harness: workspaceScope.harness,

View file

@ -5,6 +5,24 @@ import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runt
import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
import { createTokenProductionController } from '../shared/production.mjs';
const TELEGRAM_USER_ID = /^[1-9]\d{0,15}$/;
export function normalizeTelegramAllowedUsers(value) {
if (value === undefined) return Object.freeze([]);
if (!Array.isArray(value)) {
throw new TypeError('telegram.allowedUsers must be an array of numeric Telegram User IDs');
}
const normalized = value.map((entry) => {
const userId = typeof entry === 'number' && Number.isSafeInteger(entry)
? String(entry) : typeof entry === 'string' ? entry.trim() : '';
if (!TELEGRAM_USER_ID.test(userId)) {
throw new TypeError('telegram.allowedUsers contains an invalid Telegram User ID');
}
return userId;
});
return Object.freeze([...new Set(normalized)]);
}
export function createProductionController(ctx, config = {}, internals = {}) {
return createTokenProductionController(ctx, config, internals, {
channel: 'telegram',
@ -13,5 +31,8 @@ export function createProductionController(ctx, config = {}, internals = {}) {
HarnessClient: TelegramHarnessClient,
Controller: TelegramController,
Runtime: TelegramRuntime,
runtimeOptions: (runtimeConfig) => ({
allowedPrivateUserIds: normalizeTelegramAllowedUsers(runtimeConfig.allowedUsers),
}),
});
}

View file

@ -1,5 +1,6 @@
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
import { apply as applyDiscord } from './channels/discord/index.mjs';
import { apply as applyOffice } from './channels/office/index.mjs';
import { apply as applyFeishu } from './channels/feishu/index.mjs';
import { apply as applyQq } from './channels/qq/index.mjs';
import { apply as applySlack } from './channels/slack/index.mjs';
@ -27,6 +28,7 @@ export function createImHostPlugin(internals = {}) {
const startSlack = internals.applySlack ?? applySlack;
const startTelegram = internals.applyTelegram ?? applyTelegram;
const startDiscord = internals.applyDiscord ?? applyDiscord;
const startOffice = internals.applyOffice ?? applyOffice;
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
return Object.freeze({
name,
@ -41,6 +43,7 @@ export function createImHostPlugin(internals = {}) {
await startTelegram(ctx, channelConfig(config, 'telegram'));
await startDiscord(ctx, channelConfig(config, 'discord'));
await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
await startOffice(ctx, channelConfig(config, 'office'));
},
});
}