@xmanrui/dsh-im 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.en.md +25 -10
  2. package/README.md +26 -11
  3. package/assets/logo-dsh-im-connecting-readme-3x2.png +0 -0
  4. package/assets/logo-dsh-im-connecting-square-v4.png +0 -0
  5. package/assets/logo-plugin-phone.png +0 -0
  6. package/lib/client.js +803 -171
  7. package/lib/index.js +142 -130
  8. package/package.json +4 -3
  9. package/plugin-src/client/build.mjs +1 -6
  10. package/plugin-src/client/channel-logos.js +12 -0
  11. package/plugin-src/client/channels/office/api.js +44 -0
  12. package/plugin-src/client/channels/office/index.js +152 -0
  13. package/plugin-src/client/channels/office/styles.js +50 -0
  14. package/plugin-src/client/channels/shared/token-api.js +5 -1
  15. package/plugin-src/client/channels/shared/token-channel.js +16 -1
  16. package/plugin-src/client/channels/telegram/api.js +17 -2
  17. package/plugin-src/client/channels/telegram/index.js +112 -0
  18. package/plugin-src/client/channels/telegram/styles.js +26 -0
  19. package/plugin-src/client/i18n.js +68 -0
  20. package/plugin-src/client/index.js +21 -11
  21. package/plugin-src/client/styles.js +5 -3
  22. package/plugin-src/host/channels/office/index.mjs +10 -0
  23. package/plugin-src/host/channels/office/production.mjs +45 -0
  24. package/plugin-src/host/channels/office/rpc.mjs +48 -0
  25. package/plugin-src/host/channels/shared/production.mjs +9 -1
  26. package/plugin-src/host/channels/telegram/production.mjs +2 -0
  27. package/plugin-src/host/channels/telegram/rpc.mjs +58 -8
  28. package/plugin-src/host/index.mjs +3 -0
  29. package/src/channels/discord/discord-api.mjs +1 -1
  30. package/src/channels/office/config-store.mjs +107 -0
  31. package/src/channels/office/office-controller.mjs +167 -0
  32. package/src/channels/office/office-job-executor.mjs +390 -0
  33. package/src/channels/office/office-runtime.mjs +171 -0
  34. package/src/channels/office/office-transport.mjs +170 -0
  35. package/src/channels/office/protocol.mjs +43 -0
  36. package/src/channels/shared/token-bot-controller.mjs +28 -0
  37. package/src/channels/shared/token-config-store.mjs +14 -1
  38. package/src/channels/telegram/config-store.mjs +62 -1
  39. package/src/channels/telegram/telegram-controller.mjs +25 -1
  40. package/src/channels/telegram/telegram-runtime.mjs +26 -1
  41. package/src/channels/weixin/weixin-api.mjs +1 -1
@@ -8,6 +8,51 @@ const EN = Object.freeze({
8
8
  'IM机器人设置': 'IM bot settings',
9
9
  'IM 渠道': 'IM channels',
10
10
  '让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach',
11
+ 'AI Office': 'AI Office',
12
+ 'AI Office 设置': 'AI Office settings',
13
+ 'AI Office 设置页缺少 RPC 连接': 'AI Office settings are missing an RPC connection',
14
+ '正在读取 AI Office Connector…': 'Loading AI Office Connector…',
15
+ '本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ': 'This machine connects outward to the public Office; Harness exposes no port. Protocol hooks: ',
16
+ '尚未配置': 'Not configured',
17
+ '已连接 Office': 'Connected to Office',
18
+ '已配置': 'Configured',
19
+ '等待重连': 'Waiting to reconnect',
20
+ '凭据缺失': 'Credential missing',
21
+ '最近心跳': 'Last heartbeat',
22
+ '最近事件': 'Last event',
23
+ '重连次数': 'Reconnects',
24
+ '运行 Job': 'Running Jobs',
25
+ '完成 Job': 'Completed Jobs',
26
+ '尚无': 'None yet',
27
+ '设备连接': 'Device connection',
28
+ 'Token 只写入本机凭据存储': 'Token is written only to the local credential store',
29
+ '粘贴 Office 一次性凭据': 'Paste the one-time Office credential',
30
+ '已安全保存;留空保持不变': 'Stored securely; leave blank to keep it',
31
+ '最大并发': 'Max concurrency',
32
+ 'Heartbeat 秒数': 'Heartbeat seconds',
33
+ 'Workspace 映射': 'Workspace mappings',
34
+ '每行 alias=/本机/绝对路径;Office 只能看到 alias。': 'One alias=/local/absolute/path per line; Office sees only aliases.',
35
+ 'Instruction Preset 映射': 'Instruction preset mappings',
36
+ '每行 alias=指令;新增 preset 不需要改 Office 代码。': 'One alias=instruction per line; new presets require no Office code change.',
37
+ '保存并连接': 'Save and connect',
38
+ '测试连接': 'Test connection',
39
+ '测试中…': 'Testing…',
40
+ '重新连接': 'Reconnect',
41
+ '移除连接': 'Remove connection',
42
+ '连接测试通过。': 'Connection test passed.',
43
+ '配置已保存。': 'Configuration saved.',
44
+ '协议 Hook 预览': 'Protocol hook preview',
45
+ '由 Base URL 自动派生,不单独填写': 'Derived from Base URL; no separate input',
46
+ 'Base URL 无效': 'Invalid Base URL',
47
+ '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.',
48
+ 'Workspace 映射每行必须使用 alias=value': 'Each workspace mapping must use alias=value',
49
+ 'Instruction Preset 映射每行必须使用 alias=value': 'Each instruction preset mapping must use alias=value',
50
+ 'action-items=转换为负责人、截止和验收明确的工单': 'action-items=Turn this into accountable tasks with deadlines and acceptance criteria',
51
+ 'AI Office 拒绝了 Device Token。': 'AI Office rejected the Device Token.',
52
+ 'AI Office Connector Hook 尚未就绪。': 'AI Office Connector hooks are not available yet.',
53
+ 'AI Office Connector 协议版本不兼容。': 'The AI Office Connector protocol is incompatible.',
54
+ '本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
55
+ 'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
11
56
  '帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
12
57
  '微信': 'WeChat',
13
58
  '飞书': 'Feishu',
@@ -236,6 +281,29 @@ const EN = Object.freeze({
236
281
  '接入 Telegram 机器人': 'Connect a Telegram bot',
237
282
  '先通过 @BotFather 获取 Bot Token,再在这里完成接入。': 'Get a Bot Token from @BotFather, then connect it here.',
238
283
  '填写 @BotFather 生成的 Bot Token': 'Enter the Bot Token from @BotFather',
284
+ '访问模式': 'Access mode',
285
+ '访问设置': 'Access settings',
286
+ 'Telegram 访问模式': 'Telegram access mode',
287
+ '查看 Telegram 访问模式说明': 'View Telegram access mode details',
288
+ '群聊全部忽略,私聊仅允许白名单用户。': 'All group messages are ignored; only allowlisted users may send DMs.',
289
+ '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。': 'Keep the original behavior: respond to DMs and to group mentions or replies.',
290
+ '安全模式': 'Safe mode',
291
+ '兼容模式': 'Compatible mode',
292
+ '已生效:安全模式': 'Active: Safe mode',
293
+ '已生效:兼容模式': 'Active: Compatible mode',
294
+ '模式': 'Mode',
295
+ '兼容模式(默认)': 'Compatible mode (default)',
296
+ '安全模式(私聊白名单)': 'Safe mode (private-chat allowlist)',
297
+ '允许私聊的 Telegram User ID': 'Telegram User IDs allowed to send DMs',
298
+ '每行一个数字 User ID': 'One numeric User ID per line',
299
+ '白名单仅属于当前机器人。': 'This allowlist belongs only to the current bot.',
300
+ '兼容模式下暂不使用白名单,切换模式时会保留。': 'Compatible mode does not enforce the allowlist; it is retained when modes change.',
301
+ '白名单为空;保存后该机器人会拒绝所有入站消息。': 'The allowlist is empty; this bot will reject all inbound messages after saving.',
302
+ '正在保存…': 'Saving…',
303
+ '保存访问设置': 'Save access settings',
304
+ 'User ID 必须是 1–16 位正整数,每行一个。': 'Each User ID must be a 1–16 digit positive integer on its own line.',
305
+ 'Telegram 访问设置暂不可用。': 'Telegram access settings are currently unavailable.',
306
+ 'Telegram 访问设置保存失败。': 'Could not save Telegram access settings.',
239
307
  '接入 Discord 机器人': 'Connect a Discord bot',
240
308
  '先在 Developer Portal 创建 Bot 并邀请到服务器,再在这里完成接入。': 'Create a bot in the Developer Portal and invite it to your server, then connect it here.',
241
309
  '填写 Discord Developer Portal 的 Bot Token': 'Enter the Bot Token from the Discord Developer Portal',
@@ -4,6 +4,7 @@ import {
4
4
  DingtalkLogoGlyph,
5
5
  DiscordLogoGlyph,
6
6
  FeishuLogoGlyph,
7
+ OfficeLogoGlyph,
7
8
  QqLogoGlyph,
8
9
  SlackLogoGlyph,
9
10
  TelegramLogoGlyph,
@@ -22,6 +23,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
22
23
  import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
23
24
  import { QqSettingsTab } from './channels/qq/index.js';
24
25
  import { installQqStyles } from './channels/qq/styles.js';
26
+ import { OFFICE_RPC_CHANNEL } from './channels/office/api.js';
27
+ import { OfficeSettingsTab } from './channels/office/index.js';
28
+ import { installOfficeStyles } from './channels/office/styles.js';
25
29
  import { SLACK_RPC_CHANNEL } from './channels/slack/api.js';
26
30
  import { SlackSettingsTab } from './channels/slack/index.js';
27
31
  import { installSlackStyles } from './channels/slack/styles.js';
@@ -44,8 +48,6 @@ import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
44
48
  export const name = 'im-settings';
45
49
  export const inject = ['slots', 'connection', 'locale', 'workspaces'];
46
50
 
47
- const IM_PLUGIN_LOGO_URL = globalThis.__DSH_IM_LOGO_DATA_URL__ ?? 'assets/logo-icon.png';
48
-
49
51
  const CHANNELS = Object.freeze([
50
52
  { id: 'weixin', label: '微信' },
51
53
  { id: 'feishu', label: '飞书' },
@@ -56,6 +58,7 @@ const CHANNELS = Object.freeze([
56
58
  { id: 'telegram', label: 'Telegram' },
57
59
  { id: 'discord', label: 'Discord' },
58
60
  { id: 'whatsapp', label: 'WhatsApp' },
61
+ { id: 'office', label: 'AI Office' },
59
62
  ]);
60
63
 
61
64
  function WeixinLogo() {
@@ -101,6 +104,11 @@ function WhatsappLogo() {
101
104
  h(WhatsappLogoGlyph));
102
105
  }
103
106
 
107
+ function OfficeLogo() {
108
+ return h('span', { className: 'dim-logo dim-logoOffice', 'aria-hidden': 'true' },
109
+ h(OfficeLogoGlyph));
110
+ }
111
+
104
112
  function ChannelLogo({ channel }) {
105
113
  if (channel === 'weixin') return h(WeixinLogo);
106
114
  if (channel === 'feishu') return h(FeishuLogo);
@@ -110,7 +118,8 @@ function ChannelLogo({ channel }) {
110
118
  if (channel === 'slack') return h(SlackLogo);
111
119
  if (channel === 'telegram') return h(TelegramLogo);
112
120
  if (channel === 'discord') return h(DiscordLogo);
113
- return h(WhatsappLogo);
121
+ if (channel === 'whatsapp') return h(WhatsappLogo);
122
+ return h(OfficeLogo);
114
123
  }
115
124
 
116
125
  export function IMSettingsTab({
@@ -123,6 +132,7 @@ export function IMSettingsTab({
123
132
  wecomRpcCall,
124
133
  weixinRpcCall,
125
134
  whatsappRpcCall,
135
+ officeRpcCall,
126
136
  workspaceDirectoryPicker,
127
137
  }) {
128
138
  const [selected, setSelected] = React.useState('weixin');
@@ -132,13 +142,7 @@ export function IMSettingsTab({
132
142
  h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
133
143
  h('header', { className: 'dim-title' },
134
144
  h('div', { className: 'dim-brand' },
135
- h('img', {
136
- className: 'dim-brandLogo',
137
- src: IM_PLUGIN_LOGO_URL,
138
- alt: 'dsh-im',
139
- width: 48,
140
- height: 48,
141
- }),
145
+ h('strong', { className: 'dim-brandName' }, 'DSH-IM'),
142
146
  h('p', null, '让 DeepSeek Harness 触手可及')),
143
147
  h('span', { className: 'dim-githubAction' },
144
148
  h('a', {
@@ -195,7 +199,9 @@ export function IMSettingsTab({
195
199
  ? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
196
200
  : active.id === 'discord'
197
201
  ? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
198
- : h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
202
+ : active.id === 'whatsapp'
203
+ ? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
204
+ : h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
199
205
  ),
200
206
  ));
201
207
  }
@@ -218,6 +224,7 @@ export function apply(ctx) {
218
224
  installTelegramStyles(),
219
225
  installDiscordStyles(),
220
226
  installWhatsappStyles(),
227
+ installOfficeStyles(),
221
228
  installImStyles(),
222
229
  ];
223
230
  return () => {
@@ -243,6 +250,8 @@ export function apply(ctx) {
243
250
  ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
244
251
  const slackRpcCall = (endpoint, payload, signal) =>
245
252
  ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
253
+ const officeRpcCall = (endpoint, payload, signal) =>
254
+ ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
246
255
  const workspaceDirectoryPicker = Object.freeze({
247
256
  listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
248
257
  pickDirectory: () => ctx.workspaces.pickDirectory(),
@@ -264,6 +273,7 @@ export function apply(ctx) {
264
273
  wecomRpcCall,
265
274
  weixinRpcCall,
266
275
  whatsappRpcCall,
276
+ officeRpcCall,
267
277
  workspaceDirectoryPicker,
268
278
  }),
269
279
  }, IMSettingsTab));
@@ -12,9 +12,9 @@ const CSS = String.raw`
12
12
  }
13
13
  .dim-page *, .dim-page *::before, .dim-page *::after { box-sizing: border-box; }
14
14
  .dim-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 18px; }
15
- .dim-brand { min-width: 0; display: flex; align-items: center; gap: 12px; }
16
- .dim-brandLogo { width: 48px; height: 48px; flex: 0 0 48px; display: block; object-fit: contain; filter: grayscale(1) contrast(1.08); }
17
- .dim-title p { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: 20px; font-weight: 600; white-space: nowrap; }
15
+ .dim-brand { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
16
+ .dim-brandName { color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 24px; font-weight: 800; letter-spacing: .04em; }
17
+ .dim-title p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; font-weight: 500; white-space: nowrap; }
18
18
  .dim-githubAction { position: relative; display: inline-flex; flex: none; }
19
19
  .dim-githubLink { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 12px; line-height: normal; font-weight: 560; text-decoration: none; transition: border-color .15s ease, color .15s ease, background .15s ease; }
20
20
  .dim-githubLink:hover { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
@@ -44,6 +44,8 @@ const CSS = String.raw`
44
44
  .dim-logoWecom svg { width: 22px; height: 22px; }
45
45
  .dim-logoTelegram { color: white; background: #229ed9; }
46
46
  .dim-logoTelegram svg { width: 21px; height: 21px; }
47
+ .dim-logoOffice { color: white; background: linear-gradient(145deg, #12213f, #3964fe); }
48
+ .dim-logoOffice svg { width: 23px; height: 23px; }
47
49
  .dim-logoDiscord { color: white; background: #5865f2; }
48
50
  .dim-logoDiscord svg { width: 21px; height: 21px; }
49
51
  .dim-logoSlack { color: white; background: #4a154b; }
@@ -0,0 +1,10 @@
1
+ import { createProductionController } from './production.mjs';
2
+ import { installOfficeRpc } from './rpc.mjs';
3
+
4
+ export async function apply(ctx, config = {}) {
5
+ if (config.controller) return installOfficeRpc(ctx, config.controller, config.rpcAuthority);
6
+ const production = await createProductionController(ctx, config, config.internals ?? {});
7
+ const dispose = installOfficeRpc(ctx, production.controller, config.rpcAuthority);
8
+ ctx.effect(() => async () => production.close(), 'dsh-im: close AI Office connector');
9
+ return dispose;
10
+ }
@@ -0,0 +1,45 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ import { OfficeConfigStore } from '../../../../src/channels/office/config-store.mjs';
5
+ import { OfficeController } from '../../../../src/channels/office/office-controller.mjs';
6
+ import { OfficeRuntime } from '../../../../src/channels/office/office-runtime.mjs';
7
+ import { HarnessClient } from '../../../../src/channels/shared/harness-client.mjs';
8
+ import { harnessOrigin } from '../shared/production.mjs';
9
+
10
+ export function officePaths(config = {}) {
11
+ const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
12
+ const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-office'));
13
+ return { root, config: resolve(config.configPath ?? join(root, 'config.json')) };
14
+ }
15
+
16
+ export async function createProductionController(ctx, config = {}, internals = {}) {
17
+ const Store = internals.ConfigStore ?? OfficeConfigStore;
18
+ const Controller = internals.Controller ?? OfficeController;
19
+ const Runtime = internals.Runtime ?? OfficeRuntime;
20
+ const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
21
+ const paths = officePaths(config);
22
+ const configStore = await new Store(paths.config).load();
23
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:office') : (ctx.logger ?? console);
24
+ const harnessBaseUrl = harnessOrigin(ctx.webServer, config.harnessBaseUrl);
25
+ const createHarness = internals.createHarness ?? (({ workspace }) => new ResolvedHarness({
26
+ baseUrl: harnessBaseUrl,
27
+ workspace,
28
+ autostart: false,
29
+ dshBin: config.dshBin ?? 'dsh',
30
+ rpcIdPrefix: 'office',
31
+ logPrefix: 'dsh-im:office',
32
+ }));
33
+ const controller = new Controller({
34
+ credentials: ctx.credentials,
35
+ configStore,
36
+ logger,
37
+ createRuntime: (options) => new Runtime({
38
+ ...options,
39
+ createHarness,
40
+ ...(internals.transport ? { transport: internals.transport } : {}),
41
+ }),
42
+ });
43
+ await controller.initialize();
44
+ return { controller, close: () => controller.close(), paths };
45
+ }
@@ -0,0 +1,48 @@
1
+ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
2
+ import { OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS } from '../../../../src/channels/office/protocol.mjs';
3
+
4
+ function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
5
+ function exact(value, keys) { return record(value) && Object.keys(value).every((key) => keys.includes(key)); }
6
+
7
+ function validConfigure(payload) {
8
+ return exact(payload, [
9
+ 'baseUrl', 'deviceId', 'deviceToken', 'maxConcurrency', 'heartbeatSeconds',
10
+ 'workspaces', 'instructionPresets',
11
+ ]) && typeof payload.baseUrl === 'string' && typeof payload.deviceId === 'string'
12
+ && (payload.deviceToken === undefined || typeof payload.deviceToken === 'string')
13
+ && record(payload.workspaces) && record(payload.instructionPresets);
14
+ }
15
+
16
+ export function createOfficeRpcHandler(controller) {
17
+ for (const method of ['status', 'configure', 'reconnect', 'test', 'remove']) {
18
+ if (typeof controller?.[method] !== 'function') throw new TypeError(`AI Office controller requires ${method}()`);
19
+ }
20
+ return async (endpoint, payload, signal) => {
21
+ if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
22
+ try {
23
+ let value;
24
+ if (endpoint === OFFICE_RPC_ENDPOINTS.status && exact(payload, [])) value = await controller.status();
25
+ else if (endpoint === OFFICE_RPC_ENDPOINTS.configure && validConfigure(payload)) value = await controller.configure(payload);
26
+ else if (endpoint === OFFICE_RPC_ENDPOINTS.reconnect && exact(payload, [])) value = await controller.reconnect();
27
+ else if (endpoint === OFFICE_RPC_ENDPOINTS.test && exact(payload, [])) value = await controller.test();
28
+ else if (endpoint === OFFICE_RPC_ENDPOINTS.remove && exact(payload, ['confirm']) && payload.confirm === true) value = await controller.remove();
29
+ else return { ok: false, error: { code: 'bad-request', message: 'Invalid AI Office connector request.' } };
30
+ return { ok: true, value };
31
+ } catch (error) {
32
+ const code = error?.code === 'invalid-device-token' ? 'invalid-device-token'
33
+ : error?.code === 'office-hook-unavailable' ? 'office-hook-unavailable' : 'office-operation-failed';
34
+ const message = code === 'invalid-device-token' ? 'AI Office Device Token 无效。'
35
+ : code === 'office-hook-unavailable' ? 'AI Office Hook 尚未上线或地址不正确。'
36
+ : error instanceof TypeError ? error.message : 'AI Office 连接操作失败,请稍后重试。';
37
+ return { ok: false, error: { code, message } };
38
+ }
39
+ };
40
+ }
41
+
42
+ export function installOfficeRpc(ctx, controller, authority) {
43
+ return ctx.connection.rpc.handle(
44
+ OFFICE_RPC_CHANNEL,
45
+ createOfficeRpcHandler(controller),
46
+ { authority: resolveRpcAuthority(authority) },
47
+ );
48
+ }
@@ -32,7 +32,9 @@ export function pluginPaths(config, channel) {
32
32
  }
33
33
 
34
34
  export async function createTokenProductionController(ctx, config, internals, definitions) {
35
- const { channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime } = definitions;
35
+ const {
36
+ channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
37
+ } = definitions;
36
38
  if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
37
39
  if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
38
40
 
@@ -41,6 +43,11 @@ export async function createTokenProductionController(ctx, config, internals, de
41
43
  const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
42
44
  const ResolvedController = internals.Controller ?? Controller;
43
45
  const ResolvedRuntime = internals.Runtime ?? Runtime;
46
+ const channelRuntimeOptions = typeof runtimeOptions === 'function' ? runtimeOptions(config) : {};
47
+ if (!channelRuntimeOptions || typeof channelRuntimeOptions !== 'object'
48
+ || Array.isArray(channelRuntimeOptions)) {
49
+ throw new TypeError(`dsh-im ${channel} runtimeOptions must return an object`);
50
+ }
44
51
  const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
45
52
  const logger = typeof ctx.logger === 'function'
46
53
  ? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
@@ -91,6 +98,7 @@ export async function createTokenProductionController(ctx, config, internals, de
91
98
  await workspaces.ensure(botId);
92
99
  const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
93
100
  return new ResolvedRuntime({
101
+ ...channelRuntimeOptions,
94
102
  config: botConfig,
95
103
  token,
96
104
  harness: workspaceScope.harness,
@@ -5,6 +5,8 @@ import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runt
5
5
  import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
6
6
  import { createTokenProductionController } from '../shared/production.mjs';
7
7
 
8
+ export { normalizeTelegramAllowedUsers } from '../../../../src/channels/telegram/config-store.mjs';
9
+
8
10
  export function createProductionController(ctx, config = {}, internals = {}) {
9
11
  return createTokenProductionController(ctx, config, internals, {
10
12
  channel: 'telegram',
@@ -1,21 +1,71 @@
1
1
  import {
2
2
  TOKEN_BOT_ENDPOINTS,
3
3
  createTokenBotRpcHandler,
4
- installTokenBotRpc,
5
4
  } from '../shared/rpc.mjs';
5
+ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
6
+ import { normalizeTelegramAccessPolicy } from '../../../../src/channels/telegram/config-store.mjs';
6
7
 
7
8
  export const TELEGRAM_RPC_CHANNEL = '/telegram';
8
- export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
9
+ export const TELEGRAM_ENDPOINTS = Object.freeze({
10
+ ...TOKEN_BOT_ENDPOINTS,
11
+ setAccessPolicy: 'bot.access-policy.set',
12
+ });
9
13
  export const TELEGRAM_RPC_ENDPOINTS = Object.freeze(Object.values(TELEGRAM_ENDPOINTS));
10
14
 
11
15
  export function createTelegramRpcHandler(controller) {
12
- return createTokenBotRpcHandler(controller, { channel: 'Telegram' });
16
+ if (typeof controller?.setAccessPolicy !== 'function') {
17
+ throw new TypeError('A complete Telegram controller is required (setAccessPolicy)');
18
+ }
19
+ const sharedHandler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
20
+ return async (endpoint, payload, signal) => {
21
+ if (endpoint !== TELEGRAM_ENDPOINTS.setAccessPolicy) {
22
+ return sharedHandler(endpoint, payload, signal);
23
+ }
24
+ if (signal?.aborted) {
25
+ return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
26
+ }
27
+ const keys = payload && typeof payload === 'object' && !Array.isArray(payload)
28
+ ? Object.keys(payload) : [];
29
+ if (keys.length !== 3 || !keys.every((key) => (
30
+ ['botId', 'accessMode', 'allowedUsers'].includes(key)
31
+ )) || typeof payload.botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) {
32
+ return {
33
+ ok: false,
34
+ error: { code: 'bad-request', message: 'bot.access-policy.set requires a valid policy.' },
35
+ };
36
+ }
37
+ let accessPolicy;
38
+ try {
39
+ accessPolicy = normalizeTelegramAccessPolicy(payload);
40
+ } catch {
41
+ return {
42
+ ok: false,
43
+ error: { code: 'bad-request', message: '请输入有效的 Telegram 访问模式和数字 User ID。' },
44
+ };
45
+ }
46
+ try {
47
+ const value = await controller.setAccessPolicy(payload.botId, accessPolicy);
48
+ return signal?.aborted
49
+ ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
50
+ : { ok: true, value };
51
+ } catch {
52
+ return signal?.aborted
53
+ ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
54
+ : {
55
+ ok: false,
56
+ error: { code: 'telegram-operation-failed', message: 'Telegram 操作失败,请稍后重试。' },
57
+ };
58
+ }
59
+ };
13
60
  }
14
61
 
15
62
  export function installTelegramRpc(ctx, controller, authority) {
16
- return installTokenBotRpc(ctx, controller, {
17
- channel: 'Telegram',
18
- rpcChannel: TELEGRAM_RPC_CHANNEL,
19
- authority,
20
- });
63
+ if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
64
+ throw new TypeError('DSH Host Connection RPC is required');
65
+ }
66
+ return ctx.connection.rpc.handle(
67
+ TELEGRAM_RPC_CHANNEL,
68
+ createTelegramRpcHandler(controller),
69
+ { authority: resolveRpcAuthority(authority) },
70
+ );
21
71
  }
@@ -1,5 +1,6 @@
1
1
  import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
2
2
  import { apply as applyDiscord } from './channels/discord/index.mjs';
3
+ import { apply as applyOffice } from './channels/office/index.mjs';
3
4
  import { apply as applyFeishu } from './channels/feishu/index.mjs';
4
5
  import { apply as applyQq } from './channels/qq/index.mjs';
5
6
  import { apply as applySlack } from './channels/slack/index.mjs';
@@ -27,6 +28,7 @@ export function createImHostPlugin(internals = {}) {
27
28
  const startSlack = internals.applySlack ?? applySlack;
28
29
  const startTelegram = internals.applyTelegram ?? applyTelegram;
29
30
  const startDiscord = internals.applyDiscord ?? applyDiscord;
31
+ const startOffice = internals.applyOffice ?? applyOffice;
30
32
  const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
31
33
  return Object.freeze({
32
34
  name,
@@ -41,6 +43,7 @@ export function createImHostPlugin(internals = {}) {
41
43
  await startTelegram(ctx, channelConfig(config, 'telegram'));
42
44
  await startDiscord(ctx, channelConfig(config, 'discord'));
43
45
  await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
46
+ await startOffice(ctx, channelConfig(config, 'office'));
44
47
  },
45
48
  });
46
49
  }
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.13.0)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.14.0)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -0,0 +1,107 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute } from 'node:path';
3
+
4
+ import { normalizeOfficeBaseUrl, OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
5
+
6
+ const ALIAS = /^[a-z][a-z0-9-]{1,63}$/;
7
+ const DEVICE_ID = /^[a-z0-9][a-z0-9_-]{2,63}$/i;
8
+
9
+ function cleanString(value) {
10
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
11
+ }
12
+
13
+ function normalizeMap(value, { kind }) {
14
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
15
+ const output = {};
16
+ for (const [rawId, rawValue] of Object.entries(value)) {
17
+ const id = cleanString(rawId);
18
+ const item = cleanString(rawValue);
19
+ if (!id || !ALIAS.test(id) || !item) return null;
20
+ if (kind === 'workspace' && !isAbsolute(item)) return null;
21
+ if (kind === 'preset' && item.length > 8_000) return null;
22
+ output[id] = item;
23
+ }
24
+ return Object.freeze(output);
25
+ }
26
+
27
+ export function normalizeOfficeConfig(value) {
28
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
29
+ let baseUrl;
30
+ try {
31
+ baseUrl = normalizeOfficeBaseUrl(value.baseUrl).origin;
32
+ } catch {
33
+ return null;
34
+ }
35
+ const deviceId = cleanString(value.deviceId);
36
+ const deviceTokenRef = cleanString(value.deviceTokenRef);
37
+ const workspaces = normalizeMap(value.workspaces ?? {}, { kind: 'workspace' });
38
+ const instructionPresets = normalizeMap(value.instructionPresets ?? {}, { kind: 'preset' });
39
+ const maxConcurrency = Number(value.maxConcurrency ?? 1);
40
+ const heartbeatSeconds = Number(value.heartbeatSeconds ?? 30);
41
+ if (!deviceId || !DEVICE_ID.test(deviceId) || !deviceTokenRef
42
+ || !/^DSH_OFFICE_DEVICE_TOKEN_[A-F0-9]{24}$/.test(deviceTokenRef)
43
+ || !workspaces || !instructionPresets
44
+ || !Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4
45
+ || !Number.isInteger(heartbeatSeconds) || heartbeatSeconds < 10 || heartbeatSeconds > 300) {
46
+ return null;
47
+ }
48
+ return Object.freeze({
49
+ version: 1,
50
+ protocolVersion: OFFICE_PROTOCOL_VERSION,
51
+ baseUrl,
52
+ deviceId,
53
+ deviceTokenRef,
54
+ maxConcurrency,
55
+ heartbeatSeconds,
56
+ workspaces,
57
+ instructionPresets,
58
+ createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
59
+ updatedAt: cleanString(value.updatedAt) ?? new Date().toISOString(),
60
+ });
61
+ }
62
+
63
+ export class OfficeConfigStore {
64
+ #path;
65
+ #value = null;
66
+ #queue = Promise.resolve();
67
+
68
+ constructor(path) { this.#path = path; }
69
+
70
+ async load() {
71
+ try {
72
+ const normalized = normalizeOfficeConfig(JSON.parse(await readFile(this.#path, 'utf8')));
73
+ if (!normalized) throw new Error('dsh-im AI Office config is invalid');
74
+ this.#value = normalized;
75
+ } catch (error) {
76
+ if (error?.code !== 'ENOENT') throw error;
77
+ this.#value = null;
78
+ }
79
+ return this;
80
+ }
81
+
82
+ get() { return this.#value ? structuredClone(this.#value) : null; }
83
+
84
+ async save(value) {
85
+ const normalized = normalizeOfficeConfig(value);
86
+ if (!normalized) throw new Error('Refusing to persist invalid AI Office configuration');
87
+ const operation = this.#queue.then(async () => {
88
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
89
+ const temporary = `${this.#path}.tmp`;
90
+ await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
91
+ await rename(temporary, this.#path);
92
+ this.#value = normalized;
93
+ });
94
+ this.#queue = operation.then(() => undefined, () => undefined);
95
+ await operation;
96
+ return this.get();
97
+ }
98
+
99
+ async clear() {
100
+ const operation = this.#queue.then(async () => {
101
+ try { await unlink(this.#path); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
102
+ this.#value = null;
103
+ });
104
+ this.#queue = operation.then(() => undefined, () => undefined);
105
+ await operation;
106
+ }
107
+ }