@openclaw/zalo 2026.2.22 → 2026.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -4
- package/src/actions.ts +2 -13
- package/src/channel.ts +31 -13
- package/src/config-schema.ts +2 -0
- package/src/group-access.ts +48 -0
- package/src/monitor.group-policy.test.ts +106 -0
- package/src/monitor.ts +84 -237
- package/src/monitor.webhook.ts +219 -0
- package/src/types.ts +4 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/zalo",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.24",
|
|
4
4
|
"description": "OpenClaw Zalo channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"undici": "7.22.0"
|
|
8
8
|
},
|
|
9
|
-
"devDependencies": {
|
|
10
|
-
"openclaw": "workspace:*"
|
|
11
|
-
},
|
|
12
9
|
"openclaw": {
|
|
13
10
|
"extensions": [
|
|
14
11
|
"./index.ts"
|
package/src/actions.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
ChannelMessageActionName,
|
|
4
4
|
OpenClawConfig,
|
|
5
5
|
} from "openclaw/plugin-sdk";
|
|
6
|
-
import { jsonResult, readStringParam } from "openclaw/plugin-sdk";
|
|
6
|
+
import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
|
|
7
7
|
import { listEnabledZaloAccounts } from "./accounts.js";
|
|
8
8
|
import { sendMessageZalo } from "./send.js";
|
|
9
9
|
|
|
@@ -25,18 +25,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
|
|
|
25
25
|
return Array.from(actions);
|
|
26
26
|
},
|
|
27
27
|
supportsButtons: () => false,
|
|
28
|
-
extractToolSend: ({ args }) =>
|
|
29
|
-
const action = typeof args.action === "string" ? args.action.trim() : "";
|
|
30
|
-
if (action !== "sendMessage") {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
const to = typeof args.to === "string" ? args.to : undefined;
|
|
34
|
-
if (!to) {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
|
38
|
-
return { to, accountId };
|
|
39
|
-
},
|
|
28
|
+
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
|
40
29
|
handleAction: async ({ action, params, cfg, accountId }) => {
|
|
41
30
|
if (action === "send") {
|
|
42
31
|
const to = readStringParam(params, "to", { required: true });
|
package/src/channel.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
import {
|
|
8
8
|
applyAccountNameToChannelSection,
|
|
9
9
|
buildChannelConfigSchema,
|
|
10
|
+
buildTokenChannelStatusSummary,
|
|
10
11
|
DEFAULT_ACCOUNT_ID,
|
|
11
12
|
deleteAccountFromConfigSection,
|
|
12
13
|
chunkTextForOutbound,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
migrateBaseNameToDefaultAccount,
|
|
16
17
|
normalizeAccountId,
|
|
17
18
|
PAIRING_APPROVED_MESSAGE,
|
|
19
|
+
resolveDefaultGroupPolicy,
|
|
20
|
+
resolveOpenProviderRuntimeGroupPolicy,
|
|
18
21
|
resolveChannelAccountConfigBasePath,
|
|
19
22
|
setAccountEnabledInConfigSection,
|
|
20
23
|
} from "openclaw/plugin-sdk";
|
|
@@ -55,7 +58,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined {
|
|
|
55
58
|
export const zaloDock: ChannelDock = {
|
|
56
59
|
id: "zalo",
|
|
57
60
|
capabilities: {
|
|
58
|
-
chatTypes: ["direct"],
|
|
61
|
+
chatTypes: ["direct", "group"],
|
|
59
62
|
media: true,
|
|
60
63
|
blockStreaming: true,
|
|
61
64
|
},
|
|
@@ -81,7 +84,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
81
84
|
meta,
|
|
82
85
|
onboarding: zaloOnboardingAdapter,
|
|
83
86
|
capabilities: {
|
|
84
|
-
chatTypes: ["direct"],
|
|
87
|
+
chatTypes: ["direct", "group"],
|
|
85
88
|
media: true,
|
|
86
89
|
reactions: false,
|
|
87
90
|
threads: false,
|
|
@@ -142,6 +145,31 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
142
145
|
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
|
|
143
146
|
};
|
|
144
147
|
},
|
|
148
|
+
collectWarnings: ({ account, cfg }) => {
|
|
149
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
150
|
+
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
|
|
151
|
+
providerConfigPresent: cfg.channels?.zalo !== undefined,
|
|
152
|
+
groupPolicy: account.config.groupPolicy,
|
|
153
|
+
defaultGroupPolicy,
|
|
154
|
+
});
|
|
155
|
+
if (groupPolicy !== "open") {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
|
|
159
|
+
String(entry),
|
|
160
|
+
);
|
|
161
|
+
const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
|
162
|
+
const effectiveAllowFrom =
|
|
163
|
+
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
|
|
164
|
+
if (effectiveAllowFrom.length > 0) {
|
|
165
|
+
return [
|
|
166
|
+
`- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
return [
|
|
170
|
+
`- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
|
|
171
|
+
];
|
|
172
|
+
},
|
|
145
173
|
},
|
|
146
174
|
groups: {
|
|
147
175
|
resolveRequireMention: () => true,
|
|
@@ -309,17 +337,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
|
309
337
|
lastError: null,
|
|
310
338
|
},
|
|
311
339
|
collectStatusIssues: collectZaloStatusIssues,
|
|
312
|
-
buildChannelSummary: ({ snapshot }) => (
|
|
313
|
-
configured: snapshot.configured ?? false,
|
|
314
|
-
tokenSource: snapshot.tokenSource ?? "none",
|
|
315
|
-
running: snapshot.running ?? false,
|
|
316
|
-
mode: snapshot.mode ?? null,
|
|
317
|
-
lastStartAt: snapshot.lastStartAt ?? null,
|
|
318
|
-
lastStopAt: snapshot.lastStopAt ?? null,
|
|
319
|
-
lastError: snapshot.lastError ?? null,
|
|
320
|
-
probe: snapshot.probe,
|
|
321
|
-
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
322
|
-
}),
|
|
340
|
+
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
|
323
341
|
probeAccount: async ({ account, timeoutMs }) =>
|
|
324
342
|
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
|
|
325
343
|
buildAccountSnapshot: ({ account, runtime }) => {
|
package/src/config-schema.ts
CHANGED
|
@@ -14,6 +14,8 @@ const zaloAccountSchema = z.object({
|
|
|
14
14
|
webhookPath: z.string().optional(),
|
|
15
15
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
16
16
|
allowFrom: z.array(allowFromEntry).optional(),
|
|
17
|
+
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
|
18
|
+
groupAllowFrom: z.array(allowFromEntry).optional(),
|
|
17
19
|
mediaMaxMb: z.number().optional(),
|
|
18
20
|
proxy: z.string().optional(),
|
|
19
21
|
responsePrefix: z.string().optional(),
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
evaluateSenderGroupAccess,
|
|
4
|
+
isNormalizedSenderAllowed,
|
|
5
|
+
resolveOpenProviderRuntimeGroupPolicy,
|
|
6
|
+
} from "openclaw/plugin-sdk";
|
|
7
|
+
|
|
8
|
+
const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
|
|
9
|
+
|
|
10
|
+
export function isZaloSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
|
11
|
+
return isNormalizedSenderAllowed({
|
|
12
|
+
senderId,
|
|
13
|
+
allowFrom,
|
|
14
|
+
stripPrefixRe: ZALO_ALLOW_FROM_PREFIX_RE,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveZaloRuntimeGroupPolicy(params: {
|
|
19
|
+
providerConfigPresent: boolean;
|
|
20
|
+
groupPolicy?: GroupPolicy;
|
|
21
|
+
defaultGroupPolicy?: GroupPolicy;
|
|
22
|
+
}): {
|
|
23
|
+
groupPolicy: GroupPolicy;
|
|
24
|
+
providerMissingFallbackApplied: boolean;
|
|
25
|
+
} {
|
|
26
|
+
return resolveOpenProviderRuntimeGroupPolicy({
|
|
27
|
+
providerConfigPresent: params.providerConfigPresent,
|
|
28
|
+
groupPolicy: params.groupPolicy,
|
|
29
|
+
defaultGroupPolicy: params.defaultGroupPolicy,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function evaluateZaloGroupAccess(params: {
|
|
34
|
+
providerConfigPresent: boolean;
|
|
35
|
+
configuredGroupPolicy?: GroupPolicy;
|
|
36
|
+
defaultGroupPolicy?: GroupPolicy;
|
|
37
|
+
groupAllowFrom: string[];
|
|
38
|
+
senderId: string;
|
|
39
|
+
}): SenderGroupAccessDecision {
|
|
40
|
+
return evaluateSenderGroupAccess({
|
|
41
|
+
providerConfigPresent: params.providerConfigPresent,
|
|
42
|
+
configuredGroupPolicy: params.configuredGroupPolicy,
|
|
43
|
+
defaultGroupPolicy: params.defaultGroupPolicy,
|
|
44
|
+
groupAllowFrom: params.groupAllowFrom,
|
|
45
|
+
senderId: params.senderId,
|
|
46
|
+
isSenderAllowed: isZaloSenderAllowed,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { __testing } from "./monitor.js";
|
|
3
|
+
|
|
4
|
+
describe("zalo group policy access", () => {
|
|
5
|
+
it("defaults missing provider config to allowlist", () => {
|
|
6
|
+
const resolved = __testing.resolveZaloRuntimeGroupPolicy({
|
|
7
|
+
providerConfigPresent: false,
|
|
8
|
+
groupPolicy: undefined,
|
|
9
|
+
defaultGroupPolicy: "open",
|
|
10
|
+
});
|
|
11
|
+
expect(resolved).toEqual({
|
|
12
|
+
groupPolicy: "allowlist",
|
|
13
|
+
providerMissingFallbackApplied: true,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("blocks all group messages when policy is disabled", () => {
|
|
18
|
+
const decision = __testing.evaluateZaloGroupAccess({
|
|
19
|
+
providerConfigPresent: true,
|
|
20
|
+
configuredGroupPolicy: "disabled",
|
|
21
|
+
defaultGroupPolicy: "open",
|
|
22
|
+
groupAllowFrom: ["zalo:123"],
|
|
23
|
+
senderId: "123",
|
|
24
|
+
});
|
|
25
|
+
expect(decision).toMatchObject({
|
|
26
|
+
allowed: false,
|
|
27
|
+
groupPolicy: "disabled",
|
|
28
|
+
reason: "disabled",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("blocks group messages on allowlist policy with empty allowlist", () => {
|
|
33
|
+
const decision = __testing.evaluateZaloGroupAccess({
|
|
34
|
+
providerConfigPresent: true,
|
|
35
|
+
configuredGroupPolicy: "allowlist",
|
|
36
|
+
defaultGroupPolicy: "open",
|
|
37
|
+
groupAllowFrom: [],
|
|
38
|
+
senderId: "attacker",
|
|
39
|
+
});
|
|
40
|
+
expect(decision).toMatchObject({
|
|
41
|
+
allowed: false,
|
|
42
|
+
groupPolicy: "allowlist",
|
|
43
|
+
reason: "empty_allowlist",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("blocks sender not in group allowlist", () => {
|
|
48
|
+
const decision = __testing.evaluateZaloGroupAccess({
|
|
49
|
+
providerConfigPresent: true,
|
|
50
|
+
configuredGroupPolicy: "allowlist",
|
|
51
|
+
defaultGroupPolicy: "open",
|
|
52
|
+
groupAllowFrom: ["zalo:victim-user-001"],
|
|
53
|
+
senderId: "attacker-user-999",
|
|
54
|
+
});
|
|
55
|
+
expect(decision).toMatchObject({
|
|
56
|
+
allowed: false,
|
|
57
|
+
groupPolicy: "allowlist",
|
|
58
|
+
reason: "sender_not_allowlisted",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("allows sender in group allowlist", () => {
|
|
63
|
+
const decision = __testing.evaluateZaloGroupAccess({
|
|
64
|
+
providerConfigPresent: true,
|
|
65
|
+
configuredGroupPolicy: "allowlist",
|
|
66
|
+
defaultGroupPolicy: "open",
|
|
67
|
+
groupAllowFrom: ["zl:12345"],
|
|
68
|
+
senderId: "12345",
|
|
69
|
+
});
|
|
70
|
+
expect(decision).toMatchObject({
|
|
71
|
+
allowed: true,
|
|
72
|
+
groupPolicy: "allowlist",
|
|
73
|
+
reason: "allowed",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("allows any sender with wildcard allowlist", () => {
|
|
78
|
+
const decision = __testing.evaluateZaloGroupAccess({
|
|
79
|
+
providerConfigPresent: true,
|
|
80
|
+
configuredGroupPolicy: "allowlist",
|
|
81
|
+
defaultGroupPolicy: "open",
|
|
82
|
+
groupAllowFrom: ["*"],
|
|
83
|
+
senderId: "random-user",
|
|
84
|
+
});
|
|
85
|
+
expect(decision).toMatchObject({
|
|
86
|
+
allowed: true,
|
|
87
|
+
groupPolicy: "allowlist",
|
|
88
|
+
reason: "allowed",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("allows all group senders on open policy", () => {
|
|
93
|
+
const decision = __testing.evaluateZaloGroupAccess({
|
|
94
|
+
providerConfigPresent: true,
|
|
95
|
+
configuredGroupPolicy: "open",
|
|
96
|
+
defaultGroupPolicy: "allowlist",
|
|
97
|
+
groupAllowFrom: [],
|
|
98
|
+
senderId: "attacker-user-999",
|
|
99
|
+
});
|
|
100
|
+
expect(decision).toMatchObject({
|
|
101
|
+
allowed: true,
|
|
102
|
+
groupPolicy: "open",
|
|
103
|
+
reason: "allowed",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/monitor.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
-
import type { OpenClawConfig,
|
|
2
|
+
import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
|
|
4
3
|
import {
|
|
5
|
-
createDedupeCache,
|
|
6
4
|
createReplyPrefixOptions,
|
|
7
|
-
readJsonBodyWithLimit,
|
|
8
|
-
registerWebhookTarget,
|
|
9
|
-
rejectNonPostWebhookRequest,
|
|
10
|
-
resolveSingleWebhookTarget,
|
|
11
5
|
resolveSenderCommandAuthorization,
|
|
6
|
+
resolveOutboundMediaUrls,
|
|
7
|
+
resolveDefaultGroupPolicy,
|
|
8
|
+
sendMediaWithLeadingCaption,
|
|
12
9
|
resolveWebhookPath,
|
|
13
|
-
|
|
14
|
-
requestBodyErrorToText,
|
|
10
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
15
11
|
} from "openclaw/plugin-sdk";
|
|
16
12
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
17
13
|
import {
|
|
@@ -25,6 +21,16 @@ import {
|
|
|
25
21
|
type ZaloMessage,
|
|
26
22
|
type ZaloUpdate,
|
|
27
23
|
} from "./api.js";
|
|
24
|
+
import {
|
|
25
|
+
evaluateZaloGroupAccess,
|
|
26
|
+
isZaloSenderAllowed,
|
|
27
|
+
resolveZaloRuntimeGroupPolicy,
|
|
28
|
+
} from "./group-access.js";
|
|
29
|
+
import {
|
|
30
|
+
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
|
|
31
|
+
registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
|
|
32
|
+
type ZaloWebhookTarget,
|
|
33
|
+
} from "./monitor.webhook.js";
|
|
28
34
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
|
29
35
|
import { getZaloRuntime } from "./runtime.js";
|
|
30
36
|
|
|
@@ -53,13 +59,8 @@ export type ZaloMonitorResult = {
|
|
|
53
59
|
|
|
54
60
|
const ZALO_TEXT_LIMIT = 2000;
|
|
55
61
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
56
|
-
const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
57
|
-
const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
|
|
58
|
-
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
|
|
59
|
-
const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
|
|
60
62
|
|
|
61
63
|
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
|
62
|
-
type WebhookRateLimitState = { count: number; windowStartMs: number };
|
|
63
64
|
|
|
64
65
|
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
|
65
66
|
if (core.logging.shouldLogVerbose()) {
|
|
@@ -67,216 +68,27 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
const normalizedSenderId = senderId.toLowerCase();
|
|
75
|
-
return allowFrom.some((entry) => {
|
|
76
|
-
const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, "");
|
|
77
|
-
return normalized === normalizedSenderId;
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
type WebhookTarget = {
|
|
82
|
-
token: string;
|
|
83
|
-
account: ResolvedZaloAccount;
|
|
84
|
-
config: OpenClawConfig;
|
|
85
|
-
runtime: ZaloRuntimeEnv;
|
|
86
|
-
core: ZaloCoreRuntime;
|
|
87
|
-
secret: string;
|
|
88
|
-
path: string;
|
|
89
|
-
mediaMaxMb: number;
|
|
90
|
-
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
91
|
-
fetcher?: ZaloFetch;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const webhookTargets = new Map<string, WebhookTarget[]>();
|
|
95
|
-
const webhookRateLimits = new Map<string, WebhookRateLimitState>();
|
|
96
|
-
const recentWebhookEvents = createDedupeCache({
|
|
97
|
-
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
|
|
98
|
-
maxSize: 5000,
|
|
99
|
-
});
|
|
100
|
-
const webhookStatusCounters = new Map<string, number>();
|
|
101
|
-
|
|
102
|
-
function isJsonContentType(value: string | string[] | undefined): boolean {
|
|
103
|
-
const first = Array.isArray(value) ? value[0] : value;
|
|
104
|
-
if (!first) {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
|
|
108
|
-
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function timingSafeEquals(left: string, right: string): boolean {
|
|
112
|
-
const leftBuffer = Buffer.from(left);
|
|
113
|
-
const rightBuffer = Buffer.from(right);
|
|
114
|
-
|
|
115
|
-
if (leftBuffer.length !== rightBuffer.length) {
|
|
116
|
-
const length = Math.max(1, leftBuffer.length, rightBuffer.length);
|
|
117
|
-
const paddedLeft = Buffer.alloc(length);
|
|
118
|
-
const paddedRight = Buffer.alloc(length);
|
|
119
|
-
leftBuffer.copy(paddedLeft);
|
|
120
|
-
rightBuffer.copy(paddedRight);
|
|
121
|
-
timingSafeEqual(paddedLeft, paddedRight);
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function isWebhookRateLimited(key: string, nowMs: number): boolean {
|
|
129
|
-
const state = webhookRateLimits.get(key);
|
|
130
|
-
if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
|
|
131
|
-
webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
state.count += 1;
|
|
136
|
-
if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
|
|
143
|
-
const messageId = update.message?.message_id;
|
|
144
|
-
if (!messageId) {
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
const key = `${update.event_name}:${messageId}`;
|
|
148
|
-
return recentWebhookEvents.check(key, nowMs);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function recordWebhookStatus(
|
|
152
|
-
runtime: ZaloRuntimeEnv | undefined,
|
|
153
|
-
path: string,
|
|
154
|
-
statusCode: number,
|
|
155
|
-
): void {
|
|
156
|
-
if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const key = `${path}:${statusCode}`;
|
|
160
|
-
const next = (webhookStatusCounters.get(key) ?? 0) + 1;
|
|
161
|
-
webhookStatusCounters.set(key, next);
|
|
162
|
-
if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
|
|
163
|
-
runtime?.log?.(
|
|
164
|
-
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
|
|
170
|
-
return registerWebhookTarget(webhookTargets, target).unregister;
|
|
71
|
+
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
|
|
72
|
+
return registerZaloWebhookTargetInternal(target);
|
|
171
73
|
}
|
|
172
74
|
|
|
173
75
|
export async function handleZaloWebhookRequest(
|
|
174
76
|
req: IncomingMessage,
|
|
175
77
|
res: ServerResponse,
|
|
176
78
|
): Promise<boolean> {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
timingSafeEquals(entry.secret, headerToken),
|
|
190
|
-
);
|
|
191
|
-
if (matchedTarget.kind === "none") {
|
|
192
|
-
res.statusCode = 401;
|
|
193
|
-
res.end("unauthorized");
|
|
194
|
-
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
|
195
|
-
return true;
|
|
196
|
-
}
|
|
197
|
-
if (matchedTarget.kind === "ambiguous") {
|
|
198
|
-
res.statusCode = 401;
|
|
199
|
-
res.end("ambiguous webhook target");
|
|
200
|
-
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
const target = matchedTarget.target;
|
|
204
|
-
const path = req.url ?? "<unknown>";
|
|
205
|
-
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
206
|
-
const nowMs = Date.now();
|
|
207
|
-
|
|
208
|
-
if (isWebhookRateLimited(rateLimitKey, nowMs)) {
|
|
209
|
-
res.statusCode = 429;
|
|
210
|
-
res.end("Too Many Requests");
|
|
211
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (!isJsonContentType(req.headers["content-type"])) {
|
|
216
|
-
res.statusCode = 415;
|
|
217
|
-
res.end("Unsupported Media Type");
|
|
218
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
219
|
-
return true;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const body = await readJsonBodyWithLimit(req, {
|
|
223
|
-
maxBytes: 1024 * 1024,
|
|
224
|
-
timeoutMs: 30_000,
|
|
225
|
-
emptyObjectOnEmpty: false,
|
|
226
|
-
});
|
|
227
|
-
if (!body.ok) {
|
|
228
|
-
res.statusCode =
|
|
229
|
-
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
|
230
|
-
const message =
|
|
231
|
-
body.code === "PAYLOAD_TOO_LARGE"
|
|
232
|
-
? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
|
|
233
|
-
: body.code === "REQUEST_BODY_TIMEOUT"
|
|
234
|
-
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
|
|
235
|
-
: "Bad Request";
|
|
236
|
-
res.end(message);
|
|
237
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
238
|
-
return true;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
|
|
242
|
-
const raw = body.value;
|
|
243
|
-
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
244
|
-
const update: ZaloUpdate | undefined =
|
|
245
|
-
record && record.ok === true && record.result
|
|
246
|
-
? (record.result as ZaloUpdate)
|
|
247
|
-
: ((record as ZaloUpdate | null) ?? undefined);
|
|
248
|
-
|
|
249
|
-
if (!update?.event_name) {
|
|
250
|
-
res.statusCode = 400;
|
|
251
|
-
res.end("Bad Request");
|
|
252
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
253
|
-
return true;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (isReplayEvent(update, nowMs)) {
|
|
257
|
-
res.statusCode = 200;
|
|
258
|
-
res.end("ok");
|
|
259
|
-
return true;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
263
|
-
processUpdate(
|
|
264
|
-
update,
|
|
265
|
-
target.token,
|
|
266
|
-
target.account,
|
|
267
|
-
target.config,
|
|
268
|
-
target.runtime,
|
|
269
|
-
target.core,
|
|
270
|
-
target.mediaMaxMb,
|
|
271
|
-
target.statusSink,
|
|
272
|
-
target.fetcher,
|
|
273
|
-
).catch((err) => {
|
|
274
|
-
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
|
79
|
+
return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
|
|
80
|
+
await processUpdate(
|
|
81
|
+
update,
|
|
82
|
+
target.token,
|
|
83
|
+
target.account,
|
|
84
|
+
target.config,
|
|
85
|
+
target.runtime,
|
|
86
|
+
target.core as ZaloCoreRuntime,
|
|
87
|
+
target.mediaMaxMb,
|
|
88
|
+
target.statusSink,
|
|
89
|
+
target.fetcher,
|
|
90
|
+
);
|
|
275
91
|
});
|
|
276
|
-
|
|
277
|
-
res.statusCode = 200;
|
|
278
|
-
res.end("ok");
|
|
279
|
-
return true;
|
|
280
92
|
}
|
|
281
93
|
|
|
282
94
|
function startPollingLoop(params: {
|
|
@@ -500,6 +312,42 @@ async function processMessageWithPipeline(params: {
|
|
|
500
312
|
|
|
501
313
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
502
314
|
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
|
315
|
+
const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
|
|
316
|
+
const groupAllowFrom =
|
|
317
|
+
configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom;
|
|
318
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
|
319
|
+
const groupAccess = isGroup
|
|
320
|
+
? evaluateZaloGroupAccess({
|
|
321
|
+
providerConfigPresent: config.channels?.zalo !== undefined,
|
|
322
|
+
configuredGroupPolicy: account.config.groupPolicy,
|
|
323
|
+
defaultGroupPolicy,
|
|
324
|
+
groupAllowFrom,
|
|
325
|
+
senderId,
|
|
326
|
+
})
|
|
327
|
+
: undefined;
|
|
328
|
+
if (groupAccess) {
|
|
329
|
+
warnMissingProviderGroupPolicyFallbackOnce({
|
|
330
|
+
providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied,
|
|
331
|
+
providerKey: "zalo",
|
|
332
|
+
accountId: account.accountId,
|
|
333
|
+
log: (message) => logVerbose(core, runtime, message),
|
|
334
|
+
});
|
|
335
|
+
if (!groupAccess.allowed) {
|
|
336
|
+
if (groupAccess.reason === "disabled") {
|
|
337
|
+
logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`);
|
|
338
|
+
} else if (groupAccess.reason === "empty_allowlist") {
|
|
339
|
+
logVerbose(
|
|
340
|
+
core,
|
|
341
|
+
runtime,
|
|
342
|
+
`zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`,
|
|
343
|
+
);
|
|
344
|
+
} else if (groupAccess.reason === "sender_not_allowlisted") {
|
|
345
|
+
logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`);
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
503
351
|
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
|
504
352
|
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
|
|
505
353
|
cfg: config,
|
|
@@ -508,7 +356,7 @@ async function processMessageWithPipeline(params: {
|
|
|
508
356
|
dmPolicy,
|
|
509
357
|
configuredAllowFrom: configAllowFrom,
|
|
510
358
|
senderId,
|
|
511
|
-
isSenderAllowed,
|
|
359
|
+
isSenderAllowed: isZaloSenderAllowed,
|
|
512
360
|
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
|
|
513
361
|
shouldComputeCommandAuthorized: (body, cfg) =>
|
|
514
362
|
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
|
|
@@ -681,7 +529,7 @@ async function processMessageWithPipeline(params: {
|
|
|
681
529
|
}
|
|
682
530
|
|
|
683
531
|
async function deliverZaloReply(params: {
|
|
684
|
-
payload:
|
|
532
|
+
payload: OutboundReplyPayload;
|
|
685
533
|
token: string;
|
|
686
534
|
chatId: string;
|
|
687
535
|
runtime: ZaloRuntimeEnv;
|
|
@@ -696,24 +544,18 @@ async function deliverZaloReply(params: {
|
|
|
696
544
|
const tableMode = params.tableMode ?? "code";
|
|
697
545
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
698
546
|
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
:
|
|
702
|
-
|
|
703
|
-
:
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
|
712
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
713
|
-
} catch (err) {
|
|
714
|
-
runtime.error?.(`Zalo photo send failed: ${String(err)}`);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
547
|
+
const sentMedia = await sendMediaWithLeadingCaption({
|
|
548
|
+
mediaUrls: resolveOutboundMediaUrls(payload),
|
|
549
|
+
caption: text,
|
|
550
|
+
send: async ({ mediaUrl, caption }) => {
|
|
551
|
+
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
|
552
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
553
|
+
},
|
|
554
|
+
onError: (error) => {
|
|
555
|
+
runtime.error?.(`Zalo photo send failed: ${String(error)}`);
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
if (sentMedia) {
|
|
717
559
|
return;
|
|
718
560
|
}
|
|
719
561
|
|
|
@@ -822,3 +664,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
|
|
822
664
|
|
|
823
665
|
return { stop };
|
|
824
666
|
}
|
|
667
|
+
|
|
668
|
+
export const __testing = {
|
|
669
|
+
evaluateZaloGroupAccess,
|
|
670
|
+
resolveZaloRuntimeGroupPolicy,
|
|
671
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
|
+
import {
|
|
5
|
+
createDedupeCache,
|
|
6
|
+
readJsonBodyWithLimit,
|
|
7
|
+
registerWebhookTarget,
|
|
8
|
+
rejectNonPostWebhookRequest,
|
|
9
|
+
requestBodyErrorToText,
|
|
10
|
+
resolveSingleWebhookTarget,
|
|
11
|
+
resolveWebhookTargets,
|
|
12
|
+
} from "openclaw/plugin-sdk";
|
|
13
|
+
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
14
|
+
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
15
|
+
import type { ZaloRuntimeEnv } from "./monitor.js";
|
|
16
|
+
|
|
17
|
+
type WebhookRateLimitState = { count: number; windowStartMs: number };
|
|
18
|
+
|
|
19
|
+
const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
20
|
+
const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
|
|
21
|
+
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
|
|
22
|
+
const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
|
|
23
|
+
|
|
24
|
+
export type ZaloWebhookTarget = {
|
|
25
|
+
token: string;
|
|
26
|
+
account: ResolvedZaloAccount;
|
|
27
|
+
config: OpenClawConfig;
|
|
28
|
+
runtime: ZaloRuntimeEnv;
|
|
29
|
+
core: unknown;
|
|
30
|
+
secret: string;
|
|
31
|
+
path: string;
|
|
32
|
+
mediaMaxMb: number;
|
|
33
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
34
|
+
fetcher?: ZaloFetch;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ZaloWebhookProcessUpdate = (params: {
|
|
38
|
+
update: ZaloUpdate;
|
|
39
|
+
target: ZaloWebhookTarget;
|
|
40
|
+
}) => Promise<void>;
|
|
41
|
+
|
|
42
|
+
const webhookTargets = new Map<string, ZaloWebhookTarget[]>();
|
|
43
|
+
const webhookRateLimits = new Map<string, WebhookRateLimitState>();
|
|
44
|
+
const recentWebhookEvents = createDedupeCache({
|
|
45
|
+
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
|
|
46
|
+
maxSize: 5000,
|
|
47
|
+
});
|
|
48
|
+
const webhookStatusCounters = new Map<string, number>();
|
|
49
|
+
|
|
50
|
+
function isJsonContentType(value: string | string[] | undefined): boolean {
|
|
51
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
52
|
+
if (!first) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
|
|
56
|
+
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function timingSafeEquals(left: string, right: string): boolean {
|
|
60
|
+
const leftBuffer = Buffer.from(left);
|
|
61
|
+
const rightBuffer = Buffer.from(right);
|
|
62
|
+
|
|
63
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
64
|
+
const length = Math.max(1, leftBuffer.length, rightBuffer.length);
|
|
65
|
+
const paddedLeft = Buffer.alloc(length);
|
|
66
|
+
const paddedRight = Buffer.alloc(length);
|
|
67
|
+
leftBuffer.copy(paddedLeft);
|
|
68
|
+
rightBuffer.copy(paddedRight);
|
|
69
|
+
timingSafeEqual(paddedLeft, paddedRight);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isWebhookRateLimited(key: string, nowMs: number): boolean {
|
|
77
|
+
const state = webhookRateLimits.get(key);
|
|
78
|
+
if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
|
|
79
|
+
webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
state.count += 1;
|
|
84
|
+
if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
|
|
91
|
+
const messageId = update.message?.message_id;
|
|
92
|
+
if (!messageId) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const key = `${update.event_name}:${messageId}`;
|
|
96
|
+
return recentWebhookEvents.check(key, nowMs);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function recordWebhookStatus(
|
|
100
|
+
runtime: ZaloRuntimeEnv | undefined,
|
|
101
|
+
path: string,
|
|
102
|
+
statusCode: number,
|
|
103
|
+
): void {
|
|
104
|
+
if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const key = `${path}:${statusCode}`;
|
|
108
|
+
const next = (webhookStatusCounters.get(key) ?? 0) + 1;
|
|
109
|
+
webhookStatusCounters.set(key, next);
|
|
110
|
+
if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
|
|
111
|
+
runtime?.log?.(
|
|
112
|
+
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
|
|
118
|
+
return registerWebhookTarget(webhookTargets, target).unregister;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function handleZaloWebhookRequest(
|
|
122
|
+
req: IncomingMessage,
|
|
123
|
+
res: ServerResponse,
|
|
124
|
+
processUpdate: ZaloWebhookProcessUpdate,
|
|
125
|
+
): Promise<boolean> {
|
|
126
|
+
const resolved = resolveWebhookTargets(req, webhookTargets);
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const { targets } = resolved;
|
|
131
|
+
|
|
132
|
+
if (rejectNonPostWebhookRequest(req, res)) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
137
|
+
const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
|
|
138
|
+
timingSafeEquals(entry.secret, headerToken),
|
|
139
|
+
);
|
|
140
|
+
if (matchedTarget.kind === "none") {
|
|
141
|
+
res.statusCode = 401;
|
|
142
|
+
res.end("unauthorized");
|
|
143
|
+
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (matchedTarget.kind === "ambiguous") {
|
|
147
|
+
res.statusCode = 401;
|
|
148
|
+
res.end("ambiguous webhook target");
|
|
149
|
+
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
const target = matchedTarget.target;
|
|
153
|
+
const path = req.url ?? "<unknown>";
|
|
154
|
+
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
155
|
+
const nowMs = Date.now();
|
|
156
|
+
|
|
157
|
+
if (isWebhookRateLimited(rateLimitKey, nowMs)) {
|
|
158
|
+
res.statusCode = 429;
|
|
159
|
+
res.end("Too Many Requests");
|
|
160
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!isJsonContentType(req.headers["content-type"])) {
|
|
165
|
+
res.statusCode = 415;
|
|
166
|
+
res.end("Unsupported Media Type");
|
|
167
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const body = await readJsonBodyWithLimit(req, {
|
|
172
|
+
maxBytes: 1024 * 1024,
|
|
173
|
+
timeoutMs: 30_000,
|
|
174
|
+
emptyObjectOnEmpty: false,
|
|
175
|
+
});
|
|
176
|
+
if (!body.ok) {
|
|
177
|
+
res.statusCode =
|
|
178
|
+
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
|
179
|
+
const message =
|
|
180
|
+
body.code === "PAYLOAD_TOO_LARGE"
|
|
181
|
+
? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
|
|
182
|
+
: body.code === "REQUEST_BODY_TIMEOUT"
|
|
183
|
+
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
|
|
184
|
+
: "Bad Request";
|
|
185
|
+
res.end(message);
|
|
186
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
|
|
191
|
+
const raw = body.value;
|
|
192
|
+
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
193
|
+
const update: ZaloUpdate | undefined =
|
|
194
|
+
record && record.ok === true && record.result
|
|
195
|
+
? (record.result as ZaloUpdate)
|
|
196
|
+
: ((record as ZaloUpdate | null) ?? undefined);
|
|
197
|
+
|
|
198
|
+
if (!update?.event_name) {
|
|
199
|
+
res.statusCode = 400;
|
|
200
|
+
res.end("Bad Request");
|
|
201
|
+
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (isReplayEvent(update, nowMs)) {
|
|
206
|
+
res.statusCode = 200;
|
|
207
|
+
res.end("ok");
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
212
|
+
processUpdate({ update, target }).catch((err) => {
|
|
213
|
+
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
res.statusCode = 200;
|
|
217
|
+
res.end("ok");
|
|
218
|
+
return true;
|
|
219
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -17,6 +17,10 @@ export type ZaloAccountConfig = {
|
|
|
17
17
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
18
18
|
/** Allowlist for DM senders (Zalo user IDs). */
|
|
19
19
|
allowFrom?: Array<string | number>;
|
|
20
|
+
/** Group-message access policy. */
|
|
21
|
+
groupPolicy?: "open" | "allowlist" | "disabled";
|
|
22
|
+
/** Allowlist for group senders (falls back to allowFrom when unset). */
|
|
23
|
+
groupAllowFrom?: Array<string | number>;
|
|
20
24
|
/** Max inbound media size in MB. */
|
|
21
25
|
mediaMaxMb?: number;
|
|
22
26
|
/** Proxy URL for API requests. */
|