abs-zalo-bot 0.2.0
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/.env.example +48 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/SECURITY.md +22 -0
- package/config/bots.example.json +33 -0
- package/config.toml +48 -0
- package/mcp/run.sh +7 -0
- package/mcp/server.js +255 -0
- package/package.json +76 -0
- package/scripts/battle-smoke.js +118 -0
- package/scripts/corpus-stats.js +23 -0
- package/scripts/probe-history.js +76 -0
- package/scripts/public-gate.js +216 -0
- package/scripts/setup.js +233 -0
- package/src/abs_telemetry.js +60 -0
- package/src/ask.js +7 -0
- package/src/backfill.js +364 -0
- package/src/bot_registry.js +180 -0
- package/src/brand.js +31 -0
- package/src/cli.js +268 -0
- package/src/commands.js +265 -0
- package/src/config.js +223 -0
- package/src/digest.js +330 -0
- package/src/discovery.js +185 -0
- package/src/hermes_client.js +302 -0
- package/src/inbound_router.js +295 -0
- package/src/keepalive.js +375 -0
- package/src/oa_adapter.js +284 -0
- package/src/oa_auto_reply.js +75 -0
- package/src/oa_policy.js +32 -0
- package/src/oa_webhook.js +95 -0
- package/src/onboarding.js +102 -0
- package/src/ops_report.js +135 -0
- package/src/policy.js +320 -0
- package/src/privacy.js +38 -0
- package/src/schema.js +163 -0
- package/src/server.js +604 -0
- package/src/store.js +972 -0
- package/src/zalo_runtime.js +480 -0
package/mcp/server.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Zalo Personal MCP — thin MCP facade over hermes-zalo-personal-bridge.
|
|
4
|
+
*
|
|
5
|
+
* Design learned from minhkhoa0502/zalo-personal-mcp:
|
|
6
|
+
* - Own the MCP layer (tool surface + policy)
|
|
7
|
+
* - Reuse zca-js only inside the long-lived bridge/daemon
|
|
8
|
+
* - History via daemon capture, not REST getGroupChatHistory (often 404)
|
|
9
|
+
* - Never log to stdout (MCP stdio protocol)
|
|
10
|
+
*
|
|
11
|
+
* Our difference: Policy Guard + READ_ONLY_SOURCE + destination-only send.
|
|
12
|
+
*/
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
const BRIDGE_URL = (process.env.ZALO_BRIDGE_URL || "http://127.0.0.1:3871").replace(/\/$/, "");
|
|
18
|
+
const TOKEN = process.env.DASHBOARD_TOKEN || process.env.ZALO_BRIDGE_TOKEN || "";
|
|
19
|
+
|
|
20
|
+
function log(...args) {
|
|
21
|
+
console.error("[zalo-personal-mcp]", ...args);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function bridge(path, { method = "GET", body = null } = {}) {
|
|
25
|
+
const headers = { "content-type": "application/json" };
|
|
26
|
+
if (TOKEN && TOKEN !== "change-me") headers["x-bridge-token"] = TOKEN;
|
|
27
|
+
const res = await fetch(`${BRIDGE_URL}${path}`, {
|
|
28
|
+
method,
|
|
29
|
+
headers,
|
|
30
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
31
|
+
});
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
let data;
|
|
34
|
+
try {
|
|
35
|
+
data = JSON.parse(text);
|
|
36
|
+
} catch {
|
|
37
|
+
data = { raw: text.slice(0, 2000) };
|
|
38
|
+
}
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const err = data?.error || data?.message || res.statusText;
|
|
41
|
+
throw new Error(`bridge ${method} ${path} → ${res.status}: ${err}`);
|
|
42
|
+
}
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ok(data) {
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fail(err) {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: JSON.stringify({ error: String(err?.message || err) }, null, 2) }],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const server = new McpServer({
|
|
60
|
+
name: "zalo-personal-mcp",
|
|
61
|
+
version: "0.1.0",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// --- read tools ---
|
|
65
|
+
server.tool(
|
|
66
|
+
"zalo_status",
|
|
67
|
+
"Bridge/account safety status: connected?, READ_ONLY_SOURCE, destination, corpus counts.",
|
|
68
|
+
{},
|
|
69
|
+
async () => {
|
|
70
|
+
try {
|
|
71
|
+
const [status, corpus] = await Promise.all([
|
|
72
|
+
bridge("/api/status"),
|
|
73
|
+
bridge("/api/corpus/summary").catch(() => null),
|
|
74
|
+
]);
|
|
75
|
+
return ok({
|
|
76
|
+
accounts: status.accounts,
|
|
77
|
+
owner_user_id: status.owner_user_id,
|
|
78
|
+
phone_label: status.phone_label,
|
|
79
|
+
destination: status.destination,
|
|
80
|
+
safety: status.safety,
|
|
81
|
+
corpus,
|
|
82
|
+
});
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return fail(e);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
server.tool(
|
|
90
|
+
"zalo_list_groups",
|
|
91
|
+
"List known groups (source_id + name + mode). From bridge catalog / discovery.",
|
|
92
|
+
{
|
|
93
|
+
account_id: z.string().optional().describe("Account id, default bridge default"),
|
|
94
|
+
},
|
|
95
|
+
async ({ account_id }) => {
|
|
96
|
+
try {
|
|
97
|
+
const q = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "";
|
|
98
|
+
const data = await bridge(`/api/sources${q}`);
|
|
99
|
+
return ok({
|
|
100
|
+
destination: data.destination,
|
|
101
|
+
sources: (data.sources || []).map((s) => ({
|
|
102
|
+
source_id: s.source_id,
|
|
103
|
+
source_name: s.source_name,
|
|
104
|
+
mode: s.mode,
|
|
105
|
+
is_allowed: s.is_allowed,
|
|
106
|
+
muted: s.muted,
|
|
107
|
+
})),
|
|
108
|
+
});
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return fail(e);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
server.tool(
|
|
116
|
+
"zalo_list_users",
|
|
117
|
+
"List known user ids / display names from corpus (members + senders).",
|
|
118
|
+
{
|
|
119
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
120
|
+
account_id: z.string().optional(),
|
|
121
|
+
},
|
|
122
|
+
async ({ limit = 100, account_id }) => {
|
|
123
|
+
try {
|
|
124
|
+
const qs = new URLSearchParams();
|
|
125
|
+
if (account_id) qs.set("account_id", account_id);
|
|
126
|
+
qs.set("limit", String(limit));
|
|
127
|
+
const data = await bridge(`/api/users?${qs}`);
|
|
128
|
+
return ok(data);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return fail(e);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
server.tool(
|
|
136
|
+
"zalo_group_members",
|
|
137
|
+
"List members of a group source_id (user_id, role, display_name).",
|
|
138
|
+
{
|
|
139
|
+
source_id: z.string().describe("Zalo group id"),
|
|
140
|
+
account_id: z.string().optional(),
|
|
141
|
+
limit: z.number().int().min(1).max(2000).optional(),
|
|
142
|
+
},
|
|
143
|
+
async ({ source_id, account_id, limit = 500 }) => {
|
|
144
|
+
try {
|
|
145
|
+
const qs = new URLSearchParams();
|
|
146
|
+
if (account_id) qs.set("account_id", account_id);
|
|
147
|
+
qs.set("limit", String(limit));
|
|
148
|
+
const data = await bridge(`/api/sources/${encodeURIComponent(source_id)}/members?${qs}`);
|
|
149
|
+
return ok(data);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return fail(e);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
server.tool(
|
|
157
|
+
"zalo_recent_messages",
|
|
158
|
+
"Read recently captured/stored messages from the bridge corpus (daemon/listener). Prefer this over history API.",
|
|
159
|
+
{
|
|
160
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
161
|
+
account_id: z.string().optional(),
|
|
162
|
+
source_id: z.string().optional().describe("Filter by group id"),
|
|
163
|
+
},
|
|
164
|
+
async ({ limit = 50, account_id, source_id }) => {
|
|
165
|
+
try {
|
|
166
|
+
const qs = new URLSearchParams();
|
|
167
|
+
if (account_id) qs.set("account_id", account_id);
|
|
168
|
+
if (source_id) qs.set("source_id", source_id);
|
|
169
|
+
qs.set("limit", String(limit));
|
|
170
|
+
// bridge /api/events currently supports account_id+limit; filter client-side if source_id
|
|
171
|
+
const data = await bridge(`/api/events?${qs}`);
|
|
172
|
+
let events = data.events || [];
|
|
173
|
+
if (source_id) events = events.filter((e) => String(e.source_id) === String(source_id));
|
|
174
|
+
return ok({ count: events.length, events });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return fail(e);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
server.tool(
|
|
182
|
+
"zalo_corpus_summary",
|
|
183
|
+
"Corpus inventory: groups, users, member links, messages, last backfill.",
|
|
184
|
+
{
|
|
185
|
+
account_id: z.string().optional(),
|
|
186
|
+
},
|
|
187
|
+
async ({ account_id }) => {
|
|
188
|
+
try {
|
|
189
|
+
const q = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "";
|
|
190
|
+
return ok(await bridge(`/api/corpus/summary${q}`));
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return fail(e);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
server.tool(
|
|
198
|
+
"zalo_backfill",
|
|
199
|
+
"READ_ONLY backfill: groups/users/members + best-effort old messages. May take minutes. Does not send messages.",
|
|
200
|
+
{
|
|
201
|
+
history_count: z.number().int().min(1).max(200).optional(),
|
|
202
|
+
max_groups: z.number().int().min(1).max(500).optional(),
|
|
203
|
+
account_id: z.string().optional(),
|
|
204
|
+
},
|
|
205
|
+
async ({ history_count = 50, max_groups = 200, account_id }) => {
|
|
206
|
+
try {
|
|
207
|
+
const data = await bridge("/api/corpus/backfill", {
|
|
208
|
+
method: "POST",
|
|
209
|
+
body: { history_count, max_groups, account_id },
|
|
210
|
+
});
|
|
211
|
+
return ok(data);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return fail(e);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
server.tool(
|
|
219
|
+
"zalo_refresh_discovery",
|
|
220
|
+
"Re-scan groups after connect: resolve the configured destination + account owner.",
|
|
221
|
+
{
|
|
222
|
+
account_id: z.string().optional(),
|
|
223
|
+
},
|
|
224
|
+
async ({ account_id }) => {
|
|
225
|
+
try {
|
|
226
|
+
return ok(
|
|
227
|
+
await bridge("/api/discovery/refresh", {
|
|
228
|
+
method: "POST",
|
|
229
|
+
body: { account_id },
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return fail(e);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Explicitly NOT exposing: free-form send to arbitrary threads, friend mutations, group admin.
|
|
239
|
+
|
|
240
|
+
async function main() {
|
|
241
|
+
const transport = new StdioServerTransport();
|
|
242
|
+
await server.connect(transport);
|
|
243
|
+
log(`running · bridge=${BRIDGE_URL}`);
|
|
244
|
+
try {
|
|
245
|
+
const h = await bridge("/healthz");
|
|
246
|
+
log("bridge health", h);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
log("bridge not reachable yet:", String(e.message || e));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
main().catch((err) => {
|
|
253
|
+
log("fatal", err);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "abs-zalo-bot",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Zalo channel adapter for AI agents. Official OA and personal QR kept separate, fail-closed by default.",
|
|
6
|
+
"author": "teddiesloco",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"zalo",
|
|
10
|
+
"zalo-bot",
|
|
11
|
+
"zca-js",
|
|
12
|
+
"chatbot",
|
|
13
|
+
"messaging",
|
|
14
|
+
"ai-agent",
|
|
15
|
+
"mcp",
|
|
16
|
+
"vietnam",
|
|
17
|
+
"official-account",
|
|
18
|
+
"personal-qr"
|
|
19
|
+
],
|
|
20
|
+
"homepage": "https://github.com/teddiesloco/abs-zalo-bot#readme",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/teddiesloco/abs-zalo-bot.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/teddiesloco/abs-zalo-bot/issues"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src/",
|
|
30
|
+
"mcp/",
|
|
31
|
+
"scripts/",
|
|
32
|
+
"config/bots.example.json",
|
|
33
|
+
"config.toml",
|
|
34
|
+
".env.example",
|
|
35
|
+
"README.md",
|
|
36
|
+
"SECURITY.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"bin": {
|
|
40
|
+
"zalo-personal-mcp": "./mcp/server.js"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"start": "node src/cli.js serve",
|
|
44
|
+
"dev": "node --watch src/cli.js serve",
|
|
45
|
+
"self-check": "node src/cli.js self-check",
|
|
46
|
+
"status": "node src/cli.js status",
|
|
47
|
+
"qr": "node src/cli.js qr",
|
|
48
|
+
"onboarding": "node src/cli.js onboarding",
|
|
49
|
+
"digest": "node src/cli.js digest",
|
|
50
|
+
"backfill": "node src/cli.js backfill",
|
|
51
|
+
"mcp": "node mcp/server.js",
|
|
52
|
+
"setup": "node scripts/setup.js setup",
|
|
53
|
+
"install:locked": "npm ci",
|
|
54
|
+
"doctor": "node scripts/setup.js doctor",
|
|
55
|
+
"dashboard-info": "node scripts/setup.js dashboard-info",
|
|
56
|
+
"smoke": "node scripts/battle-smoke.js",
|
|
57
|
+
"battle": "node scripts/battle-smoke.js",
|
|
58
|
+
"validate-config": "node scripts/public-gate.js validate-config",
|
|
59
|
+
"secret-scan": "node scripts/public-gate.js secret-scan",
|
|
60
|
+
"syntax-check": "node scripts/public-gate.js syntax-check",
|
|
61
|
+
"test": "node --test test/*.test.js",
|
|
62
|
+
"cold": "npm test && npm run validate-config && npm run secret-scan && npm run syntax-check && npm run self-check && npm run smoke"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=22.5"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
69
|
+
"express": "^4.21.2",
|
|
70
|
+
"zca-js": "2.1.2",
|
|
71
|
+
"zod": "3.25.76"
|
|
72
|
+
},
|
|
73
|
+
"overrides": {
|
|
74
|
+
"@hono/node-server": "2.1.0"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cold battle smoke — proves bridge + MCP surface can do real work.
|
|
4
|
+
* READ_ONLY defaults. Does not spam source groups.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const BRIDGE = process.env.ZALO_BRIDGE_URL || "http://127.0.0.1:3871";
|
|
12
|
+
|
|
13
|
+
async function j(url, opts) {
|
|
14
|
+
const res = await fetch(url, opts);
|
|
15
|
+
const text = await res.text();
|
|
16
|
+
let data;
|
|
17
|
+
try {
|
|
18
|
+
data = JSON.parse(text);
|
|
19
|
+
} catch {
|
|
20
|
+
data = { raw: text.slice(0, 500) };
|
|
21
|
+
}
|
|
22
|
+
return { status: res.status, ok: res.ok, data };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pass(name, detail = "") {
|
|
26
|
+
console.log(`PASS ${name}${detail ? " · " + detail : ""}`);
|
|
27
|
+
}
|
|
28
|
+
function fail(name, detail = "") {
|
|
29
|
+
console.error(`FAIL ${name}${detail ? " · " + detail : ""}`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
console.log("=== Zalo Personal MCP battle smoke ===");
|
|
35
|
+
console.log("bridge", BRIDGE);
|
|
36
|
+
|
|
37
|
+
// 1 health
|
|
38
|
+
let r = await j(`${BRIDGE}/healthz`);
|
|
39
|
+
if (r.ok) pass("healthz");
|
|
40
|
+
else return fail("healthz", String(r.status));
|
|
41
|
+
|
|
42
|
+
// 2 battle-ready
|
|
43
|
+
r = await j(`${BRIDGE}/api/battle-ready`);
|
|
44
|
+
if (!r.ok) return fail("battle-ready endpoint", r.status);
|
|
45
|
+
const br = r.data;
|
|
46
|
+
console.log(JSON.stringify({ battle_ready: br.battle_ready, account: br.account, destination: br.destination, corpus: br.corpus, safety: br.safety }, null, 2));
|
|
47
|
+
if (br.battle_ready) pass("battle_ready");
|
|
48
|
+
else fail("battle_ready", "not connected or destination/safety incomplete");
|
|
49
|
+
|
|
50
|
+
// 3 reconnect session if needed
|
|
51
|
+
if (br.account?.status !== "connected") {
|
|
52
|
+
r = await j(`${BRIDGE}/api/accounts/default/connect`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/json" },
|
|
55
|
+
body: "{}",
|
|
56
|
+
});
|
|
57
|
+
if (r.ok && r.data?.status?.status === "connected") pass("reconnect session");
|
|
58
|
+
else fail("reconnect session", JSON.stringify(r.data).slice(0, 200));
|
|
59
|
+
} else pass("already connected");
|
|
60
|
+
|
|
61
|
+
// 4 corpus
|
|
62
|
+
r = await j(`${BRIDGE}/api/corpus/summary`);
|
|
63
|
+
if (r.ok) pass("corpus_summary", `msgs=${r.data.messages} users=${r.data.users} groups=${r.data.sources}`);
|
|
64
|
+
else fail("corpus_summary");
|
|
65
|
+
|
|
66
|
+
// 5 sources list
|
|
67
|
+
r = await j(`${BRIDGE}/api/sources`);
|
|
68
|
+
if (r.ok && Array.isArray(r.data.sources) && r.data.sources.length > 0) {
|
|
69
|
+
pass("list_groups", String(r.data.sources.length));
|
|
70
|
+
} else fail("list_groups");
|
|
71
|
+
|
|
72
|
+
// 6 policy: wrong outbound blocked via ask with empty dest would fail; use status safety
|
|
73
|
+
r = await j(`${BRIDGE}/api/status`);
|
|
74
|
+
const safety = r.data?.safety || {};
|
|
75
|
+
if (safety.READ_ONLY_SOURCE && safety.auto_reply_disabled && safety.dm_reply_disabled) {
|
|
76
|
+
pass("READ_ONLY_SOURCE safety flags");
|
|
77
|
+
} else fail("safety flags", JSON.stringify(safety));
|
|
78
|
+
|
|
79
|
+
// 7 dry ask path (may send to configured destination if explicitly enabled)
|
|
80
|
+
// Use a soft question; Policy Guard still destination-only.
|
|
81
|
+
const doSend = process.env.BATTLE_SEND === "true";
|
|
82
|
+
if (doSend) {
|
|
83
|
+
r = await j(`${BRIDGE}/api/ask`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ question: "tóm tắt nhanh corpus hiện có?", hours: 24 }),
|
|
87
|
+
});
|
|
88
|
+
if (r.ok) pass("ask_destination", r.data?.reason || r.data?.ok);
|
|
89
|
+
else fail("ask_destination", JSON.stringify(r.data).slice(0, 300));
|
|
90
|
+
} else {
|
|
91
|
+
pass("ask_destination skipped (set BATTLE_SEND=true to send real report)");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 8 MCP server boots
|
|
95
|
+
await new Promise((resolve) => {
|
|
96
|
+
const child = spawn("node", [path.join(root, "mcp/server.js")], {
|
|
97
|
+
env: { ...process.env, ZALO_BRIDGE_URL: BRIDGE },
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
});
|
|
100
|
+
let err = "";
|
|
101
|
+
child.stderr.on("data", (d) => {
|
|
102
|
+
err += d.toString();
|
|
103
|
+
});
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
child.kill("SIGTERM");
|
|
106
|
+
if (err.includes("running") || err.includes("bridge health")) pass("mcp_server_boot", err.trim().split("\n").slice(-2).join(" | "));
|
|
107
|
+
else fail("mcp_server_boot", err.slice(0, 300) || "no stderr");
|
|
108
|
+
resolve();
|
|
109
|
+
}, 1500);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log(process.exitCode ? "=== SMOKE FAILED ===" : "=== SMOKE OK — battle surface ready ===");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main().catch((e) => {
|
|
116
|
+
console.error(e);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
const db = new DatabaseSync("./data/bridge.sqlite3");
|
|
3
|
+
const q = (s) => db.prepare(s).get();
|
|
4
|
+
const out = {
|
|
5
|
+
messages: q("SELECT COUNT(*) AS c FROM zalo_messages").c,
|
|
6
|
+
users: q("SELECT COUNT(*) AS c FROM zalo_users").c,
|
|
7
|
+
members: q("SELECT COUNT(*) AS c FROM zalo_source_members").c,
|
|
8
|
+
sources: q("SELECT COUNT(*) AS c FROM zalo_sources").c,
|
|
9
|
+
sample_users: db
|
|
10
|
+
.prepare(
|
|
11
|
+
"SELECT user_id, display_name FROM zalo_users WHERE display_name != '' LIMIT 5",
|
|
12
|
+
)
|
|
13
|
+
.all(),
|
|
14
|
+
sample_groups: db
|
|
15
|
+
.prepare("SELECT source_id, source_name FROM zalo_sources LIMIT 8")
|
|
16
|
+
.all(),
|
|
17
|
+
sample_msgs: db
|
|
18
|
+
.prepare(
|
|
19
|
+
"SELECT source_name, sender_display_name, substr(text_redacted,1,100) AS t FROM zalo_messages LIMIT 5",
|
|
20
|
+
)
|
|
21
|
+
.all(),
|
|
22
|
+
};
|
|
23
|
+
console.log(JSON.stringify(out, null, 2));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Store } from "../src/store.js";
|
|
2
|
+
import { BridgeHub } from "../src/zalo_runtime.js";
|
|
3
|
+
import { PolicyGuard } from "../src/policy.js";
|
|
4
|
+
import { loadConfig } from "../src/config.js";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const config = loadConfig(path.join(root, "config.toml"));
|
|
10
|
+
const store = new Store(path.join(root, "data"));
|
|
11
|
+
const policy = new PolicyGuard({ config, store });
|
|
12
|
+
const hub = new BridgeHub({ config, store, policy });
|
|
13
|
+
const rt = hub.getRuntime("default");
|
|
14
|
+
await rt.connect({ forceQr: false });
|
|
15
|
+
const api = rt.api;
|
|
16
|
+
const gid = process.env.ZALO_GROUP_ID || process.argv[2] || "";
|
|
17
|
+
if (!gid) {
|
|
18
|
+
console.error("Missing group id. Pass ZALO_GROUP_ID or the group id as the first argument.");
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const info = await api.getGroupInfo(gid);
|
|
23
|
+
const g = info.gridInfoMap[gid];
|
|
24
|
+
console.log(
|
|
25
|
+
JSON.stringify(
|
|
26
|
+
{
|
|
27
|
+
name: g?.name,
|
|
28
|
+
totalMember: g?.totalMember,
|
|
29
|
+
memberIds_len: g?.memberIds?.length ?? null,
|
|
30
|
+
memVerList_len: g?.memVerList?.length ?? null,
|
|
31
|
+
memVerList_sample: (g?.memVerList || []).slice(0, 5),
|
|
32
|
+
currentMems_sample: (g?.currentMems || []).slice(0, 2),
|
|
33
|
+
adminIds: g?.adminIds || [],
|
|
34
|
+
creatorId: g?.creatorId || "",
|
|
35
|
+
keys: Object.keys(g || {}),
|
|
36
|
+
},
|
|
37
|
+
null,
|
|
38
|
+
2,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const h = await api.getGroupChatHistory(gid, 5);
|
|
44
|
+
console.log(
|
|
45
|
+
"history",
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
keys: Object.keys(h || {}),
|
|
48
|
+
msgs: h?.groupMsgs?.length,
|
|
49
|
+
sample: h?.groupMsgs?.[0]
|
|
50
|
+
? {
|
|
51
|
+
threadId: h.groupMsgs[0].threadId,
|
|
52
|
+
isSelf: h.groupMsgs[0].isSelf,
|
|
53
|
+
dataKeys: Object.keys(h.groupMsgs[0].data || {}),
|
|
54
|
+
msgId: h.groupMsgs[0].data?.msgId,
|
|
55
|
+
contentType: typeof h.groupMsgs[0].data?.content,
|
|
56
|
+
}
|
|
57
|
+
: null,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.log("history_err", e?.message || e);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// try getGroupMembersInfo with memVerList if present
|
|
65
|
+
const ids = (g?.memVerList || g?.memberIds || []).slice(0, 5).map(String);
|
|
66
|
+
if (ids.length) {
|
|
67
|
+
try {
|
|
68
|
+
const mi = await api.getGroupMembersInfo(ids);
|
|
69
|
+
console.log("members_info_count", Object.keys(mi?.profiles || {}).length);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.log("members_err", e?.message || e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await rt.pause();
|
|
76
|
+
store.close();
|