abs-zalo-bot 0.7.2 → 0.8.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/mcp/server.js +106 -1
- package/package.json +1 -1
- package/src/server.js +85 -0
- package/src/store.js +45 -0
- package/src/zalo_attachments.js +90 -0
- package/src/zalo_mentions.js +83 -0
- package/src/zalo_runtime.js +59 -2
- package/src/zalo_stickers.js +101 -0
package/mcp/server.js
CHANGED
|
@@ -844,7 +844,8 @@ server.tool(
|
|
|
844
844
|
"create_note", "change_avatar", "block_member", "unblock_member", "review_pending",
|
|
845
845
|
"group_link_enable", "group_link_disable", "send_card", "send_bank_card",
|
|
846
846
|
"friend_accept", "friend_reject", "friend_request", "friend_request_undo", "friend_remove",
|
|
847
|
-
"user_block", "user_unblock"
|
|
847
|
+
"user_block", "user_unblock",
|
|
848
|
+
"join_group_link", "join_group_invite_box", "get_group_link_info", "get_sticker_detail", "search_stickers"
|
|
848
849
|
]),
|
|
849
850
|
payload: z.record(z.unknown()).describe("Action-specific fields; inspect bridge docs before invoking."),
|
|
850
851
|
confirm: z.literal(true).describe("Must be true after the operator explicitly confirms the exact side effect."),
|
|
@@ -856,6 +857,110 @@ server.tool(
|
|
|
856
857
|
},
|
|
857
858
|
);
|
|
858
859
|
|
|
860
|
+
server.tool(
|
|
861
|
+
"abs_zalo_join_group_link",
|
|
862
|
+
"Automatically join a Zalo group via invite link (e.g. https://zalo.me/g/...).",
|
|
863
|
+
{
|
|
864
|
+
link: z.string().describe("Group invite link or code"),
|
|
865
|
+
account_id: z.string().optional(),
|
|
866
|
+
},
|
|
867
|
+
async ({ link, account_id }) => {
|
|
868
|
+
try {
|
|
869
|
+
return ok(await bridge("/api/groups/join-link", { method: "POST", body: { link, account_id } }));
|
|
870
|
+
} catch (e) {
|
|
871
|
+
return fail(e);
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
server.tool(
|
|
877
|
+
"abs_zalo_get_group_link_info",
|
|
878
|
+
"Inspect group details from an invite link before joining.",
|
|
879
|
+
{
|
|
880
|
+
link: z.string().describe("Group invite link or code"),
|
|
881
|
+
account_id: z.string().optional(),
|
|
882
|
+
},
|
|
883
|
+
async ({ link, account_id }) => {
|
|
884
|
+
try {
|
|
885
|
+
return ok(await bridge("/api/groups/link-info", { method: "POST", body: { link, account_id } }));
|
|
886
|
+
} catch (e) {
|
|
887
|
+
return fail(e);
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
server.tool(
|
|
893
|
+
"abs_zalo_join_invite_box",
|
|
894
|
+
"Accept an invitation to join a group.",
|
|
895
|
+
{
|
|
896
|
+
group_id: z.string().describe("Target Zalo group ID"),
|
|
897
|
+
account_id: z.string().optional(),
|
|
898
|
+
},
|
|
899
|
+
async ({ group_id, account_id }) => {
|
|
900
|
+
try {
|
|
901
|
+
return ok(await bridge("/api/groups/join-invite-box", { method: "POST", body: { group_id, account_id } }));
|
|
902
|
+
} catch (e) {
|
|
903
|
+
return fail(e);
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
server.tool(
|
|
909
|
+
"abs_zalo_get_sticker_detail",
|
|
910
|
+
"Get text description and image URL of a Zalo sticker by its ID.",
|
|
911
|
+
{
|
|
912
|
+
sticker_id: z.number().describe("Numeric sticker ID"),
|
|
913
|
+
account_id: z.string().optional(),
|
|
914
|
+
},
|
|
915
|
+
async ({ sticker_id, account_id }) => {
|
|
916
|
+
try {
|
|
917
|
+
return ok(await bridge(`/api/stickers/${encodeURIComponent(sticker_id)}${account_id ? `?account_id=${account_id}` : ""}`));
|
|
918
|
+
} catch (e) {
|
|
919
|
+
return fail(e);
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
server.tool(
|
|
925
|
+
"abs_zalo_search_stickers",
|
|
926
|
+
"Search Zalo stickers by keyword.",
|
|
927
|
+
{
|
|
928
|
+
keyword: z.string().describe("Keyword to search"),
|
|
929
|
+
account_id: z.string().optional(),
|
|
930
|
+
},
|
|
931
|
+
async ({ keyword, account_id }) => {
|
|
932
|
+
try {
|
|
933
|
+
return ok(await bridge(`/api/stickers/search?keyword=${encodeURIComponent(keyword)}${account_id ? `&account_id=${account_id}` : ""}`));
|
|
934
|
+
} catch (e) {
|
|
935
|
+
return fail(e);
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
server.tool(
|
|
941
|
+
"abs_zalo_history_range",
|
|
942
|
+
"Read chat message history across hours (since_hours, up to 168h / 7 days) from local SQLite store to summarize group discussions without calling Zalo API.",
|
|
943
|
+
{
|
|
944
|
+
group_id: z.string().describe("Target group or user ID"),
|
|
945
|
+
since_hours: z.number().optional().describe("Number of hours of history to read (default: 24, max: 168)"),
|
|
946
|
+
limit: z.number().optional().describe("Number of messages per page (default: 50, max: 100)"),
|
|
947
|
+
cursor: z.string().optional().describe("Pagination cursor for older messages"),
|
|
948
|
+
account_id: z.string().optional(),
|
|
949
|
+
},
|
|
950
|
+
async ({ group_id, since_hours, limit, cursor, account_id }) => {
|
|
951
|
+
try {
|
|
952
|
+
const q = new URLSearchParams();
|
|
953
|
+
if (since_hours) q.set("since_hours", String(since_hours));
|
|
954
|
+
if (limit) q.set("limit", String(limit));
|
|
955
|
+
if (cursor) q.set("cursor", String(cursor));
|
|
956
|
+
if (account_id) q.set("account_id", String(account_id));
|
|
957
|
+
return ok(await bridge(`/api/groups/${encodeURIComponent(group_id)}/history-range?${q.toString()}`));
|
|
958
|
+
} catch (e) {
|
|
959
|
+
return fail(e);
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
);
|
|
963
|
+
|
|
859
964
|
// Backward-compatibility aliases for older prompts
|
|
860
965
|
server.tool("zalo_status", "Alias for abs_zalo_status", {}, async () => bridge("/api/status").then(ok).catch(fail));
|
|
861
966
|
server.tool("zalo_list_groups", "Alias for abs_zalo_list_groups", { account_id: z.string().optional() }, async ({ account_id }) => bridge(`/api/sources${account_id ? `?account_id=${account_id}` : ""}`).then(ok).catch(fail));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abs-zalo-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ABS Zalo Agent Engine — Free, Transparent & Autonomous Zalo AI Agent Engine for Hermes, Claude Code & Codex. Dual Personal QR + Official OA, Group Administration, Lead Intel, Polls, Reactions & MCP Server.",
|
|
6
6
|
"author": "teddiesloco",
|
package/src/server.js
CHANGED
|
@@ -800,6 +800,91 @@ export function createApp({
|
|
|
800
800
|
}
|
|
801
801
|
});
|
|
802
802
|
|
|
803
|
+
app.post("/api/groups/join-link", async (req, res) => {
|
|
804
|
+
try {
|
|
805
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
806
|
+
const { link } = req.body || {};
|
|
807
|
+
if (!link) return res.status(400).json({ ok: false, error: "link_required" });
|
|
808
|
+
const runtime = hub.getRuntime(accountId);
|
|
809
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
810
|
+
const result = await runtime.joinGroupLink(link);
|
|
811
|
+
res.json({ ok: true, result });
|
|
812
|
+
} catch (err) {
|
|
813
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
app.post("/api/groups/link-info", async (req, res) => {
|
|
818
|
+
try {
|
|
819
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
820
|
+
const { link } = req.body || {};
|
|
821
|
+
if (!link) return res.status(400).json({ ok: false, error: "link_required" });
|
|
822
|
+
const runtime = hub.getRuntime(accountId);
|
|
823
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
824
|
+
const result = await runtime.getGroupLinkInfo(link);
|
|
825
|
+
res.json({ ok: true, result });
|
|
826
|
+
} catch (err) {
|
|
827
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
app.post("/api/groups/join-invite-box", async (req, res) => {
|
|
832
|
+
try {
|
|
833
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
834
|
+
const { group_id } = req.body || {};
|
|
835
|
+
if (!group_id) return res.status(400).json({ ok: false, error: "group_id_required" });
|
|
836
|
+
const runtime = hub.getRuntime(accountId);
|
|
837
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
838
|
+
const result = await runtime.joinGroupInviteBox(group_id);
|
|
839
|
+
res.json({ ok: true, result });
|
|
840
|
+
} catch (err) {
|
|
841
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
app.get("/api/stickers/search", async (req, res) => {
|
|
846
|
+
try {
|
|
847
|
+
const accountId = req.query?.account_id || config.default_account_id;
|
|
848
|
+
const keyword = String(req.query?.keyword || "").trim();
|
|
849
|
+
if (!keyword) return res.status(400).json({ ok: false, error: "keyword_required" });
|
|
850
|
+
const runtime = hub.getRuntime(accountId);
|
|
851
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
852
|
+
const result = await runtime.searchStickers(keyword);
|
|
853
|
+
res.json({ ok: true, result });
|
|
854
|
+
} catch (err) {
|
|
855
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
app.get("/api/stickers/:stickerId", async (req, res) => {
|
|
860
|
+
try {
|
|
861
|
+
const accountId = req.query?.account_id || config.default_account_id;
|
|
862
|
+
const runtime = hub.getRuntime(accountId);
|
|
863
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
864
|
+
const result = await runtime.getStickerDetail(req.params.stickerId);
|
|
865
|
+
res.json({ ok: true, result });
|
|
866
|
+
} catch (err) {
|
|
867
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
app.get("/api/groups/:groupId/history-range", async (req, res) => {
|
|
872
|
+
try {
|
|
873
|
+
const sinceHours = req.query?.since_hours ? Number(req.query.since_hours) : 24;
|
|
874
|
+
const limit = req.query?.limit ? Number(req.query.limit) : 50;
|
|
875
|
+
const cursor = req.query?.cursor || null;
|
|
876
|
+
const result = store.readHistoryRange({
|
|
877
|
+
sourceId: req.params.groupId,
|
|
878
|
+
sinceHours,
|
|
879
|
+
cursor,
|
|
880
|
+
limit,
|
|
881
|
+
});
|
|
882
|
+
res.json({ ok: true, ...result });
|
|
883
|
+
} catch (err) {
|
|
884
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
803
888
|
app.post("/api/groups/:groupId/link", async (req, res) => {
|
|
804
889
|
try {
|
|
805
890
|
const accountId = req.body?.account_id || config.default_account_id;
|
package/src/store.js
CHANGED
|
@@ -710,6 +710,51 @@ export class Store {
|
|
|
710
710
|
return this.db.prepare(sql).all(...params);
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Đọc dải lịch sử chat theo giờ (sinceHours) từ SQLite nội bộ để phục vụ
|
|
715
|
+
* tóm tắt thảo luận nhóm mà không cần gọi Zalo API.
|
|
716
|
+
*/
|
|
717
|
+
readHistoryRange({ sourceId, sinceHours = 24, cursor = null, limit = 50 } = {}) {
|
|
718
|
+
const hours = Math.min(Math.max(Number(sinceHours) || 24, 1), 168);
|
|
719
|
+
const lim = Math.min(Math.max(Number(limit) || 50, 1), 100);
|
|
720
|
+
const sinceIso = new Date(Date.now() - hours * 3600 * 1000).toISOString();
|
|
721
|
+
|
|
722
|
+
let sql = `SELECT id, source_id, source_name, sender_display_name, text_redacted, created_at
|
|
723
|
+
FROM zalo_messages
|
|
724
|
+
WHERE source_id = ? AND created_at >= ?`;
|
|
725
|
+
const params = [String(sourceId), sinceIso];
|
|
726
|
+
|
|
727
|
+
if (cursor) {
|
|
728
|
+
sql += ` AND created_at < ?`;
|
|
729
|
+
params.push(String(cursor));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
sql += ` ORDER BY created_at DESC LIMIT ?`;
|
|
733
|
+
params.push(lim + 1);
|
|
734
|
+
|
|
735
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
736
|
+
const hasMore = rows.length > lim;
|
|
737
|
+
const items = hasMore ? rows.slice(0, lim) : rows;
|
|
738
|
+
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].created_at : null;
|
|
739
|
+
|
|
740
|
+
// Đảo thứ tự để hiển thị xuôi dòng thời gian (từ cũ đến mới)
|
|
741
|
+
items.reverse();
|
|
742
|
+
const formattedLines = items.map((r) => {
|
|
743
|
+
const timeStr = r.created_at ? r.created_at.slice(0, 16).replace("T", " ") : "";
|
|
744
|
+
const sender = r.sender_display_name || "Ẩn danh";
|
|
745
|
+
const text = (r.text_redacted || "").trim();
|
|
746
|
+
return `[${timeStr}] ${sender}: ${text}`;
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
return {
|
|
750
|
+
messages: items,
|
|
751
|
+
text: formattedLines.join("\n"),
|
|
752
|
+
count: items.length,
|
|
753
|
+
nextCursor,
|
|
754
|
+
hasMore,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
713
758
|
/**
|
|
714
759
|
* Ordered, cursor-based event feed for an authenticated Hermes platform
|
|
715
760
|
* adapter. This intentionally returns only normalized/redacted fields; the
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phân loại tệp đính kèm tin nhắn Zalo: ảnh, video, âm thanh, tài liệu.
|
|
3
|
+
*
|
|
4
|
+
* Khắc phục tình trạng tệp PDF/DOCX bị tải về như ảnh khiến model báo lỗi
|
|
5
|
+
* "không đọc được ảnh", và tự động chọn định dạng JPG thay vì JXL.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MIME_BY_EXT = {
|
|
9
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
|
|
10
|
+
".webp": "image/webp", ".bmp": "image/bmp", ".heic": "image/heic", ".jxl": "image/jxl",
|
|
11
|
+
".mp4": "video/mp4", ".mov": "video/quicktime", ".mkv": "video/x-matroska", ".webm": "video/webm",
|
|
12
|
+
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac", ".ogg": "audio/ogg", ".wav": "audio/wav",
|
|
13
|
+
".pdf": "application/pdf",
|
|
14
|
+
".doc": "application/msword",
|
|
15
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
16
|
+
".xls": "application/vnd.ms-excel",
|
|
17
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
18
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
19
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
20
|
+
".csv": "text/csv", ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json",
|
|
21
|
+
".zip": "application/zip", ".rar": "application/vnd.rar", ".7z": "application/x-7z-compressed",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function extensionOf(value) {
|
|
25
|
+
const clean = String(value ?? "").split(/[?#]/)[0];
|
|
26
|
+
const match = /\.([A-Za-z0-9]{1,5})$/.exec(clean);
|
|
27
|
+
return match ? `.${match[1].toLowerCase()}` : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ưu tiên các định dạng ảnh phổ biến (JPG, PNG, WEBP), loại bỏ bản JXL
|
|
32
|
+
* nếu đã có bản định dạng đọc được khác trong cùng tin nhắn.
|
|
33
|
+
*/
|
|
34
|
+
export function preferReadableFormats(urls) {
|
|
35
|
+
const info = urls.map((url) => {
|
|
36
|
+
const m = /\/gr\/([a-z0-9]+)\/([^/]+)\//i.exec(String(url));
|
|
37
|
+
return { url, format: (m?.[1] || "").toLowerCase(), group: m?.[2] || "" };
|
|
38
|
+
});
|
|
39
|
+
const groupsWithReadable = new Set(
|
|
40
|
+
info.filter((x) => x.group && x.format && x.format !== "jxl").map((x) => x.group),
|
|
41
|
+
);
|
|
42
|
+
return info
|
|
43
|
+
.filter((x) => !(x.format === "jxl" && groupsWithReadable.has(x.group)))
|
|
44
|
+
.map((x) => x.url);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function kindOf(mime) {
|
|
48
|
+
if (mime.startsWith("image/")) return "image";
|
|
49
|
+
if (mime.startsWith("video/")) return "video";
|
|
50
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
51
|
+
return "document";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isFileMessage(msgType) {
|
|
55
|
+
return /file/i.test(String(msgType ?? ""));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function hasRealMedia(msgType) {
|
|
59
|
+
return !/(recommended|webchat|chat\.text|poll|ecard|undo|sticker|link)/i.test(String(msgType ?? ""));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function guessAttachment(url, { name = "", msgType = "" } = {}) {
|
|
63
|
+
const ext = extensionOf(name) || extensionOf(url);
|
|
64
|
+
let mime = MIME_BY_EXT[ext] || "";
|
|
65
|
+
if (!mime) {
|
|
66
|
+
mime = isFileMessage(msgType) ? "application/octet-stream" : "image/jpeg";
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
url: String(url),
|
|
70
|
+
name: String(name || "").trim() || (ext ? `file${ext}` : ""),
|
|
71
|
+
mime,
|
|
72
|
+
kind: kindOf(mime),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function classifyAttachments(msg, urls = []) {
|
|
77
|
+
const content = msg?.data?.content ?? msg?.content;
|
|
78
|
+
const msgType = msg?.data?.msgType ?? msg?.msgType ?? "";
|
|
79
|
+
const title = content && typeof content === "object" ? String(content.title ?? "") : "";
|
|
80
|
+
const href = content && typeof content === "object" ? String(content.href ?? "") : "";
|
|
81
|
+
|
|
82
|
+
if (!hasRealMedia(msgType)) return [];
|
|
83
|
+
const safeUrls = preferReadableFormats(urls);
|
|
84
|
+
|
|
85
|
+
if (isFileMessage(msgType)) {
|
|
86
|
+
const fileUrl = safeUrls.includes(href) ? href : safeUrls[0];
|
|
87
|
+
return fileUrl ? [guessAttachment(fileUrl, { name: title, msgType })] : [];
|
|
88
|
+
}
|
|
89
|
+
return safeUrls.map((url) => guessAttachment(url, { name: url === href ? title : "", msgType }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Đổi "@Tên hiển thị" trong tin bot gửi vào nhóm thành tag Zalo thật.
|
|
3
|
+
*
|
|
4
|
+
* Tên phải khớp một người trong danh bạ nhóm và không có dấu hiệu còn tiếp;
|
|
5
|
+
* hai người trùng tên thì để nguyên dạng chữ để tránh tag nhầm người.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const NAME_CHAR = /[\p{L}\p{N}_]/u;
|
|
9
|
+
const NAME_CONTINUES = /^ \p{Lu}/u;
|
|
10
|
+
|
|
11
|
+
function normalize(text) {
|
|
12
|
+
return String(text || "").toLocaleLowerCase("vi");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} msg Chữ đã qua bộ dịch Markdown (vị trí tính trên chuỗi này).
|
|
17
|
+
* @param {{uid: string, name: string}[]} members
|
|
18
|
+
* @param {{selfUid?: string, continuesInNextChunk?: boolean}} options
|
|
19
|
+
* @returns {{pos: number, len: number, uid: string}[]}
|
|
20
|
+
*/
|
|
21
|
+
export function findMentions(msg, members, { selfUid = "", continuesInNextChunk = false } = {}) {
|
|
22
|
+
const text = String(msg ?? "");
|
|
23
|
+
if (!text.includes("@") || !Array.isArray(members) || !members.length) return [];
|
|
24
|
+
|
|
25
|
+
const uidsByName = new Map();
|
|
26
|
+
for (const member of members) {
|
|
27
|
+
const uid = String(member?.uid ?? member?.userId ?? member?.id ?? "");
|
|
28
|
+
const name = String(member?.name ?? member?.displayName ?? member?.dName ?? "").trim();
|
|
29
|
+
if (!uid || !name || uid === String(selfUid)) continue;
|
|
30
|
+
const key = normalize(name);
|
|
31
|
+
const entry = uidsByName.get(key) ?? { length: name.length, uids: new Set() };
|
|
32
|
+
entry.uids.add(uid);
|
|
33
|
+
uidsByName.set(key, entry);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Khớp tên dài trước: "@Lương Hải Anh Cnt" không bị khớp nhầm thành "@Lương"
|
|
37
|
+
const names = [...uidsByName.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
38
|
+
|
|
39
|
+
const mentions = [];
|
|
40
|
+
for (let at = text.indexOf("@"); at !== -1; at = text.indexOf("@", at + 1)) {
|
|
41
|
+
if (at > 0 && NAME_CHAR.test(text[at - 1])) continue; // a@b trong email
|
|
42
|
+
const match = names.find(([key, { length }]) => {
|
|
43
|
+
const after = text.slice(at + 1 + length);
|
|
44
|
+
return (
|
|
45
|
+
normalize(text.slice(at + 1, at + 1 + length)) === key &&
|
|
46
|
+
!NAME_CHAR.test(after[0] ?? "") &&
|
|
47
|
+
!NAME_CONTINUES.test(after) &&
|
|
48
|
+
!(continuesInNextChunk && after.trim() === "")
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
if (!match) continue;
|
|
52
|
+
const [, { length, uids }] = match;
|
|
53
|
+
if (uids.size !== 1) continue;
|
|
54
|
+
mentions.push({ pos: at, len: length + 1, uid: [...uids][0] });
|
|
55
|
+
at += length;
|
|
56
|
+
}
|
|
57
|
+
return mentions;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Danh bạ thành viên từng nhóm, cache tạm để tối ưu tốc độ gửi tin.
|
|
62
|
+
*/
|
|
63
|
+
export function createMemberDirectory({ fetchMembers, ttlMs = 10 * 60 * 1000, now = Date.now } = {}) {
|
|
64
|
+
const cache = new Map();
|
|
65
|
+
return {
|
|
66
|
+
async get(groupId) {
|
|
67
|
+
const key = String(groupId);
|
|
68
|
+
const hit = cache.get(key);
|
|
69
|
+
if (hit && now() - hit.at < ttlMs) return hit.members;
|
|
70
|
+
try {
|
|
71
|
+
const res = typeof fetchMembers === "function" ? await fetchMembers(key) : [];
|
|
72
|
+
const members = Array.isArray(res) ? res : Array.isArray(res?.members) ? res.members : [];
|
|
73
|
+
cache.set(key, { at: now(), members });
|
|
74
|
+
return members;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return hit?.members ?? [];
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
clear() {
|
|
80
|
+
cache.clear();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/zalo_runtime.js
CHANGED
|
@@ -5,6 +5,9 @@ import { EventEmitter } from "node:events";
|
|
|
5
5
|
import { normalizeInboundMessage, utcNow } from "./schema.js";
|
|
6
6
|
import { stageHermesMedia } from "./hermes_media.js";
|
|
7
7
|
import { buildZaloStyledMessage, splitIntoSafeZaloChunks } from "./zalo_styler.js";
|
|
8
|
+
import { findMentions } from "./zalo_mentions.js";
|
|
9
|
+
import { enrichSticker, stickerRefOf } from "./zalo_stickers.js";
|
|
10
|
+
import { classifyAttachments } from "./zalo_attachments.js";
|
|
8
11
|
|
|
9
12
|
const PERSONAL_ACTIONS = new Set([
|
|
10
13
|
"send_message", "send_sticker", "send_voice", "send_video", "forward_message", "typing",
|
|
@@ -12,7 +15,8 @@ const PERSONAL_ACTIONS = new Set([
|
|
|
12
15
|
"create_note", "change_avatar", "block_member", "unblock_member", "review_pending",
|
|
13
16
|
"group_link_enable", "group_link_disable", "send_card", "send_bank_card",
|
|
14
17
|
"friend_accept", "friend_reject", "friend_request", "friend_request_undo", "friend_remove",
|
|
15
|
-
"user_block", "user_unblock"
|
|
18
|
+
"user_block", "user_unblock",
|
|
19
|
+
"join_group_link", "join_group_invite_box", "get_group_link_info", "get_sticker_detail", "search_stickers"
|
|
16
20
|
]);
|
|
17
21
|
|
|
18
22
|
function required(value, name) {
|
|
@@ -475,6 +479,31 @@ export class AccountRuntime extends EventEmitter {
|
|
|
475
479
|
return this.api.updateGroupSettings(settings, String(groupId));
|
|
476
480
|
}
|
|
477
481
|
|
|
482
|
+
async joinGroupLink(link) {
|
|
483
|
+
if (!this.api?.joinGroupLink) throw new Error("not_connected");
|
|
484
|
+
return this.api.joinGroupLink(String(link));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async getGroupLinkInfo(link) {
|
|
488
|
+
if (!this.api?.getGroupLinkInfo) throw new Error("not_connected");
|
|
489
|
+
return this.api.getGroupLinkInfo({ link: String(link) });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async joinGroupInviteBox(groupId) {
|
|
493
|
+
if (!this.api?.joinGroupInviteBox) throw new Error("not_connected");
|
|
494
|
+
return this.api.joinGroupInviteBox(String(groupId));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async getStickerDetail(stickerId) {
|
|
498
|
+
if (!this.api?.getStickersDetail) throw new Error("not_connected");
|
|
499
|
+
return this.api.getStickersDetail(Number(stickerId));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async searchStickers(keyword) {
|
|
503
|
+
if (!this.api?.getStickers) throw new Error("not_connected");
|
|
504
|
+
return this.api.getStickers(String(keyword));
|
|
505
|
+
}
|
|
506
|
+
|
|
478
507
|
async performPersonalAction(action, payload = {}) {
|
|
479
508
|
const name = String(action || "").trim();
|
|
480
509
|
if (!PERSONAL_ACTIONS.has(name)) throw new Error("unsupported_personal_action");
|
|
@@ -485,6 +514,16 @@ export class AccountRuntime extends EventEmitter {
|
|
|
485
514
|
case "send_message": {
|
|
486
515
|
if (typeof api.sendMessage !== "function") break;
|
|
487
516
|
let textContent = String(payload.text || "");
|
|
517
|
+
if (textContent.includes("[[NEW_MESSAGE]]")) {
|
|
518
|
+
const parts = textContent.split(/\[\[NEW_MESSAGE\]\]/g).map((p) => p.trim()).filter(Boolean);
|
|
519
|
+
if (parts.length > 1) {
|
|
520
|
+
let lastRes = null;
|
|
521
|
+
for (const part of parts) {
|
|
522
|
+
lastRes = await this.performPersonalAction("send_message", { ...payload, text: part });
|
|
523
|
+
}
|
|
524
|
+
return lastRes;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
488
527
|
let styles = Array.isArray(payload.styles) ? payload.styles : undefined;
|
|
489
528
|
if (!styles && (payload.styled || payload.parse_markdown || /[*_~#\[]/.test(textContent))) {
|
|
490
529
|
const parsed = buildZaloStyledMessage(textContent);
|
|
@@ -494,7 +533,20 @@ export class AccountRuntime extends EventEmitter {
|
|
|
494
533
|
const message = { msg: textContent.slice(0, 4000) };
|
|
495
534
|
if (!message.msg && !payload.attachment_path) throw new Error("text_or_attachment_required");
|
|
496
535
|
if (payload.quote) message.quote = payload.quote;
|
|
497
|
-
if (Array.isArray(payload.mentions))
|
|
536
|
+
if (Array.isArray(payload.mentions)) {
|
|
537
|
+
message.mentions = payload.mentions.slice(0, 50);
|
|
538
|
+
} else if (threadType(payload.thread_type) === 1 && textContent.includes("@") && typeof api.getGroupInfo === "function") {
|
|
539
|
+
try {
|
|
540
|
+
const groupInfo = await api.getGroupInfo(target());
|
|
541
|
+
const mems = groupInfo?.members || groupInfo?.memList || [];
|
|
542
|
+
if (Array.isArray(mems) && mems.length) {
|
|
543
|
+
const detected = findMentions(textContent, mems);
|
|
544
|
+
if (detected.length) message.mentions = detected;
|
|
545
|
+
}
|
|
546
|
+
} catch {
|
|
547
|
+
/* ignore mention fetch failure */
|
|
548
|
+
}
|
|
549
|
+
}
|
|
498
550
|
if (payload.attachment_path) message.attachments = controlledAttachment(payload.attachment_path);
|
|
499
551
|
if (styles) message.styles = styles;
|
|
500
552
|
// Plaintext fallback: if Zalo rejects a styled message with a numeric error
|
|
@@ -576,6 +628,11 @@ export class AccountRuntime extends EventEmitter {
|
|
|
576
628
|
case "friend_remove": if (typeof api.removeFriend === "function") return api.removeFriend(required(payload.user_id, "user_id")); break;
|
|
577
629
|
case "user_block": if (typeof api.blockUser === "function") return api.blockUser(required(payload.user_id, "user_id")); break;
|
|
578
630
|
case "user_unblock": if (typeof api.unblockUser === "function") return api.unblockUser(required(payload.user_id, "user_id")); break;
|
|
631
|
+
case "join_group_link": if (typeof api.joinGroupLink === "function") return api.joinGroupLink(required(payload.link, "link")); break;
|
|
632
|
+
case "join_group_invite_box": if (typeof api.joinGroupInviteBox === "function") return api.joinGroupInviteBox(required(payload.group_id, "group_id")); break;
|
|
633
|
+
case "get_group_link_info": if (typeof api.getGroupLinkInfo === "function") return api.getGroupLinkInfo({ link: required(payload.link, "link") }); break;
|
|
634
|
+
case "get_sticker_detail": if (typeof api.getStickersDetail === "function") return api.getStickersDetail(Number(required(payload.sticker_id, "sticker_id"))); break;
|
|
635
|
+
case "search_stickers": if (typeof api.getStickers === "function") return api.getStickers(required(payload.keyword, "keyword")); break;
|
|
579
636
|
default: break;
|
|
580
637
|
}
|
|
581
638
|
throw new Error(`provider_action_unavailable:${name}`);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Đọc và giải mã nhãn dán (sticker) của Zalo.
|
|
3
|
+
*
|
|
4
|
+
* Tin sticker Zalo tới dưới dạng `chat.sticker` với nội dung `{id, catId, type}`.
|
|
5
|
+
* Module này tra cứu chi tiết sticker (`getStickersDetail`) để lấy nhãn chữ và ảnh tĩnh PNG,
|
|
6
|
+
* giúp các tầng sau (lưu lịch sử, AI vision, tóm tắt) đọc được như tin bình thường.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const LOOKUP_TIMEOUT_MS = 4000;
|
|
10
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const CACHE_LIMIT = 500;
|
|
12
|
+
|
|
13
|
+
export function isStickerMessage(msgType) {
|
|
14
|
+
return /sticker/i.test(String(msgType ?? ""));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Lấy `{id, cateId, type}` của sticker trong một tin, hoặc null. */
|
|
18
|
+
export function stickerRefOf(msg) {
|
|
19
|
+
const msgType = msg?.data?.msgType ?? msg?.msgType;
|
|
20
|
+
if (!isStickerMessage(msgType)) return null;
|
|
21
|
+
const content = msg?.data?.content ?? msg?.content;
|
|
22
|
+
if (!content || typeof content !== "object") return null;
|
|
23
|
+
const id = Number(content.id);
|
|
24
|
+
if (!Number.isFinite(id) || id <= 0) return null;
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
cateId: Number(content.catId ?? content.cateId) || 0,
|
|
28
|
+
type: Number(content.type) || 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Zalo sticker text thường chứa mã nội bộ như [^10751.27703^], cần loại bỏ mã này
|
|
33
|
+
const CODE_LABEL_RE = /^\[\^[\d.]+\^\]$/;
|
|
34
|
+
|
|
35
|
+
export function stickerText(detail) {
|
|
36
|
+
const label = String(detail?.text ?? "").trim();
|
|
37
|
+
return label && !CODE_LABEL_RE.test(label) ? `[Nhãn dán: ${label}]` : "[Nhãn dán]";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function stickerAttachment(detail) {
|
|
41
|
+
const url = String(detail?.stickerUrl ?? "").trim();
|
|
42
|
+
if (!url) return null;
|
|
43
|
+
return {
|
|
44
|
+
url,
|
|
45
|
+
name: `sticker-${detail?.id ?? ""}.png`.replace("-.png", ".png"),
|
|
46
|
+
mime: "image/png",
|
|
47
|
+
kind: "image",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createStickerDirectory({
|
|
52
|
+
fetchDetail,
|
|
53
|
+
ttlMs = CACHE_TTL_MS,
|
|
54
|
+
limit = CACHE_LIMIT,
|
|
55
|
+
timeoutMs = LOOKUP_TIMEOUT_MS,
|
|
56
|
+
now = Date.now,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const cache = new Map();
|
|
59
|
+
return {
|
|
60
|
+
async get(id) {
|
|
61
|
+
const key = String(id);
|
|
62
|
+
const hit = cache.get(key);
|
|
63
|
+
if (hit && now() - hit.at < ttlMs) return hit.detail;
|
|
64
|
+
let detail = null;
|
|
65
|
+
let timer = null;
|
|
66
|
+
try {
|
|
67
|
+
detail = await Promise.race([
|
|
68
|
+
Promise.resolve(typeof fetchDetail === "function" ? fetchDetail(Number(id)) : null),
|
|
69
|
+
new Promise((_, reject) => {
|
|
70
|
+
timer = setTimeout(() => reject(new Error("sticker_lookup_timeout")), timeoutMs);
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
} catch {
|
|
74
|
+
return hit?.detail ?? null;
|
|
75
|
+
} finally {
|
|
76
|
+
if (timer) clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
const first = Array.isArray(detail) ? detail[0] : detail;
|
|
79
|
+
if (!first || typeof first !== "object") return hit?.detail ?? null;
|
|
80
|
+
if (cache.size >= limit) cache.delete(cache.keys().next().value);
|
|
81
|
+
cache.set(key, { at: now(), detail: first });
|
|
82
|
+
return first;
|
|
83
|
+
},
|
|
84
|
+
clear() {
|
|
85
|
+
cache.clear();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function enrichSticker(msg, directory) {
|
|
91
|
+
const ref = stickerRefOf(msg);
|
|
92
|
+
if (!ref) return null;
|
|
93
|
+
const detail = directory ? await directory.get(ref.id) : null;
|
|
94
|
+
const info = {
|
|
95
|
+
id: ref.id,
|
|
96
|
+
text: stickerText(detail),
|
|
97
|
+
attachment: detail ? stickerAttachment(detail) : null,
|
|
98
|
+
};
|
|
99
|
+
if (msg?.data) msg.data.__sticker = info;
|
|
100
|
+
return info;
|
|
101
|
+
}
|