@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackle.dev/zalox-plugin",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "OpenClaw channel plugin for Zalo via zca-js (in-process, single login)",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
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}`,\n SessionKey: route.sessionKey,\n AccountId: route.accountId,\n ChatType: isGroup ? 'group' : 'direct',\n ConversationLabel: fromLabel,\n SenderName: senderName || undefined,\n SenderId: senderId,\n CommandAuthorized: commandAuthorized,\n Provider: 'zalox',\n Surface: 'zalox',\n MessageSid: message.msgId ?? `${timestamp}`,\n OriginatingChannel: 'zalox',\n OriginatingTo: `zalox:${chatId}`,\n MediaPath: mediaPath,\n });\n\n await core.channel.session.recordInboundSession({\n storePath,\n sessionKey: ctxPayload.SessionKey ?? route.sessionKey,\n ctx: ctxPayload,\n onRecordError: (err: unknown) => {\n runtime.error?.(`[zalox] session meta error: ${String(err)}`);\n },\n });\n\n await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({\n ctx: ctxPayload,\n cfg: config,\n dispatcherOptions: {\n deliver: async (payload: any) => {\n const text = payload.text ?? '';\n if (!text) return;\n\n const tableMode = core.channel.text.resolveMarkdownTableMode({\n cfg: config,\n channel: 'zalox',\n 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 // STRICT: Check UID Only\n let isMentioned = Array.isArray(mentions)\n ? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)\n : false;\n\n // FALLBACK: Text Check (Safety net for empty ownId)\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')) {\n isMentioned = true;\n }\n }\n \n // DEBUG COMMAND\n if (content.trim() === '/debug') {\n try {\n await sendText(threadId, `DEBUG:\\nownId: "${ownId}"\\nmentions: ${JSON.stringify(mentions)}\\nisMentioned: ${isMentioned}`, { profile, isGroup: true });\n } catch {}\n // Don't return, let it proceed so agent can also see /debug\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
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