oh-my-dws 0.0.0-stage → 0.0.1
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/README.md +172 -2
- package/bin/oh-my-dws.js +384 -0
- package/docs//344/275/277/347/224/250/350/257/264/346/230/216.md +129 -0
- package/package.json +46 -4
- package/src/actions.js +452 -0
- package/src/archive.js +339 -0
- package/src/auth.js +127 -0
- package/src/dws.js +50 -0
- package/src/media.js +90 -0
- package/src/paths.js +40 -0
- package/src/server.js +318 -0
- package/src/util.js +85 -0
- package/web/index.html +1409 -0
package/README.md
CHANGED
|
@@ -1,3 +1,173 @@
|
|
|
1
|
-
#
|
|
1
|
+
# oh-my-dws
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把钉钉项目群的消息**归档到本地**,并用一个本地网页控制台浏览、搜索、增量拉取、导出 Markdown。
|
|
4
|
+
按「一群一目录」组织,数据全部落在 `~/.oh-my-dws`,代码与聊天数据完全分开。
|
|
5
|
+
|
|
6
|
+
- 运行时:**Node.js ≥ 18**(零第三方依赖,只用标准库)
|
|
7
|
+
- 数据源:本机 `dws`(钉钉 CLI,需已登录 `dws auth login`)
|
|
8
|
+
- 网络:只监听 `127.0.0.1`,不上传任何数据
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 直接用(不安装)
|
|
14
|
+
npx oh-my-dws start
|
|
15
|
+
|
|
16
|
+
# 或全局安装
|
|
17
|
+
npm i -g oh-my-dws
|
|
18
|
+
oh-my-dws start # 后台起服务并打开浏览器(默认 25258)
|
|
19
|
+
|
|
20
|
+
# 或从源码跑
|
|
21
|
+
git clone https://github.com/duzhenxun/oh-my-dws.git
|
|
22
|
+
cd oh-my-dws && node bin/oh-my-dws.js start
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
macOS 也可以双击仓库里的 `启动.command`。
|
|
26
|
+
|
|
27
|
+
## 用法
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
oh-my-dws start # 后台启动(默认 http://127.0.0.1:25258)
|
|
31
|
+
oh-my-dws status # 看 PID / 端口 / URL / 版本
|
|
32
|
+
oh-my-dws restart # 重启
|
|
33
|
+
oh-my-dws stop # 停止
|
|
34
|
+
oh-my-dws update # 升到 npm 最新版(在跑会自动重启)
|
|
35
|
+
|
|
36
|
+
oh-my-dws # 前台启动,Ctrl+C 结束(老用法)
|
|
37
|
+
oh-my-dws 26000 # 前台 + 指定端口(位置参数兼容写法)
|
|
38
|
+
oh-my-dws start --port 26000 --no-open
|
|
39
|
+
oh-my-dws start --data /Volumes/ext/dws # 数据目录换到移动盘
|
|
40
|
+
oh-my-dws --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| 命令 / 选项 | 说明 |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `start` | 后台启动,日志落 `~/.oh-my-dws/_运行/server.log`,PID 写 `server.json` |
|
|
46
|
+
| `stop` / `restart` | 停止 / 重启后台服务(先 SIGTERM,超时再 SIGKILL) |
|
|
47
|
+
| `status` | 运行状态(同时探 `/api/health`);退出码 `0` 在跑 / `3` 未跑 / `4` 进程在但无响应 |
|
|
48
|
+
| `update` | `npm i -g oh-my-dws@latest`,正在运行则自动重启 |
|
|
49
|
+
| `-p, --port <端口>` | 监听端口,默认 `25258` |
|
|
50
|
+
| `--data <目录>` | 聊天数据目录,默认 `~/.oh-my-dws` |
|
|
51
|
+
| `--no-open` | 不自动打开浏览器 |
|
|
52
|
+
| `-v, --version` / `-h, --help` | 版本 / 帮助 |
|
|
53
|
+
|
|
54
|
+
环境变量:`OH_MY_DWS_HOME`(等价 `--data`)、`NO_OPEN=1`(等价 `--no-open`)。
|
|
55
|
+
|
|
56
|
+
完整使用说明见 [docs/使用说明.md](docs/使用说明.md)。
|
|
57
|
+
|
|
58
|
+
## 目录结构
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
oh-my-dws/ # 代码(npm 包)
|
|
62
|
+
├── bin/oh-my-dws.js # CLI 入口
|
|
63
|
+
├── src/
|
|
64
|
+
│ ├── server.js # HTTP 服务与路由
|
|
65
|
+
│ ├── actions.js # 拉取/纳管/发送/删除/打开钉钉
|
|
66
|
+
│ ├── archive.js # 归档读写与 Markdown/索引生成
|
|
67
|
+
│ ├── media.js # 图片/文件下载与缓存
|
|
68
|
+
│ ├── auth.js # dws 登录态与登录
|
|
69
|
+
│ ├── dws.js # dws 调用封装
|
|
70
|
+
│ ├── paths.js / util.js # 路径与时间/文件工具
|
|
71
|
+
├── web/index.html # 网页界面(单文件,无外部依赖)
|
|
72
|
+
├── docs/使用说明.md # 面向使用者的完整说明
|
|
73
|
+
├── 启动.command # macOS 双击启动
|
|
74
|
+
└── README.md
|
|
75
|
+
|
|
76
|
+
~/.oh-my-dws/ # 数据(聊天相关文件全在这里,可整体备份/迁移)
|
|
77
|
+
├── 群索引.md # 群名 → 群 ID / 成员数 / 文件位置(每次拉取后自动重建)
|
|
78
|
+
├── server.json # 后台服务的 PID/端口(`start` 写、`stop` 删)
|
|
79
|
+
├── _运行/server.log # 后台服务日志
|
|
80
|
+
├── _原始数据/ # dws 原始 JSON(便于二次加工/复核)
|
|
81
|
+
│ ├── <群简称>_消息.json
|
|
82
|
+
│ ├── <群简称>_成员.json
|
|
83
|
+
│ └── dws-login.log # 网页点「登录 dws」时的后台日志
|
|
84
|
+
├── _媒体/ # 消息图片/文件缓存(可随时删,会重新拉)
|
|
85
|
+
├── _回收站/ # 删除归档时的暂存区(可手动恢复)
|
|
86
|
+
└── <群简称>/
|
|
87
|
+
├── 群信息与人员ID.md # 群 ID、群主、成员表(openDingtalkId / memberDingtalkId)
|
|
88
|
+
└── 群消息-YYYYMMDD-YYYYMMDD.md # 消息全文,带时间、发送者、messageId
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
首次启动会自动创建 `~/.oh-my-dws/_原始数据`。
|
|
92
|
+
|
|
93
|
+
## 功能
|
|
94
|
+
|
|
95
|
+
- 左侧会话列表(可搜索),分 **@我 聚合 / 单聊 / 群聊** 三组;右侧看某个会话的**全部消息**
|
|
96
|
+
- **侧栏默认收起**:收起后是 32px 窄条(`»` 展开、显示当前会话首字头像);展开后悬停侧栏会在右边缘出现 `«` 把手一点就收;**⌘/Ctrl + B** 随时切;**拖右边缘可调宽**(200–560px,双击回默认 310px),宽度和收起状态都记在 localStorage
|
|
97
|
+
- 侧栏圆点:只有**真有新消息**时才亮绿点并显示 `+N 新`;无新消息是空心灰圈(`complete=false` 才变红);打开该会话即自动清零已阅
|
|
98
|
+
- **默认倒序**:最新一条在最上面,打开即见最新(工具栏可切「最早在前」)
|
|
99
|
+
- 消息区渲染钉钉正文的 `**加粗**`、`` `代码` ``、链接、@人,按日期分段
|
|
100
|
+
- 每条消息带**头像**(按人员 ID 稳定配色 + 姓名首字;钉钉接口不返回真实头像 URL)
|
|
101
|
+
- **引用/回复消息显示成引用块**(灰底竖线),点击引用块直接跳到被引用的那条消息
|
|
102
|
+
- **图片直接看图**:`[图片消息]` 通过 `dws chat +messages-resource-download` 下载到 `_媒体/<群简称>/` 并内联显示(懒加载,滚到才下载,已下载走缓存),点击在页面内弹窗预览大图;没网络/无权限时退化成「图片加载失败」
|
|
103
|
+
- 顶部 **拉取最新消息**:从「最后一条消息时间 − 1 分钟」拉到当前,按 `messageId` 去重合并,回写 JSON + Markdown + 群索引;新消息高亮
|
|
104
|
+
- **侧栏顶部账号胶囊**:头像(姓名首字 + 按账号稳定配色)+ 姓名 · 组织,绿点=已登录 / 橙点=未登录;胶囊右侧是 **+ 新群**;点击胶囊弹出「dws 登录信息」面板,展示状态、组织、账号、profile、token/refresh 到期、最近登录(来自 `dws auth status` + `dws profile list`),底部三个按钮:**刷新 / 退出登录 / 登录(重新登录)**;**未登录时是「登录 dws」按钮**,后台跑 `dws auth login` 走 OAuth loopback 网页授权(自动开浏览器,日志落 `_原始数据/dws-login.log`),登录后自动刷新;**退出登录**走 `dws auth logout -y`(二次确认,清本机登录态,之后需重新扫码授权才能拉消息)
|
|
105
|
+
- **搜索群 / 拉取全部最新收进右键菜单**:侧栏空白处(或任意会话上)右键即见;搜索框默认收起,点「🔍 搜索群…」才展开(Esc 收起)
|
|
106
|
+
- **拉取全部最新**(右键菜单「⬇️ 拉取全部最新」):所有会话依次增量刷新
|
|
107
|
+
- **右键会话**:成员 / 人员 ID、拉取最新消息、复制会话 ID、删除归档(移到 `_回收站/`,可恢复);下面隔开的全局项:搜索群、拉取全部最新
|
|
108
|
+
- **工具栏一行到底**:搜索框 + 发送者 / 来源群 / 时间范围(全部·今天·近 4 档)/ 排序(最新在前·最早在前)+ 条数
|
|
109
|
+
- **发消息默认收起**:右下角「✏️ 发消息」悬浮按钮,点开才展开浮层(`⌘/Ctrl + Enter` 发送,× 或 Esc 收起);有草稿自动展开;点发送即直接投递(不弹确认框),发送后自动回读归档确认落库;草稿按会话保存;可勾选是否带「AI 角标」(默认**不带**)
|
|
110
|
+
- **@我 聚合**:把「**已归档群聊**」里的 `@我` 消息汇总成一条时间线。侧栏在「@我的消息」下标出**来源群子级 + 条数**;每条消息带 📍 群名标签、左侧竖条按群配色;「来源群」页看群排行。**只扫已归档的群**,新增/删除群后点「重新汇总」修正
|
|
111
|
+
- 每条 @我 消息底部三个按钮:
|
|
112
|
+
- **🗄 归档定位** = 跳到左侧该群归档并高亮这条消息(精确可靠)
|
|
113
|
+
- **💬 打开钉钉** = 唤起桌面钉钉 + 只把「群名」写进剪贴板(粘进钉钉顶部「搜索或提问」框即可命中);钉钉**没有开放「按会话/消息直达」的协议**(官方 AppLink 只收 chatId,开放接口拿不到反向映射),所以进群仍需粘贴群名搜索;完整定位信息走旁边的「复制定位信息」
|
|
114
|
+
- **📋 复制定位信息** = 只复制不动钉钉
|
|
115
|
+
- **成员 / 人员 ID**:左侧会话**右键 →「👥 成员 / 人员 ID」**弹窗查看(姓名、群昵称、角色、openDingtalkId、memberDingtalkId);@我 聚合弹「来源群」表,单聊弹参与者
|
|
116
|
+
- **+ 新群**(账号胶囊右边):关键词调 `dws chat +chat-search` 找群;也可手动填「名称 + openConversationId + 单聊/群聊」直接归档
|
|
117
|
+
- **会话项悬停出「⋯」**:点击等同右键(成员 / 复制 ID / 拉取 / 删除),不用猜在哪右键
|
|
118
|
+
- 标题一行装下全部信息;**会话 ID 默认隐藏**,悬停标题行才显示,点击即复制
|
|
119
|
+
- URL 可深链:`#<群简称>`
|
|
120
|
+
|
|
121
|
+
> **发消息是以你本人钉钉身份发送的真消息**:群聊走 `chat +messages-send --group <cid>`,单聊走 `--open-dingtalk-id <对方>`;点「发送」即直接投递(服务端带 `--yes`),不弹确认框。
|
|
122
|
+
> 单聊没有成员接口,参与者由消息发送者推断写入 `群信息与人员ID.md`。
|
|
123
|
+
> 删除默认进 `_回收站/<简称>__<时间戳>/`,确认不需要再手动清空。
|
|
124
|
+
> `群索引.md` 由服务自动重建,手改会被覆盖;其他手工文件不会被触碰。
|
|
125
|
+
|
|
126
|
+
## 约定
|
|
127
|
+
|
|
128
|
+
- **群简称**:取群名主干(如 `11.12-11.16闻香识美人`),去掉 `【待排期】` 等状态前缀。
|
|
129
|
+
- **群 ID 唯一**:`openConversationId`(形如 `cidE6Ix+J4uPPaRd1TCp/hfEw==`)是拉消息、拉成员的唯一稳定 ID,群名会变,ID 不变。
|
|
130
|
+
- **人员 ID**:发送者过滤、@人、发消息一律用 `openDingTalkId`,不要用展示名。
|
|
131
|
+
- **字段大小写不一致**:`+chat-members-list` 返回有时是 `openDingtalkId`、有时是 `openDingTalkId`,解析按两者兜底(`src/archive.js` 的 `openIdOf`)。
|
|
132
|
+
- **群主不一定在 users[0]**:成员顺序不固定,按 `role == "群主"` 找。
|
|
133
|
+
- **完整性字段**:每份消息文件保留 `complete` / `hasMore` / 条数 / 范围,不完整要显式标注,不得当全量使用。
|
|
134
|
+
|
|
135
|
+
## 常用 dws 命令
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# 搜群,拿 openConversationId
|
|
139
|
+
dws chat +chat-search --query "<群名关键词>" --format json
|
|
140
|
+
|
|
141
|
+
# 拉群成员(含姓名、角色、openDingTalkId)
|
|
142
|
+
dws chat +chat-members-list --group "<cid>" --format json
|
|
143
|
+
|
|
144
|
+
# 拉群消息(全部时间,正序,忽略表情回应)
|
|
145
|
+
dws chat +chat-messages --group "<cid>" --order asc --page-all --page-limit 50 --no-reactions --format json
|
|
146
|
+
|
|
147
|
+
# 拉指定时间范围
|
|
148
|
+
dws chat +chat-messages --group "<cid>" --start "2026-11-12T00:00:00+08:00" --end "2026-11-17T00:00:00+08:00" --order asc --page-all --no-reactions --format json
|
|
149
|
+
|
|
150
|
+
# 按关键词搜消息(默认近 7 天)
|
|
151
|
+
dws chat +search-msg --chat-query "<群名>" --query "<关键词>" --start "..." --end "..." --page-all --format json
|
|
152
|
+
|
|
153
|
+
# 下载消息里的图片/文件(仅在需要时开)
|
|
154
|
+
dws chat +chat-messages --group "<cid>" --page-all --download-resources --output-dir "<目录>" --format json
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 开发
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
node bin/oh-my-dws.js --no-open --port 8850 # 本地调试
|
|
161
|
+
npm pack --dry-run # 检查发布内容
|
|
162
|
+
npm publish # 发布(需 npm 登录)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## 注意
|
|
166
|
+
|
|
167
|
+
- 图片消息正文只返回 `mediaId`,需要实际图片时由网页按需下载。
|
|
168
|
+
- 消息正文落盘视为内部资料,注意不要外发;`_原始数据` 可能含敏感字段时请先脱敏。
|
|
169
|
+
- 历史上有 Python 实现(`server.py`),已迁移为 Node.js,旧文件保留在 git 历史里。
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
package/bin/oh-my-dws.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* oh-my-dws 命令行入口。
|
|
6
|
+
*
|
|
7
|
+
* 前台:oh-my-dws [--port 25258] [--data 目录] [--no-open]
|
|
8
|
+
* 后台:oh-my-dws start|stop|restart|status|update
|
|
9
|
+
*
|
|
10
|
+
* start 会以 detached 子进程跑 `__serve`,PID/端口写进 <数据目录>/server.json,
|
|
11
|
+
* 日志写进 <数据目录>/_运行/server.log,所以 stop/status 不依赖当前终端。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const pkg = require('../package.json');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PORT = 25258;
|
|
21
|
+
const COMMANDS = ['start', 'stop', 'restart', 'status', 'update', 'serve', '__serve'];
|
|
22
|
+
|
|
23
|
+
const HELP = `oh-my-dws ${pkg.version} — 钉钉群消息归档(本地网页控制台)
|
|
24
|
+
|
|
25
|
+
用法
|
|
26
|
+
oh-my-dws 前台启动(默认 http://127.0.0.1:${DEFAULT_PORT})
|
|
27
|
+
oh-my-dws start [选项] 后台启动(脱离终端,日志落 _运行/server.log)
|
|
28
|
+
oh-my-dws stop 停止后台服务
|
|
29
|
+
oh-my-dws restart [选项] 重启后台服务
|
|
30
|
+
oh-my-dws status 查看运行状态(PID、端口、URL、数据目录)
|
|
31
|
+
oh-my-dws update 升级到 npm 最新版,并在运行中时自动重启
|
|
32
|
+
|
|
33
|
+
选项(start / restart / 前台启动通用)
|
|
34
|
+
-p, --port <端口> 监听端口(默认 ${DEFAULT_PORT})
|
|
35
|
+
--data <目录> 聊天数据目录(默认 ~/.oh-my-dws,等价于 OH_MY_DWS_HOME)
|
|
36
|
+
--no-open 不自动打开浏览器
|
|
37
|
+
-v, --version 查看版本
|
|
38
|
+
-h, --help 查看帮助
|
|
39
|
+
|
|
40
|
+
示例
|
|
41
|
+
oh-my-dws start # 后台起服务并打开浏览器
|
|
42
|
+
oh-my-dws start --port 26000 --no-open
|
|
43
|
+
oh-my-dws status
|
|
44
|
+
oh-my-dws restart
|
|
45
|
+
oh-my-dws stop
|
|
46
|
+
oh-my-dws update
|
|
47
|
+
oh-my-dws # 前台跑,Ctrl+C 结束
|
|
48
|
+
oh-my-dws 26000 # 前台 + 指定端口(位置参数兼容写法)
|
|
49
|
+
|
|
50
|
+
数据目录结构(可整体备份/迁移)
|
|
51
|
+
~/.oh-my-dws/
|
|
52
|
+
├── 群索引.md
|
|
53
|
+
├── server.json 后台服务的 PID / 端口(start 写入,stop 删除)
|
|
54
|
+
├── _运行/ server.log(后台日志)
|
|
55
|
+
├── _原始数据/ CLI 原始 JSON + dws-login.log
|
|
56
|
+
├── _媒体/ 消息图片/文件缓存
|
|
57
|
+
├── _回收站/ 删除的归档(可恢复)
|
|
58
|
+
└── <群简称>/ 群信息与人员ID.md、群消息-*.md
|
|
59
|
+
|
|
60
|
+
依赖:dws(钉钉 CLI,需已登录 dws auth login)
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv) {
|
|
64
|
+
const opts = {
|
|
65
|
+
cmd: '',
|
|
66
|
+
port: DEFAULT_PORT,
|
|
67
|
+
portSet: false,
|
|
68
|
+
dataDir: process.env.OH_MY_DWS_HOME || '',
|
|
69
|
+
noOpen: false,
|
|
70
|
+
help: false,
|
|
71
|
+
version: false,
|
|
72
|
+
rest: [],
|
|
73
|
+
};
|
|
74
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
75
|
+
const a = argv[i];
|
|
76
|
+
if (a === '-h' || a === '--help') opts.help = true;
|
|
77
|
+
else if (a === '-v' || a === '--version') opts.version = true;
|
|
78
|
+
else if (a === '--no-open') opts.noOpen = true;
|
|
79
|
+
else if (a === '-p' || a === '--port') {
|
|
80
|
+
opts.port = Number(argv[i + 1]);
|
|
81
|
+
opts.portSet = true;
|
|
82
|
+
i += 1;
|
|
83
|
+
} else if (a.startsWith('--port=')) {
|
|
84
|
+
opts.port = Number(a.slice(7));
|
|
85
|
+
opts.portSet = true;
|
|
86
|
+
} else if (a === '--data') {
|
|
87
|
+
opts.dataDir = argv[i + 1] || '';
|
|
88
|
+
i += 1;
|
|
89
|
+
} else if (a.startsWith('--data=')) opts.dataDir = a.slice(7);
|
|
90
|
+
else opts.rest.push(a);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 第一个位置参数若是已知命令就当命令,否则按老写法视作端口
|
|
94
|
+
const first = opts.rest[0];
|
|
95
|
+
if (first && COMMANDS.includes(first)) {
|
|
96
|
+
opts.cmd = first;
|
|
97
|
+
opts.rest.shift();
|
|
98
|
+
} else if (first && /^\d+$/.test(first)) {
|
|
99
|
+
opts.port = Number(first);
|
|
100
|
+
opts.portSet = true;
|
|
101
|
+
opts.rest.shift();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!Number.isInteger(opts.port) || opts.port <= 0 || opts.port > 65535) opts.port = DEFAULT_PORT;
|
|
105
|
+
return opts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
109
|
+
|
|
110
|
+
if (opts.help) {
|
|
111
|
+
process.stdout.write(HELP);
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
if (opts.version) {
|
|
115
|
+
process.stdout.write(`${pkg.version}\n`);
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 必须在 require 业务模块之前设置:paths.js 在 require 时就确定数据目录
|
|
120
|
+
if (opts.dataDir) process.env.OH_MY_DWS_HOME = path.resolve(opts.dataDir);
|
|
121
|
+
if (opts.noOpen) process.env.NO_OPEN = '1';
|
|
122
|
+
|
|
123
|
+
const P = require('../src/paths');
|
|
124
|
+
|
|
125
|
+
const STATE_FILE = path.join(P.DATA_DIR, 'server.json');
|
|
126
|
+
const RUN_DIR = path.join(P.DATA_DIR, '_运行');
|
|
127
|
+
const LOG_FILE = path.join(RUN_DIR, 'server.log');
|
|
128
|
+
|
|
129
|
+
const out = (s) => process.stdout.write(`${s}\n`);
|
|
130
|
+
const err = (s) => process.stderr.write(`${s}\n`);
|
|
131
|
+
|
|
132
|
+
/* ---------------- 状态文件 ---------------- */
|
|
133
|
+
|
|
134
|
+
function readState() {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeState(s) {
|
|
143
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
144
|
+
fs.writeFileSync(STATE_FILE, `${JSON.stringify(s, null, 2)}\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function clearState() {
|
|
148
|
+
try {
|
|
149
|
+
fs.unlinkSync(STATE_FILE);
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function pidAlive(pid) {
|
|
154
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
155
|
+
try {
|
|
156
|
+
process.kill(pid, 0);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return e.code === 'EPERM';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 当前是否在跑:状态文件里的 pid 还活着就算 */
|
|
164
|
+
function currentState() {
|
|
165
|
+
const st = readState();
|
|
166
|
+
if (!st || !pidAlive(st.pid)) return null;
|
|
167
|
+
return st;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function urlOf(port) {
|
|
171
|
+
return `http://127.0.0.1:${port}/`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function probeHealth(port, timeout = 1200) {
|
|
175
|
+
try {
|
|
176
|
+
const ac = new AbortController();
|
|
177
|
+
const t = setTimeout(() => ac.abort(), timeout);
|
|
178
|
+
const r = await fetch(`${urlOf(port)}api/health`, { signal: ac.signal });
|
|
179
|
+
clearTimeout(t);
|
|
180
|
+
if (!r.ok) return null;
|
|
181
|
+
return await r.json();
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function openBrowser(url) {
|
|
188
|
+
const cmd = process.platform === 'darwin'
|
|
189
|
+
? ['open', [url]]
|
|
190
|
+
: process.platform === 'win32'
|
|
191
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
192
|
+
: ['xdg-open', [url]];
|
|
193
|
+
spawnSync(cmd[0], cmd[1], { stdio: 'ignore' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ---------------- 命令实现 ---------------- */
|
|
197
|
+
|
|
198
|
+
async function cmdStatus() {
|
|
199
|
+
const st = readState();
|
|
200
|
+
if (!st) {
|
|
201
|
+
out(`● 未运行(没有找到 ${STATE_FILE})`);
|
|
202
|
+
out(` 数据目录:${P.DATA_DIR}`);
|
|
203
|
+
return 3;
|
|
204
|
+
}
|
|
205
|
+
if (!pidAlive(st.pid)) {
|
|
206
|
+
out('● 未运行(状态文件是上次异常退出留下的,已清理)');
|
|
207
|
+
clearState();
|
|
208
|
+
return 3;
|
|
209
|
+
}
|
|
210
|
+
const health = await probeHealth(st.port);
|
|
211
|
+
out(`● 运行中 PID ${st.pid}`);
|
|
212
|
+
out(` URL:${urlOf(st.port)}`);
|
|
213
|
+
out(` 端口:${st.port}`);
|
|
214
|
+
out(` 启动:${st.startedAt || '—'}`);
|
|
215
|
+
out(` 版本:${health ? health.version : `${st.version || pkg.version}(HTTP 未响应)`}`);
|
|
216
|
+
out(` 数据:${health ? health.dataDir : st.dataDir || P.DATA_DIR}`);
|
|
217
|
+
out(` 日志:${LOG_FILE}`);
|
|
218
|
+
return health ? 0 : 4;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* status 退出码:0=运行中 3=未运行 4=进程在但 HTTP 无响应 1=异常 */
|
|
222
|
+
|
|
223
|
+
async function cmdStart() {
|
|
224
|
+
const running = currentState();
|
|
225
|
+
if (running) {
|
|
226
|
+
out(`● 已在运行:${urlOf(running.port)} (PID ${running.pid})`);
|
|
227
|
+
out(' 想重启用 oh-my-dws restart,想换端口先 oh-my-dws stop');
|
|
228
|
+
if (!opts.noOpen) openBrowser(urlOf(running.port));
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fs.mkdirSync(RUN_DIR, { recursive: true });
|
|
233
|
+
const log = fs.openSync(LOG_FILE, 'a');
|
|
234
|
+
const args = [__filename, '__serve', '--port', String(opts.port), '--no-open'];
|
|
235
|
+
if (opts.dataDir) args.push('--data', P.DATA_DIR);
|
|
236
|
+
const child = spawn(process.execPath, args, {
|
|
237
|
+
detached: true,
|
|
238
|
+
stdio: ['ignore', log, log],
|
|
239
|
+
env: { ...process.env, NO_OPEN: '1' },
|
|
240
|
+
});
|
|
241
|
+
fs.closeSync(log);
|
|
242
|
+
child.unref();
|
|
243
|
+
|
|
244
|
+
writeState({
|
|
245
|
+
pid: child.pid,
|
|
246
|
+
port: opts.port,
|
|
247
|
+
url: urlOf(opts.port),
|
|
248
|
+
version: pkg.version,
|
|
249
|
+
dataDir: P.DATA_DIR,
|
|
250
|
+
startedAt: new Date().toISOString(),
|
|
251
|
+
log: LOG_FILE,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 等它真的能响应(最多 ~6s),失败就把日志尾巴打出来
|
|
255
|
+
let health = null;
|
|
256
|
+
for (let i = 0; i < 30; i += 1) {
|
|
257
|
+
health = await probeHealth(opts.port, 400);
|
|
258
|
+
if (health) break;
|
|
259
|
+
if (!pidAlive(child.pid)) break;
|
|
260
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!health) {
|
|
264
|
+
if (pidAlive(child.pid)) {
|
|
265
|
+
out(`● 已启动(PID ${child.pid}),但 ${urlOf(opts.port)} 还没响应`);
|
|
266
|
+
} else {
|
|
267
|
+
err('启动失败,日志尾部:');
|
|
268
|
+
try {
|
|
269
|
+
err(fs.readFileSync(LOG_FILE, 'utf8').split('\n').slice(-15).join('\n'));
|
|
270
|
+
} catch {}
|
|
271
|
+
clearState();
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
out(`● 已启动:${urlOf(opts.port)} (PID ${child.pid})`);
|
|
276
|
+
out(` 数据:${P.DATA_DIR}`);
|
|
277
|
+
out(` 日志:${LOG_FILE}`);
|
|
278
|
+
}
|
|
279
|
+
if (!opts.noOpen) openBrowser(urlOf(opts.port));
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function stopByState(st, quiet = false) {
|
|
284
|
+
if (!st) return 0;
|
|
285
|
+
if (!pidAlive(st.pid)) {
|
|
286
|
+
clearState();
|
|
287
|
+
if (!quiet) out('● 未运行(状态文件已清理)');
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
process.kill(st.pid, 'SIGTERM');
|
|
292
|
+
} catch (e) {
|
|
293
|
+
clearState();
|
|
294
|
+
err(`停止失败:${e.message}`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
for (let i = 0; i < 40; i += 1) {
|
|
298
|
+
if (!pidAlive(st.pid)) break;
|
|
299
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
300
|
+
}
|
|
301
|
+
if (pidAlive(st.pid)) {
|
|
302
|
+
try {
|
|
303
|
+
process.kill(st.pid, 'SIGKILL');
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
clearState();
|
|
307
|
+
if (!quiet) out(`● 已停止 PID ${st.pid}`);
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function cmdStop() {
|
|
312
|
+
const st = readState();
|
|
313
|
+
if (!st) {
|
|
314
|
+
out('● 未运行');
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
return stopByState(st);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function cmdRestart() {
|
|
321
|
+
const st = readState();
|
|
322
|
+
if (st) {
|
|
323
|
+
await stopByState(st, true);
|
|
324
|
+
out('● 已停止旧进程');
|
|
325
|
+
}
|
|
326
|
+
return cmdStart();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function cmdUpdate() {
|
|
330
|
+
const running = currentState();
|
|
331
|
+
out('→ 安装最新版:npm install -g oh-my-dws@latest');
|
|
332
|
+
const r = spawnSync('npm', ['install', '-g', 'oh-my-dws@latest'], { stdio: 'inherit' });
|
|
333
|
+
if (r.status !== 0) {
|
|
334
|
+
err('升级失败(可试试 sudo npm install -g oh-my-dws@latest)');
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
if (running) {
|
|
338
|
+
out('→ 旧进程还在跑,重启以使用新版本…');
|
|
339
|
+
await stopByState(running, true);
|
|
340
|
+
return cmdStart();
|
|
341
|
+
}
|
|
342
|
+
out('● 已升级;下次 oh-my-dws start 就会用新版本');
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* 前台服务(默认行为,也是 start 拉起的子进程) */
|
|
347
|
+
function runForeground() {
|
|
348
|
+
const { createServer } = require('../src/server');
|
|
349
|
+
return createServer({ port: opts.port, open: !opts.noOpen }).then(({ server }) => {
|
|
350
|
+
const shutdown = () => {
|
|
351
|
+
server.close(() => process.exit(0));
|
|
352
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
353
|
+
};
|
|
354
|
+
process.on('SIGINT', shutdown);
|
|
355
|
+
process.on('SIGTERM', shutdown);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const dispatch = {
|
|
360
|
+
start: cmdStart,
|
|
361
|
+
stop: cmdStop,
|
|
362
|
+
restart: cmdRestart,
|
|
363
|
+
status: cmdStatus,
|
|
364
|
+
update: cmdUpdate,
|
|
365
|
+
serve: runForeground,
|
|
366
|
+
__serve: runForeground,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
(async () => {
|
|
370
|
+
const fn = dispatch[opts.cmd || 'serve'] || runForeground;
|
|
371
|
+
try {
|
|
372
|
+
const code = await fn();
|
|
373
|
+
if (typeof code === 'number' && opts.cmd && opts.cmd !== 'serve' && opts.cmd !== '__serve') {
|
|
374
|
+
process.exit(code);
|
|
375
|
+
}
|
|
376
|
+
} catch (e) {
|
|
377
|
+
if (e && e.code === 'EADDRINUSE') {
|
|
378
|
+
err(`端口 ${opts.port} 已被占用,换个端口:oh-my-dws start --port ${opts.port + 1}`);
|
|
379
|
+
} else {
|
|
380
|
+
err(`启动失败:${(e && e.message) || e}`);
|
|
381
|
+
}
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
})();
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# oh-my-dws 使用说明
|
|
2
|
+
|
|
3
|
+
把钉钉项目群的消息**归档到本机**,用一个本地网页控制台浏览、搜索、增量拉取、导出 Markdown。
|
|
4
|
+
数据全部落在 `~/.oh-my-dws`,代码与聊天数据完全分开;服务只监听 `127.0.0.1`,不上传任何东西。
|
|
5
|
+
|
|
6
|
+
- 版本:`0.0.1`
|
|
7
|
+
- 运行时:**Node.js ≥ 18**(零第三方依赖)
|
|
8
|
+
- 数据源:本机 `dws`(钉钉 CLI,需已登录 `dws auth login`)
|
|
9
|
+
- 默认地址:**http://127.0.0.1:25258**
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 一、装好并跑起来
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 1) 全局安装(或 npx oh-my-dws start 直接用)
|
|
17
|
+
npm i -g oh-my-dws
|
|
18
|
+
|
|
19
|
+
# 2) 确认本机 dws 已登录
|
|
20
|
+
dws auth status
|
|
21
|
+
|
|
22
|
+
# 3) 后台起服务(会打开浏览器)
|
|
23
|
+
oh-my-dws start
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
三条命令就够用;后面是日常会用到的那些。
|
|
27
|
+
|
|
28
|
+
## 二、命令行
|
|
29
|
+
|
|
30
|
+
| 命令 | 作用 |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `oh-my-dws start [--port N] [--data 目录] [--no-open]` | 后台启动,写 `~/.oh-my-dws/server.json`,日志到 `~/.oh-my-dws/_运行/server.log`,起来后自动开浏览器 |
|
|
33
|
+
| `oh-my-dws status` | 打印 PID / 端口 / URL / 版本 / 数据目录 / 日志路径(会探 `/api/health`) |
|
|
34
|
+
| `oh-my-dws stop` | 停止后台服务(SIGTERM,5 秒不退再 SIGKILL),并清掉 `server.json` |
|
|
35
|
+
| `oh-my-dws restart [选项]` | 先停再起(`--port` 可在这一步换端口) |
|
|
36
|
+
| `oh-my-dws update` | `npm i -g oh-my-dws@latest`;如果服务正在跑,会顺手重启 |
|
|
37
|
+
| `oh-my-dws` | 前台启动,`Ctrl+C` 结束(老用法;适合看实时日志) |
|
|
38
|
+
| `oh-my-dws 26000` | 前台启动 + 指定端口(位置参数兼容写法) |
|
|
39
|
+
| `oh-my-dws -v` / `-h` | 版本 / 完整帮助 |
|
|
40
|
+
|
|
41
|
+
`status` 的退出码:`0` 在跑、`3` 没跑、`4` 进程在但 HTTP 无响应、`1` 异常——方便写进脚本。
|
|
42
|
+
|
|
43
|
+
常用选项:
|
|
44
|
+
|
|
45
|
+
- `-p, --port <端口>`:监听端口,默认 **25258**
|
|
46
|
+
- `--data <目录>`:聊天数据目录,默认 `~/.oh-my-dws`(等价环境变量 `OH_MY_DWS_HOME`)
|
|
47
|
+
- `--no-open`:不自动打开浏览器(等价 `NO_OPEN=1`)
|
|
48
|
+
|
|
49
|
+
`start` 和 `restart` 是幂等的:已经在跑时 `start` 只提示地址,不会起第二份;要换端口先 `stop` 或用 `restart --port`。
|
|
50
|
+
|
|
51
|
+
## 三、网页怎么用
|
|
52
|
+
|
|
53
|
+
### 侧栏
|
|
54
|
+
|
|
55
|
+
- **默认收起**:收起后是一条 32px 窄条,顶部 `»` 展开,下面的圆点是**当前会话首字头像**,点它也展开。
|
|
56
|
+
- **收起**:展开状态下鼠标移到侧栏上,右边缘中部会出现 `«` 把手,点一下收起;或者按 **⌘/Ctrl + B**(随时可切,状态会记住)。
|
|
57
|
+
- **调宽**:拖侧栏右边缘(200–560px),双击边缘恢复默认 310px;宽度也记住。
|
|
58
|
+
- 搜索框不再常驻:**右键 →「🔍 搜索群…」** 才出现(按地名/会话 ID 过滤,Esc 或清空后收起)。
|
|
59
|
+
|
|
60
|
+
### 右键菜单
|
|
61
|
+
|
|
62
|
+
| 在哪右键 | 能看到 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| 某个会话 | 成员 / 人员 ID、拉取最新消息、复制会话 ID、删除归档;分隔线下:搜索群、拉取全部最新 |
|
|
65
|
+
| 侧栏空白处 / 底部 / 账号胶囊 | 搜索群、拉取全部最新 |
|
|
66
|
+
| 会话项右侧的「⋯」 | 同「某个会话」右键(鼠标悬停在会话上才出现,不用猜位置) |
|
|
67
|
+
|
|
68
|
+
### 顶部与账号
|
|
69
|
+
|
|
70
|
+
- **账号胶囊**:头像 + `姓名 · 组织`,绿点已登录 / 橙点未登录。点开是「dws 登录信息」:状态、组织、账号、profile、token / refresh 到期、最近登录;底部按钮 **刷新 / 退出登录 / 登录·重新登录**。
|
|
71
|
+
- 登录:后台跑 `dws auth login`(OAuth loopback,自动开浏览器),登录完成页面自动刷新。
|
|
72
|
+
- **退出登录**:`dws auth logout -y`,二次确认后清本机登录态;之后要重新授权才能拉消息。
|
|
73
|
+
- **+ 新群**(胶囊右侧):搜索群并纳管,或手填「名称 + openConversationId + 单聊/群聊」直接归档。
|
|
74
|
+
|
|
75
|
+
### 消息区
|
|
76
|
+
|
|
77
|
+
- **默认倒序**(最新在最上),工具栏可切「最早在前」;支持关键词、发送者、来源群、时间范围过滤。
|
|
78
|
+
- 新拉到的消息会高亮,逐条带 `messageId`、时间、发送者头像(按人员 ID 稳定配色 + 姓名首字)。
|
|
79
|
+
- 引用消息显示成引用块,点一下跳到被引用的那条;`**加粗**`、`` `代码` ``、@人、链接都会渲染。
|
|
80
|
+
- **图片直接看**:`[图片消息]` 按需下载到 `_媒体/` 并内联显示,点开可放大预览(Esc 关闭)。
|
|
81
|
+
- **拉取最新消息**(消息区右上角):从最后一条时间 −1 分钟拉到当前,按 `messageId` 去重合并,回写 JSON + Markdown + 群索引。
|
|
82
|
+
- **发消息**:右下角「✏️ 发消息」,`⌘/Ctrl + Enter` 发送,草稿按会话保存;默认不带 AI 角标。注意——**这是以你本人钉钉身份发出的真消息**。
|
|
83
|
+
- **@我 聚合**:「@我的消息」把所有已归档群里的 @我 汇总成一条时间线,来源群列在下面;每条消息可「归档定位 / 打开钉钉 / 复制定位信息」。只扫已归档的群,新增群后点「重新汇总」。
|
|
84
|
+
|
|
85
|
+
## 四、数据在哪、怎么备份
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
~/.oh-my-dws/
|
|
89
|
+
├── 群索引.md 群名 → 群 ID / 成员数 / 文件位置(自动重建,别手改)
|
|
90
|
+
├── server.json 后台服务 PID / 端口(start 写、stop 删)
|
|
91
|
+
├── _运行/server.log 后台服务日志
|
|
92
|
+
├── _原始数据/ dws 原始 JSON + dws-login.log
|
|
93
|
+
├── _媒体/ 图片/文件缓存(可删,会重新拉)
|
|
94
|
+
├── _回收站/ 删除的归档(确认不要了再手动清)
|
|
95
|
+
└── <群简称>/
|
|
96
|
+
├── 群信息与人员ID.md 群 ID、群主、成员表(openDingtalkId / memberDingtalkId)
|
|
97
|
+
└── 群消息-YYYYMMDD-YYYYMMDD.md
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- 备份/迁移:整个目录拷走即可;换机器用 `oh-my-dws start --data /path/to/备份`。
|
|
101
|
+
- 删除归档进 `_回收站/<简称>__<时间戳>/`,想恢复就搬回去再刷新页面。
|
|
102
|
+
- 归档文件是内部资料,注意别外发。
|
|
103
|
+
|
|
104
|
+
## 五、常见问题
|
|
105
|
+
|
|
106
|
+
**端口被占用**:`oh-my-dws status` 看是不是已经在跑;换端口 `oh-my-dws restart --port 25259`。
|
|
107
|
+
|
|
108
|
+
**页面提示「dws 未就绪 / 未登录」**:`dws auth status` 确认;点胶囊里的「登录 dws」,或命令行 `dws auth login --recommend`。
|
|
109
|
+
|
|
110
|
+
**拉取报超时/失败**:`dws` 单次调用默认 300 秒超时;群特别大时先小范围拉(时间范围),再增量补。日志在 `_运行/server.log`。
|
|
111
|
+
|
|
112
|
+
**没有新消息但侧栏没绿点**:只有真有新消息才亮绿点;打开会话即清零已读。
|
|
113
|
+
|
|
114
|
+
**macOS 双击 `启动.command`**:等价 `oh-my-dws start`(后台常驻 + 开浏览器),关掉终端窗口服务照旧跑;停用 `oh-my-dws stop`。
|
|
115
|
+
|
|
116
|
+
## 六、自己发布这个包
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm login # 账号:你的 npm 用户
|
|
120
|
+
npm --version && npm pack --dry-run # 先看打包内容
|
|
121
|
+
npm publish # 第一次发 0.0.1(publishConfig.access=public)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
发布后就能:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm i -g oh-my-dws # 装
|
|
128
|
+
oh-my-dws update # 以后升级 + 自动重启
|
|
129
|
+
```
|