@openclaw/zalouser 2026.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +221 -0
- package/index.ts +32 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +33 -0
- package/src/accounts.ts +117 -0
- package/src/channel.test.ts +17 -0
- package/src/channel.ts +641 -0
- package/src/config-schema.ts +27 -0
- package/src/monitor.ts +574 -0
- package/src/onboarding.ts +488 -0
- package/src/probe.ts +28 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +150 -0
- package/src/status-issues.test.ts +58 -0
- package/src/status-issues.ts +81 -0
- package/src/tool.ts +156 -0
- package/src/types.ts +102 -0
- package/src/zca.ts +208 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
4
|
+
import { mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk";
|
|
5
|
+
import { sendMessageZalouser } from "./send.js";
|
|
6
|
+
import type {
|
|
7
|
+
ResolvedZalouserAccount,
|
|
8
|
+
ZcaFriend,
|
|
9
|
+
ZcaGroup,
|
|
10
|
+
ZcaMessage,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import { getZalouserRuntime } from "./runtime.js";
|
|
13
|
+
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
|
|
14
|
+
|
|
15
|
+
export type ZalouserMonitorOptions = {
|
|
16
|
+
account: ResolvedZalouserAccount;
|
|
17
|
+
config: OpenClawConfig;
|
|
18
|
+
runtime: RuntimeEnv;
|
|
19
|
+
abortSignal: AbortSignal;
|
|
20
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ZalouserMonitorResult = {
|
|
24
|
+
stop: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ZALOUSER_TEXT_LIMIT = 2000;
|
|
28
|
+
|
|
29
|
+
function normalizeZalouserEntry(entry: string): string {
|
|
30
|
+
return entry.replace(/^(zalouser|zlu):/i, "").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildNameIndex<T>(
|
|
34
|
+
items: T[],
|
|
35
|
+
nameFn: (item: T) => string | undefined,
|
|
36
|
+
): Map<string, T[]> {
|
|
37
|
+
const index = new Map<string, T[]>();
|
|
38
|
+
for (const item of items) {
|
|
39
|
+
const name = nameFn(item)?.trim().toLowerCase();
|
|
40
|
+
if (!name) continue;
|
|
41
|
+
const list = index.get(name) ?? [];
|
|
42
|
+
list.push(item);
|
|
43
|
+
index.set(name, list);
|
|
44
|
+
}
|
|
45
|
+
return index;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type ZalouserCoreRuntime = ReturnType<typeof getZalouserRuntime>;
|
|
49
|
+
|
|
50
|
+
function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void {
|
|
51
|
+
if (core.logging.shouldLogVerbose()) {
|
|
52
|
+
runtime.log(`[zalouser] ${message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
|
57
|
+
if (allowFrom.includes("*")) return true;
|
|
58
|
+
const normalizedSenderId = senderId.toLowerCase();
|
|
59
|
+
return allowFrom.some((entry) => {
|
|
60
|
+
const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
|
|
61
|
+
return normalized === normalizedSenderId;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeGroupSlug(raw?: string | null): string {
|
|
66
|
+
const trimmed = raw?.trim().toLowerCase() ?? "";
|
|
67
|
+
if (!trimmed) return "";
|
|
68
|
+
return trimmed
|
|
69
|
+
.replace(/^#/, "")
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^-+|-+$/g, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isGroupAllowed(params: {
|
|
75
|
+
groupId: string;
|
|
76
|
+
groupName?: string | null;
|
|
77
|
+
groups: Record<string, { allow?: boolean; enabled?: boolean }>;
|
|
78
|
+
}): boolean {
|
|
79
|
+
const groups = params.groups ?? {};
|
|
80
|
+
const keys = Object.keys(groups);
|
|
81
|
+
if (keys.length === 0) return false;
|
|
82
|
+
const candidates = [
|
|
83
|
+
params.groupId,
|
|
84
|
+
`group:${params.groupId}`,
|
|
85
|
+
params.groupName ?? "",
|
|
86
|
+
normalizeGroupSlug(params.groupName ?? ""),
|
|
87
|
+
].filter(Boolean);
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
const entry = groups[candidate];
|
|
90
|
+
if (!entry) continue;
|
|
91
|
+
return entry.allow !== false && entry.enabled !== false;
|
|
92
|
+
}
|
|
93
|
+
const wildcard = groups["*"];
|
|
94
|
+
if (wildcard) return wildcard.allow !== false && wildcard.enabled !== false;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function startZcaListener(
|
|
99
|
+
runtime: RuntimeEnv,
|
|
100
|
+
profile: string,
|
|
101
|
+
onMessage: (msg: ZcaMessage) => void,
|
|
102
|
+
onError: (err: Error) => void,
|
|
103
|
+
abortSignal: AbortSignal,
|
|
104
|
+
): ChildProcess {
|
|
105
|
+
let buffer = "";
|
|
106
|
+
|
|
107
|
+
const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
|
|
108
|
+
profile,
|
|
109
|
+
onData: (chunk) => {
|
|
110
|
+
buffer += chunk;
|
|
111
|
+
const lines = buffer.split("\n");
|
|
112
|
+
buffer = lines.pop() ?? "";
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed) continue;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(trimmed) as ZcaMessage;
|
|
118
|
+
onMessage(parsed);
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore non-JSON lines
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
onError,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
proc.stderr?.on("data", (data: Buffer) => {
|
|
128
|
+
const text = data.toString().trim();
|
|
129
|
+
if (text) runtime.error(`[zalouser] zca stderr: ${text}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
void promise.then((result) => {
|
|
133
|
+
if (!result.ok && !abortSignal.aborted) {
|
|
134
|
+
onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
abortSignal.addEventListener(
|
|
139
|
+
"abort",
|
|
140
|
+
() => {
|
|
141
|
+
proc.kill("SIGTERM");
|
|
142
|
+
},
|
|
143
|
+
{ once: true },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return proc;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function processMessage(
|
|
150
|
+
message: ZcaMessage,
|
|
151
|
+
account: ResolvedZalouserAccount,
|
|
152
|
+
config: OpenClawConfig,
|
|
153
|
+
core: ZalouserCoreRuntime,
|
|
154
|
+
runtime: RuntimeEnv,
|
|
155
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const { threadId, content, timestamp, metadata } = message;
|
|
158
|
+
if (!content?.trim()) return;
|
|
159
|
+
|
|
160
|
+
const isGroup = metadata?.isGroup ?? false;
|
|
161
|
+
const senderId = metadata?.fromId ?? threadId;
|
|
162
|
+
const senderName = metadata?.senderName ?? "";
|
|
163
|
+
const groupName = metadata?.threadName ?? "";
|
|
164
|
+
const chatId = threadId;
|
|
165
|
+
|
|
166
|
+
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
|
167
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
|
|
168
|
+
const groups = account.config.groups ?? {};
|
|
169
|
+
if (isGroup) {
|
|
170
|
+
if (groupPolicy === "disabled") {
|
|
171
|
+
logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (groupPolicy === "allowlist") {
|
|
175
|
+
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
|
|
176
|
+
if (!allowed) {
|
|
177
|
+
logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
184
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
|
185
|
+
const rawBody = content.trim();
|
|
186
|
+
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
|
|
187
|
+
rawBody,
|
|
188
|
+
config,
|
|
189
|
+
);
|
|
190
|
+
const storeAllowFrom =
|
|
191
|
+
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
|
192
|
+
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
|
|
193
|
+
: [];
|
|
194
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
195
|
+
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
|
196
|
+
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
|
197
|
+
const commandAuthorized = shouldComputeAuth
|
|
198
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
199
|
+
useAccessGroups,
|
|
200
|
+
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
|
201
|
+
})
|
|
202
|
+
: undefined;
|
|
203
|
+
|
|
204
|
+
if (!isGroup) {
|
|
205
|
+
if (dmPolicy === "disabled") {
|
|
206
|
+
logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (dmPolicy !== "open") {
|
|
211
|
+
const allowed = senderAllowedForCommands;
|
|
212
|
+
|
|
213
|
+
if (!allowed) {
|
|
214
|
+
if (dmPolicy === "pairing") {
|
|
215
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
216
|
+
channel: "zalouser",
|
|
217
|
+
id: senderId,
|
|
218
|
+
meta: { name: senderName || undefined },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (created) {
|
|
222
|
+
logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
|
|
223
|
+
try {
|
|
224
|
+
await sendMessageZalouser(
|
|
225
|
+
chatId,
|
|
226
|
+
core.channel.pairing.buildPairingReply({
|
|
227
|
+
channel: "zalouser",
|
|
228
|
+
idLine: `Your Zalo user id: ${senderId}`,
|
|
229
|
+
code,
|
|
230
|
+
}),
|
|
231
|
+
{ profile: account.profile },
|
|
232
|
+
);
|
|
233
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logVerbose(
|
|
236
|
+
core,
|
|
237
|
+
runtime,
|
|
238
|
+
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
logVerbose(
|
|
244
|
+
core,
|
|
245
|
+
runtime,
|
|
246
|
+
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
isGroup &&
|
|
256
|
+
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
|
257
|
+
commandAuthorized !== true
|
|
258
|
+
) {
|
|
259
|
+
logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
|
264
|
+
|
|
265
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
266
|
+
cfg: config,
|
|
267
|
+
channel: "zalouser",
|
|
268
|
+
accountId: account.accountId,
|
|
269
|
+
peer: {
|
|
270
|
+
// Use "group" kind to avoid dmScope=main collapsing all DMs into the main session.
|
|
271
|
+
kind: peer.kind,
|
|
272
|
+
id: peer.id,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
|
277
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
278
|
+
agentId: route.agentId,
|
|
279
|
+
});
|
|
280
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
281
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
282
|
+
storePath,
|
|
283
|
+
sessionKey: route.sessionKey,
|
|
284
|
+
});
|
|
285
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
286
|
+
channel: "Zalo Personal",
|
|
287
|
+
from: fromLabel,
|
|
288
|
+
timestamp: timestamp ? timestamp * 1000 : undefined,
|
|
289
|
+
previousTimestamp,
|
|
290
|
+
envelope: envelopeOptions,
|
|
291
|
+
body: rawBody,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
295
|
+
Body: body,
|
|
296
|
+
RawBody: rawBody,
|
|
297
|
+
CommandBody: rawBody,
|
|
298
|
+
From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
|
|
299
|
+
To: `zalouser:${chatId}`,
|
|
300
|
+
SessionKey: route.sessionKey,
|
|
301
|
+
AccountId: route.accountId,
|
|
302
|
+
ChatType: isGroup ? "group" : "direct",
|
|
303
|
+
ConversationLabel: fromLabel,
|
|
304
|
+
SenderName: senderName || undefined,
|
|
305
|
+
SenderId: senderId,
|
|
306
|
+
CommandAuthorized: commandAuthorized,
|
|
307
|
+
Provider: "zalouser",
|
|
308
|
+
Surface: "zalouser",
|
|
309
|
+
MessageSid: message.msgId ?? `${timestamp}`,
|
|
310
|
+
OriginatingChannel: "zalouser",
|
|
311
|
+
OriginatingTo: `zalouser:${chatId}`,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await core.channel.session.recordInboundSession({
|
|
315
|
+
storePath,
|
|
316
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
317
|
+
ctx: ctxPayload,
|
|
318
|
+
onRecordError: (err) => {
|
|
319
|
+
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
324
|
+
ctx: ctxPayload,
|
|
325
|
+
cfg: config,
|
|
326
|
+
dispatcherOptions: {
|
|
327
|
+
deliver: async (payload) => {
|
|
328
|
+
await deliverZalouserReply({
|
|
329
|
+
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
|
330
|
+
profile: account.profile,
|
|
331
|
+
chatId,
|
|
332
|
+
isGroup,
|
|
333
|
+
runtime,
|
|
334
|
+
core,
|
|
335
|
+
config,
|
|
336
|
+
accountId: account.accountId,
|
|
337
|
+
statusSink,
|
|
338
|
+
tableMode: core.channel.text.resolveMarkdownTableMode({
|
|
339
|
+
cfg: config,
|
|
340
|
+
channel: "zalouser",
|
|
341
|
+
accountId: account.accountId,
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
onError: (err, info) => {
|
|
346
|
+
runtime.error(
|
|
347
|
+
`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`,
|
|
348
|
+
);
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function deliverZalouserReply(params: {
|
|
355
|
+
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
|
356
|
+
profile: string;
|
|
357
|
+
chatId: string;
|
|
358
|
+
isGroup: boolean;
|
|
359
|
+
runtime: RuntimeEnv;
|
|
360
|
+
core: ZalouserCoreRuntime;
|
|
361
|
+
config: OpenClawConfig;
|
|
362
|
+
accountId?: string;
|
|
363
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
364
|
+
tableMode?: MarkdownTableMode;
|
|
365
|
+
}): Promise<void> {
|
|
366
|
+
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
|
|
367
|
+
params;
|
|
368
|
+
const tableMode = params.tableMode ?? "code";
|
|
369
|
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
370
|
+
|
|
371
|
+
const mediaList = payload.mediaUrls?.length
|
|
372
|
+
? payload.mediaUrls
|
|
373
|
+
: payload.mediaUrl
|
|
374
|
+
? [payload.mediaUrl]
|
|
375
|
+
: [];
|
|
376
|
+
|
|
377
|
+
if (mediaList.length > 0) {
|
|
378
|
+
let first = true;
|
|
379
|
+
for (const mediaUrl of mediaList) {
|
|
380
|
+
const caption = first ? text : undefined;
|
|
381
|
+
first = false;
|
|
382
|
+
try {
|
|
383
|
+
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
|
384
|
+
await sendMessageZalouser(chatId, caption ?? "", {
|
|
385
|
+
profile,
|
|
386
|
+
mediaUrl,
|
|
387
|
+
isGroup,
|
|
388
|
+
});
|
|
389
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
390
|
+
} catch (err) {
|
|
391
|
+
runtime.error(`Zalouser media send failed: ${String(err)}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (text) {
|
|
398
|
+
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
|
|
399
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
|
400
|
+
text,
|
|
401
|
+
ZALOUSER_TEXT_LIMIT,
|
|
402
|
+
chunkMode,
|
|
403
|
+
);
|
|
404
|
+
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
|
405
|
+
for (const chunk of chunks) {
|
|
406
|
+
try {
|
|
407
|
+
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
|
|
408
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
409
|
+
} catch (err) {
|
|
410
|
+
runtime.error(`Zalouser message send failed: ${String(err)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function monitorZalouserProvider(
|
|
417
|
+
options: ZalouserMonitorOptions,
|
|
418
|
+
): Promise<ZalouserMonitorResult> {
|
|
419
|
+
let { account, config } = options;
|
|
420
|
+
const { abortSignal, statusSink, runtime } = options;
|
|
421
|
+
|
|
422
|
+
const core = getZalouserRuntime();
|
|
423
|
+
let stopped = false;
|
|
424
|
+
let proc: ChildProcess | null = null;
|
|
425
|
+
let restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
426
|
+
let resolveRunning: (() => void) | null = null;
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const profile = account.profile;
|
|
430
|
+
const allowFromEntries = (account.config.allowFrom ?? [])
|
|
431
|
+
.map((entry) => normalizeZalouserEntry(String(entry)))
|
|
432
|
+
.filter((entry) => entry && entry !== "*");
|
|
433
|
+
|
|
434
|
+
if (allowFromEntries.length > 0) {
|
|
435
|
+
const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
|
|
436
|
+
if (result.ok) {
|
|
437
|
+
const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
|
|
438
|
+
const byName = buildNameIndex(friends, (friend) => friend.displayName);
|
|
439
|
+
const additions: string[] = [];
|
|
440
|
+
const mapping: string[] = [];
|
|
441
|
+
const unresolved: string[] = [];
|
|
442
|
+
for (const entry of allowFromEntries) {
|
|
443
|
+
if (/^\d+$/.test(entry)) {
|
|
444
|
+
additions.push(entry);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const matches = byName.get(entry.toLowerCase()) ?? [];
|
|
448
|
+
const match = matches[0];
|
|
449
|
+
const id = match?.userId ? String(match.userId) : undefined;
|
|
450
|
+
if (id) {
|
|
451
|
+
additions.push(id);
|
|
452
|
+
mapping.push(`${entry}→${id}`);
|
|
453
|
+
} else {
|
|
454
|
+
unresolved.push(entry);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
|
|
458
|
+
account = {
|
|
459
|
+
...account,
|
|
460
|
+
config: {
|
|
461
|
+
...account.config,
|
|
462
|
+
allowFrom,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
summarizeMapping("zalouser users", mapping, unresolved, runtime);
|
|
466
|
+
} else {
|
|
467
|
+
runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const groupsConfig = account.config.groups ?? {};
|
|
472
|
+
const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
|
|
473
|
+
if (groupKeys.length > 0) {
|
|
474
|
+
const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
|
|
475
|
+
if (result.ok) {
|
|
476
|
+
const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
|
|
477
|
+
const byName = buildNameIndex(groups, (group) => group.name);
|
|
478
|
+
const mapping: string[] = [];
|
|
479
|
+
const unresolved: string[] = [];
|
|
480
|
+
const nextGroups = { ...groupsConfig };
|
|
481
|
+
for (const entry of groupKeys) {
|
|
482
|
+
const cleaned = normalizeZalouserEntry(entry);
|
|
483
|
+
if (/^\d+$/.test(cleaned)) {
|
|
484
|
+
if (!nextGroups[cleaned]) nextGroups[cleaned] = groupsConfig[entry];
|
|
485
|
+
mapping.push(`${entry}→${cleaned}`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const matches = byName.get(cleaned.toLowerCase()) ?? [];
|
|
489
|
+
const match = matches[0];
|
|
490
|
+
const id = match?.groupId ? String(match.groupId) : undefined;
|
|
491
|
+
if (id) {
|
|
492
|
+
if (!nextGroups[id]) nextGroups[id] = groupsConfig[entry];
|
|
493
|
+
mapping.push(`${entry}→${id}`);
|
|
494
|
+
} else {
|
|
495
|
+
unresolved.push(entry);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
account = {
|
|
499
|
+
...account,
|
|
500
|
+
config: {
|
|
501
|
+
...account.config,
|
|
502
|
+
groups: nextGroups,
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
summarizeMapping("zalouser groups", mapping, unresolved, runtime);
|
|
506
|
+
} else {
|
|
507
|
+
runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} catch (err) {
|
|
511
|
+
runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const stop = () => {
|
|
515
|
+
stopped = true;
|
|
516
|
+
if (restartTimer) {
|
|
517
|
+
clearTimeout(restartTimer);
|
|
518
|
+
restartTimer = null;
|
|
519
|
+
}
|
|
520
|
+
if (proc) {
|
|
521
|
+
proc.kill("SIGTERM");
|
|
522
|
+
proc = null;
|
|
523
|
+
}
|
|
524
|
+
resolveRunning?.();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const startListener = () => {
|
|
528
|
+
if (stopped || abortSignal.aborted) {
|
|
529
|
+
resolveRunning?.();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
logVerbose(
|
|
534
|
+
core,
|
|
535
|
+
runtime,
|
|
536
|
+
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
proc = startZcaListener(
|
|
540
|
+
runtime,
|
|
541
|
+
account.profile,
|
|
542
|
+
(msg) => {
|
|
543
|
+
logVerbose(core, runtime, `[${account.accountId}] inbound message`);
|
|
544
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
545
|
+
processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
|
|
546
|
+
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
|
547
|
+
});
|
|
548
|
+
},
|
|
549
|
+
(err) => {
|
|
550
|
+
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
|
|
551
|
+
if (!stopped && !abortSignal.aborted) {
|
|
552
|
+
logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
|
|
553
|
+
restartTimer = setTimeout(startListener, 5000);
|
|
554
|
+
} else {
|
|
555
|
+
resolveRunning?.();
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
abortSignal,
|
|
559
|
+
);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// Create a promise that stays pending until abort or stop
|
|
563
|
+
const runningPromise = new Promise<void>((resolve) => {
|
|
564
|
+
resolveRunning = resolve;
|
|
565
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
startListener();
|
|
569
|
+
|
|
570
|
+
// Wait for the running promise to resolve (on abort/stop)
|
|
571
|
+
await runningPromise;
|
|
572
|
+
|
|
573
|
+
return { stop };
|
|
574
|
+
}
|