@jackle.dev/zalox-plugin 1.0.29 → 1.0.31
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/package.json +1 -1
- package/src/channel.ts +1 -343
- package/src/listener.ts +38 -1
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -13,346 +13,4 @@ import type {
|
|
|
13
13
|
OpenClawConfig,
|
|
14
14
|
} from 'openclaw/plugin-sdk';
|
|
15
15
|
import {
|
|
16
|
-
DEFAULT_ACCOUNT_ID,
|
|
17
|
-
normalizeAccountId,
|
|
18
|
-
buildChannelConfigSchema,
|
|
19
|
-
formatPairingApproveHint,
|
|
20
|
-
setAccountEnabledInConfigSection,
|
|
21
|
-
deleteAccountFromConfigSection,
|
|
22
|
-
applyAccountNameToChannelSection,
|
|
23
|
-
} from 'openclaw/plugin-sdk';
|
|
24
|
-
import { z } from 'zod';
|
|
25
|
-
import type { ResolvedZaloxAccount } from './types.js';
|
|
26
|
-
import {
|
|
27
|
-
listAccountIds,
|
|
28
|
-
resolveDefaultAccountId,
|
|
29
|
-
resolveAccount,
|
|
30
|
-
resolveProfile,
|
|
31
|
-
} from './accounts.js';
|
|
32
|
-
import { getOrCreateApi, getCachedApi, hasCredentials, clearApi } from './client.js';
|
|
33
|
-
import { sendText, sendMedia } from './send.js';
|
|
34
|
-
import { startInProcessListener } from './listener.js';
|
|
35
|
-
|
|
36
|
-
// ── Metadata ─────────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
const meta = {
|
|
39
|
-
id: 'zalox',
|
|
40
|
-
label: 'Zalo (ZaloX)',
|
|
41
|
-
selectionLabel: 'Zalo Personal (ZaloX in-process)',
|
|
42
|
-
docsPath: '/channels/zalouser',
|
|
43
|
-
docsLabel: 'zalox',
|
|
44
|
-
blurb: 'Zalo personal account — in-process, single login, no session conflicts.',
|
|
45
|
-
aliases: ['zx'],
|
|
46
|
-
order: 84,
|
|
47
|
-
preferOver: ['zalouser'],
|
|
48
|
-
quickstartAllowFrom: true,
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// ── Config Schema (Zod) ──────────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
54
|
-
|
|
55
|
-
const zaloxAccountSchema = z.object({
|
|
56
|
-
name: z.string().optional(),
|
|
57
|
-
enabled: z.boolean().optional(),
|
|
58
|
-
credentialsPath: z.string().optional(),
|
|
59
|
-
dmPolicy: z.enum(['pairing', 'allowlist', 'open', 'disabled']).optional(),
|
|
60
|
-
allowFrom: z.array(allowFromEntry).optional(),
|
|
61
|
-
groupPolicy: z.enum(['disabled', 'allowlist', 'open']).optional(),
|
|
62
|
-
groups: z.object({}).catchall(z.object({
|
|
63
|
-
allow: z.boolean().optional(),
|
|
64
|
-
enabled: z.boolean().optional(),
|
|
65
|
-
})).optional(),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const ZaloxConfigSchema = zaloxAccountSchema.extend({
|
|
69
|
-
accounts: z.object({}).catchall(zaloxAccountSchema).optional(),
|
|
70
|
-
defaultAccount: z.string().optional(),
|
|
71
|
-
retryOnClose: z.boolean().optional(),
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// ── Dock (lightweight interface) ─────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
export const zaloxDock: ChannelDock = {
|
|
77
|
-
id: 'zalox',
|
|
78
|
-
capabilities: {
|
|
79
|
-
chatTypes: ['direct', 'group'],
|
|
80
|
-
media: true,
|
|
81
|
-
blockStreaming: true,
|
|
82
|
-
},
|
|
83
|
-
outbound: { textChunkLimit: 2000 },
|
|
84
|
-
config: {
|
|
85
|
-
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
86
|
-
(resolveAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
|
|
87
|
-
formatAllowFrom: ({ allowFrom }) =>
|
|
88
|
-
allowFrom.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
|
89
|
-
},
|
|
90
|
-
groups: {
|
|
91
|
-
resolveRequireMention: () => true,
|
|
92
|
-
},
|
|
93
|
-
threading: {
|
|
94
|
-
resolveReplyToMode: () => 'off',
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
// ── Channel Plugin ───────────────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
export const zaloxPlugin: ChannelPlugin<ResolvedZaloxAccount> = {
|
|
101
|
-
id: 'zalox',
|
|
102
|
-
meta,
|
|
103
|
-
capabilities: {
|
|
104
|
-
chatTypes: ['direct', 'group'],
|
|
105
|
-
media: true,
|
|
106
|
-
reactions: false,
|
|
107
|
-
threads: false,
|
|
108
|
-
polls: false,
|
|
109
|
-
nativeCommands: false,
|
|
110
|
-
blockStreaming: true,
|
|
111
|
-
},
|
|
112
|
-
reload: { configPrefixes: ['channels.zalox'] },
|
|
113
|
-
// configSchema: buildChannelConfigSchema(ZaloxConfigSchema), // Disabled due to runtime Zod version mismatch
|
|
114
|
-
configSchema: undefined,
|
|
115
|
-
|
|
116
|
-
// ── Config ────────────────────────────────────────────
|
|
117
|
-
config: {
|
|
118
|
-
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
119
|
-
resolveAccount: (cfg, accountId) => resolveAccount({ cfg, accountId }),
|
|
120
|
-
defaultAccountId: (cfg) => resolveDefaultAccountId(cfg),
|
|
121
|
-
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
122
|
-
setAccountEnabledInConfigSection({
|
|
123
|
-
cfg,
|
|
124
|
-
sectionKey: 'zalox',
|
|
125
|
-
accountId,
|
|
126
|
-
enabled,
|
|
127
|
-
allowTopLevel: true,
|
|
128
|
-
}),
|
|
129
|
-
deleteAccount: ({ cfg, accountId }) =>
|
|
130
|
-
deleteAccountFromConfigSection({
|
|
131
|
-
cfg,
|
|
132
|
-
sectionKey: 'zalox',
|
|
133
|
-
accountId,
|
|
134
|
-
clearBaseFields: ['name', 'dmPolicy', 'allowFrom', 'groupPolicy', 'groups', 'credentialsPath'],
|
|
135
|
-
}),
|
|
136
|
-
isConfigured: async (account) => {
|
|
137
|
-
// Offline check — just verify credentials file exists
|
|
138
|
-
const profile = resolveProfile(account.config, account.accountId);
|
|
139
|
-
return hasCredentials(profile);
|
|
140
|
-
},
|
|
141
|
-
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
142
|
-
accountId: account.accountId,
|
|
143
|
-
name: account.name,
|
|
144
|
-
enabled: account.enabled,
|
|
145
|
-
configured: undefined,
|
|
146
|
-
}),
|
|
147
|
-
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
148
|
-
(resolveAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
|
|
149
|
-
formatAllowFrom: ({ allowFrom }) =>
|
|
150
|
-
allowFrom.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
|
151
|
-
},
|
|
152
|
-
|
|
153
|
-
// ── Security ──────────────────────────────────────────
|
|
154
|
-
security: {
|
|
155
|
-
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
156
|
-
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
157
|
-
const useAccountPath = Boolean((cfg.channels?.zalox as any)?.accounts?.[resolvedAccountId]);
|
|
158
|
-
const basePath = useAccountPath
|
|
159
|
-
? `channels.zalox.accounts.${resolvedAccountId}.`
|
|
160
|
-
: 'channels.zalox.';
|
|
161
|
-
return {
|
|
162
|
-
policy: account.config.dmPolicy ?? 'pairing',
|
|
163
|
-
allowFrom: account.config.allowFrom ?? [],
|
|
164
|
-
policyPath: `${basePath}dmPolicy`,
|
|
165
|
-
allowFromPath: basePath,
|
|
166
|
-
approveHint: formatPairingApproveHint('zalox'),
|
|
167
|
-
normalizeEntry: (raw: string) => raw.replace(/^(zalox|zx):/i, ''),
|
|
168
|
-
};
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
|
|
172
|
-
// ── Groups ────────────────────────────────────────────
|
|
173
|
-
groups: {
|
|
174
|
-
resolveRequireMention: () => true,
|
|
175
|
-
},
|
|
176
|
-
|
|
177
|
-
// ── Threading ─────────────────────────────────────────
|
|
178
|
-
threading: {
|
|
179
|
-
resolveReplyToMode: () => 'off',
|
|
180
|
-
},
|
|
181
|
-
|
|
182
|
-
// ── Setup ─────────────────────────────────────────────
|
|
183
|
-
setup: {
|
|
184
|
-
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
185
|
-
applyAccountName: ({ cfg, accountId, name }) =>
|
|
186
|
-
applyAccountNameToChannelSection({ cfg, channelKey: 'zalox', accountId, name }),
|
|
187
|
-
validateInput: () => null,
|
|
188
|
-
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
189
|
-
const namedConfig = applyAccountNameToChannelSection({
|
|
190
|
-
cfg,
|
|
191
|
-
channelKey: 'zalox',
|
|
192
|
-
accountId,
|
|
193
|
-
name: input.name,
|
|
194
|
-
});
|
|
195
|
-
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
196
|
-
return {
|
|
197
|
-
...namedConfig,
|
|
198
|
-
channels: {
|
|
199
|
-
...namedConfig.channels,
|
|
200
|
-
zalox: { ...namedConfig.channels?.zalox, enabled: true },
|
|
201
|
-
},
|
|
202
|
-
} as OpenClawConfig;
|
|
203
|
-
}
|
|
204
|
-
return {
|
|
205
|
-
...namedConfig,
|
|
206
|
-
channels: {
|
|
207
|
-
...namedConfig.channels,
|
|
208
|
-
zalox: {
|
|
209
|
-
...namedConfig.channels?.zalox,
|
|
210
|
-
enabled: true,
|
|
211
|
-
accounts: {
|
|
212
|
-
...(namedConfig.channels?.zalox as any)?.accounts,
|
|
213
|
-
[accountId]: {
|
|
214
|
-
...(namedConfig.channels?.zalox as any)?.accounts?.[accountId],
|
|
215
|
-
enabled: true,
|
|
216
|
-
},
|
|
217
|
-
},
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
|
-
} as OpenClawConfig;
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
|
|
224
|
-
// ── Messaging ─────────────────────────────────────────
|
|
225
|
-
messaging: {
|
|
226
|
-
normalizeTarget: (raw) => raw?.trim()?.replace(/^(zalox|zx):/i, '') || undefined,
|
|
227
|
-
targetResolver: {
|
|
228
|
-
looksLikeId: (raw) => /^\d{3,}$/.test(raw.trim()),
|
|
229
|
-
hint: '<threadId>',
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
|
|
233
|
-
// ── Pairing ───────────────────────────────────────────
|
|
234
|
-
pairing: {
|
|
235
|
-
idLabel: 'zaloxUserId',
|
|
236
|
-
normalizeAllowEntry: (entry) => entry.replace(/^(zalox|zx):/i, ''),
|
|
237
|
-
notifyApproval: async ({ cfg, id }) => {
|
|
238
|
-
const account = resolveAccount({ cfg });
|
|
239
|
-
const profile = resolveProfile(account.config, account.accountId);
|
|
240
|
-
const result = await sendText(id, 'Your pairing request has been approved.', { profile });
|
|
241
|
-
if (!result.ok) throw new Error(result.error || 'Failed to notify');
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
|
|
245
|
-
// ── Outbound ──────────────────────────────────────────
|
|
246
|
-
outbound: {
|
|
247
|
-
deliveryMode: 'direct',
|
|
248
|
-
textChunkLimit: 2000,
|
|
249
|
-
sendText: async ({ to, text, accountId, cfg }) => {
|
|
250
|
-
const account = resolveAccount({ cfg, accountId });
|
|
251
|
-
const profile = resolveProfile(account.config, account.accountId);
|
|
252
|
-
|
|
253
|
-
const isGroup = to.startsWith('group:');
|
|
254
|
-
const cleanTo = to.replace(/^group:/, '');
|
|
255
|
-
|
|
256
|
-
const result = await sendText(cleanTo, text, { profile, isGroup });
|
|
257
|
-
return {
|
|
258
|
-
channel: 'zalox',
|
|
259
|
-
ok: result.ok,
|
|
260
|
-
messageId: result.messageId ?? '',
|
|
261
|
-
error: result.error ? new Error(result.error) : undefined,
|
|
262
|
-
};
|
|
263
|
-
},
|
|
264
|
-
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
|
265
|
-
const account = resolveAccount({ cfg, accountId });
|
|
266
|
-
const profile = resolveProfile(account.config, account.accountId);
|
|
267
|
-
|
|
268
|
-
const isGroup = to.startsWith('group:');
|
|
269
|
-
const cleanTo = to.replace(/^group:/, '');
|
|
270
|
-
|
|
271
|
-
const result = await sendMedia(cleanTo, text ?? '', mediaUrl ?? '', { profile, isGroup });
|
|
272
|
-
return {
|
|
273
|
-
channel: 'zalox',
|
|
274
|
-
ok: result.ok,
|
|
275
|
-
messageId: result.messageId ?? '',
|
|
276
|
-
error: result.error ? new Error(result.error) : undefined,
|
|
277
|
-
};
|
|
278
|
-
},
|
|
279
|
-
},
|
|
280
|
-
|
|
281
|
-
// ── Status ────────────────────────────────────────────
|
|
282
|
-
status: {
|
|
283
|
-
defaultRuntime: {
|
|
284
|
-
accountId: DEFAULT_ACCOUNT_ID,
|
|
285
|
-
running: false,
|
|
286
|
-
lastStartAt: null,
|
|
287
|
-
lastStopAt: null,
|
|
288
|
-
lastError: null,
|
|
289
|
-
},
|
|
290
|
-
buildChannelSummary: ({ snapshot }) => ({
|
|
291
|
-
configured: snapshot.configured ?? false,
|
|
292
|
-
running: snapshot.running ?? false,
|
|
293
|
-
lastStartAt: snapshot.lastStartAt ?? null,
|
|
294
|
-
lastStopAt: snapshot.lastStopAt ?? null,
|
|
295
|
-
lastError: snapshot.lastError ?? null,
|
|
296
|
-
}),
|
|
297
|
-
probeAccount: async ({ account }) => {
|
|
298
|
-
// Probe by checking cached API — NO login, NO subprocess
|
|
299
|
-
const profile = resolveProfile(account.config, account.accountId);
|
|
300
|
-
const cached = getCachedApi(profile);
|
|
301
|
-
if (cached) {
|
|
302
|
-
return { ok: true, user: { userId: cached.ownId, displayName: cached.displayName } };
|
|
303
|
-
}
|
|
304
|
-
// Not running = not OK
|
|
305
|
-
return { ok: false, error: 'Listener not active' };
|
|
306
|
-
},
|
|
307
|
-
buildAccountSnapshot: async ({ account, runtime }) => {
|
|
308
|
-
const profile = resolveProfile(account.config, account.accountId);
|
|
309
|
-
const configured = hasCredentials(profile);
|
|
310
|
-
return {
|
|
311
|
-
accountId: account.accountId,
|
|
312
|
-
name: account.name,
|
|
313
|
-
enabled: account.enabled,
|
|
314
|
-
configured,
|
|
315
|
-
running: runtime?.running ?? false,
|
|
316
|
-
lastStartAt: runtime?.lastStartAt ?? null,
|
|
317
|
-
lastStopAt: runtime?.lastStopAt ?? null,
|
|
318
|
-
lastError: configured ? (runtime?.lastError ?? null) : 'No credentials found',
|
|
319
|
-
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
320
|
-
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
321
|
-
dmPolicy: account.config.dmPolicy ?? 'pairing',
|
|
322
|
-
};
|
|
323
|
-
},
|
|
324
|
-
},
|
|
325
|
-
|
|
326
|
-
// ── Gateway (start/stop) ──────────────────────────────
|
|
327
|
-
gateway: {
|
|
328
|
-
startAccount: async (ctx) => {
|
|
329
|
-
const account = ctx.account;
|
|
330
|
-
const profile = resolveProfile(account.config, account.accountId);
|
|
331
|
-
|
|
332
|
-
ctx.log?.info(`[${account.accountId}] ZaloX: logging in (profile=${profile})...`);
|
|
333
|
-
|
|
334
|
-
// Login once — cached for entire gateway lifetime
|
|
335
|
-
const entry = await getOrCreateApi(profile);
|
|
336
|
-
const userLabel = entry.displayName ? ` (${entry.displayName})` : '';
|
|
337
|
-
|
|
338
|
-
ctx.setStatus({
|
|
339
|
-
accountId: account.accountId,
|
|
340
|
-
user: { userId: entry.ownId, displayName: entry.displayName },
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
ctx.log?.info(`[${account.accountId}] ZaloX: logged in as ${entry.ownId}${userLabel}`);
|
|
344
|
-
|
|
345
|
-
// Start in-process listener
|
|
346
|
-
return startInProcessListener({
|
|
347
|
-
account,
|
|
348
|
-
config: ctx.cfg,
|
|
349
|
-
runtime: ctx.runtime,
|
|
350
|
-
api: entry.api,
|
|
351
|
-
ownId: entry.ownId,
|
|
352
|
-
profile,
|
|
353
|
-
abortSignal: ctx.abortSignal,
|
|
354
|
-
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
355
|
-
});
|
|
356
|
-
},
|
|
357
|
-
},
|
|
358
|
-
};
|
|
16
|
+
DEFAULT_ACCOUNT_ID,\n normalizeAccountId,\n buildChannelConfigSchema,\n formatPairingApproveHint,\n setAccountEnabledInConfigSection,\n deleteAccountFromConfigSection,\n applyAccountNameToChannelSection,\n} from 'openclaw/plugin-sdk';\nimport { z } from 'zod';\nimport type { ResolvedZaloxAccount } from './types.js';\nimport {\n listAccountIds,\n resolveDefaultAccountId,\n resolveAccount,\n resolveProfile,\n} from './accounts.js';\nimport { getOrCreateApi, getCachedApi, hasCredentials, clearApi } from './client.js';\nimport { sendText, sendMedia } from './send.js';\nimport { startInProcessListener } from './listener.js';\n\n// ── Metadata ─────────────────────────────────────────────────────────────────\n\nconst meta = {\n id: 'zalox',\n label: 'Zalo (ZaloX)',\n selectionLabel: 'Zalo Personal (ZaloX in-process)',\n docsPath: '/channels/zalouser',\n docsLabel: 'zalox',\n blurb: 'Zalo personal account — in-process, single login, no session conflicts.',\n aliases: ['zx'],\n order: 84,\n preferOver: ['zalouser'],\n quickstartAllowFrom: true,\n};\n\n// ── Config Schema (Zod) ──────────────────────────────────────────────────────\n\nconst allowFromEntry = z.union([z.string(), z.number()]);\n\nconst zaloxAccountSchema = z.object({\n name: z.string().optional(),\n enabled: z.boolean().optional(),\n credentialsPath: z.string().optional(),\n dmPolicy: z.enum(['pairing', 'allowlist', 'open', 'disabled']).optional(),\n allowFrom: z.array(allowFromEntry).optional(),\n groupPolicy: z.enum(['disabled', 'allowlist', 'open']).optional(),\n groups: z.object({}).catchall(z.object({\n allow: z.boolean().optional(),\n enabled: z.boolean().optional(),\n })).optional(),\n});\n\nconst ZaloxConfigSchema = zaloxAccountSchema.extend({\n accounts: z.object({}).catchall(zaloxAccountSchema).optional(),\n defaultAccount: z.string().optional(),\n retryOnClose: z.boolean().optional(),\n});\n\n// ── Dock (lightweight interface) ─────────────────────────────────────────────\n\nexport const zaloxDock: ChannelDock = {\n id: 'zalox',\n capabilities: {\n chatTypes: ['direct', 'group'],\n media: true,\n blockStreaming: true,\n },\n outbound: { textChunkLimit: 2000 },\n config: {\n resolveAllowFrom: ({ cfg, accountId }) =>\n (resolveAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),\n formatAllowFrom: ({ allowFrom }) =>\n (allowFrom || []).map((e) => String(e).trim().toLowerCase()).filter(Boolean),\n },\n groups: {\n resolveRequireMention: () => true,\n },\n threading: {\n resolveReplyToMode: () => 'off',\n },\n};\n\n// ── Channel Plugin ───────────────────────────────────────────────────────────\n\nexport const zaloxPlugin: ChannelPlugin<ResolvedZaloxAccount> = {\n id: 'zalox',\n meta,\n capabilities: {\n chatTypes: ['direct', 'group'],\n media: true,\n reactions: false,\n threads: false,\n polls: false,\n nativeCommands: false,\n blockStreaming: true,\n },\n reload: { configPrefixes: ['channels.zalox'] },\n // configSchema: buildChannelConfigSchema(ZaloxConfigSchema), // Disabled due to runtime Zod version mismatch\n configSchema: undefined,\n\n // ── Config ────────────────────────────────────────────\n config: {\n listAccountIds: (cfg) => listAccountIds(cfg),\n resolveAccount: (cfg, accountId) => resolveAccount({ cfg, accountId }),\n defaultAccountId: (cfg) => resolveDefaultAccountId(cfg),\n setAccountEnabled: ({ cfg, accountId, enabled }) =>\n setAccountEnabledInConfigSection({\n cfg,\n sectionKey: 'zalox',\n accountId,\n enabled,\n allowTopLevel: true,\n }),\n deleteAccount: ({ cfg, accountId }) =>\n deleteAccountFromConfigSection({\n cfg,\n sectionKey: 'zalox',\n accountId,\n clearBaseFields: ['name', 'dmPolicy', 'allowFrom', 'groupPolicy', 'groups', 'credentialsPath'],\n }),\n isConfigured: async (account) => {\n // Offline check — just verify credentials file exists\n const profile = resolveProfile(account.config, account.accountId);\n return hasCredentials(profile);\n },\n describeAccount: (account): ChannelAccountSnapshot => ({\n accountId: account.accountId,\n name: account.name,\n enabled: account.enabled,\n configured: undefined,\n }),\n resolveAllowFrom: ({ cfg, accountId }) =>\n (resolveAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),\n formatAllowFrom: ({ allowFrom }) =>\n (allowFrom || []).map((e) => String(e).trim().toLowerCase()).filter(Boolean),\n },\n\n // ── Security ──────────────────────────────────────────\n security: {\n resolveDmPolicy: ({ cfg, accountId, account }) => {\n const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;\n const useAccountPath = Boolean((cfg.channels?.zalox as any)?.accounts?.[resolvedAccountId]);\n const basePath = useAccountPath\n ? `channels.zalox.accounts.${resolvedAccountId}.`\n : 'channels.zalox.';\n return {\n policy: account.config.dmPolicy ?? 'pairing',\n allowFrom: account.config.allowFrom ?? [],\n policyPath: `${basePath}dmPolicy`,\n allowFromPath: basePath,\n approveHint: formatPairingApproveHint('zalox'),\n normalizeEntry: (raw: string) => raw.replace(/^(zalox|zx):/i, ''),\n };\n },\n },\n\n // ── Groups ────────────────────────────────────────────\n groups: {\n resolveRequireMention: () => true,\n },\n\n // ── Threading ─────────────────────────────────────────\n threading: {\n resolveReplyToMode: () => 'off',\n },\n\n // ── Setup ─────────────────────────────────────────────\n setup: {\n resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),\n applyAccountName: ({ cfg, accountId, name }) =>\n applyAccountNameToChannelSection({ cfg, channelKey: 'zalox', accountId, name }),\n validateInput: () => null,\n applyAccountConfig: ({ cfg, accountId, input }) => {\n const namedConfig = applyAccountNameToChannelSection({\n cfg,\n channelKey: 'zalox',\n accountId,\n name: input.name,\n });\n if (accountId === DEFAULT_ACCOUNT_ID) {\n return {\n ...namedConfig,\n channels: {\n ...namedConfig.channels,\n zalox: { ...namedConfig.channels?.zalox, enabled: true },\n },\n } as OpenClawConfig;\n }\n return {\n ...namedConfig,\n channels: {\n ...namedConfig.channels,\n zalox: {\n ...namedConfig.channels?.zalox,\n enabled: true,\n accounts: {\n ...(namedConfig.channels?.zalox as any)?.accounts,\n [accountId]: {\n ...(namedConfig.channels?.zalox as any)?.accounts?.[accountId],\n enabled: true,\n },\n },\n },\n },\n } as OpenClawConfig;\n },\n },\n\n // ── Messaging ─────────────────────────────────────────\n messaging: {\n normalizeTarget: (raw) => raw?.trim()?.replace(/^(zalox|zx):/i, '') || undefined,\n targetResolver: {\n looksLikeId: (raw) => /^\\d{3,}$/.test(raw.trim()),\n hint: '<threadId>',\n },\n },\n\n // ── Pairing ───────────────────────────────────────────\n pairing: {\n idLabel: 'zaloxUserId',\n normalizeAllowEntry: (entry) => entry.replace(/^(zalox|zx):/i, ''),\n notifyApproval: async ({ cfg, id }) => {\n const account = resolveAccount({ cfg });\n const profile = resolveProfile(account.config, account.accountId);\n const result = await sendText(id, 'Your pairing request has been approved.', { profile });\n if (!result.ok) throw new Error(result.error || 'Failed to notify');\n },\n },\n\n // ── Outbound ──────────────────────────────────────────\n outbound: {\n deliveryMode: 'direct',\n textChunkLimit: 2000,\n sendText: async ({ to, text, accountId, cfg }) => {\n const account = resolveAccount({ cfg, accountId });\n const profile = resolveProfile(account.config, account.accountId);\n \n const isGroup = to.startsWith('group:');\n const cleanTo = to.replace(/^group:/, '');\n \n const result = await sendText(cleanTo, text, { profile, isGroup });\n return {\n channel: 'zalox',\n ok: result.ok,\n messageId: result.messageId ?? '',\n error: result.error ? new Error(result.error) : undefined,\n };\n },\n sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {\n const account = resolveAccount({ cfg, accountId });\n const profile = resolveProfile(account.config, account.accountId);\n \n const isGroup = to.startsWith('group:');\n const cleanTo = to.replace(/^group:/, '');\n \n const result = await sendMedia(cleanTo, text ?? '', mediaUrl ?? '', { profile, isGroup });\n return {\n channel: 'zalox',\n ok: result.ok,\n messageId: result.messageId ?? '',\n error: result.error ? new Error(result.error) : undefined,\n };\n },\n },\n\n // ── Status ────────────────────────────────────────────\n status: {\n defaultRuntime: {\n accountId: DEFAULT_ACCOUNT_ID,\n running: false,\n lastStartAt: null,\n lastStopAt: null,\n lastError: null,\n },\n buildChannelSummary: ({ snapshot }) => ({\n configured: snapshot.configured ?? false,\n running: snapshot.running ?? false,\n lastStartAt: snapshot.lastStartAt ?? null,\n lastStopAt: snapshot.lastStopAt ?? null,\n lastError: snapshot.lastError ?? null,\n }),\n probeAccount: async ({ account }) => {\n // Probe by checking cached API — NO login, NO subprocess\n const profile = resolveProfile(account.config, account.accountId);\n const cached = getCachedApi(profile);\n if (cached) {\n return { ok: true, user: { userId: cached.ownId, displayName: cached.displayName } };\n }\n // Not running = not OK\n return { ok: false, error: 'Listener not active' };\n },\n buildAccountSnapshot: async ({ account, runtime }) => {\n const profile = resolveProfile(account.config, account.accountId);\n const configured = hasCredentials(profile);\n return {\n accountId: account.accountId,\n name: account.name,\n enabled: account.enabled,\n configured,\n running: runtime?.running ?? false,\n lastStartAt: runtime?.lastStartAt ?? null,\n lastStopAt: runtime?.lastStopAt ?? null,\n lastError: configured ? (runtime?.lastError ?? null) : 'No credentials found',\n lastInboundAt: runtime?.lastInboundAt ?? null,\n lastOutboundAt: runtime?.lastOutboundAt ?? null,\n dmPolicy: account.config.dmPolicy ?? 'pairing',\n };\n },\n },\n\n // ── Gateway (start/stop) ──────────────────────────────\n gateway: {\n startAccount: async (ctx) => {\n const account = ctx.account;\n const profile = resolveProfile(account.config, account.accountId);\n\n ctx.log?.info(`[${account.accountId}] ZaloX: logging in (profile=${profile})...`);\n\n // Login once — cached for entire gateway lifetime\n const entry = await getOrCreateApi(profile);\n const userLabel = entry.displayName ? ` (${entry.displayName})` : '';\n\n ctx.setStatus({\n accountId: account.accountId,\n user: { userId: entry.ownId, displayName: entry.displayName },\n });\n\n ctx.log?.info(`[${account.accountId}] ZaloX: logged in as ${entry.ownId}${userLabel}`);\n\n // Start in-process listener\n return startInProcessListener({\n account,\n config: ctx.cfg,\n runtime: ctx.runtime,\n api: entry.api,\n ownId: entry.ownId,\n profile,\n abortSignal: ctx.abortSignal,\n statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),\n });\n },\n },\n};\n
|
package/src/listener.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { sendText } from './send.js';
|
|
|
21
21
|
import { resolveCredentialsPath } from './client.js';
|
|
22
22
|
|
|
23
23
|
let pluginRuntime: any = null;
|
|
24
|
+
const PLUGIN_VERSION = '1.0.31';
|
|
24
25
|
|
|
25
26
|
export function setPluginRuntime(rt: any): void {
|
|
26
27
|
pluginRuntime = rt;
|
|
@@ -231,4 +232,40 @@ async function processMessage(
|
|
|
231
232
|
RawBody: rawBody,
|
|
232
233
|
CommandBody: rawBody,
|
|
233
234
|
From: isGroup ? `zalox:group:${chatId}` : `zalox:${senderId}`,
|
|
234
|
-
To: `zalox:${chatId}
|
|
235
|
+
To: `zalox:${chatId}`,
|
|
236
|
+
SessionKey: route.sessionKey,
|
|
237
|
+
AccountId: route.accountId,
|
|
238
|
+
ChatType: isGroup ? 'group' : 'direct',
|
|
239
|
+
ConversationLabel: fromLabel,
|
|
240
|
+
SenderName: senderName || undefined,
|
|
241
|
+
SenderId: senderId,
|
|
242
|
+
CommandAuthorized: commandAuthorized,
|
|
243
|
+
Provider: 'zalox',
|
|
244
|
+
Surface: 'zalox',
|
|
245
|
+
MessageSid: message.msgId ?? `${timestamp}`,
|
|
246
|
+
OriginatingChannel: 'zalox',
|
|
247
|
+
OriginatingTo: `zalox:${chatId}`,
|
|
248
|
+
MediaPath: mediaPath,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await core.channel.session.recordInboundSession({
|
|
252
|
+
storePath,
|
|
253
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
254
|
+
ctx: ctxPayload,
|
|
255
|
+
onRecordError: (err: unknown) => {
|
|
256
|
+
runtime.error?.(`[zalox] session meta error: ${String(err)}`);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
261
|
+
ctx: ctxPayload,
|
|
262
|
+
cfg: config,
|
|
263
|
+
dispatcherOptions: {
|
|
264
|
+
deliver: async (payload: any) => {
|
|
265
|
+
const text = payload.text ?? '';
|
|
266
|
+
if (!text) return;
|
|
267
|
+
|
|
268
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
269
|
+
cfg: config,
|
|
270
|
+
channel: 'zalox',
|
|
271
|
+
accountId: account.accountId,\n });\n const converted = core.channel.text.convertMarkdownTables(text, tableMode ?? 'code');\n const chunkMode = core.channel.text.resolveChunkMode(config, 'zalox', account.accountId);\n const chunks = core.channel.text.chunkMarkdownTextWithMode(converted, 2000, chunkMode);\n\n for (const chunk of chunks) {\n try {\n await sendText(chatId, chunk, { profile, isGroup });\n statusSink?.({ lastOutboundAt: Date.now() });\n } catch (err) {\n runtime.error?.(`[zalox] send failed: ${String(err)}`);\n }\n }\n },\n onError: (err: unknown, info: any) => {\n runtime.error?.(`[${account.accountId}] ZaloX ${info.kind} reply failed: ${String(err)}`);\n },\n },\n });\n}\n\nexport async function startInProcessListener(\n options: ListenerOptions,\n): Promise<{ stop: () => void }> {\n const { account, config, runtime, api, ownId, profile, abortSignal, statusSink } = options;\n let stopped = false;\n\n const listener = api.listener;\n\n listener.on('message', async (msg: any) => {\n if (stopped || abortSignal.aborted) return;\n\n const data = msg.data || msg;\n const isGroup = msg.type === 1;\n const senderId = data.uidFrom ? String(data.uidFrom) : '';\n \n if (senderId === ownId) return;\n\n const threadId = isGroup\n ? String(data.idTo || data.threadId || '')\n : senderId;\n\n const msgType = data.msgType || 0;\n \n let mediaUrl = undefined;\n mediaUrl = data.url || data.href;\n if (!mediaUrl && (msgType === 2 || String(msgType) === 'chat.photo')) {\n if (data.params?.url) mediaUrl = data.params.url;\n else if (typeof data.content === 'object' && data.content !== null) {\n mediaUrl = (data.content as any).href || (data.content as any).url || (data.content as any).thumb || (data.content as any).normalUrl;\n }\n else if (typeof data.content === 'string' && (data.content.includes('http') || data.content.startsWith('{'))) {\n try {\n const parsed = JSON.parse(data.content);\n mediaUrl = parsed.href || parsed.url || parsed.thumb || parsed.normalUrl;\n } catch {\n if (data.content.startsWith('http')) mediaUrl = data.content;\n }\n }\n }\n if (!mediaUrl && data.url && (data.url.includes('.jpg') || data.url.includes('.png'))) {\n mediaUrl = data.url;\n }\n\n if (!mediaUrl && data.quote) {\n try {\n const q = data.quote;\n if (q.attach) {\n const attach = typeof q.attach === 'string' ? JSON.parse(q.attach) : q.attach;\n mediaUrl = attach.href || attach.url || attach.thumb || attach.normalUrl;\n }\n if (!mediaUrl && (q.href || q.url)) {\n mediaUrl = q.href || q.url;\n }\n } catch {} \n }\n\n if (isGroup) {\n const mentions = data.mentions || data.mentionIds || [];\n const content = typeof data.content === 'string' ? data.content : (data.msg || '');\n \n let isMentioned = false;\n\n // 1. UID Check\n if (Array.isArray(mentions) && ownId) {\n isMentioned = mentions.some((m: any) => String(m.uid || m.id || m) === ownId);\n }\n\n // 2. Fallback Text Check\n if (!isMentioned) {\n const name = account.name || 'Bot';\n // Safe string check - no regex\n if (content.includes(`@${name}`) || content.includes('@Tiệp Lê') || content.includes('@Javis') || content.includes('@Bot')) {\n isMentioned = true;\n }\n }\n\n // DEBUG LOGGING (Always log in group for troubleshooting)\n console.log(`[ZaloX v${PLUGIN_VERSION}] Group Msg: threadId=${threadId} content="${content.slice(0, 50)}..." mentions=${JSON.stringify(mentions)} ownId="${ownId}" isMentioned=${isMentioned}`);\n \n // DEBUG COMMAND\n if (content.trim() === '/debug') {\n try {\n await sendText(threadId, `[ZaloX v${PLUGIN_VERSION}] DEBUG:\\nownId: "${ownId}"\\nisMentioned: ${isMentioned}\\nContent: "${content.slice(0, 50)}..."\\nMentions: ${JSON.stringify(mentions)}`, { profile, isGroup: true });\n } catch {}\n }\n\n if (content.trim() === '/whoami') {\n // Pass\n } else if (!isMentioned && !content.startsWith('/') && content.trim() !== '/debug') {\n return;\n }\n }\n\n statusSink?.({ lastInboundAt: Date.now() });\n\n const normalized = {\n threadId,\n msgId: String(data.msgId || data.cliMsgId || ''),\n content: (() => {\n let c = data.content || data.msg || '';\n if (typeof c !== 'string') return '';\n return c;\n })(),\n msgType,\n mediaUrl,\n timestamp: data.ts ? Math.floor(data.ts / 1000) : Math.floor(Date.now() / 1000),\n isGroup,\n senderId,\n senderName: data.dName || data.senderName || undefined,\n groupName: data.threadName || data.groupName || undefined,\n };\n\n if (!normalized.content?.trim() && !normalized.mediaUrl) return;\n\n try {\n const reactThreadType = isGroup ? ThreadType.Group : ThreadType.User;\n api.addReaction(Reactions.HEART, {\n threadId: normalized.threadId,\n type: reactThreadType,\n data: {\n msgId: String(data.msgId || ''),\n cliMsgId: String(data.cliMsgId || ''),\n },\n }).catch(() => {});\n } catch {}\n\n processMessage(normalized, account, config, runtime, profile, statusSink).catch((err) => {\n runtime.error?.(`[${account.accountId}] ZaloX process error: ${String(err)}`);\n });\n });\n\n listener.on('error', (err: any) => {\n runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);\n });\n\n listener.start({ retryOnClose: true });\n runtime.log?.(`[${account.accountId}] ZaloX: WebSocket listener started`);\n\n const stop = () => {\n stopped = true;\n try {\n listener.stop();\n } catch {}\n };\n\n abortSignal.addEventListener('abort', stop, { once: true });\n\n return { stop };\n}\n
|