@mu-cabin/muc-cli 0.2.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +49 -6
  2. package/dist/muc.mjs +814 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,11 +13,14 @@ with real devices; see [Coexistence](#coexistence) below before sending.
13
13
 
14
14
  A single command (`muc`) packaging:
15
15
 
16
- - **Send** — 1:1 and group text, files, images, and voice clips.
16
+ - **Send** — 1:1 and group text, files, images, and voice clips. Add and
17
+ remove emoticon reactions on existing messages.
17
18
  - **Read** — server-side chat history, profile lookups, group lists, sticker
18
- packs, and directory search (people, departments, apps, service numbers).
19
+ packs, directory search (people, departments, apps, service numbers), and
20
+ long-lived `listen` tail of inbound messages with sender/keyword filters.
19
21
  - **Manage** — login / token refresh, withdraw a message, clear a session,
20
- download a chat-attached file.
22
+ download a chat-attached file, wait for a human emoticon reaction before
23
+ proceeding (`wait-react`).
21
24
  - **Self-describe** — every command has a `--json` mode emitting a stable
22
25
  envelope; `muc manifest --json` returns the full command graph + per-command
23
26
  output JSON Schema for agent / script consumers.
@@ -186,6 +189,9 @@ MUC_ACCOUNT=WANGLULU8 MUC_PASSWORD='…' muc login
186
189
  | `muc emoticons` | Sticker packages installed on your account. |
187
190
  | `muc history --peer UID \| --team TEAM_ID \| --to-self` | Server-side chat history via the roaming-messages endpoint. Stateless: each call fetches from the server. Default window: 30 days, 50 messages newest-first. |
188
191
  | `muc download --file-key <key> [--out <path>]` | Download a chat-attached file from the V5 file server (TCP 6101). **Read-only** — opens a fresh per-request session, no chat login, doesn't kick anyone. Pass `--mid <mid>` instead of `--file-key` plus `--peer/--team/--to-self` to look the file up via roaming history. Verifies md5 on completion. |
192
+ | `muc react --mid <MID> --sticker-id <id> [target]` | Add an emoticon reaction to an existing message. **Visible.** Defaults to `--to-self`; non-self chats need `--yes-not-self`. By default assumes the original message was sent by the current user; pass `--src-sender-id <UID>` (and `--src-sender-app <appkey>`) to react to someone else's message. |
193
+ | `muc reactions --mid <MID>` | List all emoticon reactions on a single message. Read-only. |
194
+ | `muc unreact --emoticon-id <id> [target]` | Revoke one of your own reactions (id from `muc react` or `muc reactions`). |
189
195
  | `muc manifest` | Print the full command graph + per-command output JSON Schema. Pair with `--json` for agents. |
190
196
 
191
197
  `history` flags: scope with one of `--peer UID`, `--team TEAM_ID`,
@@ -214,6 +220,8 @@ muc history --team 9009876543210000 --before 2026-04-20 --json | jq .
214
220
  | `muc send-voice <file> --duration <ms> [target]` | Same as `send-file` but as `MESSAGE_CHAT_AUDIO` (subType=9). `--duration <ms>` is required (the desktop renders the bubble's clip length from this metadata, not from the file). |
215
221
  | `muc withdraw --mid <MID> [target]` | Recall a sent message. Original gets replaced server-side with "您撤回了一条消息". Target flag must match the original message's chat. |
216
222
  | `muc clear-session [target]` | Delete a session from your client-side history (syncs to all your devices). **Doesn't delete server-side history** — `history` still returns the messages. |
223
+ | `muc wait-react --mid <MID> [--from-uid UID] [--sticker-id ID] [--timeout 300]` | Block until someone reacts with an emoticon to `--mid`, then exit. Useful as a "human-in-the-loop approval" gate after `muc send`. Exits with `TIMEOUT` (exit 5) if no matching reaction arrives within `--timeout` seconds. |
224
+ | `muc listen [--from-uid UID] [--team TEAM] [--peer UID] [--contains TEXT] [--regex …] [--at-me] [--max N]` | Long-lived TCP tap that emits one **NDJSON line per matching inbound message** (in `--json` mode each line is the standard envelope; in human mode it's a one-line summary). Supports repeatable filters (OR semantics within each kind, AND across kinds), `--at-me` to restrict to group messages where another user @-mentions you (implies excluding self), `--include-self` to include your own sends, `--include-reactions` to also surface type=3 sub=300/301 frames, `--max N` to exit after N matches. Stop with `^C`. |
217
225
 
218
226
  `--os` defaults to `iOS`. Valid values: `Windows`, `OS X`, `Mac`, `iOS`,
219
227
  `Android`. **`--os` does not provide coexistence — read the section below
@@ -222,6 +230,19 @@ before using any chat-channel command if you have a real device logged in.**
222
230
  Sending to anyone other than yourself requires the explicit `--yes-not-self`
223
231
  flag — a guard against accidentally messaging real humans during testing.
224
232
 
233
+ **Message tail (signature)**: by default `muc send` appends a short tail to
234
+ the text it sends (e.g. `"\n—— 来自王璐璐的muc-cli"`) so recipients can
235
+ tell automated traffic from a typed message. The check is idempotent —
236
+ if your text already ends with the resolved tail, it is not appended a
237
+ second time. Customise it with the
238
+ `messageTail` field in `~/.muc/config.json` — set to a non-empty string to
239
+ override, or to `""` to disable entirely. You can also use
240
+ `muc init --message-tail "<custom>"` / `muc init --no-message-tail` to
241
+ write the value, and per-call flags `muc send --tail "<one-off>"` /
242
+ `muc send --no-tail` to override on a single send. Only `muc send`
243
+ (plain-text) is affected; `send-file` / `send-image` / `send-voice` are
244
+ not.
245
+
225
246
  ```bash
226
247
  muc tcp-ping --wait 8 # safe protocol smoke test
227
248
  muc send "hello from the CLI" # to-self (default)
@@ -338,6 +359,30 @@ $ muc emoticons
338
359
  Both files share the `~/.muc/` parent (mode 700). The CLI creates them on
339
360
  first use; you should never need to edit them by hand.
340
361
 
362
+ ## Changelog
363
+
364
+ #### `0.3.0`
365
+ `2026.04.29`
366
+ - Add `muc react` / `muc unreact` / `muc reactions` to add, revoke, and list emoticon reactions on existing messages
367
+ - Add `muc wait-react` to block until a human reacts to a given mid; useful as a "human-in-the-loop approval" gate after `muc send`
368
+ - Add `muc listen`, a long-lived TCP tap with sender / chat / keyword / `--at-me` filters, one NDJSON line per match
369
+ - Exit code `5` now also covers `TIMEOUT` and `KICKED`
370
+
371
+ #### `0.2.2`
372
+ `2026.04.28`
373
+ - `muc send` plain-text now appends an idempotent signature tail by default; override via `--tail` / `--no-tail`, `muc init --message-tail`, or `messageTail` in `~/.muc/config.json`
374
+
375
+ #### `0.2.1`
376
+ `2026.04.28`
377
+ - Skills unified across macOS / Linux / Windows
378
+ - On Windows, file mode `600` is silently ignored — use NTFS ACLs on shared hosts to protect `~/.muc/`
379
+
380
+ #### `0.2.0`
381
+ `2026.04.27`
382
+ - Add `muc init`: extract `appKey` / `appId` / signing secrets out of source code, write to `~/.muc/config.json` (mode 600)
383
+ - Companion subcommands: `muc init --check` / `--show` / `--reset`
384
+ - License changed to MIT; package published to npm with public access
385
+
341
386
  ## What it can't do (yet)
342
387
 
343
388
  - **Richtext messages** (`subType=11`, mixed text + image + @-mention in one
@@ -347,8 +392,6 @@ first use; you should never need to edit them by hand.
347
392
  - **Forwarded / merged messages** (`subType=26`).
348
393
  - **SSO / scan-to-login.** REST username+password works; the SSO flow is
349
394
  not wired up.
350
- - **Live push streaming.** Use `muc history` for server-side message
351
- retrieval; there is no long-lived `talk/msg` listener wired up.
352
395
  - **Image thumbnails.** The upload step asks the server to make `tn1`/`tn5`
353
396
  thumbs, but `muc download` only fetches the original `fileKey`.
354
397
  - **End-to-end-encrypted file content.** Not active on the default tenant
@@ -391,7 +434,7 @@ first use; you should never need to edit them by hand.
391
434
  | 2 | USAGE |
392
435
  | 3 | AUTH_REQUIRED, AUTH_EXPIRED |
393
436
  | 4 | NOT_FOUND |
394
- | 5 | REMOTE_ERROR, NETWORK |
437
+ | 5 | REMOTE_ERROR, NETWORK, TIMEOUT, KICKED |
395
438
  | 6 | CONFIG_MISSING |
396
439
  | 7 | CONFIG_INVALID |
397
440
 
package/dist/muc.mjs CHANGED
@@ -21301,6 +21301,12 @@ var MucConfigSchema = external_exports.object({
21301
21301
  imPort: external_exports.number().int().min(1).max(65535),
21302
21302
  fileHost: external_exports.string().min(1, "fileHost required"),
21303
21303
  filePort: external_exports.number().int().min(1).max(65535),
21304
+ // Optional signature appended to outbound `muc send` text. Tri-state:
21305
+ // undefined → use built-in default (defaultMessageTail in constants.ts)
21306
+ // "" → explicit opt-out (no tail appended)
21307
+ // any other → that literal string (e.g. "\n— bot")
21308
+ // Per-call `--tail` / `--no-tail` flags on `muc send` override this.
21309
+ messageTail: external_exports.string().max(200).optional(),
21304
21310
  savedAt: external_exports.number().int().nonnegative(),
21305
21311
  schemaVersion: external_exports.literal(CONFIG_SCHEMA_VERSION)
21306
21312
  });
@@ -21397,6 +21403,8 @@ var EXIT_CODES = {
21397
21403
  NOT_FOUND: 4,
21398
21404
  REMOTE_ERROR: 5,
21399
21405
  NETWORK: 5,
21406
+ TIMEOUT: 5,
21407
+ KICKED: 5,
21400
21408
  CONFIG_MISSING: 6,
21401
21409
  CONFIG_INVALID: 7,
21402
21410
  INTERNAL: 1
@@ -21414,6 +21422,9 @@ var JSON_MODE = false;
21414
21422
  function setJsonMode(v) {
21415
21423
  JSON_MODE = v;
21416
21424
  }
21425
+ function isJsonMode() {
21426
+ return JSON_MODE;
21427
+ }
21417
21428
  function stableStringify(value) {
21418
21429
  return JSON.stringify(value, (_k, v) => {
21419
21430
  if (v && typeof v === "object" && !Array.isArray(v)) {
@@ -21585,6 +21596,24 @@ function requireSingleTarget(opts, label) {
21585
21596
  );
21586
21597
  }
21587
21598
  }
21599
+ function defineStream(program3, spec) {
21600
+ const cmd = program3.command(spec.name).description(spec.description);
21601
+ spec.configure?.(cmd);
21602
+ cmd.action(async () => {
21603
+ try {
21604
+ await spec.run(cmd);
21605
+ } catch (e) {
21606
+ if (e instanceof CliError) fail(e.code, e.message, e.hint);
21607
+ const cls = classifyError(e);
21608
+ fail(cls.code, cls.msg, cls.hint);
21609
+ }
21610
+ });
21611
+ REGISTRY.push({
21612
+ name: spec.name,
21613
+ description: spec.description,
21614
+ output_schema: spec.streamSchema ?? { type: "object", description: "NDJSON-stream command \u2014 one JSON object per stdout line" }
21615
+ });
21616
+ }
21588
21617
  function define2(program3, spec) {
21589
21618
  const cmd = program3.command(spec.name).description(spec.description);
21590
21619
  spec.configure?.(cmd);
@@ -21700,6 +21729,11 @@ var SECURE_SYS_KEY = "mx_app_key";
21700
21729
  var PWD_DES_IV = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]);
21701
21730
  var DEFAULT_CHAT_OS = "iOS";
21702
21731
  var DEFAULT_REST_PLATFORM = PLATFORM_IOS;
21732
+ function defaultMessageTail(userName) {
21733
+ const name = userName.trim() || "unknown";
21734
+ return `
21735
+ \u2014\u2014 \u6765\u81EA${name}\u7684muc-cli`;
21736
+ }
21703
21737
  var DEFAULTS = {
21704
21738
  // Hosts/ports for the 东航 deployment. Other tenants override at init.
21705
21739
  restHost: "https://muc.ceair.com/",
@@ -22048,6 +22082,46 @@ function register(program3) {
22048
22082
 
22049
22083
  // src/commands/init.ts
22050
22084
  import * as readline2 from "node:readline";
22085
+
22086
+ // src/lib/tail.ts
22087
+ function resolveTail(configured, userName, override) {
22088
+ if (override.disable) return "";
22089
+ if (override.custom != null) return override.custom;
22090
+ if (configured === "") return "";
22091
+ if (configured != null) return configured;
22092
+ return defaultMessageTail(userName);
22093
+ }
22094
+ function applyTail(body, tail) {
22095
+ if (!tail) return body;
22096
+ if (body.endsWith(tail)) return body;
22097
+ return body + tail;
22098
+ }
22099
+ function detectFlagConflict(argv2, name) {
22100
+ const valueLong = `--${name}`;
22101
+ const negatedLong = `--no-${name}`;
22102
+ const valueEq = `${valueLong}=`;
22103
+ let sawValueForm = false;
22104
+ let sawNegatedForm = false;
22105
+ for (let i = 0; i < argv2.length; i++) {
22106
+ const a = argv2[i];
22107
+ if (a === "--") break;
22108
+ if (a === valueLong) {
22109
+ sawValueForm = true;
22110
+ i++;
22111
+ continue;
22112
+ }
22113
+ if (a.startsWith(valueEq)) {
22114
+ sawValueForm = true;
22115
+ continue;
22116
+ }
22117
+ if (a === negatedLong) {
22118
+ sawNegatedForm = true;
22119
+ }
22120
+ }
22121
+ return { sawValueForm, sawNegatedForm };
22122
+ }
22123
+
22124
+ // src/commands/init.ts
22051
22125
  var FingerprintShape = external_exports.object({
22052
22126
  appKey: external_exports.string(),
22053
22127
  appId: external_exports.string(),
@@ -22060,6 +22134,8 @@ var FingerprintShape = external_exports.object({
22060
22134
  imPort: external_exports.number(),
22061
22135
  fileHost: external_exports.string(),
22062
22136
  filePort: external_exports.number(),
22137
+ messageTail: external_exports.string(),
22138
+ // summary: "default" / "none" / "custom (N chars)"
22063
22139
  savedAt: external_exports.number(),
22064
22140
  schemaVersion: external_exports.literal(CONFIG_SCHEMA_VERSION)
22065
22141
  });
@@ -22080,6 +22156,11 @@ function fingerprint2(v) {
22080
22156
  if (v.length <= 8) return "***";
22081
22157
  return `${v.slice(0, 4)}\u2026${v.slice(-4)}`;
22082
22158
  }
22159
+ function summarizeTail(t) {
22160
+ if (t === void 0) return "default";
22161
+ if (t === "") return "none";
22162
+ return `custom (${t.length} chars)`;
22163
+ }
22083
22164
  function redactConfig(c) {
22084
22165
  return {
22085
22166
  appKey: fingerprint2(c.appKey),
@@ -22093,6 +22174,7 @@ function redactConfig(c) {
22093
22174
  imPort: c.imPort,
22094
22175
  fileHost: c.fileHost,
22095
22176
  filePort: c.filePort,
22177
+ messageTail: summarizeTail(c.messageTail),
22096
22178
  savedAt: c.savedAt,
22097
22179
  schemaVersion: c.schemaVersion
22098
22180
  };
@@ -22238,7 +22320,7 @@ function register2(program3) {
22238
22320
  description: "Bootstrap or update ~/.muc/config.json (tenant config: APP_KEY, APP_ID, signing secrets, hosts/ports). Interactive prompts by default; non-interactive via flags or MUC_* env vars. WARNING: env vars are visible to other processes via /proc/<pid>/environ and shell history \u2014 prefer interactive or flag-based for production.",
22239
22321
  output: Output2,
22240
22322
  configure: (cmd) => {
22241
- cmd.option("--check", "verify config presence and validity (exit 0 ok / 6 missing / 7 invalid); does not modify").option("--show", "print the saved config (secrets are always redacted in both human and JSON output; cat ~/.muc/config.json for raw values)").option("--reset", "delete ~/.muc/config.json (requires --yes)").option("--yes", "confirm a destructive action like --reset").option("--no-interactive", "fail rather than prompt for missing fields").option("--app-key <hex>", "tenant APP_KEY (8 lowercase hex)").option("--app-id <hex>", "tenant APP_ID (24 lowercase hex)").option("--sign-secret-rest <s>", "REST signing secret").option("--sign-secret-secure <s>", "secure-API signing secret").option("--pwd-des-key <s>", "password DES key (defaults to APP_KEY)").option("--rest-host <url>", "REST host URL").option("--sso-host <url>", "SSO host URL (optional)").option("--im-host <h>", "IM TCP host").option("--im-port <n>", "IM TCP port").option("--file-host <h>", "file-channel TCP host").option("--file-port <n>", "file-channel TCP port");
22323
+ cmd.option("--check", "verify config presence and validity (exit 0 ok / 6 missing / 7 invalid); does not modify").option("--show", "print the saved config (secrets are always redacted in both human and JSON output; cat ~/.muc/config.json for raw values)").option("--reset", "delete ~/.muc/config.json (requires --yes)").option("--yes", "confirm a destructive action like --reset").option("--no-interactive", "fail rather than prompt for missing fields").option("--app-key <hex>", "tenant APP_KEY (8 lowercase hex)").option("--app-id <hex>", "tenant APP_ID (24 lowercase hex)").option("--sign-secret-rest <s>", "REST signing secret").option("--sign-secret-secure <s>", "secure-API signing secret").option("--pwd-des-key <s>", "password DES key (defaults to APP_KEY)").option("--rest-host <url>", "REST host URL").option("--sso-host <url>", "SSO host URL (optional)").option("--im-host <h>", "IM TCP host").option("--im-port <n>", "IM TCP port").option("--file-host <h>", "file-channel TCP host").option("--file-port <n>", "file-channel TCP port").option("--message-tail <text>", "custom signature appended to outbound `muc send` text (max 200 chars)").option("--no-message-tail", "explicitly disable the message tail (no signature appended)");
22242
22324
  },
22243
22325
  run: async (cmd) => {
22244
22326
  const opts = cmd.opts();
@@ -22336,6 +22418,18 @@ function register2(program3) {
22336
22418
  partial[f.key] = parsed;
22337
22419
  }
22338
22420
  }
22421
+ const mtConflict = detectFlagConflict(process.argv.slice(2), "message-tail");
22422
+ if (mtConflict.sawValueForm && mtConflict.sawNegatedForm) {
22423
+ throw new CliError("USAGE", "--message-tail and --no-message-tail are mutually exclusive");
22424
+ }
22425
+ let messageTail;
22426
+ if (opts.messageTail === false) {
22427
+ messageTail = "";
22428
+ } else if (typeof opts.messageTail === "string") {
22429
+ messageTail = opts.messageTail;
22430
+ } else if (existing && "messageTail" in existing) {
22431
+ messageTail = existing.messageTail;
22432
+ }
22339
22433
  const draft = {
22340
22434
  appKey: String(partial.appKey ?? ""),
22341
22435
  appId: String(partial.appId ?? ""),
@@ -22348,6 +22442,7 @@ function register2(program3) {
22348
22442
  imPort: Number(partial.imPort ?? 0),
22349
22443
  fileHost: String(partial.fileHost ?? ""),
22350
22444
  filePort: Number(partial.filePort ?? 0),
22445
+ messageTail,
22351
22446
  savedAt: Date.now(),
22352
22447
  schemaVersion: CONFIG_SCHEMA_VERSION
22353
22448
  };
@@ -22763,17 +22858,20 @@ var MucChatSocket = class extends EventEmitter {
22763
22858
  host;
22764
22859
  port;
22765
22860
  ackTimeoutMs;
22861
+ idleTimeoutMs;
22766
22862
  debug;
22767
22863
  sock = null;
22768
22864
  reader;
22769
22865
  sq = 2;
22770
22866
  pending = /* @__PURE__ */ new Map();
22771
22867
  closed = false;
22868
+ idleTimer = null;
22772
22869
  constructor(opts) {
22773
22870
  super();
22774
22871
  this.host = opts.host;
22775
22872
  this.port = opts.port;
22776
22873
  this.ackTimeoutMs = opts.ackTimeoutMs ?? 25e3;
22874
+ this.idleTimeoutMs = opts.idleTimeoutMs ?? 75e3;
22777
22875
  this.debug = !!opts.debug;
22778
22876
  this.reader = new FrameReader((f) => this.handleFrame(f));
22779
22877
  }
@@ -22783,12 +22881,20 @@ var MucChatSocket = class extends EventEmitter {
22783
22881
  const sock = createConnection({ host: this.host, port: this.port });
22784
22882
  this.sock = sock;
22785
22883
  sock.setKeepAlive(true, 2e4);
22884
+ const errorSink = (_e) => {
22885
+ };
22886
+ this.on("error", errorSink);
22786
22887
  const onErr = (e) => {
22787
22888
  cleanup();
22788
22889
  reject(e);
22789
22890
  };
22891
+ const onPreReadyClose = () => {
22892
+ cleanup();
22893
+ reject(new Error("chat socket closed before negotiate-ack"));
22894
+ };
22790
22895
  const cleanup = () => {
22791
22896
  sock.off("error", onErr);
22897
+ sock.off("close", onPreReadyClose);
22792
22898
  this.off("ready", onReady);
22793
22899
  };
22794
22900
  const onReady = () => {
@@ -22796,26 +22902,56 @@ var MucChatSocket = class extends EventEmitter {
22796
22902
  resolve2();
22797
22903
  };
22798
22904
  sock.once("error", onErr);
22905
+ sock.once("close", onPreReadyClose);
22799
22906
  this.once("ready", onReady);
22800
22907
  sock.on("connect", () => {
22801
22908
  if (this.debug) console.error("[wire] tcp connect");
22802
22909
  this.emit("connect");
22910
+ this.armIdle();
22803
22911
  this.sendNegotiate();
22804
22912
  });
22805
- sock.on("data", (buf) => this.reader.feed(buf));
22806
- sock.on("error", (e) => this.emit("error", e));
22807
- sock.on("close", () => {
22808
- if (this.closed) return;
22809
- this.closed = true;
22810
- for (const { reject: reject2, timer } of this.pending.values()) {
22811
- clearTimeout(timer);
22812
- reject2(new Error("socket closed"));
22813
- }
22814
- this.pending.clear();
22815
- this.emit("close");
22913
+ sock.on("data", (buf) => {
22914
+ this.armIdle();
22915
+ this.reader.feed(buf);
22816
22916
  });
22917
+ sock.on("error", (e) => this.emit("error", e));
22918
+ sock.on("close", () => this.teardown(new Error("socket closed")));
22817
22919
  });
22818
22920
  }
22921
+ /**
22922
+ * (Re)arm the inactivity watchdog. Called every time inbound bytes land
22923
+ * (data callback) or any active confirmation. If no traffic arrives for
22924
+ * `idleTimeoutMs`, we synthesize a teardown so commands don't hang on a
22925
+ * half-open socket the kernel hasn't surfaced yet.
22926
+ */
22927
+ armIdle() {
22928
+ if (this.closed) return;
22929
+ if (this.idleTimer) clearTimeout(this.idleTimer);
22930
+ this.idleTimer = setTimeout(() => {
22931
+ if (this.closed) return;
22932
+ this.emit("error", new Error(`chat socket idle for ${Math.round(this.idleTimeoutMs / 1e3)}s \u2014 tearing down`));
22933
+ try {
22934
+ this.sock?.destroy();
22935
+ } catch {
22936
+ }
22937
+ this.teardown(new Error("idle timeout"));
22938
+ }, this.idleTimeoutMs);
22939
+ }
22940
+ /** Single owner of close-side cleanup: clears idle timer, rejects all pending sends, emits 'close' once. */
22941
+ teardown(reason) {
22942
+ if (this.closed) return;
22943
+ this.closed = true;
22944
+ if (this.idleTimer) {
22945
+ clearTimeout(this.idleTimer);
22946
+ this.idleTimer = null;
22947
+ }
22948
+ for (const { reject, timer } of this.pending.values()) {
22949
+ clearTimeout(timer);
22950
+ reject(reason);
22951
+ }
22952
+ this.pending.clear();
22953
+ this.emit("close");
22954
+ }
22819
22955
  sendNegotiate() {
22820
22956
  const frame = buildFrame(T.NEGOTIATE, NEGOTIATE_BODY, NEGOTIATE_CRC);
22821
22957
  if (this.debug) console.error("[wire] \u2192 NEGOTIATE", frame.toString("hex"));
@@ -22854,11 +22990,26 @@ var MucChatSocket = class extends EventEmitter {
22854
22990
  if (!this.sock || this.closed) throw new Error("not connected");
22855
22991
  this.sock.write(buildFrame(type, payload, crcOverride));
22856
22992
  }
22993
+ /**
22994
+ * Send an unsolicited frame that *re-uses* a sq from a server-pushed frame
22995
+ * (rather than allocating a new one via nextSq). This is how the desktop
22996
+ * SDK ACKs inbound `talk/msg` (message-server/modules/message.js:961-984):
22997
+ * it echoes the original frame's sq back with `{sid, cid, data:{tids}}`.
22998
+ * Fire-and-forget — no pending registration, no response expected.
22999
+ */
23000
+ sendAck(sid, cid, sq, data = {}) {
23001
+ if (!this.sock || this.closed) throw new Error("not connected");
23002
+ const obj = { sid, cid, sq, data };
23003
+ const payload = aesEncrypt(JSON.stringify(obj));
23004
+ if (this.debug) console.error(`[wire] \u2192 ACK ${sid}/${cid} sq=${sq}`);
23005
+ this.sock.write(buildFrame(T.AES_JSON, payload));
23006
+ }
22857
23007
  close() {
22858
23008
  if (!this.sock || this.closed) return;
22859
- this.closed = true;
23009
+ const sock = this.sock;
23010
+ this.teardown(new Error("socket closed by client"));
22860
23011
  try {
22861
- this.sock.end();
23012
+ sock.end();
22862
23013
  } catch {
22863
23014
  }
22864
23015
  }
@@ -22868,10 +23019,10 @@ var MucChatSocket = class extends EventEmitter {
22868
23019
  try {
22869
23020
  bodyText = aesDecrypt(f.payload);
22870
23021
  } catch (e) {
22871
- if (this.listenerCount("error") > 0) {
22872
- this.emit("error", new Error(`aes decrypt failed (type=${f.type} len=${f.payload.length}): ${e.message}`));
22873
- } else {
22874
- console.error(`[wire] aes decrypt failed (type=${f.type} len=${f.payload.length}): ${e.message}`);
23022
+ const err = new Error(`aes decrypt failed (type=${f.type} len=${f.payload.length}): ${e.message}`);
23023
+ this.emit("error", err);
23024
+ if (this.listenerCount("error") <= 1) {
23025
+ console.error(`[wire] ${err.message}`);
22875
23026
  }
22876
23027
  return;
22877
23028
  }
@@ -23519,7 +23670,7 @@ function register15(program3) {
23519
23670
  description: "Send a plain-text chat message via the TCP chat channel. VISIBLE TO THE RECIPIENT \u2014 non-self targets require --yes-not-self.",
23520
23671
  output: Output15,
23521
23672
  configure: (cmd) => {
23522
- cmd.argument("<text...>", "message text (variadic; words rejoined with spaces)").option("--peer <uid>", "1:1 chat with a peer uid").option("--team <id>", "group chat with a team id").option("--to-self", "send to your own uid (default if no target given)").option("--yes-not-self", "required to confirm sending to anyone other than yourself").option("--dry-run", "build the frame but do not connect").option("--os <os>", "chat-channel os slot", parseChatOs, DEFAULT_CHAT_OS).option("--debug", "forward wire-level debug events to stderr");
23673
+ cmd.argument("<text...>", "message text (variadic; words rejoined with spaces)").option("--peer <uid>", "1:1 chat with a peer uid").option("--team <id>", "group chat with a team id").option("--to-self", "send to your own uid (default if no target given)").option("--yes-not-self", "required to confirm sending to anyone other than yourself").option("--dry-run", "build the frame but do not connect").option("--os <os>", "chat-channel os slot", parseChatOs, DEFAULT_CHAT_OS).option("--tail <text>", "override message tail for this call only").option("--no-tail", "do not append a message tail for this call").option("--debug", "forward wire-level debug events to stderr");
23523
23674
  },
23524
23675
  run: async (cmd) => {
23525
23676
  const text = cmd.args.join(" ").trim();
@@ -23528,6 +23679,15 @@ function register15(program3) {
23528
23679
  const cfg = requireConfig();
23529
23680
  const cred = requireAuth(loadCredentials());
23530
23681
  requireSingleTarget(opts, "send");
23682
+ const conflict = detectFlagConflict(process.argv.slice(2), "tail");
23683
+ if (conflict.sawValueForm && conflict.sawNegatedForm) {
23684
+ throw new CliError("USAGE", "--tail and --no-tail are mutually exclusive");
23685
+ }
23686
+ const override = {};
23687
+ if (opts.tail === false) override.disable = true;
23688
+ else if (typeof opts.tail === "string") override.custom = opts.tail;
23689
+ const tail = resolveTail(cfg.messageTail, cred.name ?? "", override);
23690
+ const finalText = applyTail(text, tail);
23531
23691
  let scene;
23532
23692
  let toId;
23533
23693
  if (opts.team) {
@@ -23554,7 +23714,7 @@ function register15(program3) {
23554
23714
  scene,
23555
23715
  type: 1,
23556
23716
  subType: 1,
23557
- body: text,
23717
+ body: finalText,
23558
23718
  fName: cred.name,
23559
23719
  platform: opts.os === "Mac" || opts.os === "OS X" ? "Mac" : "Windows",
23560
23720
  appKey: cfg.appKey
@@ -25010,8 +25170,634 @@ function register21(program3) {
25010
25170
  });
25011
25171
  }
25012
25172
 
25013
- // src/commands/manifest.ts
25173
+ // src/lib/reactions.ts
25174
+ async function saveReply(cfg, args) {
25175
+ const { ctx, stickerId, senderName, isFromMe, selfAppKey } = args;
25176
+ const isGroup = ctx.scene === "team_g";
25177
+ const targetApp = ctx.targetApp ?? selfAppKey;
25178
+ const body = {
25179
+ sid: ctx.sid,
25180
+ mid: ctx.mid,
25181
+ appkey: selfAppKey,
25182
+ toId: isGroup ? ctx.sid : isFromMe ? ctx.peerUid : ctx.srcSenderId,
25183
+ toAppKey: isGroup ? targetApp : isFromMe ? targetApp : ctx.srcSenderApp || ctx.peerApp,
25184
+ groupId: isGroup ? ctx.sid : void 0,
25185
+ stickerId,
25186
+ senderName,
25187
+ srcSenderId: ctx.srcSenderId,
25188
+ srcSenderApp: ctx.srcSenderApp
25189
+ };
25190
+ return await request({
25191
+ method: "POST",
25192
+ auth: true,
25193
+ url: `${apiUrls(cfg).IMM}/emoticon/reply/save`,
25194
+ body,
25195
+ headers: { "Content-Type": "application/json" }
25196
+ });
25197
+ }
25198
+ async function listReplies(cfg, mid) {
25199
+ return await request({
25200
+ method: "GET",
25201
+ auth: true,
25202
+ url: `${apiUrls(cfg).IMM}/emoticon/reply/list`,
25203
+ qs: { mid }
25204
+ });
25205
+ }
25206
+ async function revokeReply(cfg, args) {
25207
+ const { emoticonId, ctx, isFromMe, selfAppKey } = args;
25208
+ const targetApp = ctx.targetApp ?? selfAppKey;
25209
+ const body = {
25210
+ emoticonId,
25211
+ appKey: selfAppKey
25212
+ };
25213
+ if (ctx.scene === "p2p") {
25214
+ body.toId = isFromMe ? ctx.peerUid : ctx.srcSenderId;
25215
+ body.toAppKey = isFromMe ? targetApp : ctx.srcSenderApp || ctx.peerApp;
25216
+ } else {
25217
+ body.groupId = ctx.sid;
25218
+ body.toAppKey = targetApp;
25219
+ }
25220
+ return await request({
25221
+ method: "POST",
25222
+ auth: true,
25223
+ url: `${apiUrls(cfg).IMM}/emoticon/reply/revocation`,
25224
+ body,
25225
+ headers: { "Content-Type": "application/json" }
25226
+ });
25227
+ }
25228
+
25229
+ // src/commands/react.ts
25014
25230
  var Output22 = external_exports.object({
25231
+ scene: external_exports.enum(["p2p", "team_g"]),
25232
+ sId: external_exports.string(),
25233
+ mid: external_exports.string(),
25234
+ stickerId: external_exports.string(),
25235
+ emoticonId: external_exports.string().optional(),
25236
+ raw: external_exports.unknown()
25237
+ });
25238
+ function register22(program3) {
25239
+ define2(program3, {
25240
+ name: "react",
25241
+ description: "Add an emoticon reaction to a message (POST emoticon/reply/save). VISIBLE TO THE TARGET CHAT \u2014 non-self targets require --yes-not-self.",
25242
+ output: Output22,
25243
+ configure: (cmd) => {
25244
+ cmd.requiredOption("--mid <mid>", "message id to react to").requiredOption("--sticker-id <id>", "emoticon/sticker id (run `muc emoticons` to list packages)").option("--peer <uid>", "1:1 chat with a peer uid (the OTHER side of the conversation)").option("--team <id>", "group chat with a team id").option("--to-self", "react in your self-chat (default if no target)").option("--src-sender-id <uid>", "UID of the ORIGINAL message sender (defaults to current user \u2014 i.e. reacting to your own message)").option("--src-sender-app <appkey>", "appKey of the original message sender (defaults to your app key)").option("--target-app-key <appkey>", "original message `app_key` field \u2014 distinct from --src-sender-app (`fApp`); only matters in cross-tenant reacts. Defaults to your app key.").option("--sender-name <name>", "override displayed sender name (defaults to your saved name)").option("--yes-not-self", "required to confirm reacting in a non-self conversation");
25245
+ },
25246
+ run: async (cmd) => {
25247
+ const opts = cmd.opts();
25248
+ const cfg = requireConfig();
25249
+ const cred = requireAuth(loadCredentials());
25250
+ requireSingleTarget(opts, "react");
25251
+ let scene;
25252
+ let peerUid;
25253
+ if (opts.team) {
25254
+ scene = "team_g";
25255
+ } else if (opts.toSelf || !opts.peer && !opts.team) {
25256
+ scene = "p2p";
25257
+ peerUid = cred.uid;
25258
+ } else {
25259
+ scene = "p2p";
25260
+ peerUid = opts.peer.toUpperCase();
25261
+ }
25262
+ const isSelf = scene === "p2p" && peerUid === cred.uid;
25263
+ if (!isSelf && !opts.yesNotSelf) {
25264
+ throw new CliError(
25265
+ "USAGE",
25266
+ `refusing to react in ${scene}:${opts.team ?? peerUid} without --yes-not-self`,
25267
+ "add --yes-not-self to confirm \u2014 reactions are visible to all participants"
25268
+ );
25269
+ }
25270
+ const srcSenderId = (opts.srcSenderId ?? cred.uid).toUpperCase();
25271
+ const srcSenderApp = opts.srcSenderApp ?? cfg.appKey;
25272
+ const isFromMe = srcSenderId === cred.uid && srcSenderApp === cfg.appKey;
25273
+ const sid = scene === "team_g" ? opts.team : buildSId("p2p", cred.uid, peerUid);
25274
+ const ctx = {
25275
+ scene,
25276
+ sid,
25277
+ mid: opts.mid,
25278
+ srcSenderId,
25279
+ srcSenderApp,
25280
+ peerUid,
25281
+ peerApp: cfg.appKey,
25282
+ targetApp: opts.targetAppKey
25283
+ };
25284
+ const res = await saveReply(cfg, {
25285
+ ctx,
25286
+ stickerId: opts.stickerId,
25287
+ senderName: opts.senderName ?? cred.name ?? cred.uid,
25288
+ isFromMe,
25289
+ selfAppKey: cfg.appKey
25290
+ });
25291
+ if (res.code !== 0) {
25292
+ throw new CliError("REMOTE_ERROR", `react rejected: code=${res.code} msg=${res.msg ?? ""}`.trim());
25293
+ }
25294
+ const data = res.data ?? {};
25295
+ const emoticonId = pickEmoticonId(data);
25296
+ return {
25297
+ scene,
25298
+ sId: sid,
25299
+ mid: opts.mid,
25300
+ stickerId: opts.stickerId,
25301
+ emoticonId,
25302
+ raw: res
25303
+ };
25304
+ }
25305
+ });
25306
+ }
25307
+ function pickEmoticonId(data) {
25308
+ if (!data || typeof data !== "object") return void 0;
25309
+ const d = data;
25310
+ for (const k of ["emoticonId", "id", "replyId", "commentId"]) {
25311
+ const v = d[k];
25312
+ if (typeof v === "string" && v.length > 0) return v;
25313
+ if (typeof v === "number") return String(v);
25314
+ }
25315
+ return void 0;
25316
+ }
25317
+
25318
+ // src/commands/reactions.ts
25319
+ var Output23 = external_exports.object({
25320
+ mid: external_exports.string(),
25321
+ count: external_exports.number(),
25322
+ reactions: external_exports.array(external_exports.unknown()),
25323
+ raw: external_exports.unknown()
25324
+ });
25325
+ function register23(program3) {
25326
+ define2(program3, {
25327
+ name: "reactions",
25328
+ description: "List emoticon reactions on a message (GET emoticon/reply/list?mid=\u2026).",
25329
+ output: Output23,
25330
+ configure: (cmd) => {
25331
+ cmd.requiredOption("--mid <mid>", "message id to inspect");
25332
+ },
25333
+ run: async (cmd) => {
25334
+ const { mid } = cmd.opts();
25335
+ const cfg = requireConfig();
25336
+ requireAuth(loadCredentials());
25337
+ const res = await listReplies(cfg, mid);
25338
+ if (res.code !== 0) {
25339
+ throw new CliError("REMOTE_ERROR", `reactions list rejected: code=${res.code} msg=${res.msg ?? ""}`.trim());
25340
+ }
25341
+ const reactions = extractList(res.data);
25342
+ return {
25343
+ mid,
25344
+ count: reactions.length,
25345
+ reactions,
25346
+ raw: res
25347
+ };
25348
+ }
25349
+ });
25350
+ }
25351
+ function extractList(data) {
25352
+ if (Array.isArray(data)) return data;
25353
+ if (data && typeof data === "object") {
25354
+ for (const k of ["list", "reactions", "replies", "records", "items"]) {
25355
+ const v = data[k];
25356
+ if (Array.isArray(v)) return v;
25357
+ }
25358
+ }
25359
+ return [];
25360
+ }
25361
+
25362
+ // src/commands/unreact.ts
25363
+ var Output24 = external_exports.object({
25364
+ scene: external_exports.enum(["p2p", "team_g"]),
25365
+ emoticonId: external_exports.string(),
25366
+ raw: external_exports.unknown()
25367
+ });
25368
+ function register24(program3) {
25369
+ define2(program3, {
25370
+ name: "unreact",
25371
+ description: "Revoke one of your emoticon reactions (POST emoticon/reply/revocation). VISIBLE TO THE TARGET CHAT \u2014 non-self targets require --yes-not-self.",
25372
+ output: Output24,
25373
+ configure: (cmd) => {
25374
+ cmd.requiredOption("--emoticon-id <id>", "reaction id (returned by `muc react` or seen in `muc reactions --mid \u2026`)").option("--peer <uid>", "1:1 chat with a peer uid").option("--team <id>", "group chat with a team id").option("--to-self", "revoke in your self-chat (default if no target)").option("--src-sender-id <uid>", "UID of the original message sender (defaults to current user)").option("--src-sender-app <appkey>", "appKey of the original message sender (defaults to your app key)").option("--target-app-key <appkey>", "original message `app_key` field \u2014 distinct from --src-sender-app (`fApp`); only matters in cross-tenant revokes. Defaults to your app key.").option("--yes-not-self", "required to confirm revoking in a non-self conversation");
25375
+ },
25376
+ run: async (cmd) => {
25377
+ const opts = cmd.opts();
25378
+ const cfg = requireConfig();
25379
+ const cred = requireAuth(loadCredentials());
25380
+ requireSingleTarget(opts, "unreact");
25381
+ let scene;
25382
+ let peerUid;
25383
+ if (opts.team) {
25384
+ scene = "team_g";
25385
+ } else if (opts.toSelf || !opts.peer && !opts.team) {
25386
+ scene = "p2p";
25387
+ peerUid = cred.uid;
25388
+ } else {
25389
+ scene = "p2p";
25390
+ peerUid = opts.peer.toUpperCase();
25391
+ }
25392
+ const isSelf = scene === "p2p" && peerUid === cred.uid;
25393
+ if (!isSelf && !opts.yesNotSelf) {
25394
+ throw new CliError(
25395
+ "USAGE",
25396
+ `refusing to revoke reaction in ${scene}:${opts.team ?? peerUid} without --yes-not-self`,
25397
+ "add --yes-not-self to confirm"
25398
+ );
25399
+ }
25400
+ const srcSenderId = (opts.srcSenderId ?? cred.uid).toUpperCase();
25401
+ const srcSenderApp = opts.srcSenderApp ?? cfg.appKey;
25402
+ const isFromMe = srcSenderId === cred.uid && srcSenderApp === cfg.appKey;
25403
+ const sid = scene === "team_g" ? opts.team : buildSId("p2p", cred.uid, peerUid);
25404
+ const ctx = {
25405
+ scene,
25406
+ sid,
25407
+ // mid not needed for revoke (server identifies by emoticonId)
25408
+ mid: "",
25409
+ srcSenderId,
25410
+ srcSenderApp,
25411
+ peerUid,
25412
+ peerApp: cfg.appKey,
25413
+ targetApp: opts.targetAppKey
25414
+ };
25415
+ const res = await revokeReply(cfg, {
25416
+ emoticonId: opts.emoticonId,
25417
+ ctx,
25418
+ isFromMe,
25419
+ selfAppKey: cfg.appKey
25420
+ });
25421
+ if (res.code !== 0) {
25422
+ throw new CliError("REMOTE_ERROR", `unreact rejected: code=${res.code} msg=${res.msg ?? ""}`.trim());
25423
+ }
25424
+ return {
25425
+ scene,
25426
+ emoticonId: opts.emoticonId,
25427
+ raw: res
25428
+ };
25429
+ }
25430
+ });
25431
+ }
25432
+
25433
+ // src/commands/wait-react.ts
25434
+ var Output25 = external_exports.object({
25435
+ matched: external_exports.literal(true),
25436
+ mid: external_exports.string(),
25437
+ reaction: external_exports.unknown()
25438
+ });
25439
+ function register25(program3) {
25440
+ define2(program3, {
25441
+ name: "wait-react",
25442
+ description: "Block until someone adds an emoticon reaction (subType=300) to --mid. Consumes a TCP slot \u2014 defaults to --os iOS (Mobile slot).",
25443
+ output: Output25,
25444
+ configure: (cmd) => {
25445
+ cmd.requiredOption("--mid <mid>", "message id to wait for a reaction on").option("--from-uid <uid>", "only consider reactions from this UID (case-insensitive match against fId of the type=3 notification)").option("--sticker-id <id>", "only consider reactions whose stickerId matches").option("--timeout <seconds>", "give up after this many seconds (default 300)", parsePosInt("--timeout"), 300).option("--os <os>", "chat-channel os slot", parseChatOs, DEFAULT_CHAT_OS).option("--debug", "forward wire-level debug events to stderr");
25446
+ },
25447
+ run: async (cmd) => {
25448
+ const opts = cmd.opts();
25449
+ const cred = requireAuth(loadCredentials());
25450
+ const wantedFrom = opts.fromUid?.toUpperCase();
25451
+ const session = await openChatSession(cred, { os: opts.os, debug: opts.debug });
25452
+ const { socket } = session;
25453
+ try {
25454
+ return await new Promise((resolve2, reject) => {
25455
+ const timer = setTimeout(() => {
25456
+ reject(new CliError(
25457
+ "TIMEOUT",
25458
+ `no matching reaction on mid=${opts.mid} after ${opts.timeout}s`
25459
+ ));
25460
+ }, opts.timeout * 1e3);
25461
+ const cleanup = () => clearTimeout(timer);
25462
+ socket.on("kicked", (payload) => {
25463
+ cleanup();
25464
+ reject(new CliError(
25465
+ "KICKED",
25466
+ `chat session kicked: ${JSON.stringify(payload).slice(0, 200)}`,
25467
+ "another login claimed the same device slot \u2014 see HANDOFF.md slot model"
25468
+ ));
25469
+ });
25470
+ socket.on("error", (e) => {
25471
+ cleanup();
25472
+ reject(new CliError("NETWORK", `chat socket error: ${e.message}`));
25473
+ });
25474
+ socket.on("close", () => {
25475
+ cleanup();
25476
+ reject(new CliError("NETWORK", "chat socket closed before a matching reaction arrived"));
25477
+ });
25478
+ socket.on("push", (frame) => {
25479
+ ackInboundIfNeeded(socket, frame);
25480
+ if (frame.sid !== "talk") return;
25481
+ if (frame.cid !== "msg" && frame.cid !== "offMsg") return;
25482
+ const msgs = frame.data?.msgs ?? [];
25483
+ for (const m of msgs) {
25484
+ if (m?.type !== 3 || m?.subType !== 300) continue;
25485
+ const body = m.body ?? {};
25486
+ const refMid = String(body.mid ?? body.refMid ?? "");
25487
+ if (refMid !== opts.mid) continue;
25488
+ if (wantedFrom && String(m.fId ?? "").toUpperCase() !== wantedFrom) continue;
25489
+ if (opts.stickerId) {
25490
+ const sid = String(body.stickerId ?? "");
25491
+ if (sid !== opts.stickerId) continue;
25492
+ }
25493
+ cleanup();
25494
+ resolve2({ matched: true, mid: opts.mid, reaction: m });
25495
+ return;
25496
+ }
25497
+ });
25498
+ socket.send("sync", "syncDone", { timestamp: 0 }).catch(() => {
25499
+ });
25500
+ });
25501
+ } finally {
25502
+ socket.close();
25503
+ }
25504
+ }
25505
+ });
25506
+ }
25507
+ function ackInboundIfNeeded(socket, frame) {
25508
+ if (frame.sid !== "talk") return;
25509
+ if (frame.cid !== "msg" && frame.cid !== "offMsg") return;
25510
+ const sqRaw = frame.sq;
25511
+ const sq = typeof sqRaw === "number" ? sqRaw : Number.parseInt(String(sqRaw ?? ""), 10);
25512
+ if (!Number.isFinite(sq)) return;
25513
+ const tids = Array.isArray(frame.data?.tids) ? frame.data.tids : [];
25514
+ if (tids.length === 0) return;
25515
+ try {
25516
+ socket.sendAck(frame.sid, frame.cid, sq, { tids });
25517
+ } catch {
25518
+ }
25519
+ }
25520
+
25521
+ // src/commands/listen.ts
25522
+ var MESSAGE_DATA_SCHEMA = {
25523
+ type: "object",
25524
+ properties: {
25525
+ mid: { type: "string" },
25526
+ sId: { type: "string" },
25527
+ fId: { type: "string" },
25528
+ fApp: { type: "string" },
25529
+ toId: { type: "string" },
25530
+ scene: { type: "string", enum: ["p2p", "team_g"] },
25531
+ type: { type: "number" },
25532
+ subType: { type: "number" },
25533
+ timestamp: { type: "number" },
25534
+ timestamp_u: { type: "number" },
25535
+ body: {}
25536
+ }
25537
+ };
25538
+ var STREAM_SCHEMA = {
25539
+ description: "NDJSON stream of envelopes. Under --json each stdout line is one JSON object: either {ok:true, schema_version, data:<message>} for a match, or {ok:false, schema_version, code, msg, hint?} for an in-band error (KICKED / NETWORK). Without --json each line is a one-line human summary (NOT JSON). Use --json for any pipe consumer.",
25540
+ oneOf: [
25541
+ {
25542
+ type: "object",
25543
+ required: ["ok", "schema_version", "data"],
25544
+ properties: {
25545
+ ok: { const: true },
25546
+ schema_version: { type: "number" },
25547
+ data: MESSAGE_DATA_SCHEMA
25548
+ }
25549
+ },
25550
+ {
25551
+ type: "object",
25552
+ required: ["ok", "schema_version", "code", "msg"],
25553
+ properties: {
25554
+ ok: { const: false },
25555
+ schema_version: { type: "number" },
25556
+ // The streaming `push` path emits KICKED / NETWORK in-band; the
25557
+ // wrapping `defineStream` action handler may also fail() with any
25558
+ // CliError code raised before the loop starts (USAGE for bad
25559
+ // --regex/--type, AUTH_REQUIRED/AUTH_EXPIRED from the credential
25560
+ // gate, CONFIG_MISSING/CONFIG_INVALID from `requireConfig`,
25561
+ // REMOTE_ERROR from the chat-login refresh path, INTERNAL for
25562
+ // unexpected throws). Advertise all of them.
25563
+ code: {
25564
+ type: "string",
25565
+ enum: [
25566
+ "KICKED",
25567
+ "NETWORK",
25568
+ "TIMEOUT",
25569
+ "USAGE",
25570
+ "AUTH_REQUIRED",
25571
+ "AUTH_EXPIRED",
25572
+ "NOT_FOUND",
25573
+ "REMOTE_ERROR",
25574
+ "CONFIG_MISSING",
25575
+ "CONFIG_INVALID",
25576
+ "INTERNAL"
25577
+ ]
25578
+ },
25579
+ msg: { type: "string" },
25580
+ hint: { type: "string" }
25581
+ }
25582
+ }
25583
+ ]
25584
+ };
25585
+ function register26(program3) {
25586
+ defineStream(program3, {
25587
+ name: "listen",
25588
+ description: "Tail incoming chat messages, filtered by sender / team / peer / keyword / regex / @-mention. Emit one line per match \u2014 NDJSON envelope under `--json`, human summary otherwise. Consumes a TCP slot.",
25589
+ streamSchema: STREAM_SCHEMA,
25590
+ configure: (cmd) => {
25591
+ cmd.option("--from-uid <uid>", "only emit messages from this UID (repeat for OR; matched against fId, case-insensitive)", collect, []).option("--team <id>", "only emit messages in this team (repeat for OR)", collect, []).option("--peer <uid>", "only emit p2p messages with this peer UID (repeat for OR)", collect, []).option("--contains <text>", "only emit messages whose text contains this substring, case-insensitive (repeat for OR)", collect, []).option("--regex <pattern>", "only emit messages whose text matches this RE2-flavoured regex (repeat for OR)", collect, []).option("--type <n>", "only emit messages of this `type` field (default 1; repeat for OR)", collectInt("--type"), []).option("--subtype <n>", "only emit messages of this `subType` field (repeat for OR; default = any)", collectInt("--subtype"), []).option("--max <n>", "exit after emitting this many matching lines", parsePosInt("--max")).option("--at-me", "only emit group messages that @-mention you (mirrors the desktop SDK isAtMe: scene=team_g, fId\u2260self, atIds includes self uid). Implies excluding self.").option("--include-self", "do not filter out messages whose fId is your own UID").option("--include-reactions", "also emit type=3 sub=300/301 (reaction add/remove) frames").option("--include-offline", "also emit messages flushed via cid=offMsg on connect (default: only post-connect msg)").option("--os <os>", "chat-channel os slot", parseChatOs, DEFAULT_CHAT_OS).option("--debug", "forward wire-level debug events to stderr");
25592
+ },
25593
+ run: async (cmd) => {
25594
+ const opts = cmd.opts();
25595
+ const cred = requireAuth(loadCredentials());
25596
+ const wantedFromUids = new Set(opts.fromUid.map((s) => s.toUpperCase()));
25597
+ const wantedTeams = new Set(opts.team.map((s) => s));
25598
+ const wantedPeers = new Set(opts.peer.map((s) => s.toUpperCase()));
25599
+ const containsTerms = opts.contains.map((s) => s.toLowerCase());
25600
+ const regexes = opts.regex.map((p) => {
25601
+ try {
25602
+ return new RegExp(p);
25603
+ } catch (e) {
25604
+ throw new CliError("USAGE", `invalid --regex "${p}": ${e.message}`);
25605
+ }
25606
+ });
25607
+ const subtypeImpliesReactions = opts.subtype.some((s) => s === 300 || s === 301);
25608
+ const wantedTypes = opts.type.length ? new Set(opts.type) : /* @__PURE__ */ new Set([1, ...opts.includeReactions || subtypeImpliesReactions ? [3] : []]);
25609
+ const wantedSubtypes = opts.subtype.length ? new Set(opts.subtype) : null;
25610
+ const includeReactions = !!opts.includeReactions;
25611
+ const max = opts.max;
25612
+ const atMeOnly = !!opts.atMe;
25613
+ if (atMeOnly && opts.includeSelf) {
25614
+ throw new CliError("USAGE", "--at-me and --include-self are mutually exclusive (--at-me only matches messages from other users)");
25615
+ }
25616
+ const includeSelf = !!opts.includeSelf;
25617
+ const selfUidUpper = cred.uid.toUpperCase();
25618
+ const selfAppKey = requireConfig().appKey;
25619
+ const session = await openChatSession(cred, { os: opts.os, debug: opts.debug });
25620
+ const { socket } = session;
25621
+ const json = isJsonMode();
25622
+ let matched = 0;
25623
+ let exited = false;
25624
+ const closeAndExit = (code) => {
25625
+ if (exited) return;
25626
+ exited = true;
25627
+ try {
25628
+ socket.close();
25629
+ } catch {
25630
+ }
25631
+ process.exit(code);
25632
+ };
25633
+ const onSigint = () => closeAndExit(0);
25634
+ process.on("SIGINT", onSigint);
25635
+ process.on("SIGTERM", onSigint);
25636
+ socket.on("kicked", (payload) => {
25637
+ emitErr(
25638
+ "KICKED",
25639
+ `chat session kicked: ${JSON.stringify(payload).slice(0, 200)}`,
25640
+ "another login claimed the same device slot \u2014 see HANDOFF.md slot model"
25641
+ );
25642
+ closeAndExit(5);
25643
+ });
25644
+ socket.on("error", (e) => {
25645
+ emitErr("NETWORK", `chat socket error: ${e.message}`);
25646
+ closeAndExit(5);
25647
+ });
25648
+ socket.on("close", () => {
25649
+ if (!exited) {
25650
+ emitErr("NETWORK", "chat socket closed unexpectedly");
25651
+ closeAndExit(5);
25652
+ }
25653
+ });
25654
+ socket.on("push", (frame) => {
25655
+ ackInboundIfNeeded2(socket, frame);
25656
+ if (frame.sid !== "talk") return;
25657
+ if (frame.cid !== "msg" && !(opts.includeOffline && frame.cid === "offMsg")) return;
25658
+ const msgs = frame.data?.msgs ?? [];
25659
+ for (const m of msgs) {
25660
+ if (!matches(m)) continue;
25661
+ emitMatch(m);
25662
+ matched++;
25663
+ if (max && matched >= max) {
25664
+ closeAndExit(0);
25665
+ return;
25666
+ }
25667
+ }
25668
+ });
25669
+ socket.send("sync", "syncDone", { timestamp: 0 }).catch(() => {
25670
+ });
25671
+ function matches(m) {
25672
+ if (!m || typeof m !== "object") return false;
25673
+ if (!wantedTypes.has(m.type)) {
25674
+ return false;
25675
+ }
25676
+ if (wantedSubtypes && !wantedSubtypes.has(m.subType)) return false;
25677
+ if (m.type === 3 && (m.subType === 300 || m.subType === 301) && !includeReactions && !opts.subtype.length && !opts.type.length) {
25678
+ return false;
25679
+ }
25680
+ if (!includeSelf && String(m.fId ?? "").toUpperCase() === selfUidUpper) {
25681
+ return false;
25682
+ }
25683
+ if (atMeOnly) {
25684
+ if (m.scene !== "team_g") return false;
25685
+ if (String(m.fId ?? "").toUpperCase() === selfUidUpper) return false;
25686
+ const atIds = Array.isArray(m.atIds) ? m.atIds : [];
25687
+ const idx = atIds.findIndex((id) => String(id ?? "").toUpperCase() === selfUidUpper);
25688
+ if (idx < 0) return false;
25689
+ const atAppkeys = Array.isArray(m.atAppkeys) ? m.atAppkeys : void 0;
25690
+ if (atAppkeys && selfAppKey) {
25691
+ const includesSelfApp = atAppkeys.includes(selfAppKey);
25692
+ const slotEmpty = !atAppkeys[idx];
25693
+ if (!includesSelfApp && !slotEmpty) return false;
25694
+ }
25695
+ }
25696
+ if (wantedFromUids.size && !wantedFromUids.has(String(m.fId ?? "").toUpperCase())) return false;
25697
+ if (wantedTeams.size) {
25698
+ if (m.scene !== "team_g") return false;
25699
+ if (!wantedTeams.has(String(m.toId ?? ""))) return false;
25700
+ }
25701
+ if (wantedPeers.size) {
25702
+ if (m.scene !== "p2p") return false;
25703
+ const fromId = String(m.fId ?? "").toUpperCase();
25704
+ const toId = String(m.toId ?? "").toUpperCase();
25705
+ const peer = fromId === cred.uid.toUpperCase() ? toId : fromId;
25706
+ if (!wantedPeers.has(peer)) return false;
25707
+ }
25708
+ if (containsTerms.length || regexes.length) {
25709
+ const text = extractText(m).toLowerCase();
25710
+ if (containsTerms.length && !containsTerms.some((t) => text.includes(t))) return false;
25711
+ if (regexes.length && !regexes.some((r) => r.test(text))) return false;
25712
+ }
25713
+ return true;
25714
+ }
25715
+ function emitMatch(m) {
25716
+ const out = {
25717
+ mid: String(m.mid ?? ""),
25718
+ sId: String(m.sId ?? ""),
25719
+ fId: String(m.fId ?? ""),
25720
+ fApp: String(m.fApp ?? ""),
25721
+ toId: String(m.toId ?? ""),
25722
+ scene: m.scene,
25723
+ type: m.type,
25724
+ subType: m.subType,
25725
+ timestamp: m.timestamp,
25726
+ timestamp_u: m.timestamp_u,
25727
+ body: m.body
25728
+ };
25729
+ if (json) {
25730
+ process.stdout.write(JSON.stringify({ ok: true, schema_version: SCHEMA_VERSION, data: out }) + "\n");
25731
+ } else {
25732
+ process.stdout.write(formatHuman(out) + "\n");
25733
+ }
25734
+ }
25735
+ function emitErr(code, msg, hint) {
25736
+ if (json) {
25737
+ process.stdout.write(JSON.stringify({ ok: false, schema_version: SCHEMA_VERSION, code, msg, hint }) + "\n");
25738
+ } else {
25739
+ process.stderr.write(`error: ${msg}
25740
+ `);
25741
+ if (hint) process.stderr.write(` hint: ${hint}
25742
+ `);
25743
+ process.stderr.write(` code: ${code}
25744
+ `);
25745
+ }
25746
+ }
25747
+ await new Promise(() => {
25748
+ });
25749
+ }
25750
+ });
25751
+ }
25752
+ function collect(value, prev) {
25753
+ return [...prev, value];
25754
+ }
25755
+ function collectInt(label) {
25756
+ return (raw, prev) => {
25757
+ if (!/^\d+$/.test(raw)) throw new CliError("USAGE", `${label} must be a non-negative integer (got ${raw})`);
25758
+ return [...prev, Number(raw)];
25759
+ };
25760
+ }
25761
+ function ackInboundIfNeeded2(socket, frame) {
25762
+ if (frame.sid !== "talk") return;
25763
+ if (frame.cid !== "msg" && frame.cid !== "offMsg") return;
25764
+ const sqRaw = frame.sq;
25765
+ const sq = typeof sqRaw === "number" ? sqRaw : Number.parseInt(String(sqRaw ?? ""), 10);
25766
+ if (!Number.isFinite(sq)) return;
25767
+ const tids = Array.isArray(frame.data?.tids) ? frame.data.tids : [];
25768
+ if (tids.length === 0) return;
25769
+ try {
25770
+ socket.sendAck(frame.sid, frame.cid, sq, { tids });
25771
+ } catch {
25772
+ }
25773
+ }
25774
+ function extractText(m) {
25775
+ const body = m?.body;
25776
+ if (typeof body === "string") return body;
25777
+ if (Array.isArray(body)) {
25778
+ return body.map((seg) => typeof seg?.content === "string" ? seg.content : typeof seg?.text === "string" ? seg.text : "").join(" ");
25779
+ }
25780
+ if (body && typeof body === "object") {
25781
+ const candidates = ["text", "content", "fName", "name"];
25782
+ for (const k of candidates) {
25783
+ if (typeof body[k] === "string") return body[k];
25784
+ }
25785
+ }
25786
+ return "";
25787
+ }
25788
+ function formatHuman(m) {
25789
+ const ts = typeof m.timestamp === "number" ? new Date(m.timestamp * 1e3).toISOString() : "?";
25790
+ const where = m.scene === "team_g" ? `team:${m.toId}` : `p2p:${m.toId}`;
25791
+ const text = (() => {
25792
+ if (typeof m.body === "string") return m.body;
25793
+ if (m.body == null) return "";
25794
+ return JSON.stringify(m.body).slice(0, 200);
25795
+ })();
25796
+ return `${ts} ${m.fId} \u2192 ${where} (type=${m.type}/${m.subType}): ${text}`;
25797
+ }
25798
+
25799
+ // src/commands/manifest.ts
25800
+ var Output26 = external_exports.object({
25015
25801
  version: external_exports.string(),
25016
25802
  schema_version: external_exports.literal(1),
25017
25803
  commands: external_exports.array(external_exports.object({
@@ -25020,11 +25806,11 @@ var Output22 = external_exports.object({
25020
25806
  output_schema: external_exports.unknown()
25021
25807
  }))
25022
25808
  });
25023
- function register22(program3) {
25809
+ function register27(program3) {
25024
25810
  define2(program3, {
25025
25811
  name: "manifest",
25026
25812
  description: "Emit the full command graph + per-command output JSON Schema. Skill authors should consume this rather than parse --help.",
25027
- output: Output22,
25813
+ output: Output26,
25028
25814
  run: async () => ({
25029
25815
  version: CLI_VERSION,
25030
25816
  schema_version: 1,
@@ -25071,6 +25857,11 @@ register19(program2);
25071
25857
  register20(program2);
25072
25858
  register21(program2);
25073
25859
  register22(program2);
25860
+ register23(program2);
25861
+ register24(program2);
25862
+ register25(program2);
25863
+ register26(program2);
25864
+ register27(program2);
25074
25865
  async function main() {
25075
25866
  try {
25076
25867
  await program2.parseAsync(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mu-cabin/muc-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal client for MUC IM (com.meicloud.im.dh).",
5
5
  "license": "MIT",
6
6
  "type": "module",