@openclaw/zalo 2026.3.11 → 2026.3.13
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 +13 -0
- package/package.json +2 -2
- package/src/api.test.ts +15 -20
- package/src/channel.directory.test.ts +5 -12
- package/src/channel.startup.test.ts +17 -23
- package/src/monitor.lifecycle.test.ts +44 -78
- package/src/monitor.ts +72 -103
- package/src/monitor.webhook.test.ts +93 -2
- package/src/monitor.webhook.ts +34 -6
- package/src/send.ts +52 -30
- package/src/status-issues.test.ts +29 -0
- package/src/status-issues.ts +4 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.3.13
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- Version alignment with core OpenClaw release numbers.
|
|
8
|
+
|
|
9
|
+
## 2026.3.12
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- Version alignment with core OpenClaw release numbers.
|
|
14
|
+
|
|
3
15
|
## 2026.3.11
|
|
4
16
|
|
|
5
17
|
### Changes
|
|
18
|
+
|
|
6
19
|
- Version alignment with core OpenClaw release numbers.
|
|
7
20
|
|
|
8
21
|
## 2026.3.10
|
package/package.json
CHANGED
package/src/api.test.ts
CHANGED
|
@@ -1,31 +1,26 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
|
|
3
3
|
|
|
4
|
+
function createOkFetcher() {
|
|
5
|
+
return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise<unknown>) {
|
|
9
|
+
const fetcher = createOkFetcher();
|
|
10
|
+
await run("test-token", fetcher);
|
|
11
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
12
|
+
const [, init] = fetcher.mock.calls[0] ?? [];
|
|
13
|
+
expect(init?.method).toBe("POST");
|
|
14
|
+
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
describe("Zalo API request methods", () => {
|
|
5
18
|
it("uses POST for getWebhookInfo", async () => {
|
|
6
|
-
|
|
7
|
-
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
|
8
|
-
);
|
|
9
|
-
|
|
10
|
-
await getWebhookInfo("test-token", fetcher);
|
|
11
|
-
|
|
12
|
-
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
13
|
-
const [, init] = fetcher.mock.calls[0] ?? [];
|
|
14
|
-
expect(init?.method).toBe("POST");
|
|
15
|
-
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
|
19
|
+
await expectPostJsonRequest(getWebhookInfo);
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
it("keeps POST for deleteWebhook", async () => {
|
|
19
|
-
|
|
20
|
-
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
await deleteWebhook("test-token", fetcher);
|
|
24
|
-
|
|
25
|
-
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
26
|
-
const [, init] = fetcher.mock.calls[0] ?? [];
|
|
27
|
-
expect(init?.method).toBe("POST");
|
|
28
|
-
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
|
23
|
+
await expectPostJsonRequest(deleteWebhook);
|
|
29
24
|
});
|
|
30
25
|
|
|
31
26
|
it("aborts sendChatAction when the typing timeout elapses", async () => {
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js";
|
|
3
4
|
import { zaloPlugin } from "./channel.js";
|
|
4
5
|
|
|
5
6
|
describe("zalo directory", () => {
|
|
6
|
-
const runtimeEnv
|
|
7
|
-
log: () => {},
|
|
8
|
-
error: () => {},
|
|
9
|
-
exit: (code: number): never => {
|
|
10
|
-
throw new Error(`exit ${code}`);
|
|
11
|
-
},
|
|
12
|
-
};
|
|
7
|
+
const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
|
|
13
8
|
|
|
14
9
|
it("lists peers from allowFrom", async () => {
|
|
15
10
|
const cfg = {
|
|
@@ -20,12 +15,10 @@ describe("zalo directory", () => {
|
|
|
20
15
|
},
|
|
21
16
|
} as unknown as OpenClawConfig;
|
|
22
17
|
|
|
23
|
-
|
|
24
|
-
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
|
|
25
|
-
expect(zaloPlugin.directory?.listGroups).toBeTruthy();
|
|
18
|
+
const directory = expectDirectorySurface(zaloPlugin.directory);
|
|
26
19
|
|
|
27
20
|
await expect(
|
|
28
|
-
|
|
21
|
+
directory.listPeers({
|
|
29
22
|
cfg,
|
|
30
23
|
accountId: undefined,
|
|
31
24
|
query: undefined,
|
|
@@ -41,7 +34,7 @@ describe("zalo directory", () => {
|
|
|
41
34
|
);
|
|
42
35
|
|
|
43
36
|
await expect(
|
|
44
|
-
|
|
37
|
+
directory.listGroups({
|
|
45
38
|
cfg,
|
|
46
39
|
accountId: undefined,
|
|
47
40
|
query: undefined,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo";
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
expectPendingUntilAbort,
|
|
5
|
+
startAccountAndTrackLifecycle,
|
|
6
|
+
} from "../../test-utils/start-account-lifecycle.js";
|
|
4
7
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
5
8
|
|
|
6
9
|
const hoisted = vi.hoisted(() => ({
|
|
@@ -57,37 +60,28 @@ describe("zaloPlugin gateway.startAccount", () => {
|
|
|
57
60
|
}),
|
|
58
61
|
);
|
|
59
62
|
|
|
60
|
-
const patches
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
createStartAccountContext({
|
|
64
|
-
account: buildAccount(),
|
|
65
|
-
abortSignal: abort.signal,
|
|
66
|
-
statusPatchSink: (next) => patches.push({ ...next }),
|
|
67
|
-
}),
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
let settled = false;
|
|
71
|
-
void task.then(() => {
|
|
72
|
-
settled = true;
|
|
63
|
+
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
|
64
|
+
startAccount: zaloPlugin.gateway!.startAccount!,
|
|
65
|
+
account: buildAccount(),
|
|
73
66
|
});
|
|
74
67
|
|
|
75
|
-
await
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
await expectPendingUntilAbort({
|
|
69
|
+
waitForStarted: () =>
|
|
70
|
+
vi.waitFor(() => {
|
|
71
|
+
expect(hoisted.probeZalo).toHaveBeenCalledOnce();
|
|
72
|
+
expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
|
|
73
|
+
}),
|
|
74
|
+
isSettled,
|
|
75
|
+
abort,
|
|
76
|
+
task,
|
|
78
77
|
});
|
|
79
78
|
|
|
80
|
-
expect(settled).toBe(false);
|
|
81
79
|
expect(patches).toContainEqual(
|
|
82
80
|
expect.objectContaining({
|
|
83
81
|
accountId: "default",
|
|
84
82
|
}),
|
|
85
83
|
);
|
|
86
|
-
|
|
87
|
-
abort.abort();
|
|
88
|
-
await task;
|
|
89
|
-
|
|
90
|
-
expect(settled).toBe(true);
|
|
84
|
+
expect(isSettled()).toBe(true);
|
|
91
85
|
expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
|
|
92
86
|
expect.objectContaining({
|
|
93
87
|
token: "test-token",
|
|
@@ -32,6 +32,41 @@ async function waitForPollingLoopStart(): Promise<void> {
|
|
|
32
32
|
await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const TEST_ACCOUNT = {
|
|
36
|
+
accountId: "default",
|
|
37
|
+
config: {},
|
|
38
|
+
} as unknown as ResolvedZaloAccount;
|
|
39
|
+
|
|
40
|
+
const TEST_CONFIG = {} as OpenClawConfig;
|
|
41
|
+
|
|
42
|
+
function createLifecycleRuntime() {
|
|
43
|
+
return {
|
|
44
|
+
log: vi.fn<(message: string) => void>(),
|
|
45
|
+
error: vi.fn<(message: string) => void>(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function startLifecycleMonitor(
|
|
50
|
+
options: {
|
|
51
|
+
useWebhook?: boolean;
|
|
52
|
+
webhookSecret?: string;
|
|
53
|
+
webhookUrl?: string;
|
|
54
|
+
} = {},
|
|
55
|
+
) {
|
|
56
|
+
const { monitorZaloProvider } = await import("./monitor.js");
|
|
57
|
+
const abort = new AbortController();
|
|
58
|
+
const runtime = createLifecycleRuntime();
|
|
59
|
+
const run = monitorZaloProvider({
|
|
60
|
+
token: "test-token",
|
|
61
|
+
account: TEST_ACCOUNT,
|
|
62
|
+
config: TEST_CONFIG,
|
|
63
|
+
runtime,
|
|
64
|
+
abortSignal: abort.signal,
|
|
65
|
+
...options,
|
|
66
|
+
});
|
|
67
|
+
return { abort, runtime, run };
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
describe("monitorZaloProvider lifecycle", () => {
|
|
36
71
|
afterEach(() => {
|
|
37
72
|
vi.clearAllMocks();
|
|
@@ -39,26 +74,9 @@ describe("monitorZaloProvider lifecycle", () => {
|
|
|
39
74
|
});
|
|
40
75
|
|
|
41
76
|
it("stays alive in polling mode until abort", async () => {
|
|
42
|
-
const { monitorZaloProvider } = await import("./monitor.js");
|
|
43
|
-
const abort = new AbortController();
|
|
44
|
-
const runtime = {
|
|
45
|
-
log: vi.fn<(message: string) => void>(),
|
|
46
|
-
error: vi.fn<(message: string) => void>(),
|
|
47
|
-
};
|
|
48
|
-
const account = {
|
|
49
|
-
accountId: "default",
|
|
50
|
-
config: {},
|
|
51
|
-
} as unknown as ResolvedZaloAccount;
|
|
52
|
-
const config = {} as OpenClawConfig;
|
|
53
|
-
|
|
54
77
|
let settled = false;
|
|
55
|
-
const run =
|
|
56
|
-
|
|
57
|
-
account,
|
|
58
|
-
config,
|
|
59
|
-
runtime,
|
|
60
|
-
abortSignal: abort.signal,
|
|
61
|
-
}).then(() => {
|
|
78
|
+
const { abort, runtime, run } = await startLifecycleMonitor();
|
|
79
|
+
const monitoredRun = run.then(() => {
|
|
62
80
|
settled = true;
|
|
63
81
|
});
|
|
64
82
|
|
|
@@ -70,7 +88,7 @@ describe("monitorZaloProvider lifecycle", () => {
|
|
|
70
88
|
expect(settled).toBe(false);
|
|
71
89
|
|
|
72
90
|
abort.abort();
|
|
73
|
-
await
|
|
91
|
+
await monitoredRun;
|
|
74
92
|
|
|
75
93
|
expect(settled).toBe(true);
|
|
76
94
|
expect(runtime.log).toHaveBeenCalledWith(
|
|
@@ -84,25 +102,7 @@ describe("monitorZaloProvider lifecycle", () => {
|
|
|
84
102
|
result: { url: "https://example.com/hooks/zalo" },
|
|
85
103
|
});
|
|
86
104
|
|
|
87
|
-
const {
|
|
88
|
-
const abort = new AbortController();
|
|
89
|
-
const runtime = {
|
|
90
|
-
log: vi.fn<(message: string) => void>(),
|
|
91
|
-
error: vi.fn<(message: string) => void>(),
|
|
92
|
-
};
|
|
93
|
-
const account = {
|
|
94
|
-
accountId: "default",
|
|
95
|
-
config: {},
|
|
96
|
-
} as unknown as ResolvedZaloAccount;
|
|
97
|
-
const config = {} as OpenClawConfig;
|
|
98
|
-
|
|
99
|
-
const run = monitorZaloProvider({
|
|
100
|
-
token: "test-token",
|
|
101
|
-
account,
|
|
102
|
-
config,
|
|
103
|
-
runtime,
|
|
104
|
-
abortSignal: abort.signal,
|
|
105
|
-
});
|
|
105
|
+
const { abort, runtime, run } = await startLifecycleMonitor();
|
|
106
106
|
|
|
107
107
|
await waitForPollingLoopStart();
|
|
108
108
|
|
|
@@ -120,25 +120,7 @@ describe("monitorZaloProvider lifecycle", () => {
|
|
|
120
120
|
const { ZaloApiError } = await import("./api.js");
|
|
121
121
|
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
|
|
122
122
|
|
|
123
|
-
const {
|
|
124
|
-
const abort = new AbortController();
|
|
125
|
-
const runtime = {
|
|
126
|
-
log: vi.fn<(message: string) => void>(),
|
|
127
|
-
error: vi.fn<(message: string) => void>(),
|
|
128
|
-
};
|
|
129
|
-
const account = {
|
|
130
|
-
accountId: "default",
|
|
131
|
-
config: {},
|
|
132
|
-
} as unknown as ResolvedZaloAccount;
|
|
133
|
-
const config = {} as OpenClawConfig;
|
|
134
|
-
|
|
135
|
-
const run = monitorZaloProvider({
|
|
136
|
-
token: "test-token",
|
|
137
|
-
account,
|
|
138
|
-
config,
|
|
139
|
-
runtime,
|
|
140
|
-
abortSignal: abort.signal,
|
|
141
|
-
});
|
|
123
|
+
const { abort, runtime, run } = await startLifecycleMonitor();
|
|
142
124
|
|
|
143
125
|
await waitForPollingLoopStart();
|
|
144
126
|
|
|
@@ -165,29 +147,13 @@ describe("monitorZaloProvider lifecycle", () => {
|
|
|
165
147
|
}),
|
|
166
148
|
);
|
|
167
149
|
|
|
168
|
-
const { monitorZaloProvider } = await import("./monitor.js");
|
|
169
|
-
const abort = new AbortController();
|
|
170
|
-
const runtime = {
|
|
171
|
-
log: vi.fn<(message: string) => void>(),
|
|
172
|
-
error: vi.fn<(message: string) => void>(),
|
|
173
|
-
};
|
|
174
|
-
const account = {
|
|
175
|
-
accountId: "default",
|
|
176
|
-
config: {},
|
|
177
|
-
} as unknown as ResolvedZaloAccount;
|
|
178
|
-
const config = {} as OpenClawConfig;
|
|
179
|
-
|
|
180
150
|
let settled = false;
|
|
181
|
-
const run =
|
|
182
|
-
token: "test-token",
|
|
183
|
-
account,
|
|
184
|
-
config,
|
|
185
|
-
runtime,
|
|
186
|
-
abortSignal: abort.signal,
|
|
151
|
+
const { abort, runtime, run } = await startLifecycleMonitor({
|
|
187
152
|
useWebhook: true,
|
|
188
153
|
webhookUrl: "https://example.com/hooks/zalo",
|
|
189
154
|
webhookSecret: "supersecret", // pragma: allowlist secret
|
|
190
|
-
})
|
|
155
|
+
});
|
|
156
|
+
const monitoredRun = run.then(() => {
|
|
191
157
|
settled = true;
|
|
192
158
|
});
|
|
193
159
|
|
|
@@ -202,7 +168,7 @@ describe("monitorZaloProvider lifecycle", () => {
|
|
|
202
168
|
expect(registry.httpRoutes).toHaveLength(1);
|
|
203
169
|
|
|
204
170
|
resolveDeleteWebhook?.();
|
|
205
|
-
await
|
|
171
|
+
await monitoredRun;
|
|
206
172
|
|
|
207
173
|
expect(settled).toBe(true);
|
|
208
174
|
expect(registry.httpRoutes).toHaveLength(0);
|
package/src/monitor.ts
CHANGED
|
@@ -75,6 +75,35 @@ const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
|
|
|
75
75
|
const ZALO_TYPING_TIMEOUT_MS = 5_000;
|
|
76
76
|
|
|
77
77
|
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
|
78
|
+
type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
79
|
+
type ZaloProcessingContext = {
|
|
80
|
+
token: string;
|
|
81
|
+
account: ResolvedZaloAccount;
|
|
82
|
+
config: OpenClawConfig;
|
|
83
|
+
runtime: ZaloRuntimeEnv;
|
|
84
|
+
core: ZaloCoreRuntime;
|
|
85
|
+
statusSink?: ZaloStatusSink;
|
|
86
|
+
fetcher?: ZaloFetch;
|
|
87
|
+
};
|
|
88
|
+
type ZaloPollingLoopParams = ZaloProcessingContext & {
|
|
89
|
+
abortSignal: AbortSignal;
|
|
90
|
+
isStopped: () => boolean;
|
|
91
|
+
mediaMaxMb: number;
|
|
92
|
+
};
|
|
93
|
+
type ZaloUpdateProcessingParams = ZaloProcessingContext & {
|
|
94
|
+
update: ZaloUpdate;
|
|
95
|
+
mediaMaxMb: number;
|
|
96
|
+
};
|
|
97
|
+
type ZaloMessagePipelineParams = ZaloProcessingContext & {
|
|
98
|
+
message: ZaloMessage;
|
|
99
|
+
text?: string;
|
|
100
|
+
mediaPath?: string;
|
|
101
|
+
mediaType?: string;
|
|
102
|
+
};
|
|
103
|
+
type ZaloImageMessageParams = ZaloProcessingContext & {
|
|
104
|
+
message: ZaloMessage;
|
|
105
|
+
mediaMaxMb: number;
|
|
106
|
+
};
|
|
78
107
|
|
|
79
108
|
function formatZaloError(error: unknown): string {
|
|
80
109
|
if (error instanceof Error) {
|
|
@@ -135,32 +164,21 @@ export async function handleZaloWebhookRequest(
|
|
|
135
164
|
res: ServerResponse,
|
|
136
165
|
): Promise<boolean> {
|
|
137
166
|
return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
|
|
138
|
-
await processUpdate(
|
|
167
|
+
await processUpdate({
|
|
139
168
|
update,
|
|
140
|
-
target.token,
|
|
141
|
-
target.account,
|
|
142
|
-
target.config,
|
|
143
|
-
target.runtime,
|
|
144
|
-
target.core as ZaloCoreRuntime,
|
|
145
|
-
target.mediaMaxMb,
|
|
146
|
-
target.statusSink,
|
|
147
|
-
target.fetcher,
|
|
148
|
-
);
|
|
169
|
+
token: target.token,
|
|
170
|
+
account: target.account,
|
|
171
|
+
config: target.config,
|
|
172
|
+
runtime: target.runtime,
|
|
173
|
+
core: target.core as ZaloCoreRuntime,
|
|
174
|
+
mediaMaxMb: target.mediaMaxMb,
|
|
175
|
+
statusSink: target.statusSink,
|
|
176
|
+
fetcher: target.fetcher,
|
|
177
|
+
});
|
|
149
178
|
});
|
|
150
179
|
}
|
|
151
180
|
|
|
152
|
-
function startPollingLoop(params: {
|
|
153
|
-
token: string;
|
|
154
|
-
account: ResolvedZaloAccount;
|
|
155
|
-
config: OpenClawConfig;
|
|
156
|
-
runtime: ZaloRuntimeEnv;
|
|
157
|
-
core: ZaloCoreRuntime;
|
|
158
|
-
abortSignal: AbortSignal;
|
|
159
|
-
isStopped: () => boolean;
|
|
160
|
-
mediaMaxMb: number;
|
|
161
|
-
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
162
|
-
fetcher?: ZaloFetch;
|
|
163
|
-
}) {
|
|
181
|
+
function startPollingLoop(params: ZaloPollingLoopParams) {
|
|
164
182
|
const {
|
|
165
183
|
token,
|
|
166
184
|
account,
|
|
@@ -174,6 +192,16 @@ function startPollingLoop(params: {
|
|
|
174
192
|
fetcher,
|
|
175
193
|
} = params;
|
|
176
194
|
const pollTimeout = 30;
|
|
195
|
+
const processingContext = {
|
|
196
|
+
token,
|
|
197
|
+
account,
|
|
198
|
+
config,
|
|
199
|
+
runtime,
|
|
200
|
+
core,
|
|
201
|
+
mediaMaxMb,
|
|
202
|
+
statusSink,
|
|
203
|
+
fetcher,
|
|
204
|
+
};
|
|
177
205
|
|
|
178
206
|
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
|
|
179
207
|
|
|
@@ -186,17 +214,10 @@ function startPollingLoop(params: {
|
|
|
186
214
|
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
|
|
187
215
|
if (response.ok && response.result) {
|
|
188
216
|
statusSink?.({ lastInboundAt: Date.now() });
|
|
189
|
-
await processUpdate(
|
|
190
|
-
response.result,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
config,
|
|
194
|
-
runtime,
|
|
195
|
-
core,
|
|
196
|
-
mediaMaxMb,
|
|
197
|
-
statusSink,
|
|
198
|
-
fetcher,
|
|
199
|
-
);
|
|
217
|
+
await processUpdate({
|
|
218
|
+
update: response.result,
|
|
219
|
+
...processingContext,
|
|
220
|
+
});
|
|
200
221
|
}
|
|
201
222
|
} catch (err) {
|
|
202
223
|
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
|
@@ -215,38 +236,27 @@ function startPollingLoop(params: {
|
|
|
215
236
|
void poll();
|
|
216
237
|
}
|
|
217
238
|
|
|
218
|
-
async function processUpdate(
|
|
219
|
-
update
|
|
220
|
-
token: string,
|
|
221
|
-
account: ResolvedZaloAccount,
|
|
222
|
-
config: OpenClawConfig,
|
|
223
|
-
runtime: ZaloRuntimeEnv,
|
|
224
|
-
core: ZaloCoreRuntime,
|
|
225
|
-
mediaMaxMb: number,
|
|
226
|
-
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
227
|
-
fetcher?: ZaloFetch,
|
|
228
|
-
): Promise<void> {
|
|
239
|
+
async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void> {
|
|
240
|
+
const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params;
|
|
229
241
|
const { event_name, message } = update;
|
|
242
|
+
const sharedContext = { token, account, config, runtime, core, statusSink, fetcher };
|
|
230
243
|
if (!message) {
|
|
231
244
|
return;
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
switch (event_name) {
|
|
235
248
|
case "message.text.received":
|
|
236
|
-
await handleTextMessage(
|
|
249
|
+
await handleTextMessage({
|
|
250
|
+
message,
|
|
251
|
+
...sharedContext,
|
|
252
|
+
});
|
|
237
253
|
break;
|
|
238
254
|
case "message.image.received":
|
|
239
|
-
await handleImageMessage(
|
|
255
|
+
await handleImageMessage({
|
|
240
256
|
message,
|
|
241
|
-
|
|
242
|
-
account,
|
|
243
|
-
config,
|
|
244
|
-
runtime,
|
|
245
|
-
core,
|
|
257
|
+
...sharedContext,
|
|
246
258
|
mediaMaxMb,
|
|
247
|
-
|
|
248
|
-
fetcher,
|
|
249
|
-
);
|
|
259
|
+
});
|
|
250
260
|
break;
|
|
251
261
|
case "message.sticker.received":
|
|
252
262
|
logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
|
|
@@ -262,46 +272,24 @@ async function processUpdate(
|
|
|
262
272
|
}
|
|
263
273
|
|
|
264
274
|
async function handleTextMessage(
|
|
265
|
-
message: ZaloMessage,
|
|
266
|
-
token: string,
|
|
267
|
-
account: ResolvedZaloAccount,
|
|
268
|
-
config: OpenClawConfig,
|
|
269
|
-
runtime: ZaloRuntimeEnv,
|
|
270
|
-
core: ZaloCoreRuntime,
|
|
271
|
-
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
272
|
-
fetcher?: ZaloFetch,
|
|
275
|
+
params: ZaloProcessingContext & { message: ZaloMessage },
|
|
273
276
|
): Promise<void> {
|
|
277
|
+
const { message } = params;
|
|
274
278
|
const { text } = message;
|
|
275
279
|
if (!text?.trim()) {
|
|
276
280
|
return;
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
await processMessageWithPipeline({
|
|
280
|
-
|
|
281
|
-
token,
|
|
282
|
-
account,
|
|
283
|
-
config,
|
|
284
|
-
runtime,
|
|
285
|
-
core,
|
|
284
|
+
...params,
|
|
286
285
|
text,
|
|
287
286
|
mediaPath: undefined,
|
|
288
287
|
mediaType: undefined,
|
|
289
|
-
statusSink,
|
|
290
|
-
fetcher,
|
|
291
288
|
});
|
|
292
289
|
}
|
|
293
290
|
|
|
294
|
-
async function handleImageMessage(
|
|
295
|
-
message
|
|
296
|
-
token: string,
|
|
297
|
-
account: ResolvedZaloAccount,
|
|
298
|
-
config: OpenClawConfig,
|
|
299
|
-
runtime: ZaloRuntimeEnv,
|
|
300
|
-
core: ZaloCoreRuntime,
|
|
301
|
-
mediaMaxMb: number,
|
|
302
|
-
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
303
|
-
fetcher?: ZaloFetch,
|
|
304
|
-
): Promise<void> {
|
|
291
|
+
async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
|
|
292
|
+
const { message, mediaMaxMb, account, core, runtime } = params;
|
|
305
293
|
const { photo, caption } = message;
|
|
306
294
|
|
|
307
295
|
let mediaPath: string | undefined;
|
|
@@ -325,33 +313,14 @@ async function handleImageMessage(
|
|
|
325
313
|
}
|
|
326
314
|
|
|
327
315
|
await processMessageWithPipeline({
|
|
328
|
-
|
|
329
|
-
token,
|
|
330
|
-
account,
|
|
331
|
-
config,
|
|
332
|
-
runtime,
|
|
333
|
-
core,
|
|
316
|
+
...params,
|
|
334
317
|
text: caption,
|
|
335
318
|
mediaPath,
|
|
336
319
|
mediaType,
|
|
337
|
-
statusSink,
|
|
338
|
-
fetcher,
|
|
339
320
|
});
|
|
340
321
|
}
|
|
341
322
|
|
|
342
|
-
async function processMessageWithPipeline(params: {
|
|
343
|
-
message: ZaloMessage;
|
|
344
|
-
token: string;
|
|
345
|
-
account: ResolvedZaloAccount;
|
|
346
|
-
config: OpenClawConfig;
|
|
347
|
-
runtime: ZaloRuntimeEnv;
|
|
348
|
-
core: ZaloCoreRuntime;
|
|
349
|
-
text?: string;
|
|
350
|
-
mediaPath?: string;
|
|
351
|
-
mediaType?: string;
|
|
352
|
-
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
353
|
-
fetcher?: ZaloFetch;
|
|
354
|
-
}): Promise<void> {
|
|
323
|
+
async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Promise<void> {
|
|
355
324
|
const {
|
|
356
325
|
message,
|
|
357
326
|
token,
|
|
@@ -609,7 +578,7 @@ async function deliverZaloReply(params: {
|
|
|
609
578
|
core: ZaloCoreRuntime;
|
|
610
579
|
config: OpenClawConfig;
|
|
611
580
|
accountId?: string;
|
|
612
|
-
statusSink?:
|
|
581
|
+
statusSink?: ZaloStatusSink;
|
|
613
582
|
fetcher?: ZaloFetch;
|
|
614
583
|
tableMode?: MarkdownTableMode;
|
|
615
584
|
}): Promise<void> {
|
|
@@ -283,6 +283,7 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
283
283
|
|
|
284
284
|
try {
|
|
285
285
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
286
|
+
let saw429 = false;
|
|
286
287
|
for (let i = 0; i < 200; i += 1) {
|
|
287
288
|
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
|
|
288
289
|
method: "POST",
|
|
@@ -292,10 +293,15 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
292
293
|
},
|
|
293
294
|
body: "{}",
|
|
294
295
|
});
|
|
295
|
-
expect(response.status)
|
|
296
|
+
expect([401, 429]).toContain(response.status);
|
|
297
|
+
if (response.status === 429) {
|
|
298
|
+
saw429 = true;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
296
301
|
}
|
|
297
302
|
|
|
298
|
-
expect(
|
|
303
|
+
expect(saw429).toBe(true);
|
|
304
|
+
expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
|
|
299
305
|
});
|
|
300
306
|
} finally {
|
|
301
307
|
unregister();
|
|
@@ -322,6 +328,91 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
322
328
|
}
|
|
323
329
|
});
|
|
324
330
|
|
|
331
|
+
it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
|
|
332
|
+
const unregister = registerTarget({ path: "/hook-preauth-rate" });
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
336
|
+
const saw429 = await postUntilRateLimited({
|
|
337
|
+
baseUrl,
|
|
338
|
+
path: "/hook-preauth-rate",
|
|
339
|
+
secret: "invalid-token", // pragma: allowlist secret
|
|
340
|
+
withNonceQuery: true,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(saw429).toBe(true);
|
|
344
|
+
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
|
345
|
+
});
|
|
346
|
+
} finally {
|
|
347
|
+
unregister();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
|
|
352
|
+
const unregister = registerTarget({
|
|
353
|
+
path: "/hook-preauth-split",
|
|
354
|
+
config: {
|
|
355
|
+
gateway: {
|
|
356
|
+
trustedProxies: ["127.0.0.1"],
|
|
357
|
+
},
|
|
358
|
+
} as OpenClawConfig,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
363
|
+
for (let i = 0; i < 130; i += 1) {
|
|
364
|
+
const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: {
|
|
367
|
+
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
368
|
+
"content-type": "application/json",
|
|
369
|
+
"x-forwarded-for": "203.0.113.10",
|
|
370
|
+
},
|
|
371
|
+
body: "{}",
|
|
372
|
+
});
|
|
373
|
+
if (response.status === 429) {
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers: {
|
|
381
|
+
"x-bot-api-secret-token": "secret",
|
|
382
|
+
"content-type": "application/json",
|
|
383
|
+
"x-forwarded-for": "198.51.100.20",
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({ event_name: "message.unsupported.received" }),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(validResponse.status).toBe(200);
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
unregister();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
|
|
396
|
+
const unregister = registerTarget({ path: "/hook-auth-before-type" });
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
400
|
+
const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: {
|
|
403
|
+
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
404
|
+
"content-type": "text/plain",
|
|
405
|
+
},
|
|
406
|
+
body: "not-json",
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(response.status).toBe(401);
|
|
410
|
+
});
|
|
411
|
+
} finally {
|
|
412
|
+
unregister();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
325
416
|
it("scopes DM pairing store reads and writes to accountId", async () => {
|
|
326
417
|
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
|
|
327
418
|
pairingCreated: false,
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
17
17
|
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
18
18
|
} from "openclaw/plugin-sdk/zalo";
|
|
19
|
+
import { resolveClientIp } from "../../../src/gateway/net.js";
|
|
19
20
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
20
21
|
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
21
22
|
import type { ZaloRuntimeEnv } from "./monitor.js";
|
|
@@ -109,6 +110,10 @@ function recordWebhookStatus(
|
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
function headerValue(value: string | string[] | undefined): string | undefined {
|
|
114
|
+
return Array.isArray(value) ? value[0] : value;
|
|
115
|
+
}
|
|
116
|
+
|
|
112
117
|
export function registerZaloWebhookTarget(
|
|
113
118
|
target: ZaloWebhookTarget,
|
|
114
119
|
opts?: {
|
|
@@ -140,6 +145,33 @@ export async function handleZaloWebhookRequest(
|
|
|
140
145
|
targetsByPath: webhookTargets,
|
|
141
146
|
allowMethods: ["POST"],
|
|
142
147
|
handle: async ({ targets, path }) => {
|
|
148
|
+
const trustedProxies = targets[0]?.config.gateway?.trustedProxies;
|
|
149
|
+
const allowRealIpFallback = targets[0]?.config.gateway?.allowRealIpFallback === true;
|
|
150
|
+
const clientIp =
|
|
151
|
+
resolveClientIp({
|
|
152
|
+
remoteAddr: req.socket.remoteAddress,
|
|
153
|
+
forwardedFor: headerValue(req.headers["x-forwarded-for"]),
|
|
154
|
+
realIp: headerValue(req.headers["x-real-ip"]),
|
|
155
|
+
trustedProxies,
|
|
156
|
+
allowRealIpFallback,
|
|
157
|
+
}) ??
|
|
158
|
+
req.socket.remoteAddress ??
|
|
159
|
+
"unknown";
|
|
160
|
+
const rateLimitKey = `${path}:${clientIp}`;
|
|
161
|
+
const nowMs = Date.now();
|
|
162
|
+
if (
|
|
163
|
+
!applyBasicWebhookRequestGuards({
|
|
164
|
+
req,
|
|
165
|
+
res,
|
|
166
|
+
rateLimiter: webhookRateLimiter,
|
|
167
|
+
rateLimitKey,
|
|
168
|
+
nowMs,
|
|
169
|
+
})
|
|
170
|
+
) {
|
|
171
|
+
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
143
175
|
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
144
176
|
const target = resolveWebhookTargetWithAuthOrRejectSync({
|
|
145
177
|
targets,
|
|
@@ -150,16 +182,12 @@ export async function handleZaloWebhookRequest(
|
|
|
150
182
|
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
151
183
|
return true;
|
|
152
184
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
// Preserve the historical 401-before-415 ordering for invalid secrets while still
|
|
186
|
+
// consuming rate-limit budget on unauthenticated guesses.
|
|
156
187
|
if (
|
|
157
188
|
!applyBasicWebhookRequestGuards({
|
|
158
189
|
req,
|
|
159
190
|
res,
|
|
160
|
-
rateLimiter: webhookRateLimiter,
|
|
161
|
-
rateLimitKey,
|
|
162
|
-
nowMs,
|
|
163
191
|
requireJsonContentType: true,
|
|
164
192
|
})
|
|
165
193
|
) {
|
package/src/send.ts
CHANGED
|
@@ -21,6 +21,28 @@ export type ZaloSendResult = {
|
|
|
21
21
|
error?: string;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
function toZaloSendResult(response: {
|
|
25
|
+
ok?: boolean;
|
|
26
|
+
result?: { message_id?: string };
|
|
27
|
+
}): ZaloSendResult {
|
|
28
|
+
if (response.ok && response.result) {
|
|
29
|
+
return { ok: true, messageId: response.result.message_id };
|
|
30
|
+
}
|
|
31
|
+
return { ok: false, error: "Failed to send message" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runZaloSend(
|
|
35
|
+
failureMessage: string,
|
|
36
|
+
send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>,
|
|
37
|
+
): Promise<ZaloSendResult> {
|
|
38
|
+
try {
|
|
39
|
+
const result = toZaloSendResult(await send());
|
|
40
|
+
return result.ok ? result : { ok: false, error: failureMessage };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
function resolveSendContext(options: ZaloSendOptions): {
|
|
25
47
|
token: string;
|
|
26
48
|
fetcher?: ZaloFetch;
|
|
@@ -55,15 +77,30 @@ function resolveValidatedSendContext(
|
|
|
55
77
|
return { ok: true, chatId: trimmedChatId, token, fetcher };
|
|
56
78
|
}
|
|
57
79
|
|
|
80
|
+
function resolveSendContextOrFailure(
|
|
81
|
+
chatId: string,
|
|
82
|
+
options: ZaloSendOptions,
|
|
83
|
+
):
|
|
84
|
+
| { context: { chatId: string; token: string; fetcher?: ZaloFetch } }
|
|
85
|
+
| { failure: ZaloSendResult } {
|
|
86
|
+
const context = resolveValidatedSendContext(chatId, options);
|
|
87
|
+
return context.ok
|
|
88
|
+
? { context }
|
|
89
|
+
: {
|
|
90
|
+
failure: { ok: false, error: context.error },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
export async function sendMessageZalo(
|
|
59
95
|
chatId: string,
|
|
60
96
|
text: string,
|
|
61
97
|
options: ZaloSendOptions = {},
|
|
62
98
|
): Promise<ZaloSendResult> {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
return
|
|
99
|
+
const resolved = resolveSendContextOrFailure(chatId, options);
|
|
100
|
+
if ("failure" in resolved) {
|
|
101
|
+
return resolved.failure;
|
|
66
102
|
}
|
|
103
|
+
const { context } = resolved;
|
|
67
104
|
|
|
68
105
|
if (options.mediaUrl) {
|
|
69
106
|
return sendPhotoZalo(context.chatId, options.mediaUrl, {
|
|
@@ -73,24 +110,16 @@ export async function sendMessageZalo(
|
|
|
73
110
|
});
|
|
74
111
|
}
|
|
75
112
|
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
return await runZaloSend("Failed to send message", () =>
|
|
114
|
+
sendMessage(
|
|
78
115
|
context.token,
|
|
79
116
|
{
|
|
80
117
|
chat_id: context.chatId,
|
|
81
118
|
text: text.slice(0, 2000),
|
|
82
119
|
},
|
|
83
120
|
context.fetcher,
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
if (response.ok && response.result) {
|
|
87
|
-
return { ok: true, messageId: response.result.message_id };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return { ok: false, error: "Failed to send message" };
|
|
91
|
-
} catch (err) {
|
|
92
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
93
|
-
}
|
|
121
|
+
),
|
|
122
|
+
);
|
|
94
123
|
}
|
|
95
124
|
|
|
96
125
|
export async function sendPhotoZalo(
|
|
@@ -98,17 +127,18 @@ export async function sendPhotoZalo(
|
|
|
98
127
|
photoUrl: string,
|
|
99
128
|
options: ZaloSendOptions = {},
|
|
100
129
|
): Promise<ZaloSendResult> {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
return
|
|
130
|
+
const resolved = resolveSendContextOrFailure(chatId, options);
|
|
131
|
+
if ("failure" in resolved) {
|
|
132
|
+
return resolved.failure;
|
|
104
133
|
}
|
|
134
|
+
const { context } = resolved;
|
|
105
135
|
|
|
106
136
|
if (!photoUrl?.trim()) {
|
|
107
137
|
return { ok: false, error: "No photo URL provided" };
|
|
108
138
|
}
|
|
109
139
|
|
|
110
|
-
|
|
111
|
-
|
|
140
|
+
return await runZaloSend("Failed to send photo", () =>
|
|
141
|
+
sendPhoto(
|
|
112
142
|
context.token,
|
|
113
143
|
{
|
|
114
144
|
chat_id: context.chatId,
|
|
@@ -116,14 +146,6 @@ export async function sendPhotoZalo(
|
|
|
116
146
|
caption: options.caption?.slice(0, 2000),
|
|
117
147
|
},
|
|
118
148
|
context.fetcher,
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
if (response.ok && response.result) {
|
|
122
|
-
return { ok: true, messageId: response.result.message_id };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return { ok: false, error: "Failed to send photo" };
|
|
126
|
-
} catch (err) {
|
|
127
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
128
|
-
}
|
|
149
|
+
),
|
|
150
|
+
);
|
|
129
151
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js";
|
|
3
|
+
import { collectZaloStatusIssues } from "./status-issues.js";
|
|
4
|
+
|
|
5
|
+
describe("collectZaloStatusIssues", () => {
|
|
6
|
+
it("warns when dmPolicy is open", () => {
|
|
7
|
+
expectOpenDmPolicyConfigIssue({
|
|
8
|
+
collectIssues: collectZaloStatusIssues,
|
|
9
|
+
account: {
|
|
10
|
+
accountId: "default",
|
|
11
|
+
enabled: true,
|
|
12
|
+
configured: true,
|
|
13
|
+
dmPolicy: "open",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("skips unconfigured accounts", () => {
|
|
19
|
+
const issues = collectZaloStatusIssues([
|
|
20
|
+
{
|
|
21
|
+
accountId: "default",
|
|
22
|
+
enabled: true,
|
|
23
|
+
configured: false,
|
|
24
|
+
dmPolicy: "open",
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
expect(issues).toHaveLength(0);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/status-issues.ts
CHANGED
|
@@ -1,38 +1,16 @@
|
|
|
1
1
|
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk/zalo";
|
|
2
|
+
import { coerceStatusIssueAccountId, readStatusIssueFields } from "../../shared/status-issues.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
accountId?: unknown;
|
|
5
|
-
enabled?: unknown;
|
|
6
|
-
configured?: unknown;
|
|
7
|
-
dmPolicy?: unknown;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
11
|
-
Boolean(value && typeof value === "object");
|
|
12
|
-
|
|
13
|
-
const asString = (value: unknown): string | undefined =>
|
|
14
|
-
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
|
|
15
|
-
|
|
16
|
-
function readZaloAccountStatus(value: ChannelAccountSnapshot): ZaloAccountStatus | null {
|
|
17
|
-
if (!isRecord(value)) {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
return {
|
|
21
|
-
accountId: value.accountId,
|
|
22
|
-
enabled: value.enabled,
|
|
23
|
-
configured: value.configured,
|
|
24
|
-
dmPolicy: value.dmPolicy,
|
|
25
|
-
};
|
|
26
|
-
}
|
|
4
|
+
const ZALO_STATUS_FIELDS = ["accountId", "enabled", "configured", "dmPolicy"] as const;
|
|
27
5
|
|
|
28
6
|
export function collectZaloStatusIssues(accounts: ChannelAccountSnapshot[]): ChannelStatusIssue[] {
|
|
29
7
|
const issues: ChannelStatusIssue[] = [];
|
|
30
8
|
for (const entry of accounts) {
|
|
31
|
-
const account =
|
|
9
|
+
const account = readStatusIssueFields(entry, ZALO_STATUS_FIELDS);
|
|
32
10
|
if (!account) {
|
|
33
11
|
continue;
|
|
34
12
|
}
|
|
35
|
-
const accountId =
|
|
13
|
+
const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
|
|
36
14
|
const enabled = account.enabled !== false;
|
|
37
15
|
const configured = account.configured === true;
|
|
38
16
|
if (!enabled || !configured) {
|