@xmanrui/dsh-im 0.13.0 → 0.15.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 (43) 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 +815 -173
  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 +76 -0
  20. package/plugin-src/client/index.js +22 -11
  21. package/plugin-src/client/styles.js +7 -4
  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
  42. package/src/channels/weixin/weixin-controller.mjs +56 -12
  43. package/src/channels/weixin/weixin-runtime.mjs +12 -1
@@ -8,6 +8,52 @@ 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
+ '(实验功能)': '(Experimental)',
13
+ 'AI Office 设置': 'AI Office settings',
14
+ 'AI Office 设置页缺少 RPC 连接': 'AI Office settings are missing an RPC connection',
15
+ '正在读取 AI Office Connector…': 'Loading AI Office Connector…',
16
+ '本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ': 'This machine connects outward to the public Office; Harness exposes no port. Protocol hooks: ',
17
+ '尚未配置': 'Not configured',
18
+ '已连接 Office': 'Connected to Office',
19
+ '已配置': 'Configured',
20
+ '等待重连': 'Waiting to reconnect',
21
+ '凭据缺失': 'Credential missing',
22
+ '最近心跳': 'Last heartbeat',
23
+ '最近事件': 'Last event',
24
+ '重连次数': 'Reconnects',
25
+ '运行 Job': 'Running Jobs',
26
+ '完成 Job': 'Completed Jobs',
27
+ '尚无': 'None yet',
28
+ '设备连接': 'Device connection',
29
+ 'Token 只写入本机凭据存储': 'Token is written only to the local credential store',
30
+ '粘贴 Office 一次性凭据': 'Paste the one-time Office credential',
31
+ '已安全保存;留空保持不变': 'Stored securely; leave blank to keep it',
32
+ '最大并发': 'Max concurrency',
33
+ 'Heartbeat 秒数': 'Heartbeat seconds',
34
+ 'Workspace 映射': 'Workspace mappings',
35
+ '每行 alias=/本机/绝对路径;Office 只能看到 alias。': 'One alias=/local/absolute/path per line; Office sees only aliases.',
36
+ 'Instruction Preset 映射': 'Instruction preset mappings',
37
+ '每行 alias=指令;新增 preset 不需要改 Office 代码。': 'One alias=instruction per line; new presets require no Office code change.',
38
+ '保存并连接': 'Save and connect',
39
+ '测试连接': 'Test connection',
40
+ '测试中…': 'Testing…',
41
+ '重新连接': 'Reconnect',
42
+ '移除连接': 'Remove connection',
43
+ '连接测试通过。': 'Connection test passed.',
44
+ '配置已保存。': 'Configuration saved.',
45
+ '协议 Hook 预览': 'Protocol hook preview',
46
+ '由 Base URL 自动派生,不单独填写': 'Derived from Base URL; no separate input',
47
+ 'Base URL 无效': 'Invalid Base URL',
48
+ '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.',
49
+ 'Workspace 映射每行必须使用 alias=value': 'Each workspace mapping must use alias=value',
50
+ 'Instruction Preset 映射每行必须使用 alias=value': 'Each instruction preset mapping must use alias=value',
51
+ 'action-items=转换为负责人、截止和验收明确的工单': 'action-items=Turn this into accountable tasks with deadlines and acceptance criteria',
52
+ 'AI Office 拒绝了 Device Token。': 'AI Office rejected the Device Token.',
53
+ 'AI Office Connector Hook 尚未就绪。': 'AI Office Connector hooks are not available yet.',
54
+ 'AI Office Connector 协议版本不兼容。': 'The AI Office Connector protocol is incompatible.',
55
+ '本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
56
+ 'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
11
57
  '帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
12
58
  '微信': 'WeChat',
13
59
  '飞书': 'Feishu',
@@ -185,6 +231,13 @@ const EN = Object.freeze({
185
231
  '这是微信附加的安全确认步骤。配对码只用于本次扫码轮询,不会写入配置或日志。': 'This is an additional WeChat confirmation step. The pairing code is used only for this connection and is never stored.',
186
232
  '正在保存凭据并验证 Harness 与微信长轮询。': 'Saving credentials and verifying the WeChat connection.',
187
233
  '微信已确认,正在启动消息连接': 'Confirmed in WeChat. Starting the message connection',
234
+ '微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Check the DSH credential store.',
235
+ '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。': 'WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.',
236
+ '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。': 'WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.',
237
+ '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。': 'WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.',
238
+ '微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。': 'WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.',
239
+ '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。': 'WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.',
240
+ '微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.',
188
241
  '微信已绑定,可以开始向已绑定的机器人发消息。': 'WeChat is connected and ready for messages.',
189
242
  '这个微信账号已经绑定并保持在线。': 'This WeChat account is connected and online.',
190
243
  '微信账号及本机凭据已移除。': 'The WeChat account and local credentials were removed.',
@@ -236,6 +289,29 @@ const EN = Object.freeze({
236
289
  '接入 Telegram 机器人': 'Connect a Telegram bot',
237
290
  '先通过 @BotFather 获取 Bot Token,再在这里完成接入。': 'Get a Bot Token from @BotFather, then connect it here.',
238
291
  '填写 @BotFather 生成的 Bot Token': 'Enter the Bot Token from @BotFather',
292
+ '访问模式': 'Access mode',
293
+ '访问设置': 'Access settings',
294
+ 'Telegram 访问模式': 'Telegram access mode',
295
+ '查看 Telegram 访问模式说明': 'View Telegram access mode details',
296
+ '群聊全部忽略,私聊仅允许白名单用户。': 'All group messages are ignored; only allowlisted users may send DMs.',
297
+ '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。': 'Keep the original behavior: respond to DMs and to group mentions or replies.',
298
+ '安全模式': 'Safe mode',
299
+ '兼容模式': 'Compatible mode',
300
+ '已生效:安全模式': 'Active: Safe mode',
301
+ '已生效:兼容模式': 'Active: Compatible mode',
302
+ '模式': 'Mode',
303
+ '兼容模式(默认)': 'Compatible mode (default)',
304
+ '安全模式(私聊白名单)': 'Safe mode (private-chat allowlist)',
305
+ '允许私聊的 Telegram User ID': 'Telegram User IDs allowed to send DMs',
306
+ '每行一个数字 User ID': 'One numeric User ID per line',
307
+ '白名单仅属于当前机器人。': 'This allowlist belongs only to the current bot.',
308
+ '兼容模式下暂不使用白名单,切换模式时会保留。': 'Compatible mode does not enforce the allowlist; it is retained when modes change.',
309
+ '白名单为空;保存后该机器人会拒绝所有入站消息。': 'The allowlist is empty; this bot will reject all inbound messages after saving.',
310
+ '正在保存…': 'Saving…',
311
+ '保存访问设置': 'Save access settings',
312
+ 'User ID 必须是 1–16 位正整数,每行一个。': 'Each User ID must be a 1–16 digit positive integer on its own line.',
313
+ 'Telegram 访问设置暂不可用。': 'Telegram access settings are currently unavailable.',
314
+ 'Telegram 访问设置保存失败。': 'Could not save Telegram access settings.',
239
315
  '接入 Discord 机器人': 'Connect a Discord bot',
240
316
  '先在 Developer Portal 创建 Bot 并邀请到服务器,再在这里完成接入。': 'Create a bot in the Developer Portal and invite it to your server, then connect it here.',
241
317
  '填写 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', note: '(实验功能)' },
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', {
@@ -172,6 +176,7 @@ export function IMSettingsTab({
172
176
  h(ChannelLogo, { channel: channel.id }),
173
177
  h('span', { className: 'dim-channelCopy' },
174
178
  h('strong', null, channel.label),
179
+ channel.note ? h('small', { className: 'dim-channelNote' }, channel.note) : null,
175
180
  )))),
176
181
  h('div', { className: 'dim-divider', 'aria-hidden': 'true' }),
177
182
  h('main', {
@@ -195,7 +200,9 @@ export function IMSettingsTab({
195
200
  ? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
196
201
  : active.id === 'discord'
197
202
  ? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
198
- : h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
203
+ : active.id === 'whatsapp'
204
+ ? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
205
+ : h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
199
206
  ),
200
207
  ));
201
208
  }
@@ -218,6 +225,7 @@ export function apply(ctx) {
218
225
  installTelegramStyles(),
219
226
  installDiscordStyles(),
220
227
  installWhatsappStyles(),
228
+ installOfficeStyles(),
221
229
  installImStyles(),
222
230
  ];
223
231
  return () => {
@@ -243,6 +251,8 @@ export function apply(ctx) {
243
251
  ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
244
252
  const slackRpcCall = (endpoint, payload, signal) =>
245
253
  ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
254
+ const officeRpcCall = (endpoint, payload, signal) =>
255
+ ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
246
256
  const workspaceDirectoryPicker = Object.freeze({
247
257
  listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
248
258
  pickDirectory: () => ctx.workspaces.pickDirectory(),
@@ -264,6 +274,7 @@ export function apply(ctx) {
264
274
  wecomRpcCall,
265
275
  weixinRpcCall,
266
276
  whatsappRpcCall,
277
+ officeRpcCall,
267
278
  workspaceDirectoryPicker,
268
279
  }),
269
280
  }, 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,14 +44,17 @@ 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; }
50
52
  .dim-logoSlack svg { width: 21px; height: 21px; }
51
53
  .dim-logoWhatsapp { color: white; background: #25d366; }
52
54
  .dim-logoWhatsapp svg { width: 21px; height: 21px; }
53
- .dim-channelCopy { min-width: 0; display: block; }
55
+ .dim-channelCopy { min-width: 0; display: grid; }
54
56
  .dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
57
+ .dim-channelNote { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
55
58
  .dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-border-l1, #eef0f3); }
56
59
  .dim-panel { min-width: 0; container-type: inline-size; }
57
60
  .dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dsl-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
@@ -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.15.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
+ }