@twinsearth/w2m-dsh-plugin 0.48.0 → 0.49.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/CHANGELOG.md CHANGED
@@ -4,6 +4,68 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.49.0] — 2026-10-15
8
+
9
+ **Every message now gets an answer. Five inputs were being silently dropped, and two of the three input
10
+ types the request asked me to advertise are not supported.**
11
+
12
+ ### What was silent, measured before it was changed
13
+
14
+ ```
15
+ 文字 /run node --version → dispatch
16
+ 文字 /run --ask 你好 → reply
17
+ 文字 你好 → ignore why=not a /run line
18
+ 图片 (type 2) → ignore why=not a text message
19
+ 语音 (type 3) → ignore why=not a text message
20
+ 空 item_list → ignore why=not a text message
21
+ ```
22
+
23
+ **Five inputs, no answer.** A bot that reads messages and replies to none of them is
24
+ **indistinguishable from a bot that is broken**, and the operator's only move was to send the command again.
25
+
26
+ ### The one silence that survived, and why it had to
27
+
28
+ `no sender` and `no context_token` still return `ignore`. **With no sender or no context token there is
29
+ nowhere to send an answer**, so returning `reply` would be a plan that lies about what the caller can do.
30
+ **That is the reason the vocabulary still contains `ignore` at all** — collapsing it into `reply` would have
31
+ made every plan look successful while nothing was sent.
32
+
33
+ ### The request asked the help to advertise "text, voice and images". I refused two of the three.
34
+
35
+ `textOf` reads only `item type: 1` — **measured, not inferred: an image and a voice note both answer
36
+ `not a text message`.** So the help says:
37
+
38
+ > 支持:文字。中文可以。
39
+ > 不支持:语音、图片、文件、表情——它们只会得到这条帮助。
40
+
41
+ **A help text that promised a capability the bridge does not have would be read as a bug the first time
42
+ somebody sent a photo**, and the bug report would be about the bridge rather than about the promise.
43
+
44
+ ### The three sections are asserted by heading
45
+
46
+ `【1】开关指令` · `【2】指令规则与格式` · `【3】参考与边界` — asserted by name rather than by "the text mentions
47
+ commands", because **a help text that loses a section still reads fine.**
48
+
49
+ Two of the seven new assertions are worth naming:
50
+
51
+ * **`--ask` is in the help.** It was reachable only by reading the source, which is the entire
52
+ discoverability defect this section exists to close. If it ever leaves the help, the defect is back.
53
+ * **every `/run` example in the help is put through the real parser.** The help cannot drift into
54
+ advertising syntax that is refused.
55
+
56
+ ### The activation instruction, answered honestly
57
+
58
+ *"If refused, provide the activation instruction"* — and the honest answer is that **a chat window cannot
59
+ activate itself**. The help says the capabilities are granted by whoever starts the bridge, through
60
+ `W2M_CAPABILITIES`, and that a refusal names the missing capability. **Implying otherwise would have sent
61
+ the operator looking for a command that does not exist.**
62
+
63
+ **Three existing tests demanded `ignore` and now demand `reply`.** They were right about the rule — a typo
64
+ must never become a fleet execution — and wrong about how the rule is expressed. They were updated rather
65
+ than deleted, because **the old expectation is the record of what changed.**
66
+
67
+ **7 new tests, 0 failures.**
68
+
7
69
  ## [0.48.0] — 2026-10-15
8
70
 
9
71
  **`--dry-run` dispatched. Two dry runs sent a command to five machines, and one made a real task.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -140,6 +140,64 @@ export function sendSucceeded(response) {
140
140
  return response.ret === 0 || response.errcode === 0;
141
141
  }
142
142
 
143
+ /**
144
+ * What to say when a message is not a command.
145
+ *
146
+ * ## The requirement this satisfies, and the part of it that had to be refused
147
+ *
148
+ * *"Every sentence from WeChat must get a reply; a mistake must get help."* Before this, **five measured
149
+ * inputs were silently dropped**: a plain sentence, an image, a voice note, an empty `item_list`, and an
150
+ * empty string. The operator sees a bot that reads messages and answers nothing, which is
151
+ * indistinguishable from a bot that is broken.
152
+ *
153
+ * The request also asked the help to advertise *"text, voice and images"*. **Two of those three are not
154
+ * supported**, and `textOf` reading only `type: 1` is the proof: an image and a voice note both answer
155
+ * `not a text message`. So the boundary section says what is true — **a help text that promises a
156
+ * capability the bridge does not have would be read as a bug the first time somebody sent a photo.**
157
+ *
158
+ * ## Why the boundary is a section rather than a footnote
159
+ *
160
+ * A user cannot discover a limitation they are never told about. **"Voice is not supported" arriving in
161
+ * the same breath as the command syntax is the difference between a limitation and a mystery.**
162
+ *
163
+ * @param {{reason?: string|null}} [context]
164
+ * @returns {string}
165
+ */
166
+ export function helpText(context = {}) {
167
+ const why = typeof context.reason === 'string' && context.reason !== '' ? context.reason : null;
168
+ const lines = [];
169
+ if (why !== null) lines.push(`没有执行这条消息:${why}`, '');
170
+
171
+ lines.push(
172
+ '【1】开关指令',
173
+ ' /run <命令> 把一个命令派发到编队(默认所有在线机器)',
174
+ ' /run --ask <一句话> 问一句话,不执行任何东西',
175
+ ' /run --on <机器> <命令> 只派发到指定机器(逗号分隔)',
176
+ ' /run --timeout <毫秒> <命令> 覆盖等待上限',
177
+ ' /run --mode <模式> <命令> replicate | split | broadcast | pipeline',
178
+ '',
179
+ ' —— 关于权限:这个窗口当前的能力是 operate、fleet.dispatch。',
180
+ ' 若被拒绝,回复里会写明缺哪一个能力;能力由启动桥接的人通过',
181
+ ' W2M_CAPABILITIES 授予,聊天窗口本身不能自行激活。',
182
+ '',
183
+ '【2】指令规则与格式',
184
+ ' · 只能以 /run 开头,其余文字一律当作普通消息并回这条帮助。',
185
+ ' · /run 之后是 argv(逐个词),不是 shell 字符串:',
186
+ ' 管道 | 、重定向 > 、&& 都不会生效,会被拒绝。',
187
+ ' · 每个词里的空格要用引号包起来。',
188
+ ' · 举例: /run node --version',
189
+ ' /run git rev-parse --short HEAD',
190
+ ' /run --ask 现在编队里有几台机器在线?',
191
+ '',
192
+ '【3】参考与边界',
193
+ ' · 支持:文字。中文可以。',
194
+ ' · 不支持:语音、图片、文件、表情——它们只会得到这条帮助。',
195
+ ' · 命令是否被允许由目标机器自己的允许清单决定;',
196
+ ' 被拒绝时回复会带上原因码(如 COMMAND_NOT_ALLOWED)。',
197
+ );
198
+ return lines.join('\n');
199
+ }
200
+
143
201
  /**
144
202
  * Decide what to do with one inbound message.
145
203
  *
@@ -158,14 +216,34 @@ export function planMessage({ message, authorize }) {
158
216
  const contextToken = message?.context_token;
159
217
  const text = textOf(message);
160
218
 
219
+ /**
220
+ * The only two real silences, and both are structural rather than a decision.
221
+ *
222
+ * With no `from_user_id` or no `context_token` there is nowhere to send an answer to — so a `reply`
223
+ * here would be a promise the caller cannot keep. **These stay `ignore`, and they are the reason the
224
+ * vocabulary still has one**: collapsing them into `reply` would make the plan lie about what the
225
+ * script can do. Everything below this line gets an answer, because it can.
226
+ */
161
227
  if (typeof from !== 'string' || from === '') return { act: 'ignore', why: 'no sender' };
162
228
  if (typeof contextToken !== 'string' || contextToken === '') return { act: 'ignore', why: 'no context_token' };
163
- if (text === null) return { act: 'ignore', why: 'not a text message' };
229
+
230
+ // A non-text message is answered rather than dropped: "every sentence must get a reply" includes the
231
+ // sentences the bridge cannot read.
232
+ if (text === null) {
233
+ return { act: 'reply', to: from, contextToken, text: helpText({ reason: '这条消息不是文字(语音/图片/文件暂不支持)' }) };
234
+ }
164
235
 
165
236
  const trimmed = text.trim();
166
- // Only the exact `/run` prefix is an action. Everything else is conversation, which is what keeps
167
- // a typo from becoming a fleet execution.
168
- if (!/^\/run(\s|$)/i.test(trimmed)) return { act: 'ignore', why: 'not a /run line' };
237
+ // Only the exact `/run` prefix is an action, and that rule is unchanged: a typo must never become a
238
+ // fleet execution. What changed is that it now gets an answer instead of silence.
239
+ if (!/^\/run(\s|$)/i.test(trimmed)) {
240
+ return {
241
+ act: 'reply',
242
+ to: from,
243
+ contextToken,
244
+ text: helpText({ reason: trimmed === '' ? '空消息' : '这条消息没有以 /run 开头' }),
245
+ };
246
+ }
169
247
 
170
248
  const body = trimmed.replace(/^\/run\s*/i, '');
171
249
  const invocation = parseInvocation(body);
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '0.48.0';
211
+ const PLUGIN_VERSION = '0.49.0';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;