dsh-remote-wecom 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/NOTICE.md +11 -0
- package/README.md +203 -2
- package/bin/dsh-remote.mjs +217 -0
- package/cordis.patch.yml +3 -0
- package/dist/client.js +554 -0
- package/docs/DESIGN.md +150 -0
- package/lib/core/aliases.mjs +69 -0
- package/lib/core/artifacts.mjs +134 -0
- package/lib/core/config.mjs +130 -0
- package/lib/core/qr-auth.mjs +126 -0
- package/lib/core/remote.mjs +3116 -0
- package/lib/core/watchdog.mjs +167 -0
- package/lib/core/wecom.mjs +521 -0
- package/lib/host/dsh-host.mjs +288 -0
- package/lib/host/settings-rpc.mjs +239 -0
- package/lib/index.mjs +136 -0
- package/package.json +76 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luke
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Source notices
|
|
2
|
+
|
|
3
|
+
DSH Remote WeCom is distributed under the [MIT License](LICENSE), copyright 2026 Luke.
|
|
4
|
+
|
|
5
|
+
## Included source
|
|
6
|
+
|
|
7
|
+
- `lib/core/` (session engine, commands, cards, WeCom transport, configuration, directory aliases and artifact filtering) was adapted from [pi-remote-wecom](https://github.com/lukeinx/pi-remote-wecom) `0.3.17` (npm `pi-remote-wecom@0.3.17`), also copyright 2026 Luke under MIT. Its own included-source notices (pi-remote, pi-tab-title) apply to the parts carried over; the tab-title module itself is not included here.
|
|
8
|
+
|
|
9
|
+
## Dependencies
|
|
10
|
+
|
|
11
|
+
The WeCom transport uses the official [@wecom/aibot-node-sdk](https://github.com/WecomTeam/aibot-node-sdk), installed as a dependency. DeepSeek Harness modules are supplied by the host at runtime; `@deepseek-ai/dsh` is a development dependency used only by the end-to-end test. Dependency packages retain their own licenses; see their installed package contents and the lockfile metadata.
|
package/README.md
CHANGED
|
@@ -1,3 +1,204 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-remote-wecom
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-remote-wecom)
|
|
4
|
+
|
|
5
|
+
> Control DeepSeek Harness Desktop sessions from WeCom (企业微信) on your phone: send messages, receive results, switch sessions and models, answer approvals and questions, fetch artifacts. The docs are in Chinese; commands and cards are Chinese-first.
|
|
6
|
+
|
|
7
|
+
在手机企业微信里遥控 DeepSeek Harness Desktop 的会话:发消息、看结果、切会话、切模型、审批与回答提问、取回产物。命令、卡片和推送策略与 [pi-remote-wecom](https://github.com/lukeinx/pi-remote-wecom) 一致,宿主换成 DSH。
|
|
8
|
+
|
|
9
|
+
它是一个 DSH bundle 插件,跑在 Desktop 的 host 进程里,自己持有企微智能机器人长连接;Desktop 关闭即离线。设计与宿主接口见 [docs/DESIGN.md](docs/DESIGN.md)。
|
|
10
|
+
|
|
11
|
+
## 与 pi-remote-wecom、dsh-im 的关系
|
|
12
|
+
|
|
13
|
+
- [pi-remote-wecom](https://github.com/lukeinx/pi-remote-wecom):遥控 pi 终端 / 后台会话的 launchd daemon + pi 扩展。本项目搬来它的宿主无关部分(企微传输、命令与中文别名、卡片、推送与引用路由、关注/取关、目录别名、产物、outbox、附件),把 TUI / RPC 会话换成 Desktop 会话,HTTP 协议换成插件内直接调用 DSH 会话控制器。两者互不依赖,可以同时运行,但要用不同的机器人。
|
|
14
|
+
- `@xmanrui/dsh-im`:DSH 官方生态里的多 IM 插件,企微交互是斜杠命令 + 单聊绑定单会话。本项目只参考了它的插件写法和审批 / 提问抢答方式,不依赖它;如果它的企微通道和本插件用同一个 BotID,两边会互相顶掉长连接。
|
|
15
|
+
|
|
16
|
+
## 要求
|
|
17
|
+
|
|
18
|
+
- DeepSeek Harness Desktop(DSH `>=0.2.0-rc.2`,在 `0.2.1-rc.2` 上验证)
|
|
19
|
+
- Node.js `>=22.19.0`(运行配置命令用)
|
|
20
|
+
- 一个专用的企业微信智能机器人(长连接模式,拿到 BotID 和 Secret)。同一个 BotID 只能有一条长连接,不能与 pi-remote-wecom、dsh-im 企微通道共用。
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
`desktop` profile 只归 Desktop 管,`dsh plugin --profile desktop add` 会报 `managed exclusively by the Electron application`。所以要先退出 Desktop,手动改 profile 的 `package.json`,再用 Desktop 自带的 pnpm 安装。
|
|
25
|
+
|
|
26
|
+
1. 完全退出 DeepSeek Harness Desktop。
|
|
27
|
+
2. 编辑 `~/.dsh/profiles/desktop/package.json`,保留已有条目,只加两处:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"dsh-remote-wecom": "^0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"dsh": {
|
|
35
|
+
"profile": {
|
|
36
|
+
"bundles": ["…已有条目…", "dsh-remote-wecom"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
3. 安装并启动 Desktop:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
cd ~/.dsh/profiles/desktop
|
|
46
|
+
node "/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/pnpm/bin/pnpm.mjs" install
|
|
47
|
+
open -a "DeepSeek Harness"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
4. 打开「设置 → 企微远程」,能看到二维码即安装成功,接着按下面「配置与绑定」操作。
|
|
51
|
+
|
|
52
|
+
升级:改 `dependencies` 里的版本号后重复第 3 步。
|
|
53
|
+
|
|
54
|
+
从源码安装(开发用):先 `git clone https://github.com/lukeinx/dsh-remote-wecom.git && cd dsh-remote-wecom && npm install && npm run build`(`build` 生成设置页前端 `dist/client.js`,它不进 git,缺了 Desktop 会加载失败),第 2 步的依赖写成 `"dsh-remote-wecom": "link:<仓库绝对路径>"`。改了插件代码(含重新 `npm run build` 的设置页)要重启 Desktop 才生效,配置变化不用。
|
|
55
|
+
|
|
56
|
+
### 命令行
|
|
57
|
+
|
|
58
|
+
配置、绑定和看门狗还有命令行入口。下文的 `dsh-remote-wecom` 命令指向 profile 里装好的那份:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
alias dsh-remote-wecom='node ~/.dsh/profiles/desktop/node_modules/dsh-remote-wecom/bin/dsh-remote.mjs'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
源码安装时直接用仓库里的 `node bin/dsh-remote.mjs`。不要用 `npx dsh-remote-wecom` 安装看门狗,`npx` 的缓存目录随时会被清掉。
|
|
65
|
+
|
|
66
|
+
### 让 Agent 代装
|
|
67
|
+
|
|
68
|
+
把本 README 交给 coding agent 时,请它遵守:
|
|
69
|
+
|
|
70
|
+
- 改 `~/.dsh/profiles/desktop/package.json` 前先退出 Desktop,只追加条目,不删除、不重排已有的 `dependencies` 和 `bundles`。
|
|
71
|
+
- 不用 `dsh plugin --profile desktop add`(会被拒绝),用上面的 pnpm 命令。
|
|
72
|
+
- 不读取、不打印 `config.json` 里的 Secret;扫码和发绑定码需要用户本人在手机上完成,Agent 只负责打开设置页或运行 `dsh-remote-wecom setup`。
|
|
73
|
+
- 验证用 `dsh-remote-wecom status`:出现 `Secret:已配置` 和 `主人:已绑定` 即完成;末尾日志可以看连接情况。
|
|
74
|
+
- 在本仓库里改代码前先读 [AGENTS.md](AGENTS.md)。
|
|
75
|
+
|
|
76
|
+
### Desktop 离线看门狗(可选,macOS)
|
|
77
|
+
|
|
78
|
+
插件跑在 Desktop 进程里,Desktop 退出后企微就没人接了。看门狗是一个 launchd 常驻进程,补上这段:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
dsh-remote-wecom watchdog install # 写入 ~/Library/LaunchAgents 并启动,登录后自动运行
|
|
82
|
+
dsh-remote-wecom watchdog status # 是否运行、插件心跳、暂存消息数、最近日志
|
|
83
|
+
dsh-remote-wecom watchdog uninstall
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- 插件运行时每 10 秒写一次心跳(`alive.json`),看门狗只在心跳超过 30 秒没更新时才连企微,平时不占连接。
|
|
87
|
+
- Desktop 离线时发来的消息先暂存到 `spool/`(附件已下载到 `inbox/`),看门狗用 `open -g -j` 在后台拉起 Desktop。插件上线后连上企微(同一机器人只能有一个连接,看门狗随即被挤下线),按顺序处理暂存的消息;超过 30 分钟的只提示不执行。拉起是预期路径,中间不发「已启动 / 已重启 / 开始处理」之类的通知,手机直接收到结果;只有拉起失败或超时才提醒。
|
|
88
|
+
- 卡片点击引用的是插件内存里的状态,离线期间点了只提示重新操作,Desktop 拉起上线后再提醒一次。
|
|
89
|
+
- 拉起 2 分钟后插件仍未上线(比如插件被停用),手机会收到提醒。
|
|
90
|
+
- 安装时如果 Desktop 正在运行却没有心跳(还是旧版本插件),会拒绝安装:先重启 Desktop 加载新插件再装,否则看门狗会把正在运行的插件挤下线。
|
|
91
|
+
- 安装时记下当前 Node 和插件所在路径,换 Node 版本、升级插件或移动仓库后要重新 `watchdog install`。
|
|
92
|
+
|
|
93
|
+
## 配置与绑定
|
|
94
|
+
|
|
95
|
+
推荐在 Desktop 里完成:**设置 → 企微远程**。
|
|
96
|
+
|
|
97
|
+
1. 页面显示二维码,用企业微信扫码并确认,自动创建智能机器人并写入 BotID / Secret(`config.json`,权限 600),插件随即连接。
|
|
98
|
+
2. 扫码成功后 10 分钟内,在企业微信里给这个机器人发任意一条单聊消息,发送人即成为主人。页面同时给出 6 位备用绑定码,自动绑定窗口过了也可以发绑定码完成绑定。之后只接主人的单聊消息。
|
|
99
|
+
3. 绑定后在同一页可以:更换机器人(重新扫码)、解绑账号或换绑定码、选「新会话位置」(Desktop 工作区或未分组,与手机上的 `工作区` 命令是同一项;宿主不支持工作区时不显示)、改「手机审批」的超时策略与秒数、开关「新建会话时打开 Desktop」、重新连接;目录别名只能用命令行 `alias` 管理。改动即时保存、立即生效。
|
|
100
|
+
|
|
101
|
+
扫不了码时,页面上的「手动填写 BotID」可以直接输入已有机器人的 BotID / Secret。也可以用命令行(结果相同):
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
dsh-remote-wecom setup # 交互输入 BotID / Secret
|
|
105
|
+
dsh-remote-wecom setup --bot-id ID --secret S # 非交互
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`setup` 写入 `config.json` 并打印 6 位绑定码,在机器人单聊里发送绑定码,第一个发对的人成为主人。运行中的插件每 5 秒检查一次 `config.json` / `bind.json`,命令行改动后自动重连,不必重启 Desktop。
|
|
109
|
+
|
|
110
|
+
| 命令 | 作用 |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `setup [--bot-id ID --secret S]` | 写入凭证;未绑定时生成绑定码(换机器人会清掉原主人) |
|
|
113
|
+
| `bind` | 解除当前主人,生成新绑定码(10 分钟有效,错 5 次作废) |
|
|
114
|
+
| `status` | 配置、绑定状态、待发消息和最近日志(不显示 Secret) |
|
|
115
|
+
| `alias [名称 目录 \| rm 名称]` | 查看或修改目录别名(「创建会话 别名 消息」「工作区 别名」用;别名目录是 Desktop 工作区时归入该工作区,否则进「未分组」) |
|
|
116
|
+
| `watchdog install\|uninstall\|status` | Desktop 离线看门狗,见上面「安装」 |
|
|
117
|
+
|
|
118
|
+
所有命令都可加 `--dir 目录` 指定数据目录。
|
|
119
|
+
|
|
120
|
+
数据目录默认 `$DSH_HOME/integrations/dsh-remote-wecom/`(`DSH_HOME` 默认 `~/.dsh`),里面有 `config.json`、`bind.json`、`aliases.json`、`.default-place`(新建会话的默认工作区)、`phone-sessions.json`(最近一次用户消息来自手机的会话)、`outbox/`(断网待发)、`inbox/`(手机发来的附件)、`alive.json`(插件心跳)、`spool/` 与 `watchdog.log`(看门狗暂存的消息和日志)、`bridge.log` 等。
|
|
121
|
+
|
|
122
|
+
`config.json` 可选项:
|
|
123
|
+
|
|
124
|
+
| 字段 | 默认 | 说明 |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `remoteConfirm` | `ask-then-allow` | 手机发起的任务里,审批转企微卡片;超时后 `ask-then-allow` 允许、`ask` 拒绝;`allow` 不问直接允许 |
|
|
127
|
+
| `remoteConfirmTimeoutMs` | `600000` | 审批与提问的等待上限(1 秒到 1 天) |
|
|
128
|
+
| `openDesktop` | `false` | 「创建会话」后用 `open dsh://open` 把 Desktop 窗口拉到前台(锁屏时跳过);Desktop 对这个链接只做聚焦窗口,不会切到新会话 |
|
|
129
|
+
| `nameWaitMs` | `10000` | 创建会话后等模型起标题的时间,超时用首条消息截断的临时标题 |
|
|
130
|
+
| `inboxDir` | `<数据目录>/inbox` | 附件落地目录 |
|
|
131
|
+
| `dirAliases` | `{"home": "~"}` | 在没有 `aliases.json` 时使用的初始目录别名 |
|
|
132
|
+
|
|
133
|
+
## 常用命令
|
|
134
|
+
|
|
135
|
+
**手机企微**:选中会话后,直接发消息即可。引用某条推送回复,消息会发给推送所属的会话,并把它切为当前会话。有其他会话推送过、你还没回过消息时,不引用直接发的消息会先暂存,弹卡片让你在当前会话和推送过的会话里选,选完直接投递。
|
|
136
|
+
|
|
137
|
+
命令与 pi-remote-wecom 相同;「DSH 下」一栏列出行为差异,空着的表示一致。
|
|
138
|
+
|
|
139
|
+
| 缩写 | 命令 | 用途 | DSH 下 |
|
|
140
|
+
|---|---|---|---|
|
|
141
|
+
| `ls` | `活跃会话` / `切换会话` | 选择活跃会话 | 活跃 = 运行中、当前选中、Desktop 置顶的会话,加上最近 24 小时有活动的(最多 10 个);不含 subagent 和已归档,Desktop 重启不影响;置顶在前(📌),其余按 Desktop 侧栏的工作区顺序分组、未分组最后,每项标出工作区 |
|
|
142
|
+
| `h` | `历史会话 [关键词]` | 查找并继续历史会话;默认隐藏 subagent 会话,加 `全部` 才显示(如 `历史会话 全部 关键词`) | 只检索会话名和每轮用户消息摘要,不检索回复;Desktop 里已归档的会话也要加 `全部` 才显示 |
|
|
143
|
+
| `n` | `创建会话 [工作区] [消息]` / `新建会话` | 新建会话并投递首条消息 | 第一个词是工作区名、`未分组`、目录别名或 `~/路径` 时作为位置,否则用 `工作区` 选的默认位置;`openDesktop` 打开时新建后 `open dsh://open` 拉起窗口(没有跳到指定会话的入口,需在侧栏点开),默认不拉起 |
|
|
144
|
+
| `nb` | `创建后台会话 [工作区] [消息]` / `新建后台会话` | 新建会话,不拉起窗口 | 同样是 Desktop 会话,不管 `openDesktop` 都不拉起 |
|
|
145
|
+
| `steer` | `插话 内容` | 把内容插进正在运行的这一轮,结果随这一轮推回;空闲时等同普通消息 | DSH 独有。直接发消息仍是排到下一轮 |
|
|
146
|
+
| `perm` | `权限模式 [模式名]` / `切换权限模式 [模式名]`(也可 `切换权限`;单发 `权限` 只弹卡片) | 切换当前会话的权限模式(如 read-only / workspace-write) | DSH 独有,走 `/permission`,只影响这个会话 |
|
|
147
|
+
| `goal` | `目标 [描述]` / `目标 修改 描述` / `目标 暂停` / `目标 继续` / `目标 清除` | 给当前会话设一个长期目标,模型在同一会话里自动续跑,直到完成、受阻、暂停或到达轮数上限;不带参数弹卡片看状态、点选操作 | DSH 独有,走 `/goal`。中间各轮不推,结束时推一条并附最后一轮结果;Desktop 重启后执行中的目标不会自己接着跑,发 `目标 继续` |
|
|
148
|
+
| `select N [消息]` | `选择会话 N [消息]` | 按列表序号切换,可顺带消息 | |
|
|
149
|
+
| `model` | `切换模型 [关键词]` / `模型 [关键词]` | 切换当前会话的模型 | 列出 DSH 模型目录里可路由的全部模型,没有 scope 之分 |
|
|
150
|
+
| `think` | `切换思考强度 [强度]` / `思考强度 [强度]` | 切换思考强度;只列当前模型支持的级别 | 级别取模型目录的 `reasoning.efforts` |
|
|
151
|
+
| `compact` | `压缩会话 [说明]` / `压缩 [说明]` | 手动压缩当前会话上下文;运行中不可压缩 | 不支持指定压缩重点,带说明时照常压缩并在卡片提示 |
|
|
152
|
+
| `restart` | `重启 [force]` / `重启服务` | 重启遥控服务;有任务运行时默认拒绝并列出占用会话,`重启 force` 强制 | 只重建插件内的遥控实例(重读配置、重连企微),不重启 Desktop,任务继续跑 |
|
|
153
|
+
| `cd` | `工作区 [名称]` / `切换工作区`(也可 `目录`、`切换目录`) | 选择新建会话的默认位置 | pi 里是选目录;DSH 里列出 Desktop 的工作区和「未分组」(默认),不是工作区的别名目录新会话进未分组 |
|
|
154
|
+
| `art` | `产物` / `查看产物` | 列出当前会话生成的 html/md/pdf/docx/xlsx/pptx/png/svg 文件(不含 git 跟踪的文件、隐藏目录和临时目录):不超过 3 个直接发送,更多时卡片多选 | 来源是 `write` / `edit` / `present` 工具和回复里的文件链接 |
|
|
155
|
+
| `remote` / `mute` | `关注` / `取关`(也可 `取消关注`、`静音`) | 出门时 `关注`:电脑上发起的任务完成后也推送;回到电脑前 `取关`:只推手机发起的 | `关注` 后电脑发起的任务的审批也发到手机(Desktop 关窗后任务还在跑),`取关` 后留在 Desktop |
|
|
156
|
+
| `status` / `stop` / `help` | `状态` / `停止` / `帮助` | 查看状态、中断任务、查看全部命令 | 状态卡片显示工作区(分支)、模型·思考强度、上下文、权限模式与 token 用量,没有花费和扩展状态项 |
|
|
157
|
+
|
|
158
|
+
图片或文件可先发送,再在 5 分钟内补充说明;每次最多 10 件,每件不超过 100MiB。任务完成后自动回传结果,长结果以文件发送。
|
|
159
|
+
|
|
160
|
+
中文别名需要与参数用空白字符分隔:`模型 high` 是命令,`模型high` 会当作普通消息发出。
|
|
161
|
+
|
|
162
|
+
**电脑**:在「设置 → 企微远程」里管理,见上面的「配置与绑定」;没有 pi 里的 `/remote`、`/tabmodel`、`/tabname`(会话名由 DSH 自动生成)。
|
|
163
|
+
|
|
164
|
+
其他行为:
|
|
165
|
+
|
|
166
|
+
- 手机发的消息 2 秒内完成就直接回结果,否则回「运行中」,完成后推送;超长结果转成 Markdown 文件。
|
|
167
|
+
- 手机发起的任务里,审批和 `ask_user_question` 提问会同时出现在手机和 Desktop,谁先答用谁的;当前没选中的会话的弹窗先挂起,切过去时补推。多题逐题推送,可点选也可自己写。审批无人处理时按 `remoteConfirm` 决定。电脑发起的任务:提问也推手机,审批只在 `关注` 后推。
|
|
168
|
+
- 定时提醒(`schedule_*` 工具设的)和目标续跑(goal)自动跑的轮次:会话最近一次用户消息来自手机(或目标是在手机上设定 / 继续的)时推回手机,最近一次来自电脑时按电脑发起处理。定时任务每次都推(「⏰ 定时任务完成」);目标续跑只在结束时推一条(「🎯 目标已完成 / 受阻 / 已暂停 / 到达轮数上限 / 已清除」),正文是最后一轮的结果,中途被中断或出错的一轮照常推。
|
|
169
|
+
- 往 Desktop 里已归档的会话发消息(引用旧推送、「历史会话 全部」选中)会先取消归档并提示;取消失败时 DSH 会拦下这一轮,手机收到「任务失败」。
|
|
170
|
+
|
|
171
|
+
## 限制
|
|
172
|
+
|
|
173
|
+
- 不装看门狗时,Desktop 未运行就离线;装了也只是暂存消息、拉起 Desktop,任务仍在 Desktop 里跑。
|
|
174
|
+
- `compact` 不支持指定压缩重点(DSH `0.2.1-rc.2` 的 `/compact` 不接受参数),带说明时照常压缩并提示。
|
|
175
|
+
- 历史检索只搜会话名和每轮用户消息的摘要(`turnOutline`),不搜回复。
|
|
176
|
+
- 产物来自插件运行期间的事件,打开会话时再用最近 1000 条消息范围的快照补扫;临时目录、隐藏目录和 git 跟踪的文件不算产物。
|
|
177
|
+
- 不能在手机上添加、移除或重命名工作区,也不能改会话名、分叉会话。
|
|
178
|
+
- 插件随 Desktop 启停:Desktop 退出时,运行中的手机任务回一条「任务中断」(结果不再送回,任务本身可能已在电脑上完成),下次启动后收到「已重启」通知。「已重启」只在手机端用得上时发:手机发了 `重启`、重启时有推送中的任务被中断,或手机 30 分钟内用过;在电脑上随手重启 Desktop 不提醒;被看门狗拉起时也不发。看门狗暂存的消息是「至多一次」:插件取出即删,处理到一半时 Desktop 崩溃,剩下的不会重放。
|
|
179
|
+
- 「关注」「取关」只在内存里生效:插件重启后恢复推送绑定会话的电脑任务,电脑发起的审批不再转手机。
|
|
180
|
+
|
|
181
|
+
## 开发
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
npm install
|
|
185
|
+
npm run build # 打包设置页前端 client/ → dist/client.js
|
|
186
|
+
npm test # 单元测试
|
|
187
|
+
npm run check # build + 全部源文件语法检查 + 单元测试
|
|
188
|
+
npm run check:public # 发布前检查:不含内网链接、本机路径,package.json 指向公开仓库
|
|
189
|
+
npm run test:e2e # build 后用临时 DSH_HOME 起隔离的 dsh --profile web + 假 LLM + 假企微 + 假扫码接口,约 30 秒
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
端到端测试不碰 `~/.dsh`;会在 `$HOME` 下建一个临时工作目录(产物过滤会排除系统临时目录),结束时删除。
|
|
193
|
+
|
|
194
|
+
预览设置页(不碰 Desktop 和 `~/.dsh`,不请求真实扫码接口):
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
node scripts/preview.mjs --state unconfigured # 或 bound / unbound;--port 指定端口,默认随机
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
脚本用临时 `DSH_HOME` 起 web 版 DSH,接假企微和假扫码接口(第 3 次轮询返回成功),打印带 `?token=` 的地址,第一次必须用这个地址打开,之后浏览器靠 cookie 免 token。打开后点「继续」关掉预览版说明,再点「更多 → 设置 → 企微远程」。在终端输入一行文字,等于用户 alice 在企微单聊里发给机器人,用来走扫码后的自动绑定。Ctrl-C 退出并删除临时目录。
|
|
201
|
+
|
|
202
|
+
## 许可
|
|
203
|
+
|
|
204
|
+
MIT,见 [LICENSE](LICENSE) 与 [NOTICE.md](NOTICE.md)。
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 电脑端配置工具:只读写数据目录,运行中的插件约 5 秒内发现配置变化并重连。
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import readline from 'node:readline/promises';
|
|
6
|
+
import {parseArgs} from 'node:util';
|
|
7
|
+
import {BIND_FILE, BIND_TTL_MS, CONFIG_FILE, VERSION, applyCredentials, defaultDataDir, hasCredentials, loadConfig, readUserConfig, resetOwner} from '../lib/core/config.mjs';
|
|
8
|
+
import {checkName, expandDir, loadAliases, missingDirs, saveAliases, tildeDir} from '../lib/core/aliases.mjs';
|
|
9
|
+
import {execFileSync} from 'node:child_process';
|
|
10
|
+
import {fileURLToPath} from 'node:url';
|
|
11
|
+
import {createWatchdog, heartbeatAge, heartbeatFresh, spoolCount, watchdogState} from '../lib/core/watchdog.mjs';
|
|
12
|
+
|
|
13
|
+
const USAGE = `dsh-remote-wecom ${VERSION}
|
|
14
|
+
|
|
15
|
+
用法:
|
|
16
|
+
dsh-remote-wecom setup [--bot-id ID --secret SECRET] 写入机器人凭证并生成绑定码(不带参数则交互输入)
|
|
17
|
+
dsh-remote-wecom bind 重新生成绑定码(解除当前主人)
|
|
18
|
+
dsh-remote-wecom status 查看配置与最近日志
|
|
19
|
+
dsh-remote-wecom alias [名称 目录 | rm 名称] 查看或修改目录别名
|
|
20
|
+
dsh-remote-wecom watchdog install|uninstall|status Desktop 离线看门狗(launchd):暂存消息并后台拉起 Desktop
|
|
21
|
+
|
|
22
|
+
选项:
|
|
23
|
+
--dir 目录 数据目录,默认 $DSH_HOME/integrations/dsh-remote-wecom(DSH_HOME 默认 ~/.dsh)`;
|
|
24
|
+
|
|
25
|
+
const LOG_TAIL = 20;
|
|
26
|
+
|
|
27
|
+
function printCode(code) {
|
|
28
|
+
console.log(`\n绑定码:${code}(${BIND_TTL_MS / 60000} 分钟内有效)`);
|
|
29
|
+
console.log('在企业微信里打开与该机器人的单聊,发送这 6 位数字完成绑定。');
|
|
30
|
+
}
|
|
31
|
+
async function prompt(question, {secret = false} = {}) {
|
|
32
|
+
const rl = readline.createInterface({input: process.stdin, output: process.stdout, terminal: true});
|
|
33
|
+
if (secret) rl._writeToOutput = s => { if (/\n|\r/.test(s) || s.startsWith(question)) process.stdout.write(s.startsWith(question) ? question : s); };
|
|
34
|
+
try { return (await rl.question(question)).trim(); } finally { rl.close(); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function setup(dir, values) {
|
|
38
|
+
const user = readUserConfig(dir);
|
|
39
|
+
let botId = values['bot-id'];
|
|
40
|
+
let secret = values.secret;
|
|
41
|
+
if (!botId && !secret) {
|
|
42
|
+
if (!process.stdin.isTTY) throw new Error('非交互环境请用 setup --bot-id ID --secret SECRET');
|
|
43
|
+
botId = await prompt(`BotID${user.botId ? `(回车沿用 ${user.botId})` : ''}:`) || user.botId;
|
|
44
|
+
secret = await prompt('Secret:', {secret: true});
|
|
45
|
+
process.stdout.write('\n');
|
|
46
|
+
}
|
|
47
|
+
if (!botId || !secret) throw new Error('BotID 和 Secret 都需要提供');
|
|
48
|
+
const {bound, code} = applyCredentials(dir, {botId, secret});
|
|
49
|
+
console.log(`配置已保存:${path.join(dir, CONFIG_FILE)}`);
|
|
50
|
+
if (bound) {
|
|
51
|
+
console.log('已绑定主人,保持不变;需要换绑时执行 dsh-remote-wecom bind。');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
printCode(code);
|
|
55
|
+
console.log('运行中的 DeepSeek Harness 会在约 5 秒内自动连接;若未安装插件,请先按 README 安装并重启 Desktop。');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bind(dir) {
|
|
59
|
+
const {unbound, code} = resetOwner(dir);
|
|
60
|
+
if (unbound) console.log('已解除原主人的绑定。');
|
|
61
|
+
printCode(code);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function status(dir) {
|
|
65
|
+
console.log(`dsh-remote-wecom ${VERSION}`);
|
|
66
|
+
console.log(`数据目录:${dir}`);
|
|
67
|
+
let cfg;
|
|
68
|
+
try { cfg = loadConfig(dir); }
|
|
69
|
+
catch (e) { console.log(`配置:无效(${e.message})`); return; }
|
|
70
|
+
if (!hasCredentials(cfg)) console.log('配置:未配置机器人,运行 dsh-remote-wecom setup');
|
|
71
|
+
else {
|
|
72
|
+
console.log(`BotID:${cfg.botId}`);
|
|
73
|
+
console.log('Secret:已配置');
|
|
74
|
+
console.log(`主人:${cfg.ownerUserId ? '已绑定' : '未绑定'}`);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const b = JSON.parse(fs.readFileSync(path.join(dir, BIND_FILE), 'utf8'));
|
|
78
|
+
const left = b.expiresAt - Date.now();
|
|
79
|
+
console.log(`绑定码:${left > 0 ? `有效,剩余 ${Math.ceil(left / 60000)} 分钟` : '已过期,运行 dsh-remote-wecom bind 重新生成'}`);
|
|
80
|
+
} catch {}
|
|
81
|
+
console.log(`审批超时:${Math.round(cfg.remoteConfirmTimeoutMs / 1000)} 秒后${cfg.remoteConfirm === 'ask' ? '拒绝' : '允许'}${cfg.remoteConfirm === 'allow' ? '(remoteConfirm=allow,不询问)' : ''}`);
|
|
82
|
+
let pending = 0;
|
|
83
|
+
try { pending = fs.readdirSync(path.join(dir, 'outbox')).filter(f => f.endsWith('.json')).length; } catch {}
|
|
84
|
+
if (pending) console.log(`待发消息:${pending} 条`);
|
|
85
|
+
let lines = [];
|
|
86
|
+
try { lines = fs.readFileSync(path.join(dir, 'bridge.log'), 'utf8').trimEnd().split('\n').slice(-LOG_TAIL); } catch {}
|
|
87
|
+
console.log(lines.length ? `\n最近日志:\n${lines.join('\n')}` : '\n还没有日志:插件未运行过,或 Desktop 未重启。');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function alias(dir, args) {
|
|
91
|
+
let table = loadAliases(dir);
|
|
92
|
+
if (args[0] === 'rm' && args[1]) {
|
|
93
|
+
const name = checkName(args[1]);
|
|
94
|
+
if (!(name in table)) throw new Error(`没有别名「${name}」`);
|
|
95
|
+
delete table[name];
|
|
96
|
+
table = saveAliases(dir, table);
|
|
97
|
+
} else if (args.length === 2) {
|
|
98
|
+
const name = checkName(args[0]);
|
|
99
|
+
table = saveAliases(dir, {...table, [name]: tildeDir(expandDir(args[1]))});
|
|
100
|
+
} else if (args.length) throw new Error('用法:dsh-remote-wecom alias 名称 目录 | alias rm 名称');
|
|
101
|
+
const rows = Object.entries(table);
|
|
102
|
+
const missing = new Set(missingDirs(table));
|
|
103
|
+
console.log(rows.length ? rows.map(([n, d]) => `${n} → ${d}${missing.has(n) ? '(目录不存在)' : ''}`).join('\n') : '还没有目录别名。');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const WATCHDOG_LABEL = 'com.dsh-remote-wecom.watchdog';
|
|
107
|
+
const WATCHDOG_TICK_MS = 2000;
|
|
108
|
+
const WATCHDOG_LOG = 'watchdog.log';
|
|
109
|
+
const WATCHDOG_LOG_MAX = 1024 * 1024;
|
|
110
|
+
const plistFile = () => path.join(process.env.HOME, 'Library', 'LaunchAgents', `${WATCHDOG_LABEL}.plist`);
|
|
111
|
+
const domain = () => `gui/${process.getuid()}`;
|
|
112
|
+
const xml = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
113
|
+
|
|
114
|
+
function watchdogRun(dir) {
|
|
115
|
+
const file = path.join(dir, WATCHDOG_LOG);
|
|
116
|
+
const log = line => {
|
|
117
|
+
try {
|
|
118
|
+
if (fs.statSync(file, {throwIfNoEntry: false})?.size > WATCHDOG_LOG_MAX) fs.renameSync(file, `${file}.1`);
|
|
119
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, {mode: 0o600});
|
|
120
|
+
} catch {}
|
|
121
|
+
};
|
|
122
|
+
const dog = createWatchdog({dir, log});
|
|
123
|
+
log('看门狗启动');
|
|
124
|
+
const tick = () => { try { dog.tick(); } catch (e) { log(`tick 失败:${e?.message || e}`); } };
|
|
125
|
+
tick();
|
|
126
|
+
const timer = setInterval(tick, WATCHDOG_TICK_MS);
|
|
127
|
+
const quit = () => { clearInterval(timer); dog.stop(); setTimeout(() => process.exit(0), 300); };
|
|
128
|
+
process.on('SIGTERM', quit);
|
|
129
|
+
process.on('SIGINT', quit);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function desktopRunning() {
|
|
133
|
+
try { execFileSync('pgrep', ['-x', 'DeepSeek Harness'], {stdio: 'ignore'}); return true; } catch { return false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function watchdogInstall(dir, force) {
|
|
137
|
+
// 旧版插件不写心跳:此时装上看门狗会把正在运行的插件踢下线。
|
|
138
|
+
if (!force && desktopRunning() && !heartbeatFresh(dir))
|
|
139
|
+
throw new Error('DSH Desktop 正在运行,但插件没有心跳(可能是旧版本插件或插件未启用)。请先重启 Desktop 加载新版插件再安装;确认无误可加 --force。');
|
|
140
|
+
const args = [process.execPath, fileURLToPath(import.meta.url), 'watchdog', 'run', '--dir', dir];
|
|
141
|
+
const env = {PATH: '/usr/bin:/bin:/usr/sbin:/sbin', ...(process.env.DSH_HOME ? {DSH_HOME: process.env.DSH_HOME} : {})};
|
|
142
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
143
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
144
|
+
<plist version="1.0">
|
|
145
|
+
<dict>
|
|
146
|
+
<key>Label</key><string>${WATCHDOG_LABEL}</string>
|
|
147
|
+
<key>ProgramArguments</key>
|
|
148
|
+
<array>
|
|
149
|
+
${args.map(a => ` <string>${xml(a)}</string>`).join('\n')}
|
|
150
|
+
</array>
|
|
151
|
+
<key>EnvironmentVariables</key>
|
|
152
|
+
<dict>
|
|
153
|
+
${Object.entries(env).map(([k, v]) => ` <key>${k}</key><string>${xml(v)}</string>`).join('\n')}
|
|
154
|
+
</dict>
|
|
155
|
+
<key>RunAtLoad</key><true/>
|
|
156
|
+
<key>KeepAlive</key><true/>
|
|
157
|
+
<key>ProcessType</key><string>Background</string>
|
|
158
|
+
<key>StandardErrorPath</key><string>${xml(path.join(dir, 'watchdog.err'))}</string>
|
|
159
|
+
</dict>
|
|
160
|
+
</plist>
|
|
161
|
+
`;
|
|
162
|
+
fs.mkdirSync(path.dirname(plistFile()), {recursive: true});
|
|
163
|
+
fs.mkdirSync(dir, {recursive: true, mode: 0o700});
|
|
164
|
+
try { execFileSync('launchctl', ['bootout', `${domain()}/${WATCHDOG_LABEL}`], {stdio: 'ignore'}); } catch {}
|
|
165
|
+
fs.writeFileSync(plistFile(), plist);
|
|
166
|
+
execFileSync('launchctl', ['bootstrap', domain(), plistFile()]);
|
|
167
|
+
console.log(`看门狗已安装并启动:${plistFile()}`);
|
|
168
|
+
console.log(`Node:${process.execPath}(升级或切换 Node 后请重新执行 watchdog install)`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function watchdogUninstall() {
|
|
172
|
+
try { execFileSync('launchctl', ['bootout', `${domain()}/${WATCHDOG_LABEL}`], {stdio: 'ignore'}); } catch {}
|
|
173
|
+
fs.rmSync(plistFile(), {force: true});
|
|
174
|
+
console.log('看门狗已卸载。');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function watchdogStatus(dir) {
|
|
178
|
+
let loaded = false;
|
|
179
|
+
try { execFileSync('launchctl', ['print', `${domain()}/${WATCHDOG_LABEL}`], {stdio: 'ignore'}); loaded = true; } catch {}
|
|
180
|
+
console.log(`看门狗:${loaded ? '已安装并运行' : fs.existsSync(plistFile()) ? '已安装但未加载' : '未安装'}`);
|
|
181
|
+
const age = heartbeatAge(dir);
|
|
182
|
+
console.log(`插件心跳:${Number.isFinite(age) ? `${Math.round(age / 1000)} 秒前` : '无'}`);
|
|
183
|
+
const wd = watchdogState(dir);
|
|
184
|
+
if (wd) console.log(`企微连接:${wd.connected ? '看门狗持有' : '看门狗未连接'}(${new Date(wd.at).toLocaleString()})`);
|
|
185
|
+
const n = spoolCount(dir);
|
|
186
|
+
if (n) console.log(`暂存消息:${n} 条`);
|
|
187
|
+
let lines = [];
|
|
188
|
+
try { lines = fs.readFileSync(path.join(dir, WATCHDOG_LOG), 'utf8').trimEnd().split('\n').slice(-LOG_TAIL); } catch {}
|
|
189
|
+
if (lines.length) console.log(`\n最近日志:\n${lines.join('\n')}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function watchdog(dir, [sub = 'status'], force) {
|
|
193
|
+
if (process.platform !== 'darwin' && sub !== 'run') throw new Error('看门狗仅支持 macOS(launchd)');
|
|
194
|
+
if (sub === 'run') watchdogRun(dir);
|
|
195
|
+
else if (sub === 'install') watchdogInstall(dir, force);
|
|
196
|
+
else if (sub === 'uninstall') watchdogUninstall();
|
|
197
|
+
else if (sub === 'status') watchdogStatus(dir);
|
|
198
|
+
else throw new Error('用法:dsh-remote-wecom watchdog install|uninstall|status');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const {values, positionals} = parseArgs({allowPositionals: true, options: {
|
|
203
|
+
'bot-id': {type: 'string'}, secret: {type: 'string'}, dir: {type: 'string'}, force: {type: 'boolean'}, help: {type: 'boolean', short: 'h'},
|
|
204
|
+
}});
|
|
205
|
+
const [command = 'help', ...rest] = positionals;
|
|
206
|
+
const dir = values.dir ? path.resolve(expandDir(values.dir)) : defaultDataDir();
|
|
207
|
+
if (values.help || command === 'help') console.log(USAGE);
|
|
208
|
+
else if (command === 'setup') await setup(dir, values);
|
|
209
|
+
else if (command === 'bind') bind(dir);
|
|
210
|
+
else if (command === 'status') status(dir);
|
|
211
|
+
else if (command === 'alias') alias(dir, rest);
|
|
212
|
+
else if (command === 'watchdog') watchdog(dir, rest, values.force);
|
|
213
|
+
else { console.log(USAGE); process.exitCode = 2; }
|
|
214
|
+
} catch (e) {
|
|
215
|
+
console.error(e.message);
|
|
216
|
+
process.exitCode = 1;
|
|
217
|
+
}
|
package/cordis.patch.yml
ADDED