@agent-wechat/agent-wechat 0.14.0 → 0.15.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 (2) hide show
  1. package/dist/index.js +160 -88
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1127,6 +1127,9 @@ function getWeChatRuntime() {
1127
1127
  return runtime;
1128
1128
  }
1129
1129
 
1130
+ // src/monitor.ts
1131
+ import { createHash as createHash3 } from "node:crypto";
1132
+
1130
1133
  // ../shared/dist/client.js
1131
1134
  function normalizeUrl(base) {
1132
1135
  const url = base.startsWith("http") ? base : `http://${base}`;
@@ -1230,6 +1233,23 @@ var WeChatClient = class {
1230
1233
  async sendMessage(params) {
1231
1234
  return this.post("/api/messages/send", params);
1232
1235
  }
1236
+ async createVoiceJob(chatId, audio, idempotencyKey) {
1237
+ const form = new FormData();
1238
+ form.set("chatId", chatId);
1239
+ form.set("audio", new Blob([new Uint8Array(audio)]), "voice-audio");
1240
+ const headers = { ...this.headers, "Idempotency-Key": idempotencyKey };
1241
+ delete headers["Content-Type"];
1242
+ const res = await fetch(`${this.base}/api/messages/voice`, { method: "POST", headers, body: form });
1243
+ if (!res.ok)
1244
+ throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`);
1245
+ return res.json();
1246
+ }
1247
+ async getVoiceJob(jobId) {
1248
+ return this.get(`/api/messages/voice/${encodeURIComponent(jobId)}`);
1249
+ }
1250
+ async cancelVoiceJob(jobId) {
1251
+ return this.post(`/api/messages/voice/${encodeURIComponent(jobId)}/cancel`);
1252
+ }
1233
1253
  // ---- Debug ----
1234
1254
  async screenshot() {
1235
1255
  return this.get("/api/debug/screenshot");
@@ -5779,6 +5799,96 @@ async function listMessageWindow(client, chatId, previousLastSeen, firstPoll, un
5779
5799
  return messages;
5780
5800
  }
5781
5801
 
5802
+ // src/outbound-media.ts
5803
+ import { randomUUID as randomUUID2 } from "node:crypto";
5804
+ import { readFile } from "node:fs/promises";
5805
+ import { basename, extname } from "node:path";
5806
+ var EXT_MIME = {
5807
+ png: "image/png",
5808
+ jpg: "image/jpeg",
5809
+ jpeg: "image/jpeg",
5810
+ gif: "image/gif",
5811
+ webp: "image/webp",
5812
+ mp3: "audio/mpeg",
5813
+ wav: "audio/wav",
5814
+ ogg: "audio/ogg",
5815
+ opus: "audio/ogg",
5816
+ m4a: "audio/mp4",
5817
+ flac: "audio/flac",
5818
+ aac: "audio/aac"
5819
+ };
5820
+ function isTerminal(job) {
5821
+ return ["completed", "failed", "cancelled", "needs_review"].includes(job.status);
5822
+ }
5823
+ async function waitForVoiceJob(client, jobId) {
5824
+ const deadline = Date.now() + 25 * 6e4;
5825
+ let lastStatusAt = Date.now();
5826
+ while (Date.now() < deadline) {
5827
+ let job;
5828
+ try {
5829
+ job = await client.getVoiceJob(jobId);
5830
+ lastStatusAt = Date.now();
5831
+ } catch (error) {
5832
+ if (Date.now() - lastStatusAt > 3e4) {
5833
+ throw new Error(`Voice job ${jobId} status is unavailable: ${String(error)}. Inspect the job before retrying.`);
5834
+ }
5835
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
5836
+ continue;
5837
+ }
5838
+ if (isTerminal(job)) {
5839
+ if (job.status === "completed") return job;
5840
+ const sent = job.chunks.filter((chunk) => chunk.status === "verified").length;
5841
+ throw new Error(`Voice job ${jobId} ${job.status}: ${sent}/${job.chunks.length} notes verified. ${job.error ?? ""} Do not resend the whole recording.`);
5842
+ }
5843
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
5844
+ }
5845
+ throw new Error(`Voice job ${jobId} exceeded the 25-minute wait. Inspect the job before retrying.`);
5846
+ }
5847
+ async function sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice = false, idempotencyKey) {
5848
+ const account = resolveWeChatAccount(cfg);
5849
+ if (!account?.serverUrl) throw new Error("No serverUrl configured");
5850
+ const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
5851
+ if (!mediaUrl) {
5852
+ if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
5853
+ const result2 = await client.sendMessage({ chatId: to, text: text || void 0 });
5854
+ if (!result2.success) throw new Error(result2.error ?? "Send failed");
5855
+ return `agent-wechat:${to}:${Date.now()}`;
5856
+ }
5857
+ let bytes;
5858
+ let mimeType;
5859
+ let filename;
5860
+ if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
5861
+ const res = await fetch(mediaUrl);
5862
+ if (!res.ok) throw new Error(`Media fetch failed: ${res.status}`);
5863
+ bytes = new Uint8Array(await res.arrayBuffer());
5864
+ mimeType = res.headers.get("content-type")?.split(";")[0] ?? "application/octet-stream";
5865
+ filename = basename(new URL(mediaUrl).pathname) || "file";
5866
+ if (mimeType === "application/octet-stream") {
5867
+ mimeType = EXT_MIME[extname(filename).toLowerCase().slice(1)] ?? mimeType;
5868
+ }
5869
+ } else {
5870
+ const localPath = mediaUrl.startsWith("file://") ? new URL(mediaUrl) : mediaUrl;
5871
+ const pathText = localPath instanceof URL ? localPath.pathname : localPath;
5872
+ bytes = new Uint8Array(await readFile(localPath));
5873
+ filename = basename(pathText);
5874
+ mimeType = EXT_MIME[extname(filename).toLowerCase().slice(1)] ?? "application/octet-stream";
5875
+ }
5876
+ if (audioAsVoice) {
5877
+ if (!mimeType.startsWith("audio/")) throw new Error(`Voice media must be audio, got ${mimeType}`);
5878
+ const job = await client.createVoiceJob(to, bytes, idempotencyKey ?? randomUUID2());
5879
+ await waitForVoiceJob(client, job.jobId);
5880
+ if (text) {
5881
+ const caption = await client.sendMessage({ chatId: to, text });
5882
+ if (!caption.success) throw new Error(`Voice job ${job.jobId} completed, but caption failed: ${caption.error ?? "unknown error"}`);
5883
+ }
5884
+ return `agent-wechat:voice:${job.jobId}`;
5885
+ }
5886
+ const base64 = Buffer.from(bytes).toString("base64");
5887
+ const result = mimeType.startsWith("image/") ? await client.sendMessage({ chatId: to, text: text || void 0, image: { data: base64, mimeType } }) : await client.sendMessage({ chatId: to, text: text || void 0, file: { data: base64, filename } });
5888
+ if (!result.success) throw new Error(result.error ?? "Send media failed");
5889
+ return `agent-wechat:${to}:${Date.now()}`;
5890
+ }
5891
+
5782
5892
  // src/access-control.ts
5783
5893
  import {
5784
5894
  buildChannelKeyCandidates,
@@ -5989,6 +6099,7 @@ function resolveWeChatMentionGate(params) {
5989
6099
  // src/monitor.ts
5990
6100
  var HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
5991
6101
  var CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
6102
+ var voiceTranscriptCache = /* @__PURE__ */ new Map();
5992
6103
  function isProcessedMessage(value) {
5993
6104
  if (!value || typeof value !== "object") return false;
5994
6105
  const message = value;
@@ -6360,6 +6471,36 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6360
6471
  return "ignored";
6361
6472
  }
6362
6473
  try {
6474
+ for (const item of segment) {
6475
+ const attachment = item.attachment;
6476
+ if (item.mediaPath || attachment?.kind !== "audio" || attachment.status !== "ready" || !attachment.path) continue;
6477
+ const cacheKey = `${liveAccount.accountId}:${chatId}:${item.msg.localId}:${attachment.path}`;
6478
+ let transcript = voiceTranscriptCache.get(cacheKey);
6479
+ if (!transcript) {
6480
+ try {
6481
+ const result = await core.mediaUnderstanding.transcribeAudioFile({
6482
+ filePath: attachment.path,
6483
+ cfg,
6484
+ mime: attachment.mime
6485
+ });
6486
+ transcript = result.text?.trim();
6487
+ if (transcript) {
6488
+ if (voiceTranscriptCache.size >= 500) voiceTranscriptCache.clear();
6489
+ voiceTranscriptCache.set(cacheKey, transcript);
6490
+ }
6491
+ } catch (error) {
6492
+ log?.error?.(`[wechat:${liveAccount.accountId}] Voice transcription failed for ${item.msg.localId}: ${String(error)}`);
6493
+ }
6494
+ }
6495
+ if (transcript) {
6496
+ item.rawBody += `
6497
+ [Voice transcript: ${transcript.slice(0, 1e4)}]`;
6498
+ item.commandBody = normalizeWeChatCommandBody(item.rawBody, {
6499
+ isGroup: item.isGroup,
6500
+ wasMentioned: item.isMentioned
6501
+ });
6502
+ }
6503
+ }
6363
6504
  const route = core.channel.routing.resolveAgentRoute({
6364
6505
  cfg,
6365
6506
  channel: "agent-wechat",
@@ -6388,7 +6529,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6388
6529
  timestamp,
6389
6530
  previousTimestamp,
6390
6531
  envelope: envelopeOptions,
6391
- body: isGroup ? `${senderName}: ${rawBody}` : rawBody
6532
+ body: isGroup ? `${senderName}: ${lastMsg.rawBody}` : lastMsg.rawBody
6392
6533
  });
6393
6534
  } else {
6394
6535
  const historyMessages = segment.slice(0, -1);
@@ -6408,7 +6549,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6408
6549
  timestamp,
6409
6550
  previousTimestamp,
6410
6551
  envelope: envelopeOptions,
6411
- body: isGroup ? `${senderName}: ${rawBody}` : rawBody
6552
+ body: isGroup ? `${senderName}: ${lastMsg.rawBody}` : lastMsg.rawBody
6412
6553
  });
6413
6554
  body = [
6414
6555
  HISTORY_CONTEXT_MARKER,
@@ -6430,8 +6571,8 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6430
6571
  }
6431
6572
  const ctxPayload = core.channel.reply.finalizeInboundContext({
6432
6573
  Body: body,
6433
- BodyForAgent: rawBody,
6434
- RawBody: rawBody,
6574
+ BodyForAgent: lastMsg.rawBody,
6575
+ RawBody: lastMsg.rawBody,
6435
6576
  CommandBody: commandBody,
6436
6577
  InboundHistory: inboundHistory,
6437
6578
  From: isGroup ? `wechat:group:${chatId}` : `wechat:${senderId}`,
@@ -6473,12 +6614,14 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6473
6614
  accountId: liveAccount.accountId
6474
6615
  });
6475
6616
  let replyFailed = false;
6617
+ let deliverySequence = 0;
6476
6618
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
6477
6619
  ctx: ctxPayload,
6478
6620
  cfg,
6479
6621
  dispatcherOptions: {
6480
6622
  ...prefixOptions,
6481
6623
  deliver: async (payload) => {
6624
+ const deliveryIndex = deliverySequence++;
6482
6625
  const mediaList = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
6483
6626
  const tableMode = core.channel.text.resolveMarkdownTableMode({
6484
6627
  cfg,
@@ -6490,49 +6633,17 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6490
6633
  tableMode
6491
6634
  );
6492
6635
  if (mediaList.length > 0) {
6493
- for (const mediaUrl of mediaList) {
6494
- try {
6495
- const fsmod = await import("fs/promises");
6496
- const pathmod = await import("path");
6497
- let base64;
6498
- let mimeType;
6499
- let filename;
6500
- if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
6501
- const res = await fetch(mediaUrl);
6502
- const buffer = await res.arrayBuffer();
6503
- base64 = Buffer.from(buffer).toString("base64");
6504
- mimeType = res.headers.get("content-type") ?? "application/octet-stream";
6505
- const urlPath = new URL(mediaUrl).pathname;
6506
- filename = pathmod.basename(urlPath) || "file";
6507
- } else {
6508
- const buf = await fsmod.readFile(mediaUrl);
6509
- base64 = buf.toString("base64");
6510
- filename = pathmod.basename(mediaUrl);
6511
- const ext = pathmod.extname(mediaUrl).toLowerCase().replace(".", "");
6512
- const extMime = {
6513
- png: "image/png",
6514
- jpg: "image/jpeg",
6515
- jpeg: "image/jpeg",
6516
- gif: "image/gif",
6517
- webp: "image/webp"
6518
- };
6519
- mimeType = extMime[ext] ?? "application/octet-stream";
6520
- }
6521
- const isImage = mimeType.startsWith("image/");
6522
- if (isImage) {
6523
- await client.sendMessage({ chatId, image: { data: base64, mimeType } });
6524
- } else {
6525
- await client.sendMessage({ chatId, file: { data: base64, filename } });
6526
- }
6527
- } catch (err) {
6528
- log?.error?.(`[wechat:${liveAccount.accountId}] Failed to send media: ${err}`);
6529
- }
6636
+ for (let index = 0; index < mediaList.length; index++) {
6637
+ const retryKey = createHash3("sha256").update(`${liveAccount.accountId}\0${chatId}\0${msg.localId}\0${deliveryIndex}\0${index}`).digest("hex");
6638
+ await sendWeChatMedia(cfg, chatId, "", mediaList[index], payload.audioAsVoice === true, retryKey);
6530
6639
  }
6531
6640
  if (text) {
6532
- await client.sendMessage({ chatId, text });
6641
+ const result = await client.sendMessage({ chatId, text });
6642
+ if (!result.success) throw new Error(result.error ?? "Text send failed");
6533
6643
  }
6534
6644
  } else if (text) {
6535
- await client.sendMessage({ chatId, text });
6645
+ const result = await client.sendMessage({ chatId, text });
6646
+ if (!result.success) throw new Error(result.error ?? "Text send failed");
6536
6647
  }
6537
6648
  },
6538
6649
  onError: (err, info) => {
@@ -7109,46 +7220,6 @@ async function sendWeChatText(cfg, to, text) {
7109
7220
  if (!result.success) throw new Error(result.error ?? "Send failed");
7110
7221
  return `agent-wechat:${to}:${Date.now()}`;
7111
7222
  }
7112
- async function sendWeChatMedia(cfg, to, text, mediaUrl) {
7113
- const account = resolveWeChatAccount(cfg);
7114
- if (!account?.serverUrl) throw new Error("No serverUrl configured");
7115
- const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
7116
- if (!mediaUrl) {
7117
- const result2 = await client.sendMessage({ chatId: to, text: text || void 0 });
7118
- if (!result2.success) throw new Error(result2.error ?? "Send failed");
7119
- return `agent-wechat:${to}:${Date.now()}`;
7120
- }
7121
- const fsmod = await import("fs/promises");
7122
- const pathmod = await import("path");
7123
- let base64;
7124
- let mimeType;
7125
- let filename;
7126
- if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
7127
- const res = await fetch(mediaUrl);
7128
- const buffer = await res.arrayBuffer();
7129
- base64 = Buffer.from(buffer).toString("base64");
7130
- mimeType = res.headers.get("content-type") ?? "application/octet-stream";
7131
- const urlPath = new URL(mediaUrl).pathname;
7132
- filename = pathmod.basename(urlPath) || "file";
7133
- } else {
7134
- const buf = await fsmod.readFile(mediaUrl);
7135
- base64 = buf.toString("base64");
7136
- filename = pathmod.basename(mediaUrl);
7137
- const ext = pathmod.extname(mediaUrl).toLowerCase().replace(".", "");
7138
- const extMime = {
7139
- png: "image/png",
7140
- jpg: "image/jpeg",
7141
- jpeg: "image/jpeg",
7142
- gif: "image/gif",
7143
- webp: "image/webp"
7144
- };
7145
- mimeType = extMime[ext] ?? "application/octet-stream";
7146
- }
7147
- const isImage = mimeType.startsWith("image/");
7148
- const result = isImage ? await client.sendMessage({ chatId: to, text: text || void 0, image: { data: base64, mimeType } }) : await client.sendMessage({ chatId: to, text: text || void 0, file: { data: base64, filename } });
7149
- if (!result.success) throw new Error(result.error ?? "Send media failed");
7150
- return `agent-wechat:${to}:${Date.now()}`;
7151
- }
7152
7223
  var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
7153
7224
  id: "agent-wechat",
7154
7225
  capabilities: {
@@ -7161,9 +7232,9 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
7161
7232
  channel: "agent-wechat",
7162
7233
  messageId: await sendWeChatText(cfg, to, text)
7163
7234
  }),
7164
- sendMedia: async ({ cfg, to, text, mediaUrl }) => ({
7235
+ sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
7165
7236
  channel: "agent-wechat",
7166
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl)
7237
+ messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
7167
7238
  })
7168
7239
  }
7169
7240
  });
@@ -7184,7 +7255,8 @@ var wechatPlugin = {
7184
7255
  reactions: false,
7185
7256
  threads: false,
7186
7257
  media: true,
7187
- reply: true
7258
+ reply: true,
7259
+ tts: { voice: { synthesisTarget: "voice-note", transcodesAudio: true } }
7188
7260
  },
7189
7261
  reload: { configPrefixes: ["channels.agent-wechat"] },
7190
7262
  configSchema: {
@@ -7311,9 +7383,9 @@ var wechatPlugin = {
7311
7383
  channel: "agent-wechat",
7312
7384
  messageId: await sendWeChatText(cfg, to, text)
7313
7385
  }),
7314
- sendMedia: async ({ cfg, to, text, mediaUrl }) => ({
7386
+ sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
7315
7387
  channel: "agent-wechat",
7316
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl)
7388
+ messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
7317
7389
  })
7318
7390
  },
7319
7391
  message: wechatMessageAdapter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-wechat/agent-wechat",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -11,7 +11,7 @@
11
11
  "esbuild": "^0.25.0",
12
12
  "openclaw": "^2026.8.1",
13
13
  "typescript": "^5.4.5",
14
- "@agent-wechat/shared": "0.2.0"
14
+ "@agent-wechat/shared": "0.3.0"
15
15
  },
16
16
  "openclaw": {
17
17
  "extensions": [