Merge pull request #20 from mrpompy/local/telegram-private-allowlist

feat: connect AI Office jobs to local Harness
This commit is contained in:
xiemanR 2026-08-21 01:12:18 +08:00 • committed by GitHub
commit 7f114e1c36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2639 additions and 314 deletions

View file

@ -131,3 +131,15 @@ export function WhatsappLogoGlyph({ size } = {}) {
d: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479s1.065 2.875 1.213 3.074c.149.198 2.095 3.2 5.077 4.487.709.306 1.262.489 1.694.626.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.29.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.981.999-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.83 9.83 0 0 1 2.893 6.991c-.003 5.45-4.437 9.884-9.886 9.884m8.413-18.297A11.8 11.8 0 0 0 12.055 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.688 1.448h.005c6.557 0 11.892-5.335 11.895-11.893a11.82 11.82 0 0 0-3.486-8.413',
}));
}
export function OfficeLogoGlyph({ size } = {}) {
return h('svg', {
...dimensions(size),
viewBox: '0 0 24 24',
focusable: 'false',
'aria-hidden': 'true',
'data-im-channel-logo': 'office',
},
h('path', { fill: 'currentColor', d: 'M4 3.5h10.5a2 2 0 0 1 2 2v13H4v-15Zm2.2 3v1.8h2V6.5h-2Zm4.1 0v1.8h2V6.5h-2Zm-4.1 4v1.8h2v-1.8h-2Zm4.1 0v1.8h2v-1.8h-2ZM8.4 15v3.5h3V15h-3Z' }),
h('path', { fill: 'currentColor', d: 'M18.3 8.2h1.5v3h3v1.5h-3v3h-1.5v-3h-3v-1.5h3v-3Z' }));
}

View file

@ -0,0 +1,44 @@
import {
OFFICE_PROTOCOL_VERSION,
OFFICE_RPC_CHANNEL,
OFFICE_RPC_ENDPOINTS,
officeHookUrls,
} from '../../../../src/channels/office/protocol.mjs';
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
export function unwrapOfficeRpc(result) {
if (!record(result) || typeof result.ok !== 'boolean') throw new Error('AI Office 服务返回了无法识别的响应');
if (!result.ok) {
const error = new Error(typeof result.error?.message === 'string' ? result.error.message : 'AI Office 操作失败');
error.code = typeof result.error?.code === 'string' ? result.error.code : 'office-rpc-error';
throw error;
}
return result.value;
}
export function normalizeOfficeStatus(value) {
if (!record(value) || value.configured !== true) {
return { configured: false, connected: false, state: 'unconfigured', config: null, health: null };
}
const config = record(value.config) ? value.config : {};
return {
configured: true,
connected: value.connected === true,
state: typeof value.state === 'string' ? value.state : 'idle',
tokenConfigured: value.tokenConfigured === true,
config: {
protocolVersion: config.protocolVersion ?? OFFICE_PROTOCOL_VERSION,
baseUrl: typeof config.baseUrl === 'string' ? config.baseUrl : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
maxConcurrency: Number(config.maxConcurrency ?? 1),
heartbeatSeconds: Number(config.heartbeatSeconds ?? 30),
workspaces: record(config.workspaces) ? config.workspaces : {},
instructionPresets: record(config.instructionPresets) ? config.instructionPresets : {},
hooks: record(config.hooks) ? config.hooks : {},
},
health: record(value.health) ? value.health : null,
};
}
export { OFFICE_PROTOCOL_VERSION, OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS, officeHookUrls };

View file

@ -0,0 +1,152 @@
import * as React from 'react';
import { h } from '../../i18n.js';
import {
OFFICE_PROTOCOL_VERSION,
OFFICE_RPC_ENDPOINTS,
normalizeOfficeStatus,
officeHookUrls,
unwrapOfficeRpc,
} from './api.js';
function Button({ children, kind = 'secondary', ...props }) {
return h('button', { ...props, type: 'button', className: 'ddt-button', 'data-kind': kind }, children);
}
function mapText(value) {
return Object.entries(value ?? {}).map(([key, item]) => `${key}=${item}`).join('\n');
}
function parseMap(value, label) {
const output = {};
for (const raw of value.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const index = line.indexOf('=');
if (index < 1 || !line.slice(index + 1).trim()) {
throw new Error(label === 'Workspace 映射'
? 'Workspace 映射每行必须使用 alias=value'
: 'Instruction Preset 映射每行必须使用 alias=value');
}
output[line.slice(0, index).trim()] = line.slice(index + 1).trim();
}
return output;
}
function stateLabel(model) {
if (model.connected) return '已连接 Office';
if (!model.configured) return '尚未配置';
if (model.state === 'connecting') return '正在连接';
if (model.state === 'reconnecting') return '等待重连';
if (model.state === 'missing-token') return '凭据缺失';
return '已配置';
}
export function OfficeSettingsTab({ rpcCall, initialStatus }) {
const [model, setModel] = React.useState(normalizeOfficeStatus(initialStatus));
const [phase, setPhase] = React.useState(initialStatus === undefined ? 'loading' : 'ready');
const [busy, setBusy] = React.useState('');
const [error, setError] = React.useState('');
const [notice, setNotice] = React.useState('');
const [form, setForm] = React.useState({
baseUrl: 'https://fission.gridmind.ai', deviceId: 'local-harness', deviceToken: '',
maxConcurrency: '1', heartbeatSeconds: '30', workspaces: '', instructionPresets: '',
});
const invoke = React.useCallback(async (endpoint, payload = {}) => {
if (typeof rpcCall !== 'function') throw new Error('AI Office 设置页缺少 RPC 连接');
return unwrapOfficeRpc(await rpcCall(endpoint, payload));
}, [rpcCall]);
const adopt = React.useCallback((value) => {
const next = normalizeOfficeStatus(value?.snapshot ?? value);
setModel(next);
if (next.config) setForm((current) => ({
...current,
baseUrl: next.config.baseUrl,
deviceId: next.config.deviceId,
maxConcurrency: String(next.config.maxConcurrency),
heartbeatSeconds: String(next.config.heartbeatSeconds),
workspaces: mapText(next.config.workspaces),
instructionPresets: mapText(next.config.instructionPresets),
deviceToken: '',
}));
return next;
}, []);
const load = React.useCallback(async () => {
try { adopt(await invoke(OFFICE_RPC_ENDPOINTS.status)); setPhase('ready'); setError(''); }
catch (caught) { setPhase('error'); setError(caught.message); }
}, [adopt, invoke]);
React.useEffect(() => { void load(); }, [load]);
const run = async (name, operation) => {
setBusy(name); setError(''); setNotice('');
try { const value = await operation(); adopt(value); setNotice(name === 'test' ? '连接测试通过。' : '配置已保存。'); }
catch (caught) { setError(caught.message); }
finally { setBusy(''); }
};
const hooks = React.useMemo(() => {
try { return officeHookUrls(form.baseUrl); } catch { return {}; }
}, [form.baseUrl]);
const health = model.health ?? {};
if (phase === 'loading') return h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' }, '正在读取 AI Office Connector…');
return h('section', { className: 'dof-page', 'aria-label': 'AI Office 设置' },
h('div', { className: 'dof-hero' },
h('div', { className: 'dof-heroCopy' },
h('h3', null, 'AI Office Connector'),
h('p', null, '本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ', OFFICE_PROTOCOL_VERSION, '。')),
h('span', { className: 'dof-status', 'data-connected': String(model.connected) },
h('span', { className: 'dof-dot' }), stateLabel(model))),
model.configured ? h('div', { className: 'dof-metrics' },
h('div', { className: 'dof-metric' }, h('span', null, '最近心跳'), h('strong', null, health.lastHeartbeatAt ?? '尚无')),
h('div', { className: 'dof-metric' }, h('span', null, '最近事件'), h('strong', null, health.lastEventType ?? '尚无')),
h('div', { className: 'dof-metric' }, h('span', null, '重连次数'), h('strong', null, String(health.reconnects ?? 0))),
h('div', { className: 'dof-metric' }, h('span', null, 'Job Offer'), h('strong', null, String(health.jobsOffered ?? 0))),
h('div', { className: 'dof-metric' }, h('span', null, '运行 Job'), h('strong', null, String(health.jobs?.running ?? 0))),
h('div', { className: 'dof-metric' }, h('span', null, '完成 Job'), h('strong', null, String(health.jobs?.completed ?? 0)))) : null,
h('div', { className: 'dof-card' },
h('div', { className: 'dof-cardTitle' }, h('h4', null, '设备连接'), h('span', null, 'Token 只写入本机凭据存储')),
h('div', { className: 'dof-grid' },
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Office Base URL',
h('input', { value: form.baseUrl, placeholder: 'https://fission.gridmind.ai', onChange: (event) => setForm({ ...form, baseUrl: event.target.value }) })),
h('label', { className: 'dof-field' }, 'Device ID',
h('input', { value: form.deviceId, placeholder: 'local-harness', onChange: (event) => setForm({ ...form, deviceId: event.target.value }) })),
h('label', { className: 'dof-field' }, 'Device Token',
h('input', { type: 'password', value: form.deviceToken, placeholder: model.tokenConfigured ? '已安全保存;留空保持不变' : '粘贴 Office 一次性凭据', autoComplete: 'new-password', onChange: (event) => setForm({ ...form, deviceToken: event.target.value }) })),
h('label', { className: 'dof-field' }, '最大并发',
h('input', { type: 'number', min: 1, max: 4, value: form.maxConcurrency, onChange: (event) => setForm({ ...form, maxConcurrency: event.target.value }) })),
h('label', { className: 'dof-field' }, 'Heartbeat 秒数',
h('input', { type: 'number', min: 10, max: 300, value: form.heartbeatSeconds, onChange: (event) => setForm({ ...form, heartbeatSeconds: event.target.value }) })),
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Workspace 映射',
h('textarea', { value: form.workspaces, placeholder: 'office-project=/Users/you/projects/ai-office', onChange: (event) => setForm({ ...form, workspaces: event.target.value }) }),
h('small', null, '每行 alias=/本机/绝对路径;Office 只能看到 alias。')),
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Instruction Preset 映射',
h('textarea', { value: form.instructionPresets, placeholder: 'action-items=转换为负责人、截止和验收明确的工单', onChange: (event) => setForm({ ...form, instructionPresets: event.target.value }) }),
h('small', null, '每行 alias=指令;新增 preset 不需要改 Office 代码。'))),
error ? h('p', { className: 'dof-error', role: 'alert' }, error) : null,
notice ? h('p', { className: 'dof-notice', role: 'status' }, notice) : null,
health.error?.message ? h('p', { className: 'dof-error' }, health.error.message) : null,
h('div', { className: 'dof-actions' },
h(Button, { kind: 'primary', disabled: Boolean(busy), onClick: () => void run('save', () => invoke(OFFICE_RPC_ENDPOINTS.configure, {
baseUrl: form.baseUrl,
deviceId: form.deviceId,
...(form.deviceToken ? { deviceToken: form.deviceToken } : {}),
maxConcurrency: Number(form.maxConcurrency),
heartbeatSeconds: Number(form.heartbeatSeconds),
workspaces: parseMap(form.workspaces, 'Workspace 映射'),
instructionPresets: parseMap(form.instructionPresets, 'Instruction Preset 映射'),
})) }, busy === 'save' ? '保存中…' : '保存并连接'),
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('test', () => invoke(OFFICE_RPC_ENDPOINTS.test)) }, busy === 'test' ? '测试中…' : '测试连接'),
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('reconnect', () => invoke(OFFICE_RPC_ENDPOINTS.reconnect)) }, '重新连接'),
h(Button, { kind: 'danger', disabled: !model.configured || Boolean(busy), onClick: () => void run('remove', () => invoke(OFFICE_RPC_ENDPOINTS.remove, { confirm: true })) }, '移除连接'))),
h('div', { className: 'dof-card' },
h('div', { className: 'dof-cardTitle' }, h('h4', null, '协议 Hook 预览'), h('span', null, '由 Base URL 自动派生,不单独填写')),
h('div', { className: 'dof-hooks' },
[['SSE', hooks.stream], ['Heartbeat', hooks.heartbeat], ['Job', hooks.job], ['Result', hooks.result]].map(([label, url]) => h('div', { className: 'dof-hook', key: label }, h('strong', null, label), h('code', null, url ?? 'Base URL 无效'))))),
h('p', { className: 'dof-notice' }, 'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。'));
}

View file

@ -0,0 +1,50 @@
export const OFFICE_STYLE_ID = 'xmanrui-dsh-im-office-settings';
const CSS = `
.dof-page { --dof-accent: var(--dsw-alias-brand-primary, #3964fe); }
.dof-hero { position: relative; overflow: hidden; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; margin-bottom: 12px; padding: 18px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 16px; background: linear-gradient(135deg, color-mix(in srgb, var(--dof-accent) 9%, var(--dsw-alias-bg-layer-1, #fff)), var(--dsw-alias-bg-layer-1, #fff) 62%); }
.dof-hero::after { content: ""; position: absolute; width: 150px; height: 150px; right: -75px; top: -90px; border: 24px solid color-mix(in srgb, var(--dof-accent) 12%, transparent); border-radius: 50%; pointer-events: none; }
.dof-heroCopy { min-width: 0; }
.dof-heroCopy h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; }
.dof-heroCopy p { margin: 6px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.6; }
.dof-status { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 999px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; }
.dof-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-warn-primary, #d97706); }
.dof-status[data-connected="true"] .dof-dot { background: var(--dsw-alias-state-success-primary, #20a162); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #20a162) 14%, transparent); }
.dof-card { margin-top: 10px; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); }
.dof-cardTitle { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-bottom: 12px; }
.dof-cardTitle h4 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; }
.dof-cardTitle span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; }
.dof-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.dof-field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
.dof-field[data-wide="true"] { grid-column: 1 / -1; }
.dof-field input, .dof-field textarea { box-sizing: border-box; width: 100%; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #1f2329); font: inherit; font-size: 13px; line-height: 1.5; outline: none; }
.dof-field input { height: 38px; padding: 0 11px; }
.dof-field textarea { min-height: 86px; resize: vertical; padding: 9px 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dof-field input:focus, .dof-field textarea:focus { border-color: var(--dof-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dof-accent) 12%, transparent); }
.dof-field small { color: var(--dsw-alias-label-tertiary, #8f959e); line-height: 1.45; }
.dof-hooks { display: grid; gap: 7px; }
.dof-hook { min-width: 0; display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 10px; align-items: center; padding: 8px 10px; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dof-hook strong { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; }
.dof-hook code { overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.dof-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.dof-actions .ddt-button[data-kind="primary"] { color: #fff; border-color: var(--dof-accent); background: var(--dof-accent); }
.dof-error, .dof-notice { margin: 10px 0 0; padding: 9px 11px; border-radius: 9px; font-size: 12px; line-height: 1.5; }
.dof-error { color: var(--dsw-alias-state-error-primary, #d54941); background: var(--dsw-alias-state-error-secondary, #fff0ef); }
.dof-notice { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dof-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.dof-metric { min-width: 0; padding: 9px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dof-metric span { display: block; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; }
.dof-metric strong { display: block; overflow: hidden; margin-top: 4px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@container (max-width: 680px) { .dof-grid { grid-template-columns: minmax(0, 1fr); } .dof-field[data-wide="true"] { grid-column: auto; } .dof-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (prefers-reduced-motion: reduce) { .dof-page * { transition: none !important; } }
`;
export function installOfficeStyles() {
if (typeof document === 'undefined') return () => {};
if (document.querySelector(`style[data-plugin-css="${OFFICE_STYLE_ID}"]`)) return () => {};
const style = document.createElement('style');
style.dataset.pluginCss = OFFICE_STYLE_ID;
style.textContent = CSS;
document.head.append(style);
return () => style.remove();
}

View file

@ -8,6 +8,51 @@ const EN = Object.freeze({
'IM机器人设置': 'IM bot settings',
'IM 渠道': 'IM channels',
'让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach',
'AI Office': 'AI Office',
'AI Office 设置': 'AI Office settings',
'AI Office 设置页缺少 RPC 连接': 'AI Office settings are missing an RPC connection',
'正在读取 AI Office Connector…': 'Loading AI Office Connector…',
'本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ': 'This machine connects outward to the public Office; Harness exposes no port. Protocol hooks: ',
'尚未配置': 'Not configured',
'已连接 Office': 'Connected to Office',
'已配置': 'Configured',
'等待重连': 'Waiting to reconnect',
'凭据缺失': 'Credential missing',
'最近心跳': 'Last heartbeat',
'最近事件': 'Last event',
'重连次数': 'Reconnects',
'运行 Job': 'Running Jobs',
'完成 Job': 'Completed Jobs',
'尚无': 'None yet',
'设备连接': 'Device connection',
'Token 只写入本机凭据存储': 'Token is written only to the local credential store',
'粘贴 Office 一次性凭据': 'Paste the one-time Office credential',
'已安全保存;留空保持不变': 'Stored securely; leave blank to keep it',
'最大并发': 'Max concurrency',
'Heartbeat 秒数': 'Heartbeat seconds',
'Workspace 映射': 'Workspace mappings',
'每行 alias=/本机/绝对路径;Office 只能看到 alias。': 'One alias=/local/absolute/path per line; Office sees only aliases.',
'Instruction Preset 映射': 'Instruction preset mappings',
'每行 alias=指令;新增 preset 不需要改 Office 代码。': 'One alias=instruction per line; new presets require no Office code change.',
'保存并连接': 'Save and connect',
'测试连接': 'Test connection',
'测试中…': 'Testing…',
'重新连接': 'Reconnect',
'移除连接': 'Remove connection',
'连接测试通过。': 'Connection test passed.',
'配置已保存。': 'Configuration saved.',
'协议 Hook 预览': 'Protocol hook preview',
'由 Base URL 自动派生,不单独填写': 'Derived from Base URL; no separate input',
'Base URL 无效': 'Invalid Base URL',
'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。': 'Configuration is saved and retried while Office hooks are unavailable; HTTP 404 means the protocol endpoint is pending, not a Harness failure.',
'Workspace 映射每行必须使用 alias=value': 'Each workspace mapping must use alias=value',
'Instruction Preset 映射每行必须使用 alias=value': 'Each instruction preset mapping must use alias=value',
'action-items=转换为负责人、截止和验收明确的工单': 'action-items=Turn this into accountable tasks with deadlines and acceptance criteria',
'AI Office 拒绝了 Device Token。': 'AI Office rejected the Device Token.',
'AI Office Connector Hook 尚未就绪。': 'AI Office Connector hooks are not available yet.',
'AI Office Connector 协议版本不兼容。': 'The AI Office Connector protocol is incompatible.',
'本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
'微信': 'WeChat',
'飞书': 'Feishu',

View file

@ -4,6 +4,7 @@ import {
DingtalkLogoGlyph,
DiscordLogoGlyph,
FeishuLogoGlyph,
OfficeLogoGlyph,
QqLogoGlyph,
SlackLogoGlyph,
TelegramLogoGlyph,
@ -22,6 +23,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
import { QqSettingsTab } from './channels/qq/index.js';
import { installQqStyles } from './channels/qq/styles.js';
import { OFFICE_RPC_CHANNEL } from './channels/office/api.js';
import { OfficeSettingsTab } from './channels/office/index.js';
import { installOfficeStyles } from './channels/office/styles.js';
import { SLACK_RPC_CHANNEL } from './channels/slack/api.js';
import { SlackSettingsTab } from './channels/slack/index.js';
import { installSlackStyles } from './channels/slack/styles.js';
@ -54,6 +58,7 @@ const CHANNELS = Object.freeze([
{ id: 'telegram', label: 'Telegram' },
{ id: 'discord', label: 'Discord' },
{ id: 'whatsapp', label: 'WhatsApp' },
{ id: 'office', label: 'AI Office' },
]);
function WeixinLogo() {
@ -99,6 +104,11 @@ function WhatsappLogo() {
h(WhatsappLogoGlyph));
}
function OfficeLogo() {
return h('span', { className: 'dim-logo dim-logoOffice', 'aria-hidden': 'true' },
h(OfficeLogoGlyph));
}
function ChannelLogo({ channel }) {
if (channel === 'weixin') return h(WeixinLogo);
if (channel === 'feishu') return h(FeishuLogo);
@ -108,7 +118,8 @@ function ChannelLogo({ channel }) {
if (channel === 'slack') return h(SlackLogo);
if (channel === 'telegram') return h(TelegramLogo);
if (channel === 'discord') return h(DiscordLogo);
return h(WhatsappLogo);
if (channel === 'whatsapp') return h(WhatsappLogo);
return h(OfficeLogo);
}
export function IMSettingsTab({
@ -121,6 +132,7 @@ export function IMSettingsTab({
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
workspaceDirectoryPicker,
}) {
const [selected, setSelected] = React.useState('weixin');
@ -187,7 +199,9 @@ export function IMSettingsTab({
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
: active.id === 'discord'
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
: h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
: active.id === 'whatsapp'
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
),
));
}
@ -210,6 +224,7 @@ export function apply(ctx) {
installTelegramStyles(),
installDiscordStyles(),
installWhatsappStyles(),
installOfficeStyles(),
installImStyles(),
];
return () => {
@ -235,6 +250,8 @@ export function apply(ctx) {
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
const slackRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
const officeRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
const workspaceDirectoryPicker = Object.freeze({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
pickDirectory: () => ctx.workspaces.pickDirectory(),
@ -256,6 +273,7 @@ export function apply(ctx) {
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
workspaceDirectoryPicker,
}),
}, IMSettingsTab));

View file

@ -44,6 +44,8 @@ const CSS = String.raw`
.dim-logoWecom svg { width: 22px; height: 22px; }
.dim-logoTelegram { color: white; background: #229ed9; }
.dim-logoTelegram svg { width: 21px; height: 21px; }
.dim-logoOffice { color: white; background: linear-gradient(145deg, #12213f, #3964fe); }
.dim-logoOffice svg { width: 23px; height: 23px; }
.dim-logoDiscord { color: white; background: #5865f2; }
.dim-logoDiscord svg { width: 21px; height: 21px; }
.dim-logoSlack { color: white; background: #4a154b; }

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,45 @@
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';
import { HarnessClient } from '../../../../src/channels/shared/harness-client.mjs';
import { harnessOrigin } from '../shared/production.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 ResolvedHarness = internals.HarnessClient ?? HarnessClient;
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 harnessBaseUrl = harnessOrigin(ctx.webServer, config.harnessBaseUrl);
const createHarness = internals.createHarness ?? (({ workspace }) => new ResolvedHarness({
baseUrl: harnessBaseUrl,
workspace,
autostart: false,
dshBin: config.dshBin ?? 'dsh',
rpcIdPrefix: 'office',
logPrefix: 'dsh-im:office',
}));
const controller = new Controller({
credentials: ctx.credentials,
configStore,
logger,
createRuntime: (options) => new Runtime({
...options,
createHarness,
...(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'));
},
});
}