dsh-mobile-plugin 0.5.24 → 0.5.27
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 +6 -6
- package/knowledge/apps/alipay.md +30 -0
- package/knowledge/apps/android-settings.md +30 -0
- package/knowledge/apps/bilibili.md +30 -0
- package/knowledge/apps/boss-zhipin.md +32 -0
- package/knowledge/apps/douyin.md +32 -0
- package/knowledge/apps/taobao.md +33 -0
- package/knowledge/apps/wechat.md +33 -0
- package/knowledge/apps/weibo.md +31 -0
- package/knowledge/apps/xiaohongshu.md +29 -0
- package/knowledge/apps/zhihu.md +30 -0
- package/knowledge/sites/bilibili.com.md +27 -0
- package/knowledge/sites/github.com.md +27 -0
- package/knowledge/sites/x.com.md +28 -0
- package/knowledge/sites/xiaohongshu.com.md +27 -0
- package/knowledge/sites/youtube.com.md +28 -0
- package/knowledge/sites/zhihu.com.md +27 -0
- package/lib/impl.js +9 -0
- package/lib/mobile/cli/acp.js +3 -2
- package/lib/mobile/phone/confirm.js +189 -0
- package/lib/mobile/phone/index.js +21 -1
- package/lib/mobile/phone/irreversible.js +146 -0
- package/lib/mobile/phone/pacing.js +173 -0
- package/lib/mobile/phone/route-hint.js +164 -0
- package/lib/mobile/phone/routes-memory.js +133 -0
- package/lib/mobile/phone/routes.js +84 -0
- package/lib/mobile/phone/service.js +90 -5
- package/lib/mobile/phone/tools.js +1190 -64
- package/lib/mobile/phone-knowledge.js +301 -0
- package/lib/mobile/schedule.js +64 -7
- package/lib/mobile/sessions.js +40 -5
- package/lib/mobile/wechat.js +26 -1
- package/lib/p2p/rendezvous.cjs +118 -0
- package/lib/p2p/session.cjs +595 -0
- package/lib/p2p/wiring.cjs +117 -0
- package/lib/panel/index.html +10 -10
- package/lib/relay/status.js +2 -0
- package/lib/routes/control-plane.js +24 -1
- package/lib/routes/mobile-api.js +20 -1
- package/lib/shared/e2ee-request.js +27 -7
- package/lib/tailcat/host-process.cjs +54 -1
- package/package.json +4 -2
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 用法知识库:把 `knowledge/apps/*.md` 与 `knowledge/sites/*.md` 读进来。
|
|
5
|
+
* App 那条按前台包名匹配一段说明,网站那条按当前网页地址(主机名)匹配。
|
|
6
|
+
*
|
|
7
|
+
* 知识只讲事实(这个 App / 网站是什么、界面怎么组织、有哪些功能、有哪些容易混淆的入口),
|
|
8
|
+
* 不写行为规则 —— 该不该做、要不要确认是审批层的事,写进知识里就会和审批层打架。
|
|
9
|
+
*
|
|
10
|
+
* 两条设计取舍:
|
|
11
|
+
* - **按 mtime 缓存**:目录里十几份小文件,每次读屏都重读一遍纯属浪费;mtime 没变就用上一份。
|
|
12
|
+
* - **加载失败安静退化**:知识库目录不存在、某个文件读坏了,都只当这一条没有,绝不抛异常。
|
|
13
|
+
* 知识是锦上添花,不能因为它把整个插件装载掀翻。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
/** 知识库根目录:本文件在 lib/mobile/ 下,往上是插件根。 */
|
|
20
|
+
const KNOWLEDGE_ROOT = path.join(__dirname, '..', '..', 'knowledge');
|
|
21
|
+
|
|
22
|
+
/** 默认 App / 网站知识目录。 */
|
|
23
|
+
const DEFAULT_DIR = path.join(KNOWLEDGE_ROOT, 'apps');
|
|
24
|
+
const DEFAULT_SITES_DIR = path.join(KNOWLEDGE_ROOT, 'sites');
|
|
25
|
+
|
|
26
|
+
/** filePath -> { mtimeMs, doc }。文件没改就不重读。 */
|
|
27
|
+
const cache = new Map();
|
|
28
|
+
|
|
29
|
+
/** sessionId -> Set<知识条目名>。同一个对话、同一个 App 只附一次。 */
|
|
30
|
+
const noted = new Map();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* App 知识目录。允许用环境变量覆盖,方便测试「目录不存在时返回空」这条退化路径。
|
|
34
|
+
*/
|
|
35
|
+
function knowledgeDir() {
|
|
36
|
+
const override = process.env.DSH_PHONE_KNOWLEDGE_DIR;
|
|
37
|
+
if (typeof override === 'string' && override.trim()) return override.trim();
|
|
38
|
+
return DEFAULT_DIR;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 网站知识目录。优先用 `DSH_PHONE_SITES_DIR` 覆盖;没有时按 App 目录的同级 `sites`
|
|
43
|
+
* 推(App 目录被 `DSH_PHONE_KNOWLEDGE_DIR` 指到别处时,网站跟着它的兄弟目录走),
|
|
44
|
+
* 否则用插件自带的 `knowledge/sites`。
|
|
45
|
+
*/
|
|
46
|
+
function sitesDir() {
|
|
47
|
+
const explicit = process.env.DSH_PHONE_SITES_DIR;
|
|
48
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
|
|
49
|
+
const appsOverride = process.env.DSH_PHONE_KNOWLEDGE_DIR;
|
|
50
|
+
if (typeof appsOverride === 'string' && appsOverride.trim()) {
|
|
51
|
+
return path.join(path.dirname(appsOverride.trim()), 'sites');
|
|
52
|
+
}
|
|
53
|
+
return DEFAULT_SITES_DIR;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 把一段 `["a","b"]` 或普通标量解析成数组。解析不了就当空数组。 */
|
|
57
|
+
function toArray(value) {
|
|
58
|
+
if (Array.isArray(value)) return value.map((item) => String(item));
|
|
59
|
+
if (typeof value === 'string' && value.trim()) return [value.trim()];
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 去掉两端成对的引号。 */
|
|
64
|
+
function unquote(text) {
|
|
65
|
+
if (text.length >= 2) {
|
|
66
|
+
const first = text[0];
|
|
67
|
+
const last = text[text.length - 1];
|
|
68
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return text.slice(1, -1);
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 解析一份知识文件:frontmatter 进字段,剩下的是正文。
|
|
75
|
+
*
|
|
76
|
+
* 只认简单的一层 `key: value`,够用了;数组用 JSON(`["com.tencent.mm"]`),
|
|
77
|
+
* 与 Mika 的 APP.md 写法一致。解析不出来的键就留默认值,不抛。
|
|
78
|
+
*/
|
|
79
|
+
function parseDoc(filePath, text) {
|
|
80
|
+
const meta = {};
|
|
81
|
+
let body = typeof text === 'string' ? text : '';
|
|
82
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(body);
|
|
83
|
+
if (match) {
|
|
84
|
+
const raw = match[1];
|
|
85
|
+
body = match[2] || '';
|
|
86
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
89
|
+
const idx = trimmed.indexOf(':');
|
|
90
|
+
if (idx < 0) continue;
|
|
91
|
+
const key = trimmed.slice(0, idx).trim();
|
|
92
|
+
let value = trimmed.slice(idx + 1).trim();
|
|
93
|
+
if (value.startsWith('[')) {
|
|
94
|
+
try {
|
|
95
|
+
value = JSON.parse(value);
|
|
96
|
+
} catch {
|
|
97
|
+
value = [];
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
value = unquote(value);
|
|
101
|
+
}
|
|
102
|
+
if (key) meta[key] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
name: typeof meta.name === 'string' ? meta.name : '',
|
|
107
|
+
display: typeof meta.display === 'string' ? meta.display : '',
|
|
108
|
+
description: typeof meta.description === 'string' ? meta.description : '',
|
|
109
|
+
packages: toArray(meta.packages),
|
|
110
|
+
sites: toArray(meta.sites),
|
|
111
|
+
aliases: toArray(meta.aliases),
|
|
112
|
+
open: typeof meta.open === 'string' ? meta.open : '',
|
|
113
|
+
pace: typeof meta.pace === 'string' ? meta.pace : '',
|
|
114
|
+
body: body.trim(),
|
|
115
|
+
file: path.basename(filePath),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 读出某个目录下的全部知识条目(带正文,供匹配和守卫使用)。目录不存在或读不了就返回空数组。
|
|
121
|
+
*
|
|
122
|
+
* `dir` 缺省是 App 目录;网站知识传 `sitesDir()`。缓存按完整文件路径做键,两个目录互不干扰,
|
|
123
|
+
* 清缓存时也只清这个目录下的,不会把另一个目录的缓存顺手删掉。
|
|
124
|
+
*/
|
|
125
|
+
function loadDocs(dir = knowledgeDir()) {
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readdirSync(dir);
|
|
129
|
+
} catch {
|
|
130
|
+
// 目录不存在:只清这个目录前缀下的缓存,别误删别的目录的。
|
|
131
|
+
for (const key of [...cache.keys()]) if (key.startsWith(dir + path.sep)) cache.delete(key);
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const docs = [];
|
|
136
|
+
for (const name of entries) {
|
|
137
|
+
if (!name.endsWith('.md')) continue;
|
|
138
|
+
const filePath = path.join(dir, name);
|
|
139
|
+
let stat;
|
|
140
|
+
try {
|
|
141
|
+
stat = fs.statSync(filePath);
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!stat.isFile()) continue;
|
|
146
|
+
seen.add(filePath);
|
|
147
|
+
const hit = cache.get(filePath);
|
|
148
|
+
if (hit && hit.mtimeMs === stat.mtimeMs) {
|
|
149
|
+
docs.push(hit.doc);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
154
|
+
const doc = parseDoc(filePath, text);
|
|
155
|
+
cache.set(filePath, { mtimeMs: stat.mtimeMs, doc });
|
|
156
|
+
docs.push(doc);
|
|
157
|
+
} catch {
|
|
158
|
+
// 单个文件读坏了只算它没有,其余照常。
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const key of [...cache.keys()]) {
|
|
162
|
+
if (key.startsWith(dir + path.sep) && !seen.has(key)) cache.delete(key);
|
|
163
|
+
}
|
|
164
|
+
docs.sort((a, b) => (a.name || a.file).localeCompare(b.name || b.file));
|
|
165
|
+
return docs;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 列出知识库里的所有 App(不带正文)。 */
|
|
169
|
+
function listApps() {
|
|
170
|
+
return loadDocs().map((doc) => ({
|
|
171
|
+
name: doc.name,
|
|
172
|
+
display: doc.display,
|
|
173
|
+
packages: doc.packages.slice(),
|
|
174
|
+
aliases: doc.aliases.slice(),
|
|
175
|
+
open: doc.open,
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 按包名或别名找到一条知识。包名精确匹配优先(忽略大小写),其次才看别名。
|
|
181
|
+
* 找不到返回 null。
|
|
182
|
+
*/
|
|
183
|
+
function matchApp(packageName) {
|
|
184
|
+
const pkg = typeof packageName === 'string' ? packageName.trim() : '';
|
|
185
|
+
if (!pkg) return null;
|
|
186
|
+
const needle = pkg.toLowerCase();
|
|
187
|
+
const docs = loadDocs();
|
|
188
|
+
const byPackage = docs.find((doc) => doc.packages.some((item) => item.toLowerCase() === needle));
|
|
189
|
+
if (byPackage) return byPackage;
|
|
190
|
+
const byAlias = docs.find((doc) => doc.aliases.some((item) => item.toLowerCase() === needle));
|
|
191
|
+
return byAlias || null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 按包名或别名匹配,返回它的正文;匹配不到返回空串。 */
|
|
195
|
+
function matchByPackage(packageName) {
|
|
196
|
+
const doc = matchApp(packageName);
|
|
197
|
+
return doc && doc.body ? doc.body : '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 列出知识库里的所有网站(不带正文)。 */
|
|
201
|
+
function listSites() {
|
|
202
|
+
return loadDocs(sitesDir()).map((doc) => ({
|
|
203
|
+
name: doc.name,
|
|
204
|
+
display: doc.display,
|
|
205
|
+
description: doc.description,
|
|
206
|
+
sites: doc.sites.slice(),
|
|
207
|
+
aliases: doc.aliases.slice(),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 从一段地址里抽出主机名(小写)。认不出就退回按 `/`、`:` 切的第一段。 */
|
|
212
|
+
function hostOf(raw) {
|
|
213
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
214
|
+
if (!text) return '';
|
|
215
|
+
try {
|
|
216
|
+
const url = new URL(text.includes('://') ? text : `https://${text}`);
|
|
217
|
+
return url.hostname.toLowerCase();
|
|
218
|
+
} catch {
|
|
219
|
+
return text.toLowerCase().split('/')[0].split(':')[0];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 归一化站点条目:去协议、去路径、去开头的 www.。 */
|
|
224
|
+
function normalizeSite(site) {
|
|
225
|
+
return String(site || '')
|
|
226
|
+
.trim()
|
|
227
|
+
.toLowerCase()
|
|
228
|
+
.replace(/^https?:\/\//, '')
|
|
229
|
+
.replace(/\/.*$/, '')
|
|
230
|
+
.replace(/^www\./, '');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 按当前网页地址找到一条网站知识。主机名匹配:完全相等,或是它的子域(`www.youtube.com`
|
|
235
|
+
* 命中 `youtube.com`)。一条都没有时再退回按别名匹配整段输入。找不到返回 null。
|
|
236
|
+
*/
|
|
237
|
+
function matchSite(url) {
|
|
238
|
+
const host = hostOf(url);
|
|
239
|
+
if (!host) return null;
|
|
240
|
+
const docs = loadDocs(sitesDir());
|
|
241
|
+
const hit = docs.find((doc) => doc.sites.some((site) => {
|
|
242
|
+
const s = normalizeSite(site);
|
|
243
|
+
if (!s) return false;
|
|
244
|
+
return host === s || host === `www.${s}` || host.endsWith(`.${s}`);
|
|
245
|
+
}));
|
|
246
|
+
if (hit) return hit;
|
|
247
|
+
const needle = typeof url === 'string' ? url.trim().toLowerCase() : '';
|
|
248
|
+
if (!needle) return null;
|
|
249
|
+
return docs.find((doc) => doc.aliases.some((alias) => alias.toLowerCase() === needle)) || null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 按网址匹配,返回它的正文;匹配不到返回空串。 */
|
|
253
|
+
function matchBySite(url) {
|
|
254
|
+
const doc = matchSite(url);
|
|
255
|
+
return doc && doc.body ? doc.body : '';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 给某个对话附一次某个 App 的正文。同一个 sessionId + 同一个 App 只返回一次,之后返回空串。
|
|
260
|
+
* sessionId 或包名为空、匹配不到、正文为空,都返回空串。
|
|
261
|
+
*/
|
|
262
|
+
function noteFor(sessionId, packageName) {
|
|
263
|
+
const sid = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
264
|
+
if (!sid) return '';
|
|
265
|
+
const doc = matchApp(packageName);
|
|
266
|
+
if (!doc || !doc.body) return '';
|
|
267
|
+
let set = noted.get(sid);
|
|
268
|
+
if (!set) {
|
|
269
|
+
set = new Set();
|
|
270
|
+
noted.set(sid, set);
|
|
271
|
+
}
|
|
272
|
+
const key = doc.name || doc.file;
|
|
273
|
+
if (set.has(key)) return '';
|
|
274
|
+
set.add(key);
|
|
275
|
+
return doc.body;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 测试用:清掉「已经附过」的账本。 */
|
|
279
|
+
function resetNotes() {
|
|
280
|
+
noted.clear();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 测试用:丢掉 mtime 缓存,强制下次重读。 */
|
|
284
|
+
function resetCache() {
|
|
285
|
+
cache.clear();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
knowledgeDir,
|
|
290
|
+
sitesDir,
|
|
291
|
+
loadDocs,
|
|
292
|
+
listApps,
|
|
293
|
+
listSites,
|
|
294
|
+
matchApp,
|
|
295
|
+
matchByPackage,
|
|
296
|
+
matchSite,
|
|
297
|
+
matchBySite,
|
|
298
|
+
noteFor,
|
|
299
|
+
resetNotes,
|
|
300
|
+
resetCache,
|
|
301
|
+
};
|
package/lib/mobile/schedule.js
CHANGED
|
@@ -24,13 +24,14 @@ const SCHEDULE_ID_MAX = 200; // 任务 id / messageId 长度上限
|
|
|
24
24
|
const SCHEDULE_TITLE_MAX = 200; // 任务标题
|
|
25
25
|
const SCHEDULE_PROMPT_MAX = 4000; // prompt(列表里只用于预览)
|
|
26
26
|
const SCHEDULE_EXPRESSION_MAX = 200; // cron 五段表达式
|
|
27
|
+
const SCHEDULE_RRULE_MAX = 1000; // rrule 两行(DTSTART + RRULE)
|
|
27
28
|
const SCHEDULE_TIMEZONE_MAX = 64; // IANA 时区名
|
|
28
29
|
const SCHEDULE_TIME_MAX = 40; // HH:mm:ss.SSS 与 RFC3339 时间戳
|
|
29
30
|
const SCHEDULE_DEFAULT_LIMIT = 20; // 历史记录默认条数
|
|
30
31
|
const SCHEDULE_MAX_LIMIT = 100; // 历史记录条数上限(宿主同一口径)
|
|
31
32
|
|
|
32
|
-
/** 宿主 ScheduleRecord 的封闭 kind
|
|
33
|
-
const SCHEDULE_KINDS = new Set(['after', 'at', 'every', 'daily', 'weekly', 'cron']);
|
|
33
|
+
/** 宿主 ScheduleRecord 的封闭 kind 集(`rrule` 是 v2 宿主加的)。 */
|
|
34
|
+
const SCHEDULE_KINDS = new Set(['after', 'at', 'every', 'daily', 'weekly', 'cron', 'rrule']);
|
|
34
35
|
|
|
35
36
|
/** 宿主没有这个服务时对 App 的统一回答(不是「没有任务」)。 */
|
|
36
37
|
const UNAVAILABLE = Object.freeze({
|
|
@@ -106,6 +107,32 @@ function normalizeLastDelivery(value) {
|
|
|
106
107
|
return { scheduledAt, deliveredAt };
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 一条任务属于哪个会话。
|
|
112
|
+
*
|
|
113
|
+
* v2 宿主把它挪进了 `target`(`session` / `new_once` 带 `sessionId`,`new` 没有),v1 才有行上的
|
|
114
|
+
* `sessionId`;`involvedSessionIds` 是参与过这条任务的会话(建它的那个在前),单会话任务通常就
|
|
115
|
+
* 一条。三处依次退,第一个非空的就是它 —— `new` 目标没有确定会话,退回参与过的那条,
|
|
116
|
+
* 否则这条任务在手机上永远筛不出来(手机是按会话筛的)。
|
|
117
|
+
*/
|
|
118
|
+
function entrySessionId(item) {
|
|
119
|
+
const direct = requiredText(item.sessionId, SCHEDULE_ID_MAX);
|
|
120
|
+
if (direct) return direct;
|
|
121
|
+
const target = item.target;
|
|
122
|
+
if (target && typeof target === 'object' && !Array.isArray(target)) {
|
|
123
|
+
const scoped = requiredText(target.sessionId, SCHEDULE_ID_MAX);
|
|
124
|
+
if (scoped) return scoped;
|
|
125
|
+
}
|
|
126
|
+
const involved = item.involvedSessionIds;
|
|
127
|
+
if (Array.isArray(involved)) {
|
|
128
|
+
for (const candidate of involved) {
|
|
129
|
+
const resolved = requiredText(candidate, SCHEDULE_ID_MAX);
|
|
130
|
+
if (resolved) return resolved;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
/**
|
|
110
137
|
* 把一条宿主任务压成手机认的 `ScheduleEntry`。
|
|
111
138
|
*
|
|
@@ -122,8 +149,8 @@ function normalizeScheduleEntry(item, sessionId, state) {
|
|
|
122
149
|
if (typeof item.kind !== 'string' || !SCHEDULE_KINDS.has(item.kind)) return null;
|
|
123
150
|
const title = requiredText(item.title, SCHEDULE_TITLE_MAX);
|
|
124
151
|
if (!title) return null;
|
|
125
|
-
// catalog
|
|
126
|
-
const resolvedSessionId = sessionId ||
|
|
152
|
+
// catalog 的会话归属在 target / involvedSessionIds 上,list 的由查询串那个顶上。
|
|
153
|
+
const resolvedSessionId = sessionId || entrySessionId(item);
|
|
127
154
|
if (!resolvedSessionId) return null;
|
|
128
155
|
|
|
129
156
|
const entry = {
|
|
@@ -144,6 +171,16 @@ function normalizeScheduleEntry(item, sessionId, state) {
|
|
|
144
171
|
if (item.kind === 'after') entry.afterSeconds = seconds;
|
|
145
172
|
else entry.everySeconds = seconds;
|
|
146
173
|
}
|
|
174
|
+
} else if (item.kind === 'rrule') {
|
|
175
|
+
// `rrule` 自己带时区(`DTSTART;TZID=Asia/Shanghai:…`),行上没有单独那一格;规则整段
|
|
176
|
+
// 转给 App,时区顺手从 DTSTART 里取出来补上,详情页那一行才不是空的。
|
|
177
|
+
const rrule = optionalText(item.rrule, SCHEDULE_RRULE_MAX);
|
|
178
|
+
if (rrule !== undefined) {
|
|
179
|
+
entry.rrule = rrule;
|
|
180
|
+
const zone = /^DTSTART;TZID=([^:]+):/m.exec(rrule);
|
|
181
|
+
const timeZone = zone === null ? undefined : optionalText(zone[1], SCHEDULE_TIMEZONE_MAX);
|
|
182
|
+
if (timeZone !== undefined) entry.timeZone = timeZone;
|
|
183
|
+
}
|
|
147
184
|
} else if (item.kind !== 'at') {
|
|
148
185
|
// `at` 没有专有字段,别把宿主残留的 time / expression 顺手转发出去。
|
|
149
186
|
const time = optionalText(item.time, SCHEDULE_TIME_MAX);
|
|
@@ -186,9 +223,15 @@ function normalizeScheduleEntries(value, sessionId, state) {
|
|
|
186
223
|
return out;
|
|
187
224
|
}
|
|
188
225
|
|
|
189
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* 宿主的 `status` → 手机认的 `state`。
|
|
228
|
+
*
|
|
229
|
+
* v1 只有 `active` / `inactive`;v2 拆成 `active` / `paused` / `ended` —— 后两个都不是
|
|
230
|
+
* 「还会继续投递」,手机那一页只有两档筛选,都收进 `inactive`。缺字段时按 active:
|
|
231
|
+
* 宁可多显示一条,别整条消失。
|
|
232
|
+
*/
|
|
190
233
|
function stateOf(status) {
|
|
191
|
-
return status === 'inactive' ? 'inactive' : 'active';
|
|
234
|
+
return status === 'inactive' || status === 'paused' || status === 'ended' ? 'inactive' : 'active';
|
|
192
235
|
}
|
|
193
236
|
|
|
194
237
|
/** 错误文字:宿主 reject 的原文,取不到就退回 String(),别回一句空话。 */
|
|
@@ -372,6 +415,20 @@ async function handleScheduleHistory(ctx, req, res) {
|
|
|
372
415
|
});
|
|
373
416
|
}
|
|
374
417
|
|
|
418
|
+
/**
|
|
419
|
+
* 删一条任务。
|
|
420
|
+
*
|
|
421
|
+
* v1 宿主有独立的 `delete({sessionId, id})`;v2 把它并进了 `edit({id, action:'delete'})`
|
|
422
|
+
* —— 后者只认 id,存储本来就是全局一张表,行上的 sessionId 只是字段(见 A2-57 的坑)。
|
|
423
|
+
* 先问 `edit`,宿主没有这个方法再退回老的 `delete`。
|
|
424
|
+
*/
|
|
425
|
+
async function deleteTask(service, sessionId, id) {
|
|
426
|
+
if (typeof service.edit === 'function') {
|
|
427
|
+
return service.edit({ id, action: 'delete' });
|
|
428
|
+
}
|
|
429
|
+
return service.delete({ sessionId, id });
|
|
430
|
+
}
|
|
431
|
+
|
|
375
432
|
/** POST /api/mobile/v1/schedule/delete —— body { sessionId, id }。 */
|
|
376
433
|
async function handleScheduleDelete(ctx, req, res) {
|
|
377
434
|
const body = (await readJsonBody(req)) || {};
|
|
@@ -386,7 +443,7 @@ async function handleScheduleDelete(ctx, req, res) {
|
|
|
386
443
|
|
|
387
444
|
let value;
|
|
388
445
|
try {
|
|
389
|
-
value = await service
|
|
446
|
+
value = await deleteTask(service, sessionId, id);
|
|
390
447
|
} catch (error) {
|
|
391
448
|
return sendFailure(res, error);
|
|
392
449
|
}
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* 所以 lib/mobile 下的 import 路径一行都不用改。
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
17
19
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
18
20
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
19
21
|
const { listRevision, unchangedListBody } = require('../shared/list-revision.js');
|
|
@@ -23,6 +25,7 @@ const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
|
23
25
|
const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
|
|
24
26
|
const { unwrap } = require('./envelope.js');
|
|
25
27
|
const { callSessions } = require('./rpc.js');
|
|
28
|
+
const { workspaceRoots, isWithin } = require('./allowed-zone.js');
|
|
26
29
|
const {
|
|
27
30
|
canonicalDeviceKey, sessionListResponseState, invalidateSessionList, deletedSessionIdsOf,
|
|
28
31
|
completionGenerationOf, bumpCompletionGeneration,
|
|
@@ -336,21 +339,53 @@ async function handleStats(ctx, req, res) {
|
|
|
336
339
|
* 的 sessionIds 账上,桌面端侧栏项目下立即可见;无 workspaceId 时回退 cwd
|
|
337
340
|
* (不传则归属其默认/未分组工作区)。
|
|
338
341
|
* 走宿主 apiProxy 的 session.create(接受 workspaceId 或 cwd,二者不能同时给)。
|
|
342
|
+
*
|
|
343
|
+
* cwd 进来之前必须落在**宿主已登记的工作区**里:会话的 cwd 就是 allowed-zone 的第二个
|
|
344
|
+
* 根(见 allowed-zone.js 的 allowedRoots),随手塞一个 `/` 进来等于把整台机器变成
|
|
345
|
+
* 「允许读写区」。工作区清单读不到时照实回 502,不按「没有工作区」处理 —— 那会让每个
|
|
346
|
+
* 目录都变成 403,用户看到的是「我的目录突然不能用了」。
|
|
339
347
|
*/
|
|
340
348
|
async function handleNew(ctx, req, res) {
|
|
341
349
|
const body = await readJsonBody(req, 64 * 1024);
|
|
350
|
+
const workspaceId = body && typeof body.workspaceId === 'string' && body.workspaceId.trim()
|
|
351
|
+
? body.workspaceId.trim()
|
|
352
|
+
: '';
|
|
353
|
+
// 只有走 cwd 那条路才需要校验:workspaceId 本身就把范围交给了宿主登记过的那一项。
|
|
354
|
+
let cwd = '';
|
|
355
|
+
if (!workspaceId && body && typeof body.cwd === 'string' && body.cwd.trim()) {
|
|
356
|
+
const declared = body.cwd.trim();
|
|
357
|
+
if (!path.isAbsolute(declared)) {
|
|
358
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'cwd 必须是绝对路径' } });
|
|
359
|
+
}
|
|
360
|
+
cwd = path.resolve(declared);
|
|
361
|
+
let roots;
|
|
362
|
+
try {
|
|
363
|
+
roots = await workspaceRoots(ctx);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return sendJson(res, 502, {
|
|
366
|
+
ok: false,
|
|
367
|
+
error: {
|
|
368
|
+
code: (error && error.code) || 'workspace-list-failed',
|
|
369
|
+
message: String((error && error.message) || error),
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (!roots.some((root) => isWithin(root, cwd))) {
|
|
374
|
+
return sendJson(res, 403, {
|
|
375
|
+
ok: false,
|
|
376
|
+
error: { code: 'outside-allowed', message: '这个目录不在能建会话的范围里(只能落在已登记的工作区)' },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
342
380
|
// 新建会话是**非幂等**写:同一发请求被重投一次就会凭空多一个会话。requestId 由
|
|
343
381
|
// App 在构造请求头时生成一次,内核换通道重投复用同一个 init(同一个头),重投于是
|
|
344
382
|
// 命中这里、直接拿回第一次的 sessionId(见 mobile/idempotency.js)。
|
|
345
383
|
const outcome = await withIdempotency(ctx, canonicalDeviceKey(req), requestIdOf(req, body), async () => {
|
|
346
384
|
let payload = {};
|
|
347
|
-
const workspaceId = body && typeof body.workspaceId === 'string' && body.workspaceId.trim()
|
|
348
|
-
? body.workspaceId.trim()
|
|
349
|
-
: '';
|
|
350
385
|
if (workspaceId) {
|
|
351
386
|
payload.workspaceId = workspaceId;
|
|
352
|
-
} else if (
|
|
353
|
-
payload.cwd =
|
|
387
|
+
} else if (cwd) {
|
|
388
|
+
payload.cwd = cwd;
|
|
354
389
|
}
|
|
355
390
|
// 宿主 session.create 接受 agentPreset:新建时直接定档,省掉「先建再 select」的
|
|
356
391
|
// 两步(select 只在会话还没开跑时生效,新建后立刻 select 同样可行但多一次 RPC)。
|
package/lib/mobile/wechat.js
CHANGED
|
@@ -426,6 +426,27 @@ function mediaTypeOf(name, bytes) {
|
|
|
426
426
|
return 'image/png';
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
/**
|
|
430
|
+
* HTTP 那条路上,图片只留 `{name, base64}` 两个字段 —— `path`(以及任何路径形式的字段)一律剥掉。
|
|
431
|
+
*
|
|
432
|
+
* 为什么 HTTP 这条路不收 path:这个请求来自手机,手机口中的「本机路径」指的是手机上的文件;
|
|
433
|
+
* 宿主却跑在另一台电脑上,照着这个名字去读,读到的是电脑上同名的任意文件 —— 一次保存就顺走
|
|
434
|
+
* 了宿主机上任何一张图。工具那一侧不经过这里:模型自己有读盘权,给的路径本来就该能读,所以
|
|
435
|
+
* `writeArticle` / `mediaBytesOf` 的 path 能力原样保留。
|
|
436
|
+
*
|
|
437
|
+
* 只认 name 与 base64,别的字段(path、filePath、localPath、src……)自然全丢;`images` 不是
|
|
438
|
+
* 数组、项不是对象都按「没给」处理,不抛。name 合不合规矩交给 `writeArticle` 现有的拒绝逻辑。
|
|
439
|
+
*/
|
|
440
|
+
function httpImagesOnly(images) {
|
|
441
|
+
if (!Array.isArray(images)) return [];
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const item of images) {
|
|
444
|
+
if (!isRecord(item)) continue;
|
|
445
|
+
out.push({ name: item.name, base64: item.base64 });
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
|
|
429
450
|
/**
|
|
430
451
|
* `POST /wechat/article` —— 写一篇(App 侧目前不用,留给「在手机上删 / 改名」这类动作;
|
|
431
452
|
* AI 那条路走的是工具,见 `tools.js`)。
|
|
@@ -433,8 +454,10 @@ function mediaTypeOf(name, bytes) {
|
|
|
433
454
|
async function handleArticleWrite(_ctx, req, res) {
|
|
434
455
|
const body = await readJsonBody(req, 8 * 1024 * 1024);
|
|
435
456
|
if (!isRecord(body)) return fail(res, 400, 'bad-request', '请求体不是对象。');
|
|
457
|
+
// 只让 HTTP 入口不收 path:图片只留 {name, base64}(见 httpImagesOnly)。
|
|
458
|
+
const input = { ...body, images: httpImagesOnly(body.images) };
|
|
436
459
|
try {
|
|
437
|
-
const { article } = await writeArticle(
|
|
460
|
+
const { article } = await writeArticle(input);
|
|
438
461
|
sendJson(res, 200, {
|
|
439
462
|
ok: true,
|
|
440
463
|
id: article.id,
|
|
@@ -479,6 +502,8 @@ module.exports = {
|
|
|
479
502
|
handleArticleDelete,
|
|
480
503
|
// 工具那一侧直接用这几个(不经过 HTTP)
|
|
481
504
|
writeArticle,
|
|
505
|
+
// 纯函数:HTTP 入口剥 path 用,单独导出便于直接测
|
|
506
|
+
httpImagesOnly,
|
|
482
507
|
readArticle,
|
|
483
508
|
listArticles,
|
|
484
509
|
plainTextOf,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 点对点隧道的**会合**:手机把自己的打洞候选报上来,电脑按它把自己那半边拉起来,
|
|
5
|
+
* 再把电脑自己的候选还回去。
|
|
6
|
+
*
|
|
7
|
+
* 为什么需要这一步:打洞的本质是「两端同时往对方的出口地址发包」。手机上知道自己的
|
|
8
|
+
* 出口(它自己问 STUN),电脑也知道自己的;但**彼此不知道对方的**。没有这一次交换,
|
|
9
|
+
* 两边都只能往空气里打 —— 这正是这条通道早先一次也连不上的原因。
|
|
10
|
+
*
|
|
11
|
+
* 为什么走这条路而不是另架一个会合服务:这一发就打在我们**已经有**的那条链路上(局域网 /
|
|
12
|
+
* 中转 / tailcat 隧道),只传几十个字节的地址。它不承载业务数据,也不新增任何部署物 ——
|
|
13
|
+
* 与「用户没有服务器、我们也不提供」是同一条原则。真正的数据面完全在两端之间。
|
|
14
|
+
*
|
|
15
|
+
* 端点本身是幂等的:手机每次心跳都可以带上当前候选再来一次,电脑发现「我已经按这份候选
|
|
16
|
+
* 起来过了」就直接复用,不重新打洞(重打会让已经打通的洞白丢一次)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { sendJson } = require('../http/routing.js');
|
|
20
|
+
const { readJsonBody } = require('../shared/http-util.js');
|
|
21
|
+
|
|
22
|
+
/** 候选白名单:只收 `ip:port` 形状,长度也限住 —— 对端给的任何东西都当不可信数据。 */
|
|
23
|
+
const CANDIDATE = /^[0-9a-fA-F.:[\]]{3,64}$/;
|
|
24
|
+
|
|
25
|
+
/** 手机的公钥:只认 base64(44 字符)或 hex(64 字符)两种,长度不等就不是密钥。 */
|
|
26
|
+
const PUBLIC_KEY = /^[A-Za-z0-9+/=]{43,44}$|^[0-9a-fA-F]{64}$/;
|
|
27
|
+
|
|
28
|
+
function normalizePublicKey(value) {
|
|
29
|
+
if (typeof value !== 'string') return '';
|
|
30
|
+
const text = value.trim();
|
|
31
|
+
return PUBLIC_KEY.test(text) ? text : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeCandidates(list) {
|
|
35
|
+
if (!Array.isArray(list)) return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const item of list) {
|
|
38
|
+
if (typeof item !== 'string') continue;
|
|
39
|
+
const text = item.trim();
|
|
40
|
+
if (!text || text.length > 64 || !CANDIDATE.test(text)) continue;
|
|
41
|
+
if (!out.includes(text)) out.push(text);
|
|
42
|
+
// 上限 16 条:正常也就两三条,多出来的只可能是拼错的输入。
|
|
43
|
+
if (out.length >= 16) break;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} deps
|
|
50
|
+
* @param {object} deps.p2p 装配层交下来的 P2P 门面(`lib/p2p/wiring.cjs`)
|
|
51
|
+
* @param {() => number} [deps.lanPortInUse] 本机插件端口(隧道里那些连接最终转给它)
|
|
52
|
+
*/
|
|
53
|
+
function createP2PRendezvousHandler(deps) {
|
|
54
|
+
/**
|
|
55
|
+
* 隧道里进来的连接该转到哪个本机端口。
|
|
56
|
+
*
|
|
57
|
+
* 会话门面自己知道(`lib/p2p/wiring.cjs` 的 `targetPort`:局域网开着转局域网端口、
|
|
58
|
+
* 关着转宿主 web 端口)。门面没给时才退回 `lanPortInUse` —— 那是老形状,局域网关着时
|
|
59
|
+
* 指向一个没人听的口,隧道会「连上了却毫无反应」。
|
|
60
|
+
*/
|
|
61
|
+
const targetPortOf = () => {
|
|
62
|
+
const door = deps.p2p;
|
|
63
|
+
if (door && typeof door.targetPort === 'function') return Number(door.targetPort()) || 0;
|
|
64
|
+
return typeof deps.lanPortInUse === 'function' ? deps.lanPortInUse() : 0;
|
|
65
|
+
};
|
|
66
|
+
return async function handleRendezvous(_ctx, req, res) {
|
|
67
|
+
if (!deps.p2p || typeof deps.p2p.open !== 'function') {
|
|
68
|
+
// 这台电脑没装助手 / 没起这条通道:如实说,手机那边据此把这一条标成不可用,
|
|
69
|
+
// 而不是一直等一个永远不会来的回答。
|
|
70
|
+
sendJson(res, 200, { ok: true, available: false, reason: 'p2p-unavailable' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const body = await readJsonBody(req, 16 * 1024);
|
|
75
|
+
const candidates = normalizeCandidates(body && body.candidates);
|
|
76
|
+
const localCandidates = normalizeCandidates(body && body.localCandidates);
|
|
77
|
+
// 手机自己的公钥:电脑拉起隧道时要拿它对 WireGuard 握手。没有它,电脑配不出对端,
|
|
78
|
+
// 这条通道根本起不来(不是「打洞打不通」,是连设备都配不上)。
|
|
79
|
+
const phonePublicKey = normalizePublicKey(body && body.phonePublicKey);
|
|
80
|
+
// 手机候选为空也允许:电脑照样能「只听不打」地起来、把自己的候选报回去,等手机来打。
|
|
81
|
+
// 硬要它先有候选,等于把「谁先拨」的顺序焊死,第一发永远没有候选。
|
|
82
|
+
|
|
83
|
+
const opened = await deps.p2p.open({
|
|
84
|
+
// **不转 `body.peerKey`**:手机那一格装的是**我这台电脑的公钥**(它拿它确认「对面是那台
|
|
85
|
+
// 电脑」),不是它自己的。会话要的对端是手机那把,只有 `phonePublicKey` 一处。
|
|
86
|
+
// 早先把这一格直接当对端传下去,电脑于是拿自己的公钥配 WireGuard —— 洞能开、`path`
|
|
87
|
+
// 报 `direct`,但握手永远对不上,数据面一个包都过不去。
|
|
88
|
+
phonePublicKey,
|
|
89
|
+
candidates,
|
|
90
|
+
localCandidates,
|
|
91
|
+
targetPort: targetPortOf(),
|
|
92
|
+
});
|
|
93
|
+
if (!opened || opened.ok === false) {
|
|
94
|
+
sendJson(res, 200, {
|
|
95
|
+
ok: true,
|
|
96
|
+
available: false,
|
|
97
|
+
reason: (opened && opened.code) || 'start-failed',
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 回我自己的候选:手机拿到这份,才知道往哪打。
|
|
103
|
+
sendJson(res, 200, {
|
|
104
|
+
ok: true,
|
|
105
|
+
available: true,
|
|
106
|
+
// 隧道里那个固定端口(手机拨它)。
|
|
107
|
+
port: deps.p2p.constants ? deps.p2p.constants.P2P_PORT : undefined,
|
|
108
|
+
// **我这一把公钥**:手机要拿它确认「对面是那台电脑」并配 WireGuard 对端。二维码里
|
|
109
|
+
// 有这一份,但已经配过对、或换过电脑的老会话也得能自己拿到 —— 否则手机只能重新扫码。
|
|
110
|
+
publicKey: typeof opened.publicKey === 'string' ? opened.publicKey : '',
|
|
111
|
+
// 电脑的出口地址(STUN 那份 + 本机私网那份)。**这一份才是打洞的起点**。
|
|
112
|
+
candidates: normalizeCandidates(opened.candidates),
|
|
113
|
+
localCandidates: [],
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { createP2PRendezvousHandler, normalizeCandidates };
|