@dingxiang-me/openclaw-wechat 2.0.0 → 2.1.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.
@@ -0,0 +1,188 @@
1
+ import { normalizeWecomAllowFromEntry } from "../core.js";
2
+
3
+ function normalizeAccountId(accountId) {
4
+ const normalized = String(accountId ?? "default").trim().toLowerCase();
5
+ return normalized || "default";
6
+ }
7
+
8
+ function uniqueAllowFrom(values = []) {
9
+ const out = [];
10
+ const seen = new Set();
11
+ for (const value of Array.isArray(values) ? values : []) {
12
+ const normalized = normalizeWecomAllowFromEntry(value);
13
+ if (!normalized || seen.has(normalized)) continue;
14
+ seen.add(normalized);
15
+ out.push(normalized);
16
+ }
17
+ return out;
18
+ }
19
+
20
+ function getPairingRuntime(api) {
21
+ const pairing = api?.runtime?.channel?.pairing;
22
+ if (!pairing || typeof pairing !== "object") return null;
23
+ if (typeof pairing.readAllowFromStore !== "function") return null;
24
+ if (typeof pairing.upsertPairingRequest !== "function") return null;
25
+ if (typeof pairing.buildPairingReply !== "function") return null;
26
+ return pairing;
27
+ }
28
+
29
+ function matchesAllowFrom({ allowFrom = [], senderId = "", isWecomSenderAllowed } = {}) {
30
+ const normalizedAllowFrom = Array.isArray(allowFrom) ? allowFrom : [];
31
+ if (normalizedAllowFrom.includes("*")) return true;
32
+ if (typeof isWecomSenderAllowed !== "function") return false;
33
+ return isWecomSenderAllowed({
34
+ senderId,
35
+ allowFrom: normalizedAllowFrom,
36
+ });
37
+ }
38
+
39
+ export async function readWecomPairingAllowFromStore({ api, accountId = "default" } = {}) {
40
+ const pairing = getPairingRuntime(api);
41
+ if (!pairing) return [];
42
+ try {
43
+ const storeEntries = await pairing.readAllowFromStore({
44
+ channel: "wecom",
45
+ accountId: normalizeAccountId(accountId),
46
+ });
47
+ return uniqueAllowFrom(storeEntries);
48
+ } catch (err) {
49
+ api?.logger?.warn?.(`wecom: failed to read pairing store: ${String(err?.message || err)}`);
50
+ return [];
51
+ }
52
+ }
53
+
54
+ export async function resolveWecomDirectMessageAccess({
55
+ api,
56
+ accountId = "default",
57
+ dmPolicy = {},
58
+ allowFromPolicy = {},
59
+ normalizedFromUser = "",
60
+ isAdminUser = false,
61
+ isWecomSenderAllowed,
62
+ } = {}) {
63
+ const mode = String(dmPolicy?.mode ?? "open").trim().toLowerCase() || "open";
64
+ const normalizedSender = normalizeWecomAllowFromEntry(normalizedFromUser);
65
+ const configuredAllowFrom = uniqueAllowFrom(dmPolicy?.allowFrom);
66
+ const baseAllowFrom = Array.isArray(allowFromPolicy?.allowFrom) ? allowFromPolicy.allowFrom : [];
67
+ const senderAllowedByBasePolicy =
68
+ isAdminUser ||
69
+ matchesAllowFrom({
70
+ senderId: normalizedSender,
71
+ allowFrom: baseAllowFrom,
72
+ isWecomSenderAllowed,
73
+ });
74
+
75
+ if (mode === "deny") {
76
+ return {
77
+ decision: "block",
78
+ reason: "dm-deny",
79
+ rejectText: dmPolicy?.rejectMessage || "当前渠道私聊已关闭,请联系管理员。",
80
+ configuredAllowFrom,
81
+ effectiveAllowFrom: configuredAllowFrom,
82
+ storeAllowFrom: [],
83
+ };
84
+ }
85
+
86
+ if (mode === "open") {
87
+ return {
88
+ decision: senderAllowedByBasePolicy ? "allow" : "block",
89
+ reason: senderAllowedByBasePolicy ? "dm-open" : "allowFrom",
90
+ rejectText: senderAllowedByBasePolicy
91
+ ? ""
92
+ : allowFromPolicy?.rejectMessage || "当前账号未授权,请联系管理员。",
93
+ configuredAllowFrom,
94
+ effectiveAllowFrom: configuredAllowFrom,
95
+ storeAllowFrom: [],
96
+ };
97
+ }
98
+
99
+ if (mode === "allowlist") {
100
+ const allowed =
101
+ isAdminUser ||
102
+ matchesAllowFrom({
103
+ senderId: normalizedSender,
104
+ allowFrom: configuredAllowFrom,
105
+ isWecomSenderAllowed,
106
+ });
107
+ return {
108
+ decision: senderAllowedByBasePolicy && allowed ? "allow" : "block",
109
+ reason: !senderAllowedByBasePolicy ? "allowFrom" : allowed ? "dm-allowlist" : "dm-allowlist-blocked",
110
+ rejectText: !senderAllowedByBasePolicy
111
+ ? allowFromPolicy?.rejectMessage || "当前账号未授权,请联系管理员。"
112
+ : dmPolicy?.rejectMessage || "当前私聊账号未授权,请联系管理员。",
113
+ configuredAllowFrom,
114
+ effectiveAllowFrom: configuredAllowFrom,
115
+ storeAllowFrom: [],
116
+ };
117
+ }
118
+
119
+ if (!senderAllowedByBasePolicy) {
120
+ return {
121
+ decision: "block",
122
+ reason: "allowFrom",
123
+ rejectText: allowFromPolicy?.rejectMessage || "当前账号未授权,请联系管理员。",
124
+ configuredAllowFrom,
125
+ effectiveAllowFrom: configuredAllowFrom,
126
+ storeAllowFrom: [],
127
+ };
128
+ }
129
+
130
+ const storeAllowFrom = await readWecomPairingAllowFromStore({
131
+ api,
132
+ accountId,
133
+ });
134
+ const effectiveAllowFrom = uniqueAllowFrom([...configuredAllowFrom, ...storeAllowFrom]);
135
+ const allowed =
136
+ isAdminUser ||
137
+ matchesAllowFrom({
138
+ senderId: normalizedSender,
139
+ allowFrom: effectiveAllowFrom,
140
+ isWecomSenderAllowed,
141
+ });
142
+ return {
143
+ decision: allowed ? "allow" : "pairing",
144
+ reason: allowed ? "dm-pairing-approved" : "dm-pairing",
145
+ rejectText: allowed ? "" : dmPolicy?.rejectMessage || "当前私聊需先完成配对审批。",
146
+ configuredAllowFrom,
147
+ effectiveAllowFrom,
148
+ storeAllowFrom,
149
+ };
150
+ }
151
+
152
+ export async function issueWecomPairingChallenge({
153
+ api,
154
+ accountId = "default",
155
+ fromUser = "",
156
+ normalizedFromUser = "",
157
+ sendPairingReply,
158
+ } = {}) {
159
+ const pairing = getPairingRuntime(api);
160
+ const senderId = normalizeWecomAllowFromEntry(normalizedFromUser || fromUser);
161
+ if (!pairing || !senderId || typeof sendPairingReply !== "function") {
162
+ return { created: false, unsupported: true };
163
+ }
164
+
165
+ const { code, created } = await pairing.upsertPairingRequest({
166
+ channel: "wecom",
167
+ accountId: normalizeAccountId(accountId),
168
+ id: senderId,
169
+ meta: {
170
+ name: String(fromUser ?? "").trim() || undefined,
171
+ },
172
+ });
173
+ if (!created) {
174
+ return { created: false, code };
175
+ }
176
+
177
+ const replyText = pairing.buildPairingReply({
178
+ channel: "wecom",
179
+ idLine: `Your WeCom user id: ${senderId}`,
180
+ code,
181
+ });
182
+ try {
183
+ await sendPairingReply(replyText);
184
+ } catch (err) {
185
+ api?.logger?.warn?.(`wecom: pairing reply failed: ${String(err?.message || err)}`);
186
+ }
187
+ return { created: true, code, replyText };
188
+ }
@@ -1,5 +1,5 @@
1
1
  export const MAX_REQUEST_BODY_SIZE = 1024 * 1024;
2
- export const PLUGIN_VERSION = "2.0.0";
2
+ export const PLUGIN_VERSION = "2.1.0";
3
3
  export const WECOM_TEMP_DIR_NAME = "openclaw-wechat";
4
4
  export const WECOM_TEMP_FILE_RETENTION_MS = 30 * 60 * 1000;
5
5
  export const WECOM_MIN_FILE_SIZE = 5;
@@ -3,13 +3,38 @@ export function createWecomTargetResolver({ resolveWecomTarget } = {}) {
3
3
  throw new Error("createWecomTargetResolver: resolveWecomTarget is required");
4
4
  }
5
5
 
6
+ function readString(value) {
7
+ return String(value ?? "").trim();
8
+ }
9
+
10
+ function pickFirstString(...values) {
11
+ for (const value of values) {
12
+ const normalized = readString(value);
13
+ if (normalized) return normalized;
14
+ }
15
+ return "";
16
+ }
17
+
6
18
  function normalizeWecomResolvedTarget(rawTarget) {
7
19
  if (rawTarget && typeof rawTarget === "object") {
8
- const toUser = String(rawTarget.toUser ?? "").trim();
9
- const toParty = String(rawTarget.toParty ?? "").trim();
10
- const toTag = String(rawTarget.toTag ?? "").trim();
11
- const chatId = String(rawTarget.chatId ?? "").trim();
12
- const webhook = String(rawTarget.webhook ?? "").trim();
20
+ const toUser = pickFirstString(
21
+ rawTarget.toUser,
22
+ rawTarget.userId,
23
+ rawTarget.userid,
24
+ rawTarget.user,
25
+ rawTarget.username,
26
+ );
27
+ const toParty = pickFirstString(
28
+ rawTarget.toParty,
29
+ rawTarget.partyId,
30
+ rawTarget.partyid,
31
+ rawTarget.deptId,
32
+ rawTarget.deptid,
33
+ rawTarget.departmentId,
34
+ );
35
+ const toTag = pickFirstString(rawTarget.toTag, rawTarget.tagId, rawTarget.tagid);
36
+ const chatId = pickFirstString(rawTarget.chatId, rawTarget.chatid, rawTarget.groupId, rawTarget.groupid);
37
+ const webhook = pickFirstString(rawTarget.webhook, rawTarget.webhookId, rawTarget.webhookTarget);
13
38
  if (toUser || toParty || toTag || chatId || webhook) {
14
39
  return {
15
40
  ...(toUser ? { toUser } : {}),
@@ -19,6 +44,17 @@ export function createWecomTargetResolver({ resolveWecomTarget } = {}) {
19
44
  ...(webhook ? { webhook } : {}),
20
45
  };
21
46
  }
47
+ const nestedTarget = pickFirstString(
48
+ rawTarget.to,
49
+ rawTarget.target,
50
+ rawTarget.value,
51
+ rawTarget.address,
52
+ rawTarget.rawTarget,
53
+ );
54
+ if (nestedTarget) {
55
+ const resolvedNestedTarget = resolveWecomTarget(nestedTarget);
56
+ return resolvedNestedTarget && typeof resolvedNestedTarget === "object" ? resolvedNestedTarget : null;
57
+ }
22
58
  }
23
59
  const resolved = resolveWecomTarget(rawTarget);
24
60
  return resolved && typeof resolved === "object" ? resolved : null;
@@ -1,4 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { access } from "node:fs/promises";
4
+ import { delimiter } from "node:path";
2
5
 
3
6
  function assertFunction(name, value) {
4
7
  if (typeof value !== "function") {
@@ -9,6 +12,8 @@ function assertFunction(name, value) {
9
12
  export function createVoiceTranscriptionProcessRuntime({
10
13
  runProcessWithTimeoutImpl,
11
14
  checkCommandAvailableImpl,
15
+ processEnv = process.env,
16
+ accessImpl = access,
12
17
  } = {}) {
13
18
  const ffmpegPathCheckCache = {
14
19
  checked: false,
@@ -16,6 +21,50 @@ export function createVoiceTranscriptionProcessRuntime({
16
21
  };
17
22
  const commandPathCheckCache = new Map();
18
23
 
24
+ function readString(value) {
25
+ return String(value ?? "").trim();
26
+ }
27
+
28
+ function uniqueStrings(values) {
29
+ return Array.from(
30
+ new Set(
31
+ values
32
+ .map((value) => readString(value))
33
+ .filter(Boolean),
34
+ ),
35
+ );
36
+ }
37
+
38
+ function listCandidateCommandPaths(command) {
39
+ const normalizedCommand = readString(command);
40
+ if (!normalizedCommand) return [];
41
+ if (normalizedCommand.includes("/")) {
42
+ return [normalizedCommand];
43
+ }
44
+ const homeDir = readString(processEnv?.HOME);
45
+ const pathDirs = readString(processEnv?.PATH)
46
+ .split(delimiter)
47
+ .map((entry) => readString(entry))
48
+ .filter(Boolean);
49
+ const pythonUserBins = homeDir
50
+ ? ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"].map(
51
+ (version) => `${homeDir}/Library/Python/${version}/bin`,
52
+ )
53
+ : [];
54
+ const searchDirs = uniqueStrings([
55
+ ...pathDirs,
56
+ homeDir ? `${homeDir}/.local/bin` : "",
57
+ homeDir ? `${homeDir}/bin` : "",
58
+ ...pythonUserBins,
59
+ "/usr/local/bin",
60
+ "/opt/homebrew/bin",
61
+ ]);
62
+ return uniqueStrings([
63
+ normalizedCommand,
64
+ ...searchDirs.map((dir) => `${dir}/${normalizedCommand}`),
65
+ ]);
66
+ }
67
+
19
68
  function runProcessWithTimeout({ command, args, timeoutMs = 15000, allowNonZeroExitCode = false }) {
20
69
  if (typeof runProcessWithTimeoutImpl === "function") {
21
70
  return runProcessWithTimeoutImpl({ command, args, timeoutMs, allowNonZeroExitCode });
@@ -72,6 +121,16 @@ export function createVoiceTranscriptionProcessRuntime({
72
121
  if (commandPathCheckCache.has(normalized)) {
73
122
  return commandPathCheckCache.get(normalized);
74
123
  }
124
+ if (normalized.includes("/")) {
125
+ try {
126
+ await accessImpl(normalized, fsConstants.X_OK);
127
+ commandPathCheckCache.set(normalized, true);
128
+ return true;
129
+ } catch {
130
+ commandPathCheckCache.set(normalized, false);
131
+ return false;
132
+ }
133
+ }
75
134
  try {
76
135
  await runProcessWithTimeout({
77
136
  command: normalized,
@@ -101,15 +160,18 @@ export function createVoiceTranscriptionProcessRuntime({
101
160
  function listLocalWhisperCommandCandidates({ voiceConfig } = {}) {
102
161
  const provider = String(voiceConfig?.provider ?? "").trim().toLowerCase();
103
162
  const explicitCommand = String(voiceConfig?.command ?? "").trim();
104
- const fallbackCandidates =
163
+ const fallbackCommandNames =
105
164
  provider === "local-whisper"
106
165
  ? ["whisper"]
107
166
  : provider === "local-whisper-cli"
108
167
  ? ["whisper-cli"]
109
168
  : [];
110
- const candidates = explicitCommand ? [explicitCommand, ...fallbackCandidates] : fallbackCandidates;
169
+ const commandNames = explicitCommand
170
+ ? uniqueStrings([explicitCommand, ...fallbackCommandNames])
171
+ : uniqueStrings(fallbackCommandNames);
172
+ const candidates = uniqueStrings(commandNames.flatMap((command) => listCandidateCommandPaths(command)));
111
173
 
112
- if (candidates.length === 0) {
174
+ if (commandNames.length === 0) {
113
175
  return {
114
176
  provider,
115
177
  explicitCommand,
@@ -29,6 +29,7 @@ export function createWecomVoiceTranscriber({
29
29
  const processRuntime = createVoiceTranscriptionProcessRuntime({
30
30
  runProcessWithTimeoutImpl,
31
31
  checkCommandAvailableImpl,
32
+ processEnv,
32
33
  });
33
34
  const {
34
35
  runProcessWithTimeout,
@@ -206,7 +207,7 @@ export function createWecomVoiceTranscriber({
206
207
  if (voiceConfig.requireModelPath !== false && !voiceConfig.modelPath) {
207
208
  throw new Error("voiceTranscription.modelPath is required for local-whisper-cli (or set requireModelPath=false)");
208
209
  }
209
- return transcribeWithWhisperCli({
210
+ return await transcribeWithWhisperCli({
210
211
  command,
211
212
  modelPath: voiceConfig.modelPath,
212
213
  audioPath,
@@ -217,7 +218,7 @@ export function createWecomVoiceTranscriber({
217
218
  }
218
219
 
219
220
  if (provider === "local-whisper") {
220
- return transcribeWithWhisperPython({
221
+ return await transcribeWithWhisperPython({
221
222
  command,
222
223
  model: voiceConfig.model,
223
224
  audioPath,
@@ -18,6 +18,25 @@ export function dedupeUrlList(urls) {
18
18
  return out;
19
19
  }
20
20
 
21
+ function dedupeMediaEntries(entries) {
22
+ const seen = new Map();
23
+ for (const rawEntry of Array.isArray(entries) ? entries : []) {
24
+ if (!rawEntry || typeof rawEntry !== "object") continue;
25
+ const url = normalizeToken(rawEntry.url);
26
+ if (!url) continue;
27
+ const aesKey = normalizeToken(rawEntry.aesKey);
28
+ const existing = seen.get(url);
29
+ if (!existing) {
30
+ seen.set(url, { url, aesKey });
31
+ continue;
32
+ }
33
+ if (!existing.aesKey && aesKey) {
34
+ seen.set(url, { url, aesKey });
35
+ }
36
+ }
37
+ return Array.from(seen.values());
38
+ }
39
+
21
40
  export function collectWecomBotImageUrls(imageLike) {
22
41
  return dedupeUrlList([
23
42
  imageLike?.url,
@@ -28,6 +47,16 @@ export function collectWecomBotImageUrls(imageLike) {
28
47
  ]);
29
48
  }
30
49
 
50
+ export function collectWecomBotImageEntries(imageLike) {
51
+ const aesKey = normalizeToken(imageLike?.aeskey || imageLike?.aes_key || imageLike?.aesKey);
52
+ return dedupeMediaEntries(
53
+ collectWecomBotImageUrls(imageLike).map((url) => ({
54
+ url,
55
+ aesKey,
56
+ })),
57
+ );
58
+ }
59
+
31
60
  export function normalizeWecomBotOutboundMediaUrls(payload = {}) {
32
61
  return dedupeUrlList([
33
62
  payload?.mediaUrl,