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,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 付款确认:把「这一下点下去要花钱」送去宿主已有的审批通道,等人点了「同意」再放行。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么复用审批通道,而不是自己推一张卡
|
|
7
|
+
*
|
|
8
|
+
* 手机上那张确认卡、用户点按钮、结论交回来,这条链在 0.2.x 上已经由审批桥
|
|
9
|
+
* (`mobile/approvals.js`)铺好了:它 `prepend` 了宿主的 `approval/request` 钩子,钩子一响
|
|
10
|
+
* 就把卡推给正在看这条会话的手机,用户点了同意再 `settle` 回钩子的 promise。我们自己再造
|
|
11
|
+
* 一条推卡的路,等于让手机上同时存在两套确认卡、两套回答接口,谁先答都说不清。
|
|
12
|
+
*
|
|
13
|
+
* 所以这里只做一件事:以 `ctx.get('approval')` 的身份发起一次真正的审批请求。宿主的
|
|
14
|
+
* `ApprovalService.request()` 会把这次的 `approval/asked` / `approval/decided` 记进会话日志
|
|
15
|
+
* (审计里就是这么写的),再走一遍 `approval/request` waterfall —— 审批桥在那一步把卡交给
|
|
16
|
+
* 手机。拿回 `allowed-once` 才等于「用户就这一下点了同意」。
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么绝对不会挂死
|
|
19
|
+
*
|
|
20
|
+
* 三处都可能「永远没人答」:手机没在看(卡没有去处)、用户在别处把这一轮取消了、钩子上
|
|
21
|
+
* 下游是个不回答的转发器。前两处各自有出路(下面判 unavailable / 跟着 signal 取消),
|
|
22
|
+
* 第三处靠一道自己的硬期限兜底:到点就回 `unavailable`,且先 `abort()` 让宿主那头把
|
|
23
|
+
* 挂起的审批一起收掉。工具调用挂在这里,模型的整轮就会一直转下去 —— 那比「没确认」
|
|
24
|
+
* 严重得多,所以宁可超时也不要等。
|
|
25
|
+
*
|
|
26
|
+
* ## 三个返回值
|
|
27
|
+
*
|
|
28
|
+
* `allowed-once`(就这一次可以) / `rejected`(问过了,答案是拒绝或取消) /
|
|
29
|
+
* `unavailable`(压根没能问到人)。调用点只认 `allowed-once`,别的一律不执行。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 一次确认最多等多久。
|
|
34
|
+
*
|
|
35
|
+
* 够用户拿起手机看一眼再点同意,又不至于让模型的这一发停在那里。之所以不设「等到有人答
|
|
36
|
+
* 为止」:卡没推到人手上(手机没在看这条会话、用户把 App 切走了)时,等的是一个永远不来
|
|
37
|
+
* 的答案;而一次工具调用挂着一分钟以上,在模型那边就是「卡住了」。
|
|
38
|
+
*/
|
|
39
|
+
const APPROVAL_TIMEOUT_MS = 90_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 这一步要不要人确认。判定仍由 `irreversible.js` 给,这里只挑出付款 / 下单那一类 ——
|
|
43
|
+
* 发送、提交、删除虽然也收不回来,但它们是这条任务本来就该做的事,每一步都拦会让
|
|
44
|
+
* 「让它发条消息」变成一连串确认框。
|
|
45
|
+
*/
|
|
46
|
+
function needsConfirmation(judged) {
|
|
47
|
+
return Boolean(judged && judged.irreversible === true && judged.kind === 'payment');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 宿主的审批服务。`ctx.get()` 而不是写进 inject:这个服务的装配顺序跟手机那几条路由无关,
|
|
52
|
+
* 注入一个当前宿主版本里没有的服务会把整个插件装载拖垮(与 `mobile/host-web.js` 同一口径)。
|
|
53
|
+
*/
|
|
54
|
+
function approvalOf(ctx) {
|
|
55
|
+
if (!ctx) return null;
|
|
56
|
+
try {
|
|
57
|
+
if (typeof ctx.get === 'function') {
|
|
58
|
+
const service = ctx.get('approval');
|
|
59
|
+
if (service && typeof service.request === 'function') return service;
|
|
60
|
+
}
|
|
61
|
+
} catch { /* 服务没装就往下试属性那一种写法 */ }
|
|
62
|
+
const direct = ctx.approval;
|
|
63
|
+
return direct && typeof direct.request === 'function' ? direct : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 这条会话的 agent。审批请求要求请求方带着自己的会话(`ApprovalService.request()` 要往
|
|
68
|
+
* 那条会话里记审计事件,还要看它的 `approval/policy`),拿不到就等于没人能答。
|
|
69
|
+
*
|
|
70
|
+
* 工具层手上一般已经握着这次调用的 agent,优先用它 —— 比按 id 去服务里捞更准,
|
|
71
|
+
* 子代理那条路径上 `agents.get(childSessionId)` 本来就取不到。
|
|
72
|
+
*/
|
|
73
|
+
function agentOf(ctx, sessionId, hint) {
|
|
74
|
+
if (hint && hint.session) return hint;
|
|
75
|
+
if (!ctx || typeof ctx.get !== 'function' || !sessionId) return null;
|
|
76
|
+
try {
|
|
77
|
+
const agents = ctx.get('agents');
|
|
78
|
+
if (agents && typeof agents.get === 'function') return agents.get(sessionId) || null;
|
|
79
|
+
} catch { /* 没这个服务就没有别的路可走 */ }
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 卡片上那句话:做了什么都写进去,用户看着手机就能判断这一下该不该点。 */
|
|
84
|
+
function summaryText(summary, amountText) {
|
|
85
|
+
const parts = [];
|
|
86
|
+
const head = typeof summary === 'string' ? summary.trim().slice(0, 200) : '';
|
|
87
|
+
const amount = typeof amountText === 'string' ? amountText.trim().slice(0, 60) : '';
|
|
88
|
+
if (head) parts.push(head);
|
|
89
|
+
if (amount) parts.push(`金额 ${amount}`);
|
|
90
|
+
return parts.join(';') || '操作手机:这一步涉及付款 / 下单,需要你确认';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 宿主那套结论词表收成一个三选一。取消与拒绝对调用点是一回事:都别执行。 */
|
|
94
|
+
function normalizeOutcome(outcome) {
|
|
95
|
+
if (outcome === 'allowed-once') return 'allowed-once';
|
|
96
|
+
if (outcome === 'rejected' || outcome === 'cancelled') return 'rejected';
|
|
97
|
+
return 'unavailable';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 发起一次确认并等结论。
|
|
102
|
+
*
|
|
103
|
+
* @param {object} ctx 插件上下文(要能 `get('approval')`)
|
|
104
|
+
* @param {object} options
|
|
105
|
+
* @param {string} options.sessionId 这条对话
|
|
106
|
+
* @param {string} [options.summary] 卡片摘要:这一步在做什么
|
|
107
|
+
* @param {string} [options.amountText] 金额一类的字样,有就一起上卡
|
|
108
|
+
* @param {object} [options.agent] 工具层手上这次调用的 agent(有就用,没有按会话捞)
|
|
109
|
+
* @param {string} [options.toolName] 卡片上那个「工具 X」的名字
|
|
110
|
+
* @param {string} [options.callId] 与这一步的工具调用对上,便于手机与桌面对同一张卡
|
|
111
|
+
* @param {AbortSignal} [options.signal] 这一轮工具的取消信号(用户取消整轮时跟着结束)
|
|
112
|
+
* @param {boolean} [options.watching] 有没有手机在看着这条会话;明确为 false 就不必问了
|
|
113
|
+
* @param {number} [options.timeoutMs] 覆盖默认期限:调用方自己的预算比三分钟还短时用它,
|
|
114
|
+
* 确认不该比整条调用活得久
|
|
115
|
+
* @returns {Promise<'allowed-once'|'rejected'|'unavailable'>}
|
|
116
|
+
*/
|
|
117
|
+
async function requestConfirmation(ctx, options) {
|
|
118
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
119
|
+
const sessionId = typeof opts.sessionId === 'string' ? opts.sessionId.trim() : '';
|
|
120
|
+
// 认不出是哪条对话:卡片没有归属,宿主也会因为「没有开着的轮次」拒绝记账。
|
|
121
|
+
if (!sessionId) return 'unavailable';
|
|
122
|
+
// 没有手机在看:卡推不出去,问也白问。如实说「没问到」,别把这一步挂在那儿。
|
|
123
|
+
if (opts.watching === false) return 'unavailable';
|
|
124
|
+
|
|
125
|
+
const approval = approvalOf(ctx);
|
|
126
|
+
if (!approval) return 'unavailable';
|
|
127
|
+
const agent = agentOf(ctx, sessionId, opts.agent);
|
|
128
|
+
if (!agent || !agent.session) return 'unavailable';
|
|
129
|
+
|
|
130
|
+
const reason = summaryText(opts.summary, opts.amountText);
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const parent = opts.signal;
|
|
133
|
+
const onAbort = () => controller.abort();
|
|
134
|
+
if (parent && typeof parent.addEventListener === 'function') {
|
|
135
|
+
// 工具已经被取消了就别再发一张卡出来:那一轮已经没有人在等这个答案。
|
|
136
|
+
if (parent.aborted) return 'unavailable';
|
|
137
|
+
parent.addEventListener('abort', onAbort, { once: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const waitMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
141
|
+
? Math.min(APPROVAL_TIMEOUT_MS, Math.round(opts.timeoutMs))
|
|
142
|
+
: APPROVAL_TIMEOUT_MS;
|
|
143
|
+
let timer = null;
|
|
144
|
+
// 硬期限与请求本身赛跑:即便宿主那头不理会 abort,这里也一定在期限内交出结论。
|
|
145
|
+
const deadline = new Promise((resolve) => {
|
|
146
|
+
timer = setTimeout(() => {
|
|
147
|
+
controller.abort();
|
|
148
|
+
resolve('unavailable');
|
|
149
|
+
}, waitMs);
|
|
150
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const asked = Promise.resolve()
|
|
155
|
+
.then(() => approval.request({
|
|
156
|
+
agent,
|
|
157
|
+
...(typeof opts.toolName === 'string' && opts.toolName ? { toolName: opts.toolName } : {}),
|
|
158
|
+
...(typeof opts.callId === 'string' && opts.callId ? { callId: opts.callId } : {}),
|
|
159
|
+
reason,
|
|
160
|
+
// 桌面与手机上的卡片都优先用这句(审批桥的 `displayReasonOf` 认 zh)。
|
|
161
|
+
displayReason: { zh: reason },
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
}))
|
|
164
|
+
// 宿主在「没有开着的轮次」「策略是 never」这些情况下会抛,一律折算成没问到。
|
|
165
|
+
.then((outcome) => normalizeOutcome(outcome), () => 'unavailable');
|
|
166
|
+
return await Promise.race([asked, deadline]);
|
|
167
|
+
} catch {
|
|
168
|
+
return 'unavailable';
|
|
169
|
+
} finally {
|
|
170
|
+
if (timer) clearTimeout(timer);
|
|
171
|
+
if (parent && typeof parent.removeEventListener === 'function') parent.removeEventListener('abort', onAbort);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 确认没拿到时给模型的那句话。两种没拿到的原因要分开说,模型据此决定要不要请用户回来。 */
|
|
176
|
+
function blockedMessage(outcome, what) {
|
|
177
|
+
const step = typeof what === 'string' && what ? what : '这一步';
|
|
178
|
+
if (outcome === 'rejected') {
|
|
179
|
+
return `${step}是付款 / 下单,需要用户确认;用户没有同意(或者这一轮被取消了),所以没有执行。把这件事如实告诉用户,别自己重试。`;
|
|
180
|
+
}
|
|
181
|
+
return `${step}是付款 / 下单,需要用户确认,但刚才没能把确认卡送到用户手上(手机没有在看这条对话,或者确认通道不可用),所以没有执行。请用户回到这条对话、在手机上看着屏幕再试一次;也可以让用户自己点这一下。`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
needsConfirmation,
|
|
186
|
+
requestConfirmation,
|
|
187
|
+
blockedMessage,
|
|
188
|
+
APPROVAL_TIMEOUT_MS,
|
|
189
|
+
};
|
|
@@ -15,8 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
const { createPhoneService } = require('./service.js');
|
|
17
17
|
const { installPhoneTools } = require('./tools.js');
|
|
18
|
+
const { installRouteHint } = require('./route-hint.js');
|
|
18
19
|
const { listDevices, describeDevice } = require('./routes.js');
|
|
19
20
|
const binding = require('./binding.js');
|
|
21
|
+
const pacing = require('./pacing.js');
|
|
22
|
+
const irreversible = require('./irreversible.js');
|
|
23
|
+
const routesMemory = require('./routes-memory.js');
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* @param {object} ctx 插件上下文
|
|
@@ -52,12 +56,28 @@ function mountPhoneControl(ctx, deps) {
|
|
|
52
56
|
if (!message.includes('without inject')) throw error;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
// 只读的历史路线查询工具:单独一路注册,不经过 tools.js 那套步数/死循环守卫(它不发动作)。
|
|
60
|
+
// 同一口径:缺 tools 注入的 profile 只少这一条,不要把整块装配掀掉。
|
|
61
|
+
try {
|
|
62
|
+
installRouteHint(ctx);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const message = error && error.message ? error.message : String(error);
|
|
65
|
+
if (!message.includes('without inject')) throw error;
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
ctx.effect(() => () => service.close(), `${deps.name || 'dsh-mobile-plugin'}: phone control`);
|
|
56
69
|
// 路由表那边要凭据库与实例号来报设备名,而它只拿得到这个服务(装配层只转交一个对象)。
|
|
57
70
|
// 挂在服务上而不是再走一遍依赖注入:多一处注入就多一处能不同步的地方。
|
|
58
71
|
service.credentials = deps.credentials;
|
|
59
72
|
service.instanceId = deps.instanceId;
|
|
73
|
+
// 节奏与不可逆判定是纯函数模块,挂到服务上,工具层拿同一个服务就能顺手取到,
|
|
74
|
+
// 省得再开一路依赖注入 —— 少一处注入就少一处能不同步的地方。
|
|
75
|
+
service.pacing = pacing;
|
|
76
|
+
service.irreversible = irreversible;
|
|
77
|
+
// 成功路线记忆:工具层在结局判定为成功后调 `service.routesMemory.record(...)`,查询走
|
|
78
|
+
// `phone_route_hint`。挂到服务上,与 pacing / irreversible 同一口径,少一路依赖注入。
|
|
79
|
+
service.routesMemory = routesMemory;
|
|
60
80
|
return service;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
module.exports = { mountPhoneControl, binding };
|
|
83
|
+
module.exports = { mountPhoneControl, binding, pacing, irreversible, routesMemory };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 不可逆动作判定:纯函数,只回答「这一下点下去是不是收不回来」,不做任何动作。
|
|
5
|
+
*
|
|
6
|
+
* 给 tools 层做单步提示、给 batch 做熔断用。判据刻意窄:只有 **tap** 且能读到「点的是哪个
|
|
7
|
+
* 按钮的文字」才算数。读不到文字(只有 ref、只有坐标而没 target)就判不了,宁可放过也不
|
|
8
|
+
* 误判 —— 把整屏文字当成这一下的目标,会把「聊天页里恰好有『删除』两个字」判成删除,
|
|
9
|
+
* 那比漏判更烦人(每次点都停下来问)。
|
|
10
|
+
*
|
|
11
|
+
* 词表繁简两边都认,加上英文的 send / submit / order / pay / buy / delete。分成五类,
|
|
12
|
+
* 但输出的 kind 只有 payment / submit / send / delete / none —— 下单、购买、付款这类收钱的
|
|
13
|
+
* 都归 payment(对熔断来说它们是一回事:都花钱)。
|
|
14
|
+
*
|
|
15
|
+
* 已知局限:这是词表兜底,不看语义。订单状态一类的文字(「待付款」「付款成功」「订单记录」)
|
|
16
|
+
* 里也有付款 / 订单词,会被当成对外动作 —— 方向是「宁可多停一次」,不会误执行。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 只有点屏幕才有「点的是哪个按钮」这回事。 */
|
|
20
|
+
const TAP_ACTIONS = new Set(['tap']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 词表按 kind 分组,顺序即优先级:先看样子最重的付款 / 下单,再提交、发送、删除。
|
|
24
|
+
* 中文按包含判;纯英文词按单词边界判,免得 order 在 reorder、delete 在 undelete 里误命中。
|
|
25
|
+
*/
|
|
26
|
+
const WORD_TABLE = [
|
|
27
|
+
{
|
|
28
|
+
kind: 'payment',
|
|
29
|
+
words: [
|
|
30
|
+
'支付', '付款', '买单', '买單', '下单', '下單', '购买', '購買',
|
|
31
|
+
'结算', '結算', '订单', '訂單', '收银台', '收銀台',
|
|
32
|
+
'pay', 'payment', 'order', 'buy', 'purchase', 'checkout',
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
kind: 'submit',
|
|
37
|
+
words: ['提交', '确认提交', '確認提交', '送出', 'submit'],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kind: 'send',
|
|
41
|
+
words: ['发送', '傳送', '传送', '发布', '發布', '发表', '發表', 'send', 'post'],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
kind: 'delete',
|
|
45
|
+
words: ['删除', '刪除', '清空', '移除', 'delete', 'remove', 'clear'],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** 这些字段可能装着「点的是哪个按钮」的文字;按顺序取,取到非空就用。 */
|
|
50
|
+
const LABEL_FIELDS = [
|
|
51
|
+
'text', 'target', 'label', 'name', 'description', 'desc',
|
|
52
|
+
'contentDescription', 'title', 'content', 'value',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function isEnglishWord(word) {
|
|
56
|
+
return /^[a-z]+$/.test(word);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 在一个文字里找第一个命中的词,返回 {kind, word} 或 null。 */
|
|
60
|
+
function matchLabel(label) {
|
|
61
|
+
const lower = label.toLowerCase();
|
|
62
|
+
for (const group of WORD_TABLE) {
|
|
63
|
+
for (const word of group.words) {
|
|
64
|
+
if (isEnglishWord(word)) {
|
|
65
|
+
// 英文按整词匹配,避免 order 命中 reorder / preorder。
|
|
66
|
+
if (new RegExp(`\\b${word}\\b`, 'i').test(lower)) return { kind: group.kind, word };
|
|
67
|
+
} else if (label.includes(word)) {
|
|
68
|
+
return { kind: group.kind, word };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 从参数里能读到的、像「被点元素文字」的那几条。 */
|
|
76
|
+
function labelsOf(args) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const key of LABEL_FIELDS) {
|
|
79
|
+
const value = args[key];
|
|
80
|
+
if (typeof value === 'string') {
|
|
81
|
+
const text = value.trim();
|
|
82
|
+
if (text) out.push(text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 一个孤零零的短串才可能是按钮文字;长段多行的是整屏,不能当目标。 */
|
|
89
|
+
function looksLikeLabel(text) {
|
|
90
|
+
const trimmed = text.trim();
|
|
91
|
+
if (!trimmed || trimmed.length > 32) return false;
|
|
92
|
+
return !/[\r\n]/.test(trimmed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function none(reason) {
|
|
96
|
+
return { irreversible: false, kind: 'none', reason };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* judge({ action, args, screenText }) → { irreversible, kind, reason }。
|
|
101
|
+
*
|
|
102
|
+
* `screenText` 只在「既没 ref / 坐标、也没有任何文字字段」且它本身像个按钮短串时才当目标文字;
|
|
103
|
+
* 有 ref 或坐标却没有文字时一律判不了 —— 这是「ref 点按不误判」那条要求的落点。
|
|
104
|
+
*/
|
|
105
|
+
function judge(input) {
|
|
106
|
+
const { action, args, screenText } = input || {};
|
|
107
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
108
|
+
|
|
109
|
+
if (typeof action !== 'string' || !TAP_ACTIONS.has(action)) {
|
|
110
|
+
return none('只判 tap:别的动作没有「点的是哪个按钮」这回事');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasRef = typeof a.ref === 'string' && a.ref.trim() !== '';
|
|
114
|
+
const hasPoint = Number.isFinite(a.x) && Number.isFinite(a.y);
|
|
115
|
+
let labels = labelsOf(a);
|
|
116
|
+
|
|
117
|
+
if (labels.length === 0) {
|
|
118
|
+
if (hasRef || hasPoint) {
|
|
119
|
+
return none('只有 ref / 坐标、没有说话文字,判定不了');
|
|
120
|
+
}
|
|
121
|
+
if (typeof screenText === 'string' && looksLikeLabel(screenText)) {
|
|
122
|
+
labels = [screenText.trim()];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (labels.length === 0) return none('没有可判的文字');
|
|
127
|
+
|
|
128
|
+
for (const label of labels) {
|
|
129
|
+
const hit = matchLabel(label);
|
|
130
|
+
if (hit) {
|
|
131
|
+
return {
|
|
132
|
+
irreversible: true,
|
|
133
|
+
kind: hit.kind,
|
|
134
|
+
reason: `「${label}」命中不可逆词「${hit.word}」`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return none('没有命中不可逆词表');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
judge,
|
|
143
|
+
// 下面几个给守卫直接用真值表,不重复实现匹配逻辑。
|
|
144
|
+
WORD_TABLE,
|
|
145
|
+
matchLabel,
|
|
146
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 操作节奏:决定「动作之间停多久」「对外动作之间至少隔多久」。
|
|
5
|
+
*
|
|
6
|
+
* 三档 fast / standard / human,默认 standard,存在插件设置里(与播报开关同一处:
|
|
7
|
+
* `<DSH_HOME>/storages/mobile-plugin/phone-pacing.json`)。这里只给数字,**自己不 sleep**:
|
|
8
|
+
* 真正等待的是执行动作的那一层,本模块一睡就把测试和宿主都拖住。
|
|
9
|
+
*
|
|
10
|
+
* 对外动作的间隔照 Mika 的 OutwardPacing:最近 30 分钟内做得越多,间隔越往上涨,
|
|
11
|
+
* 上下限各 +0.5s / +1s,封顶 30s。第一次不额外等 —— 一条消息本来就要花那几个动作,
|
|
12
|
+
* 从第一条起就硬等只会让人以为手机卡了。
|
|
13
|
+
*
|
|
14
|
+
* 落盘失败不抛:节奏是锦上添花,写不进去也要让这一轮按新节奏继续跑。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const { dshHome } = require('../../shared/paths');
|
|
21
|
+
|
|
22
|
+
/** 可选的三档。 */
|
|
23
|
+
const MODES = ['fast', 'standard', 'human'];
|
|
24
|
+
const DEFAULT_MODE = 'standard';
|
|
25
|
+
|
|
26
|
+
/** 对外动作计数的时间窗:只算最近半小时做过的。 */
|
|
27
|
+
const OUTWARD_WINDOW_MS = 30 * 60 * 1000;
|
|
28
|
+
/** 每多做一次对外动作,间隔下限 / 上限各加这么多毫秒。 */
|
|
29
|
+
const PER_ACTION_MS = [500, 1000];
|
|
30
|
+
/** 间隔封顶,再密也不等超过半分钟。 */
|
|
31
|
+
const MAX_OUTWARD_MS = 30 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 三档的具体数字。
|
|
35
|
+
*
|
|
36
|
+
* `betweenActionsMs`:同一轮里相邻两个动作之间的停留范围;
|
|
37
|
+
* `outwardMinMs`:两个对外动作(发送 / 提交 / 下单这类)之间的最小间隔范围,起点也会被
|
|
38
|
+
* noteOutward 按次数往上加;
|
|
39
|
+
* `typingChunkMs`:一段一段打字时每段之间停多久(fast 一次写全文,所以是 0)。
|
|
40
|
+
*/
|
|
41
|
+
const TABLES = {
|
|
42
|
+
fast: {
|
|
43
|
+
betweenActionsMs: [0, 0],
|
|
44
|
+
outwardMinMs: [0, 0],
|
|
45
|
+
typingChunkMs: 0,
|
|
46
|
+
},
|
|
47
|
+
standard: {
|
|
48
|
+
betweenActionsMs: [500, 1200],
|
|
49
|
+
outwardMinMs: [8000, 20000],
|
|
50
|
+
typingChunkMs: 40,
|
|
51
|
+
},
|
|
52
|
+
human: {
|
|
53
|
+
betweenActionsMs: [1000, 2000],
|
|
54
|
+
outwardMinMs: [15000, 30000],
|
|
55
|
+
typingChunkMs: 90,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function settingsFile() {
|
|
60
|
+
return path.join(dshHome(), 'storages', 'mobile-plugin', 'phone-pacing.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 把大小写 / 空白各异的输入收成一个合法档名;认不出返回 null。 */
|
|
64
|
+
function normalizeMode(mode) {
|
|
65
|
+
if (typeof mode !== 'string') return null;
|
|
66
|
+
const m = mode.trim().toLowerCase();
|
|
67
|
+
return MODES.includes(m) ? m : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 上一轮写盘失败时记住的档名:写不进去也不能让调用方读回旧值。 */
|
|
71
|
+
let memoryMode = null;
|
|
72
|
+
|
|
73
|
+
function readStoredMode() {
|
|
74
|
+
try {
|
|
75
|
+
const raw = JSON.parse(fs.readFileSync(settingsFile(), 'utf8'));
|
|
76
|
+
return normalizeMode(raw && raw.mode);
|
|
77
|
+
} catch {
|
|
78
|
+
// 文件不存在 / 读坏 / 字段不对都当没设过,走默认档。
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 当前档。读不到就是 standard(坏文件同样落在这里)。 */
|
|
84
|
+
function get() {
|
|
85
|
+
if (memoryMode) return memoryMode;
|
|
86
|
+
return readStoredMode() || DEFAULT_MODE;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 改成某一档并落盘。非法档名是调用方写错了,直接抛;写盘失败只记住内存值、不抛。
|
|
91
|
+
* 返回改后的当前档,方便调用方直接回给界面。
|
|
92
|
+
*/
|
|
93
|
+
function set(mode) {
|
|
94
|
+
const m = normalizeMode(mode);
|
|
95
|
+
if (!m) throw new RangeError(`未知的操作节奏:${String(mode)}(可选 ${MODES.join(' / ')})`);
|
|
96
|
+
try {
|
|
97
|
+
const file = settingsFile();
|
|
98
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
fs.writeFileSync(file, `${JSON.stringify({ mode: m }, null, 2)}\n`);
|
|
100
|
+
memoryMode = null;
|
|
101
|
+
} catch {
|
|
102
|
+
memoryMode = m;
|
|
103
|
+
}
|
|
104
|
+
return m;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 某一档的数字。认不出的档按 standard 给,避免调用方拿到 undefined 再炸开。 */
|
|
108
|
+
function paceFor(mode) {
|
|
109
|
+
const table = TABLES[normalizeMode(mode) || DEFAULT_MODE];
|
|
110
|
+
return {
|
|
111
|
+
betweenActionsMs: [...table.betweenActionsMs],
|
|
112
|
+
outwardMinMs: [...table.outwardMinMs],
|
|
113
|
+
typingChunkMs: table.typingChunkMs,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 最近做过的对外动作时间点(升序)。只有一台手机,不按包名分组。 */
|
|
118
|
+
let outwardAt = [];
|
|
119
|
+
|
|
120
|
+
function prune(now) {
|
|
121
|
+
while (outwardAt.length > 0 && now - outwardAt[0] > OUTWARD_WINDOW_MS) outwardAt.shift();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 记一次「现在要做对外动作」,返回这一发至少要等多少毫秒。
|
|
126
|
+
*
|
|
127
|
+
* 返回的是**最小**值(下限),不是随机值:调用方自己决定要不要在这个余量之外再多停。
|
|
128
|
+
* 第一次对外动作返回 0;之后按最近 30 分钟已做的次数把下限往上抬(每次 +0.5s),
|
|
129
|
+
* 封顶 30s;如果从上一次到现在已经隔够了,返回 0。
|
|
130
|
+
*/
|
|
131
|
+
function noteOutward(nowMs) {
|
|
132
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
133
|
+
const pace = paceFor(get());
|
|
134
|
+
prune(now);
|
|
135
|
+
|
|
136
|
+
let minWaitMs = 0;
|
|
137
|
+
const baseLowMs = pace.outwardMinMs[0];
|
|
138
|
+
// fast 档下限是 0:不计数、不加码,永远不等(与 Mika 的 Off 一个意思)。
|
|
139
|
+
if (outwardAt.length > 0 && baseLowMs > 0) {
|
|
140
|
+
const extra = outwardAt.length - 1;
|
|
141
|
+
const required = Math.min(baseLowMs + extra * PER_ACTION_MS[0], MAX_OUTWARD_MS);
|
|
142
|
+
const elapsed = now - outwardAt[outwardAt.length - 1];
|
|
143
|
+
minWaitMs = Math.max(0, required - elapsed);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
outwardAt.push(now);
|
|
147
|
+
return minWaitMs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 单测用:清掉对外动作的计数。 */
|
|
151
|
+
function resetOutwardForTest() {
|
|
152
|
+
outwardAt = [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 单测用:把内存里「写盘失败留下的档」也清掉。 */
|
|
156
|
+
function resetCacheForTest() {
|
|
157
|
+
memoryMode = null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
get,
|
|
162
|
+
set,
|
|
163
|
+
paceFor,
|
|
164
|
+
noteOutward,
|
|
165
|
+
resetOutwardForTest,
|
|
166
|
+
resetCacheForTest,
|
|
167
|
+
settingsFile,
|
|
168
|
+
MODES,
|
|
169
|
+
DEFAULT_MODE,
|
|
170
|
+
OUTWARD_WINDOW_MS,
|
|
171
|
+
PER_ACTION_MS,
|
|
172
|
+
MAX_OUTWARD_MS,
|
|
173
|
+
};
|