@sunnoy/wecom 1.9.0 → 2.0.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/wecom/accounts.js CHANGED
@@ -1,249 +1,304 @@
1
- /**
2
- * Multi-account resolution layer.
3
- *
4
- * Design: dictionary-based — each key under `channels.wecom` is an account ID,
5
- * and its value contains the full per-account config (token, encodingAesKey,
6
- * agent, webhooks, etc.).
7
- *
8
- * Legacy single-account configs (where `token` exists directly under `wecom`)
9
- * are auto-detected and treated as accountId = "default".
10
- *
11
- * ── Multi-account config ───────────────────────────────────────────
12
- *
13
- * channels:
14
- * wecom:
15
- * bot1:
16
- * token: "bot-token-a"
17
- * encodingAesKey: "..."
18
- * agent:
19
- * corpId: "ww1234"
20
- * corpSecret: "secret-a"
21
- * agentId: 1000001
22
- * webhooks:
23
- * ops-group: "key-xxx"
24
- * bot2:
25
- * token: "bot-token-b"
26
- * encodingAesKey: "..."
27
- * agent:
28
- * corpId: "ww5678"
29
- * corpSecret: "secret-b"
30
- * agentId: 1000002
31
- *
32
- * ── Legacy single-account config (auto-detected, fully compatible) ─
33
- *
34
- * channels:
35
- * wecom:
36
- * token: "bot-token-a"
37
- * encodingAesKey: "..."
38
- * agent:
39
- * corpId: "ww1234"
40
- * corpSecret: "secret-a"
41
- * agentId: 1000001
42
- */
43
-
44
1
  import { logger } from "../logger.js";
45
- import { DEFAULT_ACCOUNT_ID } from "./constants.js";
2
+ import { DEFAULT_ACCOUNT_ID, DEFAULT_WS_URL } from "./constants.js";
46
3
 
47
- // Keys that belong to the top-level wecom config and are NOT account IDs.
48
4
  const RESERVED_KEYS = new Set([
49
5
  "enabled",
50
- "token",
51
- "encodingAesKey",
52
- "agent",
53
- "webhooks",
54
- "webhookPath",
55
6
  "name",
7
+ "botId",
8
+ "secret",
9
+ "websocketUrl",
10
+ "sendThinkingMessage",
11
+ "welcomeMessage",
56
12
  "allowFrom",
57
- "commandAllowlist",
58
- "commandBlockMessage",
59
- // Top-level config keys that are NOT account IDs (issue #79).
13
+ "dmPolicy",
14
+ "groupPolicy",
15
+ "groupAllowFrom",
16
+ "groups",
17
+ "commands",
18
+ "dynamicAgents",
19
+ "dm",
20
+ "groupChat",
21
+ "adminUsers",
22
+ "workspaceTemplate",
23
+ "agent",
24
+ "webhooks",
60
25
  "network",
26
+ "defaultAccount",
27
+ ]);
28
+
29
+ const SHARED_MULTI_ACCOUNT_KEYS = new Set([
30
+ "enabled",
31
+ "websocketUrl",
32
+ "sendThinkingMessage",
33
+ "welcomeMessage",
34
+ "allowFrom",
35
+ "dmPolicy",
36
+ "groupPolicy",
37
+ "groupAllowFrom",
38
+ "groups",
61
39
  "commands",
62
40
  "dynamicAgents",
63
41
  "dm",
64
42
  "groupChat",
65
43
  "adminUsers",
66
44
  "workspaceTemplate",
67
- "instances",
45
+ "agent",
46
+ "webhooks",
47
+ "network",
68
48
  ]);
69
49
 
70
- // ── Helpers ─────────────────────────────────────────────────────────
50
+ function isPlainObject(value) {
51
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
52
+ }
71
53
 
72
- /**
73
- * Detect whether the wecom config block is legacy (single-account) format.
74
- * Heuristic: if `token` exists directly under `channels.wecom`, it's legacy.
75
- */
76
- function isLegacyConfig(wecom) {
77
- return typeof wecom?.token === "string";
54
+ function normalizeAccountKey(value) {
55
+ return String(value ?? "")
56
+ .trim()
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9_-]/g, "_");
78
59
  }
79
60
 
80
- /**
81
- * Build a resolved account object from a per-account config block.
82
- */
83
- function buildAccount(accountId, accountCfg) {
84
- const agent = accountCfg?.agent;
85
- const webhooks = accountCfg?.webhooks;
86
- const agentConfigured = Boolean(agent?.corpId && agent?.corpSecret && agent?.agentId);
87
- const agentInboundConfigured = Boolean(
88
- agent?.corpId && agent?.corpSecret && agent?.agentId && agent?.token && agent?.encodingAesKey,
89
- );
61
+ function cloneValue(value) {
62
+ if (Array.isArray(value)) {
63
+ return value.map((entry) => cloneValue(entry));
64
+ }
65
+ if (!isPlainObject(value)) {
66
+ return value;
67
+ }
68
+ const cloned = {};
69
+ for (const [key, entry] of Object.entries(value)) {
70
+ cloned[key] = cloneValue(entry);
71
+ }
72
+ return cloned;
73
+ }
90
74
 
91
- const hasBotTokens = Boolean(accountCfg?.token && accountCfg?.encodingAesKey);
92
- const defaultPath = accountId === DEFAULT_ACCOUNT_ID ? "/webhooks/wecom" : `/webhooks/wecom/${accountId}`;
75
+ function pruneEmptyObjects(value) {
76
+ if (Array.isArray(value)) {
77
+ return value.map((entry) => pruneEmptyObjects(entry));
78
+ }
79
+ if (!isPlainObject(value)) {
80
+ return value;
81
+ }
82
+
83
+ const next = {};
84
+ for (const [key, entry] of Object.entries(value)) {
85
+ const pruned = pruneEmptyObjects(entry);
86
+ if (pruned === undefined) {
87
+ continue;
88
+ }
89
+ if (isPlainObject(pruned) && Object.keys(pruned).length === 0) {
90
+ continue;
91
+ }
92
+ next[key] = pruned;
93
+ }
94
+ return next;
95
+ }
96
+
97
+ function mergeConfig(base, override) {
98
+ const result = isPlainObject(base) ? cloneValue(base) : {};
99
+ for (const [key, value] of Object.entries(override ?? {})) {
100
+ if (value === undefined) {
101
+ delete result[key];
102
+ continue;
103
+ }
104
+ if (isPlainObject(value) && isPlainObject(result[key])) {
105
+ result[key] = mergeConfig(result[key], value);
106
+ continue;
107
+ }
108
+ result[key] = cloneValue(value);
109
+ }
110
+ return pruneEmptyObjects(result);
111
+ }
112
+
113
+ function getWecomConfig(cfg) {
114
+ return isPlainObject(cfg?.channels?.wecom) ? cfg.channels.wecom : {};
115
+ }
116
+
117
+ function getAccountEntries(wecom) {
118
+ const entries = [];
119
+ for (const [key, value] of Object.entries(wecom ?? {})) {
120
+ if (RESERVED_KEYS.has(key) || !isPlainObject(value)) {
121
+ continue;
122
+ }
123
+ const accountId = normalizeAccountKey(key);
124
+ if (!accountId) {
125
+ continue;
126
+ }
127
+ entries.push({ key, accountId, value });
128
+ }
129
+ return entries;
130
+ }
131
+
132
+ function hasDictionaryAccounts(wecom) {
133
+ return getAccountEntries(wecom).length > 0;
134
+ }
135
+
136
+ function getSharedMultiAccountConfig(wecom) {
137
+ const shared = {};
138
+ for (const [key, value] of Object.entries(wecom ?? {})) {
139
+ if (SHARED_MULTI_ACCOUNT_KEYS.has(key)) {
140
+ shared[key] = cloneValue(value);
141
+ }
142
+ }
143
+ return shared;
144
+ }
145
+
146
+ function findEntryByAccountId(wecom, accountId) {
147
+ return getAccountEntries(wecom).find((entry) => entry.accountId === accountId) ?? null;
148
+ }
149
+
150
+ function buildAccount(accountId, config, meta = {}) {
151
+ const safeConfig = isPlainObject(config) ? cloneValue(config) : {};
152
+ const agent = isPlainObject(safeConfig.agent) ? safeConfig.agent : {};
153
+ const botId = String(safeConfig.botId ?? "").trim();
154
+ const secret = String(safeConfig.secret ?? "").trim();
155
+ const websocketUrl = String(safeConfig.websocketUrl ?? DEFAULT_WS_URL).trim() || DEFAULT_WS_URL;
156
+ const enabled = safeConfig.enabled ?? Object.keys(safeConfig).length > 0;
157
+ const configured = Boolean(botId && secret);
158
+ const agentConfigured = Boolean(agent.corpId && agent.corpSecret && agent.agentId);
93
159
 
94
160
  return {
95
161
  accountId,
96
- name: accountCfg?.name || accountId,
97
- enabled: accountCfg?.enabled !== false,
98
- configured: hasBotTokens || agentConfigured,
99
- token: accountCfg?.token || "",
100
- encodingAesKey: accountCfg?.encodingAesKey || "",
101
- webhookPath: accountCfg?.webhookPath || (hasBotTokens ? defaultPath : ""),
102
- config: accountCfg || {},
162
+ name: String(safeConfig.name ?? accountId ?? DEFAULT_ACCOUNT_ID).trim() || accountId,
163
+ enabled,
164
+ configured,
165
+ botId,
166
+ secret,
167
+ websocketUrl,
168
+ sendThinkingMessage: safeConfig.sendThinkingMessage !== false,
169
+ config: safeConfig,
170
+ configPath: meta.configPath ?? `channels.wecom.${accountId}`,
171
+ storageMode: meta.storageMode ?? "dictionary",
172
+ entryKey: meta.entryKey ?? accountId,
103
173
  agentConfigured,
104
- agentInboundConfigured,
105
- webhooksConfigured: Boolean(webhooks && Object.keys(webhooks).length > 0),
174
+ webhooksConfigured: isPlainObject(safeConfig.webhooks) && Object.keys(safeConfig.webhooks).length > 0,
106
175
  agentCredentials: agentConfigured
107
- ? { corpId: agent.corpId, corpSecret: agent.corpSecret, agentId: agent.agentId }
176
+ ? {
177
+ corpId: String(agent.corpId),
178
+ corpSecret: String(agent.corpSecret),
179
+ agentId: agent.agentId,
180
+ }
108
181
  : null,
109
182
  };
110
183
  }
111
184
 
112
- /**
113
- * Normalize a raw account key → canonical ID (lowercase, safe chars only).
114
- */
115
- function normalizeAccountKey(key) {
116
- return String(key).trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
185
+ function buildDisabledAccount(accountId) {
186
+ return buildAccount(
187
+ accountId,
188
+ { enabled: false },
189
+ { configPath: `channels.wecom.${accountId}`, storageMode: "dictionary", entryKey: accountId },
190
+ );
117
191
  }
118
192
 
119
- // ── Public API ──────────────────────────────────────────────────────
193
+ export function isDictionaryAccountConfig(cfg) {
194
+ return hasDictionaryAccounts(getWecomConfig(cfg));
195
+ }
120
196
 
121
- /**
122
- * List all configured account IDs.
123
- * Returns `["default"]` for legacy single-account configs.
124
- */
125
197
  export function listAccountIds(cfg) {
126
- const wecom = cfg?.channels?.wecom;
127
- if (!wecom || wecom.enabled === false) return [];
128
-
129
- // Legacy single-account → just "default".
130
- if (isLegacyConfig(wecom)) return [DEFAULT_ACCOUNT_ID];
131
-
132
- // Dictionary mode — each non-reserved key is an account.
133
- const ids = [];
134
- for (const key of Object.keys(wecom)) {
135
- if (RESERVED_KEYS.has(key)) continue;
136
- const val = wecom[key];
137
- if (val && typeof val === "object" && !Array.isArray(val)) {
138
- const id = normalizeAccountKey(key);
139
- if (id && !ids.includes(id)) ids.push(id);
140
- }
198
+ const entries = getAccountEntries(getWecomConfig(cfg));
199
+ if (entries.length === 0) {
200
+ return [DEFAULT_ACCOUNT_ID];
141
201
  }
202
+ return [...new Set(entries.map((entry) => entry.accountId))].sort((left, right) => left.localeCompare(right));
203
+ }
142
204
 
143
- if (ids.length === 0) {
144
- logger.warn("[accounts] wecom config has no account entries and no legacy token — returning empty");
205
+ export function resolveDefaultAccountId(cfg) {
206
+ const preferred = normalizeAccountKey(getWecomConfig(cfg)?.defaultAccount);
207
+ const ids = listAccountIds(cfg);
208
+ if (preferred && ids.includes(preferred)) {
209
+ return preferred;
145
210
  }
146
- return ids;
211
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
212
+ return DEFAULT_ACCOUNT_ID;
213
+ }
214
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
147
215
  }
148
216
 
149
- /**
150
- * Resolve a single account by its ID.
151
- */
152
- export function resolveAccount(cfg, accountId) {
153
- const wecom = cfg?.channels?.wecom;
154
- if (!wecom) return null;
217
+ export function normalizeAccountId(accountId) {
218
+ return normalizeAccountKey(accountId) || DEFAULT_ACCOUNT_ID;
219
+ }
155
220
 
156
- const resolvedId = accountId || DEFAULT_ACCOUNT_ID;
221
+ export function resolveAccount(cfg, accountId) {
222
+ const wecom = getWecomConfig(cfg);
223
+ const requestedId = normalizeAccountKey(accountId) || resolveDefaultAccountId(cfg);
157
224
 
158
- // Legacy single-account: the entire wecom block IS the account config.
159
- if (isLegacyConfig(wecom)) {
160
- if (resolvedId !== DEFAULT_ACCOUNT_ID) {
161
- logger.warn(`[accounts] legacy config does not have account "${resolvedId}"`);
162
- return buildAccount(resolvedId, { enabled: false });
225
+ if (!hasDictionaryAccounts(wecom)) {
226
+ if (requestedId !== DEFAULT_ACCOUNT_ID) {
227
+ return buildDisabledAccount(requestedId);
163
228
  }
164
- return buildAccount(DEFAULT_ACCOUNT_ID, wecom);
229
+ return buildAccount(DEFAULT_ACCOUNT_ID, wecom, {
230
+ configPath: "channels.wecom",
231
+ storageMode: "single",
232
+ entryKey: DEFAULT_ACCOUNT_ID,
233
+ });
165
234
  }
166
235
 
167
- // Dictionary mode: look up key (case-insensitive).
168
- const normalizedId = normalizeAccountKey(resolvedId);
169
- for (const key of Object.keys(wecom)) {
170
- if (RESERVED_KEYS.has(key)) continue;
171
- if (normalizeAccountKey(key) === normalizedId) {
172
- const val = wecom[key];
173
- if (val && typeof val === "object" && !Array.isArray(val)) {
174
- return buildAccount(normalizedId, val);
175
- }
176
- }
236
+ const shared = getSharedMultiAccountConfig(wecom);
237
+ const entry = findEntryByAccountId(wecom, requestedId);
238
+ if (!entry) {
239
+ return buildDisabledAccount(requestedId);
177
240
  }
178
241
 
179
- // Not found.
180
- return buildAccount(resolvedId, { enabled: false });
242
+ return buildAccount(requestedId, mergeConfig(shared, entry.value), {
243
+ configPath: `channels.wecom.${entry.key}`,
244
+ storageMode: "dictionary",
245
+ entryKey: entry.key,
246
+ });
181
247
  }
182
248
 
183
- /**
184
- * Resolve all accounts as a Map<accountId, account>.
185
- */
186
249
  export function resolveAllAccounts(cfg) {
187
- const ids = listAccountIds(cfg);
188
250
  const accounts = new Map();
189
- for (const id of ids) {
190
- accounts.set(id, resolveAccount(cfg, id));
251
+ for (const accountId of listAccountIds(cfg)) {
252
+ accounts.set(accountId, resolveAccount(cfg, accountId));
191
253
  }
192
254
  return accounts;
193
255
  }
194
256
 
195
- /**
196
- * Extract Agent API credentials for a given accountId.
197
- * Returns `{ corpId, corpSecret, agentId }` or null.
198
- */
199
257
  export function resolveAgentConfigForAccount(cfg, accountId) {
258
+ return resolveAccount(cfg, accountId)?.agentCredentials ?? null;
259
+ }
260
+
261
+ export function resolveAllowFromForAccount(cfg, accountId) {
200
262
  const account = resolveAccount(cfg, accountId);
201
- return account?.agentCredentials ?? null;
263
+ const allowFrom = account?.config?.allowFrom;
264
+ return Array.isArray(allowFrom) ? allowFrom.map((entry) => String(entry)) : [];
202
265
  }
203
266
 
204
- /**
205
- * Detect duplicate tokens / agentIds across accounts.
206
- * Returns an array of conflict descriptions (empty = no conflicts).
207
- */
208
267
  export function detectAccountConflicts(cfg) {
209
- const accounts = resolveAllAccounts(cfg);
210
268
  const conflicts = [];
269
+ const botOwners = new Map();
270
+ const agentOwners = new Map();
211
271
 
212
- const tokenOwners = new Map();
213
- const agentIdOwners = new Map();
214
-
215
- for (const [id, account] of accounts) {
216
- if (!account.enabled) continue;
272
+ for (const [accountId, account] of resolveAllAccounts(cfg)) {
273
+ if (!account.enabled) {
274
+ continue;
275
+ }
217
276
 
218
- // Check bot token uniqueness.
219
- const token = account.token?.trim();
220
- if (token) {
221
- const key = token.toLowerCase();
222
- if (tokenOwners.has(key)) {
223
- const owner = tokenOwners.get(key);
277
+ if (account.botId) {
278
+ const botKey = account.botId.toLowerCase();
279
+ if (botOwners.has(botKey)) {
280
+ const owner = botOwners.get(botKey);
224
281
  conflicts.push({
225
- type: "duplicate_token",
226
- accounts: [owner, id],
227
- message: `账号 "${id}" 与 "${owner}" 使用了相同的 Bot Token,会导致消息错乱。`,
282
+ type: "duplicate_bot_id",
283
+ accounts: [owner, accountId],
284
+ message: `账号 "${accountId}" 与 "${owner}" 使用了相同的 botId。`,
228
285
  });
229
286
  } else {
230
- tokenOwners.set(key, id);
287
+ botOwners.set(botKey, accountId);
231
288
  }
232
289
  }
233
290
 
234
- // Check agent corpId+agentId uniqueness.
235
- const creds = account.agentCredentials;
236
- if (creds) {
237
- const key = `${creds.corpId}:${creds.agentId}`;
238
- if (agentIdOwners.has(key)) {
239
- const owner = agentIdOwners.get(key);
291
+ if (account.agentCredentials) {
292
+ const agentKey = `${account.agentCredentials.corpId}:${account.agentCredentials.agentId}`;
293
+ if (agentOwners.has(agentKey)) {
294
+ const owner = agentOwners.get(agentKey);
240
295
  conflicts.push({
241
296
  type: "duplicate_agent",
242
- accounts: [owner, id],
243
- message: `账号 "${id}" 与 "${owner}" 使用了相同的 Agent 配置 (${creds.corpId}/${creds.agentId})。`,
297
+ accounts: [owner, accountId],
298
+ message: `账号 "${accountId}" 与 "${owner}" 使用了相同的 Agent 配置 (${account.agentCredentials.corpId}/${account.agentCredentials.agentId})。`,
244
299
  });
245
300
  } else {
246
- agentIdOwners.set(key, id);
301
+ agentOwners.set(agentKey, accountId);
247
302
  }
248
303
  }
249
304
  }
@@ -251,18 +306,97 @@ export function detectAccountConflicts(cfg) {
251
306
  return conflicts;
252
307
  }
253
308
 
254
- /**
255
- * Find which accountId owns a given bot token.
256
- * Useful for inbound routing when the request carries a token.
257
- */
258
- export function findAccountByToken(cfg, token) {
259
- if (!token) return null;
260
- const key = token.trim().toLowerCase();
261
- const accounts = resolveAllAccounts(cfg);
262
- for (const [id, account] of accounts) {
263
- if (account.enabled && account.token?.trim().toLowerCase() === key) {
264
- return id;
265
- }
309
+ export function updateAccountConfig(cfg, accountId, patch, options = {}) {
310
+ const normalizedId = normalizeAccountKey(accountId) || DEFAULT_ACCOUNT_ID;
311
+ const wecom = getWecomConfig(cfg);
312
+ const nextChannels = { ...(cfg?.channels ?? {}) };
313
+
314
+ if (normalizedId === DEFAULT_ACCOUNT_ID && !hasDictionaryAccounts(wecom) && options.forceDictionary !== true) {
315
+ nextChannels.wecom = mergeConfig(wecom, patch);
316
+ return { ...cfg, channels: nextChannels };
317
+ }
318
+
319
+ const nextWecom = { ...wecom };
320
+ const existingEntry = findEntryByAccountId(wecom, normalizedId);
321
+ const entryKey = existingEntry?.key ?? normalizedId;
322
+ const previousEntry = isPlainObject(nextWecom[entryKey]) ? nextWecom[entryKey] : {};
323
+ const nextEntry = mergeConfig(previousEntry, patch);
324
+
325
+ if (Object.keys(nextEntry).length > 0) {
326
+ nextWecom[entryKey] = nextEntry;
327
+ } else {
328
+ delete nextWecom[entryKey];
329
+ }
330
+
331
+ nextChannels.wecom = nextWecom;
332
+ return { ...cfg, channels: nextChannels };
333
+ }
334
+
335
+ export function setAccountConfig(cfg, accountId, patch, options = {}) {
336
+ return updateAccountConfig(cfg, accountId, patch, options);
337
+ }
338
+
339
+ export function setAccountEnabled({ cfg, accountId = DEFAULT_ACCOUNT_ID, enabled }) {
340
+ return updateAccountConfig(cfg, accountId, { enabled });
341
+ }
342
+
343
+ export function deleteAccount({ cfg, accountId = DEFAULT_ACCOUNT_ID }) {
344
+ return deleteAccountConfig(cfg, accountId);
345
+ }
346
+
347
+ export function clearAccountCredentials({ cfg, accountId = DEFAULT_ACCOUNT_ID }) {
348
+ return updateAccountConfig(
349
+ cfg,
350
+ accountId,
351
+ {
352
+ botId: "",
353
+ secret: "",
354
+ websocketUrl: undefined,
355
+ },
356
+ { forceDictionary: accountId !== DEFAULT_ACCOUNT_ID },
357
+ );
358
+ }
359
+
360
+ export function resolveAccountBasePath(cfg, accountId) {
361
+ const account = resolveAccount(cfg, accountId);
362
+ return account?.configPath ?? "channels.wecom";
363
+ }
364
+
365
+ export function deleteAccountConfig(cfg, accountId) {
366
+ const normalizedId = normalizeAccountKey(accountId) || DEFAULT_ACCOUNT_ID;
367
+ const wecom = getWecomConfig(cfg);
368
+ const nextChannels = { ...(cfg?.channels ?? {}) };
369
+
370
+ if (normalizedId === DEFAULT_ACCOUNT_ID && !hasDictionaryAccounts(wecom)) {
371
+ delete nextChannels.wecom;
372
+ return { ...cfg, channels: nextChannels };
373
+ }
374
+
375
+ const nextWecom = { ...wecom };
376
+ const existingEntry = findEntryByAccountId(wecom, normalizedId);
377
+ if (existingEntry) {
378
+ delete nextWecom[existingEntry.key];
379
+ }
380
+ nextChannels.wecom = nextWecom;
381
+ return { ...cfg, channels: nextChannels };
382
+ }
383
+
384
+ export function describeAccount(account) {
385
+ return {
386
+ accountId: account.accountId,
387
+ name: account.name,
388
+ enabled: account.enabled,
389
+ configured: account.configured,
390
+ botId: account.botId,
391
+ websocketUrl: account.websocketUrl,
392
+ };
393
+ }
394
+
395
+ export function logAccountConflicts(cfg) {
396
+ for (const conflict of detectAccountConflicts(cfg)) {
397
+ logger.error(`[wecom/accounts] ${conflict.message}`, {
398
+ type: conflict.type,
399
+ accounts: conflict.accounts,
400
+ });
266
401
  }
267
- return null;
268
402
  }