@jackle.dev/zalox-plugin 1.0.1
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 +32 -0
- package/src/accounts.ts +57 -0
- package/src/channel.ts +349 -0
- package/src/client.ts +131 -0
- package/src/listener.ts +377 -0
- package/src/send.ts +121 -0
- package/src/types.ts +76 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jackle.dev/zalox-plugin",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "OpenClaw channel plugin for Zalo via zca-js (in-process, single login)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
15
|
+
},
|
|
16
|
+
"openclaw": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./src/index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"zca-js": "^2.0.4"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^20.0.0",
|
|
26
|
+
"typescript": "^5.0.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"openclaw": "*",
|
|
30
|
+
"zod": "^3.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZaloX Plugin — Account resolution
|
|
3
|
+
*
|
|
4
|
+
* No subprocess calls. Config-only resolution.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
8
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from 'openclaw/plugin-sdk';
|
|
9
|
+
import type { ZaloxConfig, ZaloxAccountConfig, ResolvedZaloxAccount } from './types.js';
|
|
10
|
+
import { hasCredentials } from './client.js';
|
|
11
|
+
|
|
12
|
+
export function listAccountIds(cfg: OpenClawConfig): string[] {
|
|
13
|
+
const accounts = (cfg.channels?.zalox as ZaloxConfig | undefined)?.accounts;
|
|
14
|
+
if (!accounts || typeof accounts !== 'object') return [DEFAULT_ACCOUNT_ID];
|
|
15
|
+
const ids = Object.keys(accounts).filter(Boolean);
|
|
16
|
+
return ids.length > 0 ? ids.toSorted((a, b) => a.localeCompare(b)) : [DEFAULT_ACCOUNT_ID];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveDefaultAccountId(cfg: OpenClawConfig): string {
|
|
20
|
+
const zaloxConfig = cfg.channels?.zalox as ZaloxConfig | undefined;
|
|
21
|
+
if (zaloxConfig?.defaultAccount?.trim()) return zaloxConfig.defaultAccount.trim();
|
|
22
|
+
const ids = listAccountIds(cfg);
|
|
23
|
+
return ids.includes(DEFAULT_ACCOUNT_ID) ? DEFAULT_ACCOUNT_ID : (ids[0] ?? DEFAULT_ACCOUNT_ID);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mergeAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloxAccountConfig {
|
|
27
|
+
const raw = (cfg.channels?.zalox ?? {}) as ZaloxConfig;
|
|
28
|
+
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
|
29
|
+
const account = raw.accounts?.[accountId] as ZaloxAccountConfig | undefined;
|
|
30
|
+
return { ...base, ...account };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveProfile(config: ZaloxAccountConfig, accountId: string): string {
|
|
34
|
+
if (config.credentialsPath?.trim()) return config.credentialsPath.trim();
|
|
35
|
+
if (process.env.ZCA_PROFILE?.trim()) return process.env.ZCA_PROFILE.trim();
|
|
36
|
+
if (accountId !== DEFAULT_ACCOUNT_ID) return accountId;
|
|
37
|
+
return 'default';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveAccount(params: {
|
|
41
|
+
cfg: OpenClawConfig;
|
|
42
|
+
accountId?: string | null;
|
|
43
|
+
}): ResolvedZaloxAccount {
|
|
44
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
45
|
+
const baseEnabled = (params.cfg.channels?.zalox as ZaloxConfig | undefined)?.enabled !== false;
|
|
46
|
+
const merged = mergeAccountConfig(params.cfg, accountId);
|
|
47
|
+
const accountEnabled = merged.enabled !== false;
|
|
48
|
+
const profile = resolveProfile(merged, accountId);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
accountId,
|
|
52
|
+
name: merged.name?.trim() || undefined,
|
|
53
|
+
enabled: baseEnabled && accountEnabled,
|
|
54
|
+
credentialsPath: profile,
|
|
55
|
+
config: merged,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZaloX Plugin — Channel definition
|
|
3
|
+
*
|
|
4
|
+
* Minimal but functional channel plugin for Zalo via zca-js.
|
|
5
|
+
* Key difference from zalouser: ALL operations use cached in-process API.
|
|
6
|
+
* No subprocess spawning. No login-per-command. One WebSocket per account.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ChannelAccountSnapshot,
|
|
11
|
+
ChannelDock,
|
|
12
|
+
ChannelPlugin,
|
|
13
|
+
OpenClawConfig,
|
|
14
|
+
} from 'openclaw/plugin-sdk';
|
|
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),
|
|
114
|
+
|
|
115
|
+
// ── Config ────────────────────────────────────────────
|
|
116
|
+
config: {
|
|
117
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
118
|
+
resolveAccount: (cfg, accountId) => resolveAccount({ cfg, accountId }),
|
|
119
|
+
defaultAccountId: (cfg) => resolveDefaultAccountId(cfg),
|
|
120
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
121
|
+
setAccountEnabledInConfigSection({
|
|
122
|
+
cfg,
|
|
123
|
+
sectionKey: 'zalox',
|
|
124
|
+
accountId,
|
|
125
|
+
enabled,
|
|
126
|
+
allowTopLevel: true,
|
|
127
|
+
}),
|
|
128
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
129
|
+
deleteAccountFromConfigSection({
|
|
130
|
+
cfg,
|
|
131
|
+
sectionKey: 'zalox',
|
|
132
|
+
accountId,
|
|
133
|
+
clearBaseFields: ['name', 'dmPolicy', 'allowFrom', 'groupPolicy', 'groups', 'credentialsPath'],
|
|
134
|
+
}),
|
|
135
|
+
isConfigured: async (account) => {
|
|
136
|
+
// Offline check — just verify credentials file exists
|
|
137
|
+
const profile = resolveProfile(account.config, account.accountId);
|
|
138
|
+
return hasCredentials(profile);
|
|
139
|
+
},
|
|
140
|
+
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
141
|
+
accountId: account.accountId,
|
|
142
|
+
name: account.name,
|
|
143
|
+
enabled: account.enabled,
|
|
144
|
+
configured: undefined,
|
|
145
|
+
}),
|
|
146
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
147
|
+
(resolveAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
|
|
148
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
149
|
+
allowFrom.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// ── Security ──────────────────────────────────────────
|
|
153
|
+
security: {
|
|
154
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
155
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
156
|
+
const useAccountPath = Boolean((cfg.channels?.zalox as any)?.accounts?.[resolvedAccountId]);
|
|
157
|
+
const basePath = useAccountPath
|
|
158
|
+
? `channels.zalox.accounts.${resolvedAccountId}.`
|
|
159
|
+
: 'channels.zalox.';
|
|
160
|
+
return {
|
|
161
|
+
policy: account.config.dmPolicy ?? 'pairing',
|
|
162
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
163
|
+
policyPath: `${basePath}dmPolicy`,
|
|
164
|
+
allowFromPath: basePath,
|
|
165
|
+
approveHint: formatPairingApproveHint('zalox'),
|
|
166
|
+
normalizeEntry: (raw: string) => raw.replace(/^(zalox|zx):/i, ''),
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// ── Groups ────────────────────────────────────────────
|
|
172
|
+
groups: {
|
|
173
|
+
resolveRequireMention: () => true,
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// ── Threading ─────────────────────────────────────────
|
|
177
|
+
threading: {
|
|
178
|
+
resolveReplyToMode: () => 'off',
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// ── Setup ─────────────────────────────────────────────
|
|
182
|
+
setup: {
|
|
183
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
184
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
185
|
+
applyAccountNameToChannelSection({ cfg, channelKey: 'zalox', accountId, name }),
|
|
186
|
+
validateInput: () => null,
|
|
187
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
188
|
+
const namedConfig = applyAccountNameToChannelSection({
|
|
189
|
+
cfg,
|
|
190
|
+
channelKey: 'zalox',
|
|
191
|
+
accountId,
|
|
192
|
+
name: input.name,
|
|
193
|
+
});
|
|
194
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
195
|
+
return {
|
|
196
|
+
...namedConfig,
|
|
197
|
+
channels: {
|
|
198
|
+
...namedConfig.channels,
|
|
199
|
+
zalox: { ...namedConfig.channels?.zalox, enabled: true },
|
|
200
|
+
},
|
|
201
|
+
} as OpenClawConfig;
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
...namedConfig,
|
|
205
|
+
channels: {
|
|
206
|
+
...namedConfig.channels,
|
|
207
|
+
zalox: {
|
|
208
|
+
...namedConfig.channels?.zalox,
|
|
209
|
+
enabled: true,
|
|
210
|
+
accounts: {
|
|
211
|
+
...(namedConfig.channels?.zalox as any)?.accounts,
|
|
212
|
+
[accountId]: {
|
|
213
|
+
...(namedConfig.channels?.zalox as any)?.accounts?.[accountId],
|
|
214
|
+
enabled: true,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
} as OpenClawConfig;
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
// ── Messaging ─────────────────────────────────────────
|
|
224
|
+
messaging: {
|
|
225
|
+
normalizeTarget: (raw) => raw?.trim()?.replace(/^(zalox|zx):/i, '') || undefined,
|
|
226
|
+
targetResolver: {
|
|
227
|
+
looksLikeId: (raw) => /^\d{3,}$/.test(raw.trim()),
|
|
228
|
+
hint: '<threadId>',
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// ── Pairing ───────────────────────────────────────────
|
|
233
|
+
pairing: {
|
|
234
|
+
idLabel: 'zaloxUserId',
|
|
235
|
+
normalizeAllowEntry: (entry) => entry.replace(/^(zalox|zx):/i, ''),
|
|
236
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
237
|
+
const account = resolveAccount({ cfg });
|
|
238
|
+
const profile = resolveProfile(account.config, account.accountId);
|
|
239
|
+
const result = await sendText(id, 'Your pairing request has been approved.', { profile });
|
|
240
|
+
if (!result.ok) throw new Error(result.error || 'Failed to notify');
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
// ── Outbound ──────────────────────────────────────────
|
|
245
|
+
outbound: {
|
|
246
|
+
deliveryMode: 'direct',
|
|
247
|
+
textChunkLimit: 2000,
|
|
248
|
+
sendText: async ({ to, text, accountId, cfg }) => {
|
|
249
|
+
const account = resolveAccount({ cfg, accountId });
|
|
250
|
+
const profile = resolveProfile(account.config, account.accountId);
|
|
251
|
+
const result = await sendText(to, text, { profile });
|
|
252
|
+
return {
|
|
253
|
+
channel: 'zalox',
|
|
254
|
+
ok: result.ok,
|
|
255
|
+
messageId: result.messageId ?? '',
|
|
256
|
+
error: result.error ? new Error(result.error) : undefined,
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
|
260
|
+
const account = resolveAccount({ cfg, accountId });
|
|
261
|
+
const profile = resolveProfile(account.config, account.accountId);
|
|
262
|
+
const result = await sendMedia(to, text ?? '', mediaUrl ?? '', { profile });
|
|
263
|
+
return {
|
|
264
|
+
channel: 'zalox',
|
|
265
|
+
ok: result.ok,
|
|
266
|
+
messageId: result.messageId ?? '',
|
|
267
|
+
error: result.error ? new Error(result.error) : undefined,
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// ── Status ────────────────────────────────────────────
|
|
273
|
+
status: {
|
|
274
|
+
defaultRuntime: {
|
|
275
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
276
|
+
running: false,
|
|
277
|
+
lastStartAt: null,
|
|
278
|
+
lastStopAt: null,
|
|
279
|
+
lastError: null,
|
|
280
|
+
},
|
|
281
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
282
|
+
configured: snapshot.configured ?? false,
|
|
283
|
+
running: snapshot.running ?? false,
|
|
284
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
285
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
286
|
+
lastError: snapshot.lastError ?? null,
|
|
287
|
+
}),
|
|
288
|
+
probeAccount: async ({ account }) => {
|
|
289
|
+
// Probe by checking cached API — NO login, NO subprocess
|
|
290
|
+
const profile = resolveProfile(account.config, account.accountId);
|
|
291
|
+
const cached = getCachedApi(profile);
|
|
292
|
+
if (cached) {
|
|
293
|
+
return { ok: true, user: { userId: cached.ownId, displayName: cached.displayName } };
|
|
294
|
+
}
|
|
295
|
+
// Not running = not OK
|
|
296
|
+
return { ok: false, error: 'Listener not active' };
|
|
297
|
+
},
|
|
298
|
+
buildAccountSnapshot: async ({ account, runtime }) => {
|
|
299
|
+
const profile = resolveProfile(account.config, account.accountId);
|
|
300
|
+
const configured = hasCredentials(profile);
|
|
301
|
+
return {
|
|
302
|
+
accountId: account.accountId,
|
|
303
|
+
name: account.name,
|
|
304
|
+
enabled: account.enabled,
|
|
305
|
+
configured,
|
|
306
|
+
running: runtime?.running ?? false,
|
|
307
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
308
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
309
|
+
lastError: configured ? (runtime?.lastError ?? null) : 'No credentials found',
|
|
310
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
311
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
312
|
+
dmPolicy: account.config.dmPolicy ?? 'pairing',
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
// ── Gateway (start/stop) ──────────────────────────────
|
|
318
|
+
gateway: {
|
|
319
|
+
startAccount: async (ctx) => {
|
|
320
|
+
const account = ctx.account;
|
|
321
|
+
const profile = resolveProfile(account.config, account.accountId);
|
|
322
|
+
|
|
323
|
+
ctx.log?.info(`[${account.accountId}] ZaloX: logging in (profile=${profile})...`);
|
|
324
|
+
|
|
325
|
+
// Login once — cached for entire gateway lifetime
|
|
326
|
+
const entry = await getOrCreateApi(profile);
|
|
327
|
+
const userLabel = entry.displayName ? ` (${entry.displayName})` : '';
|
|
328
|
+
|
|
329
|
+
ctx.setStatus({
|
|
330
|
+
accountId: account.accountId,
|
|
331
|
+
user: { userId: entry.ownId, displayName: entry.displayName },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
ctx.log?.info(`[${account.accountId}] ZaloX: logged in as ${entry.ownId}${userLabel}`);
|
|
335
|
+
|
|
336
|
+
// Start in-process listener
|
|
337
|
+
return startInProcessListener({
|
|
338
|
+
account,
|
|
339
|
+
config: ctx.cfg,
|
|
340
|
+
runtime: ctx.runtime,
|
|
341
|
+
api: entry.api,
|
|
342
|
+
ownId: entry.ownId,
|
|
343
|
+
profile,
|
|
344
|
+
abortSignal: ctx.abortSignal,
|
|
345
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZaloX Plugin — In-process Zalo client
|
|
3
|
+
*
|
|
4
|
+
* Single login, cached API instance. No subprocess spawning.
|
|
5
|
+
* Solves the "every zca command kills the listener" problem.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Zalo, type API, type Credentials } from 'zca-js';
|
|
9
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import type { CachedApiEntry } from './types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Image metadata getter for zca-js 2.x
|
|
16
|
+
* Required for sending images via uploadAttachment.
|
|
17
|
+
*/
|
|
18
|
+
async function imageMetadataGetter(filePath: string): Promise<{ width: number; height: number; size: number } | null> {
|
|
19
|
+
try {
|
|
20
|
+
const sharp = (await import('sharp')).default;
|
|
21
|
+
const metadata = await sharp(filePath).metadata();
|
|
22
|
+
const stat = statSync(filePath);
|
|
23
|
+
return {
|
|
24
|
+
width: metadata.width || 0,
|
|
25
|
+
height: metadata.height || 0,
|
|
26
|
+
size: stat.size,
|
|
27
|
+
};
|
|
28
|
+
} catch {
|
|
29
|
+
// Fallback: just return file size with dummy dimensions
|
|
30
|
+
try {
|
|
31
|
+
const stat = statSync(filePath);
|
|
32
|
+
return { width: 1024, height: 768, size: stat.size };
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Global API instance cache (per account/profile)
|
|
40
|
+
const apiCache = new Map<string, CachedApiEntry>();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get or create an API instance for a profile.
|
|
44
|
+
* Reuses cached instance — only logs in once per profile.
|
|
45
|
+
*/
|
|
46
|
+
export async function getOrCreateApi(profile: string): Promise<CachedApiEntry> {
|
|
47
|
+
const cached = apiCache.get(profile);
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
|
|
50
|
+
const credentialsPath = resolveCredentialsPath(profile);
|
|
51
|
+
if (!existsSync(credentialsPath)) {
|
|
52
|
+
throw new Error(`No credentials found for profile "${profile}" at ${credentialsPath}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const credentials: Credentials = JSON.parse(readFileSync(credentialsPath, 'utf-8'));
|
|
56
|
+
if (!credentials.cookie || !credentials.imei || !credentials.userAgent) {
|
|
57
|
+
throw new Error(`Incomplete credentials for profile "${profile}"`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const zalo = new Zalo({ logging: false, imageMetadataGetter } as any);
|
|
61
|
+
const api = await zalo.login(credentials);
|
|
62
|
+
|
|
63
|
+
// Get own user info
|
|
64
|
+
let ownId = '';
|
|
65
|
+
let displayName: string | undefined;
|
|
66
|
+
let avatar: string | undefined;
|
|
67
|
+
try {
|
|
68
|
+
const info = await api.fetchAccountInfo();
|
|
69
|
+
ownId = String((info as any).userId || '');
|
|
70
|
+
displayName = (info as any).displayName || (info as any).zaloName;
|
|
71
|
+
avatar = (info as any).avatar;
|
|
72
|
+
} catch {
|
|
73
|
+
// Try to get from context
|
|
74
|
+
try {
|
|
75
|
+
const ctx = await api.getContext();
|
|
76
|
+
ownId = String((ctx as any).uid || '');
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const entry: CachedApiEntry = {
|
|
81
|
+
api,
|
|
82
|
+
ownId,
|
|
83
|
+
displayName,
|
|
84
|
+
avatar,
|
|
85
|
+
connectedAt: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
apiCache.set(profile, entry);
|
|
89
|
+
return entry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get cached API instance (no login if not cached)
|
|
94
|
+
*/
|
|
95
|
+
export function getCachedApi(profile: string): CachedApiEntry | undefined {
|
|
96
|
+
return apiCache.get(profile);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if credentials exist for a profile (offline, no login)
|
|
101
|
+
*/
|
|
102
|
+
export function hasCredentials(profile: string): boolean {
|
|
103
|
+
return existsSync(resolveCredentialsPath(profile));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clear cached API instance
|
|
108
|
+
*/
|
|
109
|
+
export function clearApi(profile: string): void {
|
|
110
|
+
apiCache.delete(profile);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveOpenClawDir(): string {
|
|
114
|
+
return process.env.OPENCLAW_DIR || join(homedir(), '.openclaw');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveCredentialsPath(profile: string): string {
|
|
118
|
+
// 1. Persistent path inside .openclaw (survives container restarts)
|
|
119
|
+
const openclawPath = join(resolveOpenClawDir(), 'zalox', 'profiles', `${profile}.json`);
|
|
120
|
+
if (existsSync(openclawPath)) return openclawPath;
|
|
121
|
+
|
|
122
|
+
// 2. Legacy ZaloX config path
|
|
123
|
+
const zaloxPath = join(homedir(), '.config', 'zalox', 'profiles', `${profile}.json`);
|
|
124
|
+
if (existsSync(zaloxPath)) return zaloxPath;
|
|
125
|
+
|
|
126
|
+
// 3. Legacy zca-cli path
|
|
127
|
+
const zcaPath = join(homedir(), '.config', 'zca-cli-nodejs', 'profiles', `${profile}.json`);
|
|
128
|
+
if (existsSync(zcaPath)) return zcaPath;
|
|
129
|
+
|
|
130
|
+
return openclawPath; // Default to persistent .openclaw path
|
|
131
|
+
}
|
package/src/listener.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZaloX Plugin — In-process message listener
|
|
3
|
+
*
|
|
4
|
+
* Uses zca-js API directly. Handles:
|
|
5
|
+
* - Message events (text, image, sticker)
|
|
6
|
+
* - DM policy
|
|
7
|
+
* - Pairing
|
|
8
|
+
* - Agent routing
|
|
9
|
+
* - Image downloading
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type API, Reactions, ThreadType } from 'zca-js';
|
|
13
|
+
import type { OpenClawConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
|
|
14
|
+
import { mergeAllowlist } from 'openclaw/plugin-sdk';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
16
|
+
import { join, extname } from 'path';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import type { ResolvedZaloxAccount } from './types.js';
|
|
20
|
+
import { sendText } from './send.js';
|
|
21
|
+
import { resolveCredentialsPath } from './client.js';
|
|
22
|
+
|
|
23
|
+
let pluginRuntime: any = null;
|
|
24
|
+
|
|
25
|
+
export function setPluginRuntime(rt: any): void {
|
|
26
|
+
pluginRuntime = rt;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getRuntime(): any {
|
|
30
|
+
if (!pluginRuntime) throw new Error('ZaloX runtime not initialized');
|
|
31
|
+
return pluginRuntime;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ListenerOptions = {
|
|
35
|
+
account: ResolvedZaloxAccount;
|
|
36
|
+
config: OpenClawConfig;
|
|
37
|
+
runtime: RuntimeEnv;
|
|
38
|
+
api: API;
|
|
39
|
+
ownId: string;
|
|
40
|
+
profile: string;
|
|
41
|
+
abortSignal: AbortSignal;
|
|
42
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
|
46
|
+
if (allowFrom.includes('*')) return true;
|
|
47
|
+
const normalized = senderId.toLowerCase();
|
|
48
|
+
return allowFrom.some((entry) => {
|
|
49
|
+
const clean = entry.toLowerCase().replace(/^(zalox|zx):/i, '');
|
|
50
|
+
return clean === normalized;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getInboundMediaDir(): string {
|
|
55
|
+
const dir = process.env.OPENCLAW_MEDIA_INBOUND || join(homedir(), '.openclaw', 'media', 'inbound');
|
|
56
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function downloadZaloMedia(url: string, profile: string, filename?: string): Promise<string | null> {
|
|
61
|
+
try {
|
|
62
|
+
const credentialsPath = resolveCredentialsPath(profile);
|
|
63
|
+
const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8'));
|
|
64
|
+
|
|
65
|
+
// Zalo media URLs usually require cookies
|
|
66
|
+
const headers: Record<string, string> = {
|
|
67
|
+
'User-Agent': creds.userAgent || 'Mozilla/5.0',
|
|
68
|
+
'Cookie': typeof creds.cookie === 'object' ? JSON.stringify(creds.cookie) : String(creds.cookie),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const res = await fetch(url, { headers });
|
|
72
|
+
if (!res.ok) return null;
|
|
73
|
+
|
|
74
|
+
const buffer = await res.arrayBuffer();
|
|
75
|
+
const ext = extname(url).split('?')[0] || '.jpg';
|
|
76
|
+
const name = filename || `zalox-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
|
|
77
|
+
const path = join(getInboundMediaDir(), name);
|
|
78
|
+
|
|
79
|
+
writeFileSync(path, Buffer.from(buffer));
|
|
80
|
+
console.log(`[ZaloX] Downloaded media to ${path}`); // DEBUG
|
|
81
|
+
return path;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`[ZaloX] Download media failed: ${err}`); // DEBUG
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function processMessage(
|
|
89
|
+
message: {
|
|
90
|
+
threadId: string;
|
|
91
|
+
content: string;
|
|
92
|
+
msgType: number;
|
|
93
|
+
timestamp?: number;
|
|
94
|
+
msgId?: string;
|
|
95
|
+
isGroup: boolean;
|
|
96
|
+
senderId: string;
|
|
97
|
+
senderName?: string;
|
|
98
|
+
groupName?: string;
|
|
99
|
+
mediaUrl?: string;
|
|
100
|
+
},
|
|
101
|
+
account: ResolvedZaloxAccount,
|
|
102
|
+
config: OpenClawConfig,
|
|
103
|
+
runtime: RuntimeEnv,
|
|
104
|
+
profile: string,
|
|
105
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const core = getRuntime();
|
|
108
|
+
const { threadId, content, msgType, timestamp, isGroup, senderId, senderName, groupName, mediaUrl } = message;
|
|
109
|
+
|
|
110
|
+
// Allow empty content if it's an image
|
|
111
|
+
if (!content?.trim() && !mediaUrl) return;
|
|
112
|
+
|
|
113
|
+
const chatId = threadId;
|
|
114
|
+
const rawBody = content.trim();
|
|
115
|
+
|
|
116
|
+
// Handle media download
|
|
117
|
+
let mediaPath: string | undefined;
|
|
118
|
+
if (mediaUrl) {
|
|
119
|
+
const downloaded = await downloadZaloMedia(mediaUrl, profile);
|
|
120
|
+
if (downloaded) mediaPath = downloaded;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// DM policy check
|
|
124
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map(String);
|
|
125
|
+
const dmPolicy = account.config.dmPolicy ?? 'pairing';
|
|
126
|
+
|
|
127
|
+
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
|
128
|
+
const storeAllowFrom =
|
|
129
|
+
!isGroup && (dmPolicy !== 'open' || shouldComputeAuth)
|
|
130
|
+
? await core.channel.pairing.readAllowFromStore('zalox').catch(() => [] as string[])
|
|
131
|
+
: [];
|
|
132
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
133
|
+
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
|
134
|
+
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
|
135
|
+
const commandAuthorized = shouldComputeAuth
|
|
136
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
137
|
+
useAccessGroups,
|
|
138
|
+
authorizers: [\n { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },\n ],\n })
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
if (!isGroup) {
|
|
142
|
+
if (dmPolicy === 'disabled') {
|
|
143
|
+
runtime.log?.(`[zalox] Blocked DM from ${senderId} (dmPolicy=disabled)`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (dmPolicy !== 'open') {
|
|
147
|
+
if (!senderAllowedForCommands) {
|
|
148
|
+
if (dmPolicy === 'pairing') {
|
|
149
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
150
|
+
channel: 'zalox',
|
|
151
|
+
id: senderId,
|
|
152
|
+
meta: { name: senderName || undefined },
|
|
153
|
+
});
|
|
154
|
+
if (created) {
|
|
155
|
+
runtime.log?.(`[zalox] pairing request sender=${senderId}`);
|
|
156
|
+
try {
|
|
157
|
+
await sendText(
|
|
158
|
+
chatId,
|
|
159
|
+
core.channel.pairing.buildPairingReply({
|
|
160
|
+
channel: 'zalox',
|
|
161
|
+
idLine: `Your Zalo user id: ${senderId}`,
|
|
162
|
+
code,
|
|
163
|
+
}),
|
|
164
|
+
{ profile },
|
|
165
|
+
);
|
|
166
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
runtime.error?.(`[zalox] pairing reply failed: ${String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Group command authorization
|
|
178
|
+
if (
|
|
179
|
+
isGroup &&
|
|
180
|
+
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
|
181
|
+
commandAuthorized !== true
|
|
182
|
+
) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Route to agent
|
|
187
|
+
const peer = isGroup
|
|
188
|
+
? { kind: 'group' as const, id: chatId }
|
|
189
|
+
: { kind: 'dm' as const, id: senderId };
|
|
190
|
+
|
|
191
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
192
|
+
cfg: config,
|
|
193
|
+
channel: 'zalox',
|
|
194
|
+
accountId: account.accountId,
|
|
195
|
+
peer,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
|
199
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
200
|
+
agentId: route.agentId,
|
|
201
|
+
});
|
|
202
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
203
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
204
|
+
storePath,
|
|
205
|
+
sessionKey: route.sessionKey,
|
|
206
|
+
});
|
|
207
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
208
|
+
channel: 'Zalo (ZaloX)',
|
|
209
|
+
from: fromLabel,
|
|
210
|
+
timestamp: timestamp ? timestamp * 1000 : undefined,
|
|
211
|
+
previousTimestamp,
|
|
212
|
+
envelope: envelopeOptions,
|
|
213
|
+
body: rawBody || (mediaPath ? '[Image]' : ''),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
217
|
+
Body: body,
|
|
218
|
+
RawBody: rawBody,
|
|
219
|
+
CommandBody: rawBody,
|
|
220
|
+
From: isGroup ? `zalox:group:${chatId}` : `zalox:${senderId}`,
|
|
221
|
+
To: `zalox:${chatId}`,
|
|
222
|
+
SessionKey: route.sessionKey,
|
|
223
|
+
AccountId: route.accountId,
|
|
224
|
+
ChatType: isGroup ? 'group' : 'direct',
|
|
225
|
+
ConversationLabel: fromLabel,
|
|
226
|
+
SenderName: senderName || undefined,
|
|
227
|
+
SenderId: senderId,
|
|
228
|
+
CommandAuthorized: commandAuthorized,
|
|
229
|
+
Provider: 'zalox',
|
|
230
|
+
Surface: 'zalox',
|
|
231
|
+
MessageSid: message.msgId ?? `${timestamp}`,
|
|
232
|
+
OriginatingChannel: 'zalox',
|
|
233
|
+
OriginatingTo: `zalox:${chatId}`,
|
|
234
|
+
MediaPath: mediaPath, // Pass media path here
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await core.channel.session.recordInboundSession({
|
|
238
|
+
storePath,
|
|
239
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
240
|
+
ctx: ctxPayload,
|
|
241
|
+
onRecordError: (err: unknown) => {
|
|
242
|
+
runtime.error?.(`[zalox] session meta error: ${String(err)}`);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
247
|
+
ctx: ctxPayload,
|
|
248
|
+
cfg: config,
|
|
249
|
+
dispatcherOptions: {
|
|
250
|
+
deliver: async (payload: any) => {
|
|
251
|
+
const text = payload.text ?? '';
|
|
252
|
+
if (!text) return;
|
|
253
|
+
|
|
254
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
255
|
+
cfg: config,
|
|
256
|
+
channel: 'zalox',
|
|
257
|
+
accountId: account.accountId,
|
|
258
|
+
});
|
|
259
|
+
const converted = core.channel.text.convertMarkdownTables(text, tableMode ?? 'code');
|
|
260
|
+
const chunkMode = core.channel.text.resolveChunkMode(config, 'zalox', account.accountId);
|
|
261
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(converted, 2000, chunkMode);
|
|
262
|
+
|
|
263
|
+
for (const chunk of chunks) {
|
|
264
|
+
try {
|
|
265
|
+
await sendText(chatId, chunk, { profile, isGroup });
|
|
266
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
267
|
+
} catch (err) {
|
|
268
|
+
runtime.error?.(`[zalox] send failed: ${String(err)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
onError: (err: unknown, info: any) => {
|
|
273
|
+
runtime.error?.(`[${account.accountId}] ZaloX ${info.kind} reply failed: ${String(err)}`);
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function startInProcessListener(
|
|
280
|
+
options: ListenerOptions,
|
|
281
|
+
): Promise<{ stop: () => void }> {
|
|
282
|
+
const { account, config, runtime, api, ownId, profile, abortSignal, statusSink } = options;
|
|
283
|
+
let stopped = false;
|
|
284
|
+
|
|
285
|
+
const listener = api.listener;
|
|
286
|
+
|
|
287
|
+
// Handle incoming messages
|
|
288
|
+
listener.on('message', (msg: any) => {
|
|
289
|
+
if (stopped || abortSignal.aborted) return;
|
|
290
|
+
|
|
291
|
+
const data = msg.data || msg;
|
|
292
|
+
const isGroup = msg.type === 1;
|
|
293
|
+
const senderId = data.uidFrom ? String(data.uidFrom) : '';
|
|
294
|
+
|
|
295
|
+
runtime.log?.(`[${account.accountId}] ZaloX: inbound raw data: ${JSON.stringify(data)}`); // DEBUG LOG
|
|
296
|
+
|
|
297
|
+
// Skip own messages
|
|
298
|
+
if (senderId === ownId) return;
|
|
299
|
+
|
|
300
|
+
// Determine message type and media
|
|
301
|
+
// zca-js: type 1=group text, direct text unknown (usually also content)
|
|
302
|
+
// Images often come with msgType=2 or data.url
|
|
303
|
+
const msgType = data.msgType || 0;
|
|
304
|
+
let mediaUrl = undefined;
|
|
305
|
+
|
|
306
|
+
// DEBUG: Log image detection logic
|
|
307
|
+
if (data.url) runtime.log?.(`[${account.accountId}] ZaloX: found URL ${data.url} (msgType=${msgType})`);
|
|
308
|
+
|
|
309
|
+
if (msgType === 2 && data.url) { // Image
|
|
310
|
+
mediaUrl = data.url;
|
|
311
|
+
} else if (msgType === 3 && data.url) { // Sticker (treat as media if needed, but usually just url)
|
|
312
|
+
// mediaUrl = data.url; // Optional: download stickers?
|
|
313
|
+
} else if (data.url && (data.url.includes('.jpg') || data.url.includes('.png'))) {
|
|
314
|
+
mediaUrl = data.url;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Group messages: only respond when bot is mentioned (@tagged)
|
|
318
|
+
// UNLESS it's a media message — we might want to analyze all images?
|
|
319
|
+
// No, keep same policy for now to avoid spam.
|
|
320
|
+
if (isGroup) {
|
|
321
|
+
const mentions = data.mentions || data.mentionIds || [];
|
|
322
|
+
const content = typeof data.content === 'string' ? data.content : (data.msg || '');
|
|
323
|
+
|
|
324
|
+
const isMentioned = Array.isArray(mentions)
|
|
325
|
+
? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)
|
|
326
|
+
: false;
|
|
327
|
+
const textMention = content.includes(`@${account.name || 'Tiệp Lê'}`);
|
|
328
|
+
|
|
329
|
+
// If has media, we might want to check if the caption mentions bot
|
|
330
|
+
if (!isMentioned && !textMention) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
336
|
+
|
|
337
|
+
const threadId = isGroup
|
|
338
|
+
? String(data.idTo || data.threadId || '')
|
|
339
|
+
: senderId;
|
|
340
|
+
|
|
341
|
+
const normalized = {
|
|
342
|
+
threadId,
|
|
343
|
+
msgId: String(data.msgId || data.cliMsgId || ''),
|
|
344
|
+
content: (() => {
|
|
345
|
+
let c = data.content || data.msg || '';
|
|
346
|
+
if (typeof c !== 'string') return '';
|
|
347
|
+
if (isGroup) {
|
|
348
|
+
c = c.replace(new RegExp(`@${account.name || 'Tiệp Lê'}\\s*`, 'gi'), '').trim();
|
|
349
|
+
}
|
|
350
|
+
return c;
|
|
351
|
+
})(),
|
|
352
|
+
msgType,
|
|
353
|
+
mediaUrl,
|
|
354
|
+
timestamp: data.ts ? Math.floor(data.ts / 1000) : Math.floor(Date.now() / 1000),
|
|
355
|
+
isGroup,
|
|
356
|
+
senderId,
|
|
357
|
+
senderName: data.dName || data.senderName || undefined,
|
|
358
|
+
groupName: data.threadName || data.groupName || undefined,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (!normalized.content?.trim() && !normalized.mediaUrl) return;
|
|
362
|
+
|
|
363
|
+
runtime.log?.(`[${account.accountId}] ZaloX: inbound from ${senderId} (type=${msgType} media=${!!mediaUrl})`);
|
|
364
|
+
|
|
365
|
+
// React with ❤️
|
|
366
|
+
try {
|
|
367
|
+
const reactThreadType = isGroup ? ThreadType.Group : ThreadType.User;
|
|
368
|
+
api.addReaction(Reactions.HEART, {
|
|
369
|
+
threadId: normalized.threadId,
|
|
370
|
+
type: reactThreadType,
|
|
371
|
+
data: {
|
|
372
|
+
msgId: String(data.msgId || ''),\n cliMsgId: String(data.cliMsgId || ''),
|
|
373
|
+
},
|
|
374
|
+
}).catch((err: any) => {
|
|
375
|
+
runtime.error?.(`[${account.accountId}] ZaloX reaction failed: ${String(err)}`);
|
|
376
|
+
});
|
|
377
|
+
} 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 // Handle errors\n listener.on('error', (err: any) => {\n runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);\n });\n\n // Start WebSocket listener with retry\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
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZaloX Plugin — Send messages via cached API
|
|
3
|
+
*
|
|
4
|
+
* No subprocess spawning. Uses cached zca-js API instance directly.
|
|
5
|
+
* Supports text, images (local files + URLs), and other attachments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ThreadType } from 'zca-js';
|
|
9
|
+
import { getCachedApi } from './client.js';
|
|
10
|
+
import { existsSync, writeFileSync, unlinkSync } from 'fs';
|
|
11
|
+
import { resolve } from 'path';
|
|
12
|
+
|
|
13
|
+
export type SendResult = {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
messageId?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function sendText(
|
|
20
|
+
threadId: string,
|
|
21
|
+
text: string,
|
|
22
|
+
options: { profile: string; isGroup?: boolean } = { profile: 'default' },
|
|
23
|
+
): Promise<SendResult> {
|
|
24
|
+
if (!threadId?.trim()) {
|
|
25
|
+
return { ok: false, error: 'No threadId provided' };
|
|
26
|
+
}
|
|
27
|
+
if (!text?.trim()) {
|
|
28
|
+
return { ok: false, error: 'No text provided' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cached = getCachedApi(options.profile);
|
|
32
|
+
if (!cached) {
|
|
33
|
+
return { ok: false, error: `No active API session for profile "${options.profile}". Listener may not be running.` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
|
38
|
+
const result = await cached.api.sendMessage(text.slice(0, 2000), threadId.trim(), type);
|
|
39
|
+
const msgId = result?.message?.msgId ? String(result.message.msgId) : undefined;
|
|
40
|
+
return { ok: true, messageId: msgId };
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
return { ok: false, error: err.message || String(err) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function sendMedia(
|
|
47
|
+
threadId: string,
|
|
48
|
+
text: string,
|
|
49
|
+
mediaUrl: string,
|
|
50
|
+
options: { profile: string; isGroup?: boolean } = { profile: 'default' },
|
|
51
|
+
): Promise<SendResult> {
|
|
52
|
+
if (!threadId?.trim()) {
|
|
53
|
+
return { ok: false, error: 'No threadId provided' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cached = getCachedApi(options.profile);
|
|
57
|
+
if (!cached) {
|
|
58
|
+
return { ok: false, error: `No active API session for profile "${options.profile}". Listener may not be running.` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
|
63
|
+
|
|
64
|
+
// Check if mediaUrl is a local file path
|
|
65
|
+
let filePath: string | null = null;
|
|
66
|
+
if (mediaUrl && !mediaUrl.startsWith('http')) {
|
|
67
|
+
const resolved = resolve(mediaUrl);
|
|
68
|
+
if (existsSync(resolved)) {
|
|
69
|
+
filePath = resolved;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (filePath) {
|
|
74
|
+
// Send with local file attachment
|
|
75
|
+
const result = await cached.api.sendMessage(
|
|
76
|
+
{ msg: text || '', attachments: [filePath] },
|
|
77
|
+
threadId.trim(),
|
|
78
|
+
type,
|
|
79
|
+
);
|
|
80
|
+
const msgId = result?.message?.msgId ? String(result.message.msgId) : undefined;
|
|
81
|
+
return { ok: true, messageId: msgId };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// If it's a URL, download first then send as attachment
|
|
85
|
+
if (mediaUrl?.startsWith('http')) {
|
|
86
|
+
let tmpPath: string | null = null;
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(mediaUrl);
|
|
89
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
90
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
91
|
+
|
|
92
|
+
const urlPath = new URL(mediaUrl).pathname;
|
|
93
|
+
const ext = urlPath.split('.').pop()?.toLowerCase() || 'jpg';
|
|
94
|
+
const filename = `zalox_attach_${Date.now()}.${ext}`;
|
|
95
|
+
tmpPath = `/tmp/${filename}`;
|
|
96
|
+
writeFileSync(tmpPath, buffer);
|
|
97
|
+
|
|
98
|
+
const result = await cached.api.sendMessage(
|
|
99
|
+
{ msg: text || '', attachments: [tmpPath] },
|
|
100
|
+
threadId.trim(),
|
|
101
|
+
type,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const msgId = result?.message?.msgId ? String(result.message.msgId) : undefined;
|
|
105
|
+
return { ok: true, messageId: msgId };
|
|
106
|
+
} catch (dlErr: any) {
|
|
107
|
+
// Fallback: send text with URL as link
|
|
108
|
+
const fullText = text ? `${text}\n${mediaUrl}` : mediaUrl;
|
|
109
|
+
return sendText(threadId, fullText, options);
|
|
110
|
+
} finally {
|
|
111
|
+
if (tmpPath) try { unlinkSync(tmpPath); } catch {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fallback: send text only
|
|
116
|
+
const fullText = text ? `${text}\n${mediaUrl}` : mediaUrl;
|
|
117
|
+
return sendText(threadId, fullText, options);
|
|
118
|
+
} catch (err: any) {
|
|
119
|
+
return { ok: false, error: err.message || String(err) };
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZaloX Plugin — Type definitions
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the zalouser types but adapted for in-process zca-js usage.
|
|
5
|
+
* No subprocess types needed — everything runs in-process.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { API } from "zca-js";
|
|
9
|
+
|
|
10
|
+
// ── Account config (per-account under channels.zalox.accounts.<id>) ──────────
|
|
11
|
+
|
|
12
|
+
export type ZaloxAccountConfig = {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
name?: string;
|
|
15
|
+
credentialsPath?: string;
|
|
16
|
+
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
17
|
+
allowFrom?: Array<string | number>;
|
|
18
|
+
groupPolicy?: "open" | "allowlist" | "disabled";
|
|
19
|
+
groups?: Record<
|
|
20
|
+
string,
|
|
21
|
+
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
|
|
22
|
+
>;
|
|
23
|
+
messagePrefix?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── Top-level config shape (channels.zalox) ──────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export type ZaloxConfig = {
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
name?: string;
|
|
31
|
+
credentialsPath?: string;
|
|
32
|
+
defaultAccount?: string;
|
|
33
|
+
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
34
|
+
allowFrom?: Array<string | number>;
|
|
35
|
+
groupPolicy?: "open" | "allowlist" | "disabled";
|
|
36
|
+
groups?: Record<
|
|
37
|
+
string,
|
|
38
|
+
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
|
|
39
|
+
>;
|
|
40
|
+
messagePrefix?: string;
|
|
41
|
+
accounts?: Record<string, ZaloxAccountConfig>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── Resolved account (after config merge) ────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export type ResolvedZaloxAccount = {
|
|
47
|
+
accountId: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
credentialsPath: string;
|
|
51
|
+
config: ZaloxAccountConfig;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ── Cached API instance per account ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export type CachedApiEntry = {
|
|
57
|
+
api: API;
|
|
58
|
+
ownId: string;
|
|
59
|
+
displayName?: string;
|
|
60
|
+
avatar?: string;
|
|
61
|
+
connectedAt: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── Inbound message (normalized from zca-js Message types) ───────────────────
|
|
65
|
+
|
|
66
|
+
export type ZaloxInboundMessage = {
|
|
67
|
+
threadId: string;
|
|
68
|
+
msgId?: string;
|
|
69
|
+
content: string;
|
|
70
|
+
timestamp: number;
|
|
71
|
+
isGroup: boolean;
|
|
72
|
+
senderId: string;
|
|
73
|
+
senderName?: string;
|
|
74
|
+
groupName?: string;
|
|
75
|
+
isSelf: boolean;
|
|
76
|
+
};
|