From 19fafed11d9c2bcf101d7bbf0312f819a9344f23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 22:16:37 +0000 Subject: [PATCH] refactor(zalouser): extract policy and message helpers --- extensions/zalouser/src/channel.ts | 71 ++---- extensions/zalouser/src/group-policy.test.ts | 49 ++++ extensions/zalouser/src/group-policy.ts | 78 ++++++ extensions/zalouser/src/message-sid.test.ts | 66 +++++ extensions/zalouser/src/message-sid.ts | 80 ++++++ extensions/zalouser/src/monitor.ts | 85 +++---- extensions/zalouser/src/reaction.test.ts | 19 ++ extensions/zalouser/src/reaction.ts | 29 +++ extensions/zalouser/src/types.ts | 4 +- extensions/zalouser/src/zalo-js.ts | 80 +++--- extensions/zalouser/src/zca-client.ts | 249 +++++++++++++++++++ extensions/zalouser/src/zca-js-exports.d.ts | 221 +--------------- 12 files changed, 678 insertions(+), 353 deletions(-) create mode 100644 extensions/zalouser/src/group-policy.test.ts create mode 100644 extensions/zalouser/src/group-policy.ts create mode 100644 extensions/zalouser/src/message-sid.test.ts create mode 100644 extensions/zalouser/src/message-sid.ts create mode 100644 extensions/zalouser/src/reaction.test.ts create mode 100644 extensions/zalouser/src/reaction.ts create mode 100644 extensions/zalouser/src/zca-client.ts diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 93b7449e1a5..2c1770b6ebd 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -33,6 +33,8 @@ import { type ResolvedZalouserAccount, } from "./accounts.js"; import { ZalouserConfigSchema } from "./config-schema.js"; +import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; +import { resolveZalouserReactionMessageIds } from "./message-sid.js"; import { zalouserOnboardingAdapter } from "./onboarding.js"; import { probeZalouser } from "./probe.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; @@ -122,18 +124,15 @@ function resolveZalouserGroupToolPolicy( accountId: params.accountId ?? undefined, }); const groups = account.config.groups ?? {}; - const groupId = params.groupId?.trim(); - const groupChannel = params.groupChannel?.trim(); - const candidates = [groupId, groupChannel, "*"].filter((value): value is string => - Boolean(value), + const entry = findZalouserGroupEntry( + groups, + buildZalouserGroupCandidates({ + groupId: params.groupId, + groupChannel: params.groupChannel, + includeWildcard: true, + }), ); - for (const key of candidates) { - const entry = groups[key]; - if (entry?.tools) { - return entry.tools; - } - } - return undefined; + return entry?.tools; } function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { @@ -142,52 +141,20 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { accountId: params.accountId ?? undefined, }); const groups = account.config.groups ?? {}; - const candidates = [params.groupId?.trim(), params.groupChannel?.trim()].filter( - (value): value is string => Boolean(value), + const entry = findZalouserGroupEntry( + groups, + buildZalouserGroupCandidates({ + groupId: params.groupId, + groupChannel: params.groupChannel, + includeWildcard: true, + }), ); - for (const candidate of candidates) { - const entry = groups[candidate]; - if (typeof entry?.requireMention === "boolean") { - return entry.requireMention; - } - } - if (typeof groups["*"]?.requireMention === "boolean") { - return groups["*"].requireMention; + if (typeof entry?.requireMention === "boolean") { + return entry.requireMention; } return true; } -function resolveZalouserReactionMessageIds(params: { - messageId?: string; - cliMsgId?: string; - currentMessageId?: string | number; -}): { msgId: string; cliMsgId: string } | null { - const explicitMessageId = params.messageId?.trim() ?? ""; - const explicitCliMsgId = params.cliMsgId?.trim() ?? ""; - if (explicitMessageId && explicitCliMsgId) { - return { msgId: explicitMessageId, cliMsgId: explicitCliMsgId }; - } - - const current = - typeof params.currentMessageId === "number" ? String(params.currentMessageId) : ""; - const currentRaw = - typeof params.currentMessageId === "string" ? params.currentMessageId.trim() : current; - if (!currentRaw) { - return null; - } - const [msgIdPart, cliMsgIdPart] = currentRaw.split(":").map((value) => value.trim()); - if (msgIdPart && cliMsgIdPart) { - return { msgId: msgIdPart, cliMsgId: cliMsgIdPart }; - } - if (explicitMessageId && !explicitCliMsgId) { - return { msgId: explicitMessageId, cliMsgId: currentRaw }; - } - if (!explicitMessageId && explicitCliMsgId) { - return { msgId: currentRaw, cliMsgId: explicitCliMsgId }; - } - return { msgId: currentRaw, cliMsgId: currentRaw }; -} - const zalouserMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { const accounts = listZalouserAccountIds(cfg) diff --git a/extensions/zalouser/src/group-policy.test.ts b/extensions/zalouser/src/group-policy.test.ts new file mode 100644 index 00000000000..0ab0e01d763 --- /dev/null +++ b/extensions/zalouser/src/group-policy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + buildZalouserGroupCandidates, + findZalouserGroupEntry, + isZalouserGroupEntryAllowed, + normalizeZalouserGroupSlug, +} from "./group-policy.js"; + +describe("zalouser group policy helpers", () => { + it("normalizes group slug names", () => { + expect(normalizeZalouserGroupSlug(" Team Alpha ")).toBe("team-alpha"); + expect(normalizeZalouserGroupSlug("#Roadmap Updates")).toBe("roadmap-updates"); + }); + + it("builds ordered candidates with optional aliases", () => { + expect( + buildZalouserGroupCandidates({ + groupId: "123", + groupChannel: "chan-1", + groupName: "Team Alpha", + includeGroupIdAlias: true, + }), + ).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]); + }); + + it("finds the first matching group entry", () => { + const groups = { + "group:123": { allow: true }, + "team-alpha": { requireMention: false }, + "*": { requireMention: true }, + }; + const entry = findZalouserGroupEntry( + groups, + buildZalouserGroupCandidates({ + groupId: "123", + groupName: "Team Alpha", + includeGroupIdAlias: true, + }), + ); + expect(entry).toEqual({ allow: true }); + }); + + it("evaluates allow/enable flags", () => { + expect(isZalouserGroupEntryAllowed({ allow: true, enabled: true })).toBe(true); + expect(isZalouserGroupEntryAllowed({ allow: false })).toBe(false); + expect(isZalouserGroupEntryAllowed({ enabled: false })).toBe(false); + expect(isZalouserGroupEntryAllowed(undefined)).toBe(false); + }); +}); diff --git a/extensions/zalouser/src/group-policy.ts b/extensions/zalouser/src/group-policy.ts new file mode 100644 index 00000000000..1b6ca8e200e --- /dev/null +++ b/extensions/zalouser/src/group-policy.ts @@ -0,0 +1,78 @@ +import type { ZalouserGroupConfig } from "./types.js"; + +type ZalouserGroups = Record; + +function toGroupCandidate(value?: string | null): string { + return value?.trim() ?? ""; +} + +export function normalizeZalouserGroupSlug(raw?: string | null): string { + const trimmed = raw?.trim().toLowerCase() ?? ""; + if (!trimmed) { + return ""; + } + return trimmed + .replace(/^#/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function buildZalouserGroupCandidates(params: { + groupId?: string | null; + groupChannel?: string | null; + groupName?: string | null; + includeGroupIdAlias?: boolean; + includeWildcard?: boolean; +}): string[] { + const seen = new Set(); + const out: string[] = []; + const push = (value?: string | null) => { + const normalized = toGroupCandidate(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + out.push(normalized); + }; + + const groupId = toGroupCandidate(params.groupId); + const groupChannel = toGroupCandidate(params.groupChannel); + const groupName = toGroupCandidate(params.groupName); + + push(groupId); + if (params.includeGroupIdAlias === true && groupId) { + push(`group:${groupId}`); + } + push(groupChannel); + push(groupName); + if (groupName) { + push(normalizeZalouserGroupSlug(groupName)); + } + if (params.includeWildcard !== false) { + push("*"); + } + return out; +} + +export function findZalouserGroupEntry( + groups: ZalouserGroups | undefined, + candidates: string[], +): ZalouserGroupConfig | undefined { + if (!groups) { + return undefined; + } + for (const candidate of candidates) { + const entry = groups[candidate]; + if (entry) { + return entry; + } + } + return undefined; +} + +export function isZalouserGroupEntryAllowed(entry: ZalouserGroupConfig | undefined): boolean { + if (!entry) { + return false; + } + return entry.allow !== false && entry.enabled !== false; +} diff --git a/extensions/zalouser/src/message-sid.test.ts b/extensions/zalouser/src/message-sid.test.ts new file mode 100644 index 00000000000..f964b0a791a --- /dev/null +++ b/extensions/zalouser/src/message-sid.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + formatZalouserMessageSidFull, + parseZalouserMessageSidFull, + resolveZalouserMessageSid, + resolveZalouserReactionMessageIds, +} from "./message-sid.js"; + +describe("zalouser message sid helpers", () => { + it("parses MessageSidFull pairs", () => { + expect(parseZalouserMessageSidFull("111:222")).toEqual({ + msgId: "111", + cliMsgId: "222", + }); + expect(parseZalouserMessageSidFull("111")).toBeNull(); + expect(parseZalouserMessageSidFull(undefined)).toBeNull(); + }); + + it("resolves reaction ids from explicit params first", () => { + expect( + resolveZalouserReactionMessageIds({ + messageId: "m-1", + cliMsgId: "c-1", + currentMessageId: "x:y", + }), + ).toEqual({ + msgId: "m-1", + cliMsgId: "c-1", + }); + }); + + it("resolves reaction ids from current message sid full", () => { + expect( + resolveZalouserReactionMessageIds({ + currentMessageId: "m-2:c-2", + }), + ).toEqual({ + msgId: "m-2", + cliMsgId: "c-2", + }); + }); + + it("falls back to duplicated current id when no pair is available", () => { + expect( + resolveZalouserReactionMessageIds({ + currentMessageId: "solo", + }), + ).toEqual({ + msgId: "solo", + cliMsgId: "solo", + }); + }); + + it("formats message sid fields for context payload", () => { + expect(formatZalouserMessageSidFull({ msgId: "1", cliMsgId: "2" })).toBe("1:2"); + expect(formatZalouserMessageSidFull({ msgId: "1" })).toBe("1"); + expect(formatZalouserMessageSidFull({ cliMsgId: "2" })).toBe("2"); + expect(formatZalouserMessageSidFull({})).toBeUndefined(); + }); + + it("resolves primary message sid with fallback timestamp", () => { + expect(resolveZalouserMessageSid({ msgId: "1", cliMsgId: "2", fallback: "t" })).toBe("1"); + expect(resolveZalouserMessageSid({ cliMsgId: "2", fallback: "t" })).toBe("2"); + expect(resolveZalouserMessageSid({ fallback: "t" })).toBe("t"); + }); +}); diff --git a/extensions/zalouser/src/message-sid.ts b/extensions/zalouser/src/message-sid.ts new file mode 100644 index 00000000000..f68f131177d --- /dev/null +++ b/extensions/zalouser/src/message-sid.ts @@ -0,0 +1,80 @@ +function toMessageSidPart(value?: string | number | null): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(Math.trunc(value)); + } + return ""; +} + +export function parseZalouserMessageSidFull( + value?: string | number | null, +): { msgId: string; cliMsgId: string } | null { + const raw = toMessageSidPart(value); + if (!raw) { + return null; + } + const [msgIdPart, cliMsgIdPart] = raw.split(":").map((entry) => entry.trim()); + if (!msgIdPart || !cliMsgIdPart) { + return null; + } + return { msgId: msgIdPart, cliMsgId: cliMsgIdPart }; +} + +export function resolveZalouserReactionMessageIds(params: { + messageId?: string; + cliMsgId?: string; + currentMessageId?: string | number; +}): { msgId: string; cliMsgId: string } | null { + const explicitMessageId = toMessageSidPart(params.messageId); + const explicitCliMsgId = toMessageSidPart(params.cliMsgId); + if (explicitMessageId && explicitCliMsgId) { + return { msgId: explicitMessageId, cliMsgId: explicitCliMsgId }; + } + + const parsedFromCurrent = parseZalouserMessageSidFull(params.currentMessageId); + if (parsedFromCurrent) { + return parsedFromCurrent; + } + + const currentRaw = toMessageSidPart(params.currentMessageId); + if (!currentRaw) { + return null; + } + if (explicitMessageId && !explicitCliMsgId) { + return { msgId: explicitMessageId, cliMsgId: currentRaw }; + } + if (!explicitMessageId && explicitCliMsgId) { + return { msgId: currentRaw, cliMsgId: explicitCliMsgId }; + } + return { msgId: currentRaw, cliMsgId: currentRaw }; +} + +export function formatZalouserMessageSidFull(params: { + msgId?: string | null; + cliMsgId?: string | null; +}): string | undefined { + const msgId = toMessageSidPart(params.msgId); + const cliMsgId = toMessageSidPart(params.cliMsgId); + if (!msgId && !cliMsgId) { + return undefined; + } + if (msgId && cliMsgId) { + return `${msgId}:${cliMsgId}`; + } + return msgId || cliMsgId || undefined; +} + +export function resolveZalouserMessageSid(params: { + msgId?: string | null; + cliMsgId?: string | null; + fallback?: string | null; +}): string | undefined { + const msgId = toMessageSidPart(params.msgId); + const cliMsgId = toMessageSidPart(params.cliMsgId); + if (msgId || cliMsgId) { + return msgId || cliMsgId; + } + return toMessageSidPart(params.fallback) || undefined; +} diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index d0a9b099f9c..c6cb79a9d9f 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -18,6 +18,12 @@ import { summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; +import { + buildZalouserGroupCandidates, + findZalouserGroupEntry, + isZalouserGroupEntryAllowed, +} from "./group-policy.js"; +import { formatZalouserMessageSidFull, resolveZalouserMessageSid } from "./message-sid.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendDeliveredZalouser, @@ -87,17 +93,6 @@ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boo }); } -function normalizeGroupSlug(raw?: string | null): string { - const trimmed = raw?.trim().toLowerCase() ?? ""; - if (!trimmed) { - return ""; - } - return trimmed - .replace(/^#/, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - function isGroupAllowed(params: { groupId: string; groupName?: string | null; @@ -108,24 +103,16 @@ function isGroupAllowed(params: { if (keys.length === 0) { return false; } - const candidates = [ - params.groupId, - `group:${params.groupId}`, - params.groupName ?? "", - normalizeGroupSlug(params.groupName ?? ""), - ].filter(Boolean); - for (const candidate of candidates) { - const entry = groups[candidate]; - if (!entry) { - continue; - } - return entry.allow !== false && entry.enabled !== false; - } - const wildcard = groups["*"]; - if (wildcard) { - return wildcard.allow !== false && wildcard.enabled !== false; - } - return false; + const entry = findZalouserGroupEntry( + groups, + buildZalouserGroupCandidates({ + groupId: params.groupId, + groupName: params.groupName, + includeGroupIdAlias: true, + includeWildcard: true, + }), + ); + return isZalouserGroupEntryAllowed(entry); } function resolveGroupRequireMention(params: { @@ -133,21 +120,17 @@ function resolveGroupRequireMention(params: { groupName?: string | null; groups: Record; }): boolean { - const groups = params.groups ?? {}; - const candidates = [ - params.groupId, - `group:${params.groupId}`, - params.groupName ?? "", - normalizeGroupSlug(params.groupName ?? ""), - ].filter(Boolean); - for (const candidate of candidates) { - const entry = groups[candidate]; - if (typeof entry?.requireMention === "boolean") { - return entry.requireMention; - } - } - if (typeof groups["*"]?.requireMention === "boolean") { - return groups["*"].requireMention; + const entry = findZalouserGroupEntry( + params.groups ?? {}, + buildZalouserGroupCandidates({ + groupId: params.groupId, + groupName: params.groupName, + includeGroupIdAlias: true, + includeWildcard: true, + }), + ); + if (typeof entry?.requireMention === "boolean") { + return entry.requireMention; } return true; } @@ -419,11 +402,15 @@ async function processMessage( CommandAuthorized: commandAuthorized, Provider: "zalouser", Surface: "zalouser", - MessageSid: message.msgId ?? message.cliMsgId ?? `${message.timestampMs}`, - MessageSidFull: - message.msgId && message.cliMsgId - ? `${message.msgId}:${message.cliMsgId}` - : (message.msgId ?? message.cliMsgId ?? undefined), + MessageSid: resolveZalouserMessageSid({ + msgId: message.msgId, + cliMsgId: message.cliMsgId, + fallback: `${message.timestampMs}`, + }), + MessageSidFull: formatZalouserMessageSidFull({ + msgId: message.msgId, + cliMsgId: message.cliMsgId, + }), OriginatingChannel: "zalouser", OriginatingTo: `zalouser:${chatId}`, }); diff --git a/extensions/zalouser/src/reaction.test.ts b/extensions/zalouser/src/reaction.test.ts new file mode 100644 index 00000000000..1804752f7a6 --- /dev/null +++ b/extensions/zalouser/src/reaction.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { normalizeZaloReactionIcon } from "./reaction.js"; + +describe("zalouser reaction alias normalization", () => { + it("maps common aliases", () => { + expect(normalizeZaloReactionIcon("like")).toBe("/-strong"); + expect(normalizeZaloReactionIcon("👍")).toBe("/-strong"); + expect(normalizeZaloReactionIcon("heart")).toBe("/-heart"); + expect(normalizeZaloReactionIcon("😂")).toBe(":>"); + }); + + it("defaults empty icon to like", () => { + expect(normalizeZaloReactionIcon("")).toBe("/-strong"); + }); + + it("passes through unknown custom reactions", () => { + expect(normalizeZaloReactionIcon("/custom")).toBe("/custom"); + }); +}); diff --git a/extensions/zalouser/src/reaction.ts b/extensions/zalouser/src/reaction.ts new file mode 100644 index 00000000000..0579df86ce5 --- /dev/null +++ b/extensions/zalouser/src/reaction.ts @@ -0,0 +1,29 @@ +import { Reactions } from "./zca-client.js"; + +const REACTION_ALIAS_MAP = new Map([ + ["like", Reactions.LIKE], + ["👍", Reactions.LIKE], + [":+1:", Reactions.LIKE], + ["heart", Reactions.HEART], + ["❤️", Reactions.HEART], + ["<3", Reactions.HEART], + ["haha", Reactions.HAHA], + ["laugh", Reactions.HAHA], + ["😂", Reactions.HAHA], + ["wow", Reactions.WOW], + ["😮", Reactions.WOW], + ["cry", Reactions.CRY], + ["😢", Reactions.CRY], + ["angry", Reactions.ANGRY], + ["😡", Reactions.ANGRY], +]); + +export function normalizeZaloReactionIcon(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return Reactions.LIKE; + } + return ( + REACTION_ALIAS_MAP.get(trimmed.toLowerCase()) ?? REACTION_ALIAS_MAP.get(trimmed) ?? trimmed + ); +} diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index 960978fd8ba..aae9e43f6fa 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -77,9 +77,9 @@ export type ZaloAuthStatus = { message: string; }; -type ZalouserToolConfig = { allow?: string[]; deny?: string[] }; +export type ZalouserToolConfig = { allow?: string[]; deny?: string[] }; -type ZalouserGroupConfig = { +export type ZalouserGroupConfig = { allow?: boolean; enabled?: boolean; requireMention?: boolean; diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 6b421d68512..414a5f80207 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -4,18 +4,7 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk"; -import { - LoginQRCallbackEventType, - Reactions, - ThreadType, - Zalo, - type API, - type Credentials, - type GroupInfo, - type LoginQRCallbackEvent, - type Message, - type User, -} from "zca-js"; +import { normalizeZaloReactionIcon } from "./reaction.js"; import { getZalouserRuntime } from "./runtime.js"; import type { ZaloAuthStatus, @@ -29,6 +18,17 @@ import type { ZcaFriend, ZcaUserInfo, } from "./types.js"; +import { + LoginQRCallbackEventType, + ThreadType, + Zalo, + type API, + type Credentials, + type GroupInfo, + type LoginQRCallbackEvent, + type Message, + type User, +} from "./zca-client.js"; const API_LOGIN_TIMEOUT_MS = 20_000; const QR_LOGIN_TTL_MS = 3 * 60_000; @@ -36,6 +36,7 @@ const DEFAULT_QR_START_TIMEOUT_MS = 30_000; const DEFAULT_QR_WAIT_TIMEOUT_MS = 120_000; const GROUP_INFO_CHUNK_SIZE = 80; const GROUP_CONTEXT_CACHE_TTL_MS = 5 * 60_000; +const GROUP_CONTEXT_CACHE_MAX_ENTRIES = 500; const apiByProfile = new Map(); const apiInitByProfile = new Map>(); @@ -241,33 +242,6 @@ function buildEventMessage(data: Record): ZaloEventMessage | un }; } -function normalizeReactionIcon(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return Reactions.LIKE; - } - const lower = trimmed.toLowerCase(); - if (lower === "like" || trimmed === "👍" || trimmed === ":+1:") { - return Reactions.LIKE; - } - if (lower === "heart" || trimmed === "❤️" || trimmed === "<3") { - return Reactions.HEART; - } - if (lower === "haha" || lower === "laugh" || trimmed === "😂") { - return Reactions.HAHA; - } - if (lower === "wow" || trimmed === "😮") { - return Reactions.WOW; - } - if (lower === "cry" || trimmed === "😢") { - return Reactions.CRY; - } - if (lower === "angry" || trimmed === "😡") { - return Reactions.ANGRY; - } - return trimmed; -} - function extractSendMessageId(result: unknown): string | undefined { if (!result || typeof result !== "object") { return undefined; @@ -539,15 +513,39 @@ function readCachedGroupContext(profile: string, groupId: string): ZaloGroupCont groupContextCache.delete(key); return null; } + // Bump recency so hot groups stay in cache when enforcing max entries. + groupContextCache.delete(key); + groupContextCache.set(key, cached); return cached.value; } +function trimGroupContextCache(now: number): void { + for (const [key, value] of groupContextCache) { + if (value.expiresAt > now) { + continue; + } + groupContextCache.delete(key); + } + while (groupContextCache.size > GROUP_CONTEXT_CACHE_MAX_ENTRIES) { + const oldestKey = groupContextCache.keys().next().value; + if (!oldestKey) { + break; + } + groupContextCache.delete(oldestKey); + } +} + function writeCachedGroupContext(profile: string, context: ZaloGroupContext): void { + const now = Date.now(); const key = makeGroupContextCacheKey(profile, context.groupId); + if (groupContextCache.has(key)) { + groupContextCache.delete(key); + } groupContextCache.set(key, { value: context, - expiresAt: Date.now() + GROUP_CONTEXT_CACHE_TTL_MS, + expiresAt: now + GROUP_CONTEXT_CACHE_TTL_MS, }); + trimGroupContextCache(now); } function clearCachedGroupContext(profile: string): void { @@ -919,7 +917,7 @@ export async function sendZaloReaction(params: { const type = params.isGroup ? ThreadType.Group : ThreadType.User; const icon = params.remove ? { rType: -1, source: 6, icon: "" } - : normalizeReactionIcon(params.emoji); + : normalizeZaloReactionIcon(params.emoji); await api.addReaction(icon, { data: { msgId, cliMsgId }, threadId, diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts new file mode 100644 index 00000000000..94e291b710f --- /dev/null +++ b/extensions/zalouser/src/zca-client.ts @@ -0,0 +1,249 @@ +import { + LoginQRCallbackEventType as LoginQRCallbackEventTypeRuntime, + Reactions as ReactionsRuntime, + ThreadType as ThreadTypeRuntime, + Zalo as ZaloRuntime, +} from "zca-js"; + +export const ThreadType = ThreadTypeRuntime as { + User: 0; + Group: 1; +}; + +export const LoginQRCallbackEventType = LoginQRCallbackEventTypeRuntime as { + QRCodeGenerated: 0; + QRCodeExpired: 1; + QRCodeScanned: 2; + QRCodeDeclined: 3; + GotLoginInfo: 4; +}; + +export const Reactions = ReactionsRuntime as Record & { + HEART: string; + LIKE: string; + HAHA: string; + WOW: string; + CRY: string; + ANGRY: string; + NONE: string; +}; + +export type Credentials = { + imei: string; + cookie: unknown; + userAgent: string; + language?: string; +}; + +export type User = { + userId: string; + username: string; + displayName: string; + zaloName: string; + avatar: string; +}; + +export type GroupInfo = { + groupId: string; + name: string; + totalMember?: number; + memberIds?: unknown[]; + currentMems?: Array<{ + id?: unknown; + dName?: string; + zaloName?: string; + avatar?: string; + }>; +}; + +export type Message = { + type: number; + threadId: string; + isSelf: boolean; + data: Record; +}; + +export type LoginQRCallbackEvent = + | { + type: 0; + data: { + code: string; + image: string; + }; + actions: { + saveToFile: (qrPath?: string) => Promise; + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: 1; + data: null; + actions: { + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: 2; + data: { + avatar: string; + display_name: string; + }; + actions: { + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: 3; + data: { + code: string; + }; + actions: { + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: 4; + data: { + cookie: unknown; + imei: string; + userAgent: string; + }; + actions: null; + }; + +export type Listener = { + on(event: "message", callback: (message: Message) => void): void; + on(event: "error", callback: (error: unknown) => void): void; + on(event: "closed", callback: (code: number, reason: string) => void): void; + off(event: "message", callback: (message: Message) => void): void; + off(event: "error", callback: (error: unknown) => void): void; + off(event: "closed", callback: (code: number, reason: string) => void): void; + start(opts?: { retryOnClose?: boolean }): void; + stop(): void; +}; + +export type API = { + listener: Listener; + getContext(): { + imei: string; + userAgent: string; + language?: string; + }; + getCookie(): { + toJSON(): { + cookies: unknown[]; + }; + }; + fetchAccountInfo(): Promise<{ profile: User } | User>; + getAllFriends(): Promise; + getOwnId(): string; + getAllGroups(): Promise<{ + gridVerMap: Record; + }>; + getGroupInfo(groupId: string | string[]): Promise<{ + gridInfoMap: Record; + }>; + getGroupMembersInfo(memberId: string | string[]): Promise<{ + profiles: Record< + string, + { + id?: string; + displayName?: string; + zaloName?: string; + avatar?: string; + } + >; + }>; + sendMessage( + message: string | Record, + threadId: string, + type?: number, + ): Promise<{ + message?: { msgId?: string | number } | null; + attachment?: Array<{ msgId?: string | number }>; + }>; + sendLink( + payload: { link: string; msg?: string }, + threadId: string, + type?: number, + ): Promise<{ msgId?: string | number }>; + sendTypingEvent(threadId: string, type?: number, destType?: number): Promise<{ status: number }>; + addReaction( + icon: string | { rType: number; source: number; icon: string }, + dest: { + data: { + msgId: string; + cliMsgId: string; + }; + threadId: string; + type: number; + }, + ): Promise; + sendDeliveredEvent( + isSeen: boolean, + messages: + | { + msgId: string; + cliMsgId: string; + uidFrom: string; + idTo: string; + msgType: string; + st: number; + at: number; + cmd: number; + ts: string | number; + } + | Array<{ + msgId: string; + cliMsgId: string; + uidFrom: string; + idTo: string; + msgType: string; + st: number; + at: number; + cmd: number; + ts: string | number; + }>, + type?: number, + ): Promise; + sendSeenEvent( + messages: + | { + msgId: string; + cliMsgId: string; + uidFrom: string; + idTo: string; + msgType: string; + st: number; + at: number; + cmd: number; + ts: string | number; + } + | Array<{ + msgId: string; + cliMsgId: string; + uidFrom: string; + idTo: string; + msgType: string; + st: number; + at: number; + cmd: number; + ts: string | number; + }>, + type?: number, + ): Promise; +}; + +type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => { + login(credentials: Credentials): Promise; + loginQR( + options?: { userAgent?: string; language?: string; qrPath?: string }, + callback?: (event: LoginQRCallbackEvent) => unknown, + ): Promise; +}; + +export const Zalo = ZaloRuntime as unknown as ZaloCtor; diff --git a/extensions/zalouser/src/zca-js-exports.d.ts b/extensions/zalouser/src/zca-js-exports.d.ts index 549465e470a..78deb4c9c1f 100644 --- a/extensions/zalouser/src/zca-js-exports.d.ts +++ b/extensions/zalouser/src/zca-js-exports.d.ts @@ -1,219 +1,22 @@ declare module "zca-js" { - export enum ThreadType { - User = 0, - Group = 1, - } - - export enum Reactions { - HEART = "/-heart", - LIKE = "/-strong", - HAHA = ":>", - WOW = ":o", - CRY = ":-((", - ANGRY = ":-h", - KISS = ":-*", - TEARS_OF_JOY = ":')", - NONE = "", - } - - export enum LoginQRCallbackEventType { - QRCodeGenerated = 0, - QRCodeExpired = 1, - QRCodeScanned = 2, - QRCodeDeclined = 3, - GotLoginInfo = 4, - } - - export type Credentials = { - imei: string; - cookie: unknown; - userAgent: string; - language?: string; + export const ThreadType: { + User: number; + Group: number; }; - export type User = { - userId: string; - username: string; - displayName: string; - zaloName: string; - avatar: string; + export const LoginQRCallbackEventType: { + QRCodeGenerated: number; + QRCodeExpired: number; + QRCodeScanned: number; + QRCodeDeclined: number; + GotLoginInfo: number; }; - export type GroupInfo = { - groupId: string; - name: string; - totalMember?: number; - memberIds?: unknown[]; - currentMems?: Array<{ - id?: unknown; - dName?: string; - zaloName?: string; - avatar?: string; - }>; - }; - - export type Message = { - type: ThreadType; - threadId: string; - isSelf: boolean; - data: Record; - }; - - export type LoginQRCallbackEvent = - | { - type: LoginQRCallbackEventType.QRCodeGenerated; - data: { - code: string; - image: string; - }; - actions: { - saveToFile: (qrPath?: string) => Promise; - retry: () => unknown; - abort: () => unknown; - }; - } - | { - type: LoginQRCallbackEventType.QRCodeExpired; - data: null; - actions: { - retry: () => unknown; - abort: () => unknown; - }; - } - | { - type: LoginQRCallbackEventType.QRCodeScanned; - data: { - avatar: string; - display_name: string; - }; - actions: { - retry: () => unknown; - abort: () => unknown; - }; - } - | { - type: LoginQRCallbackEventType.QRCodeDeclined; - data: { - code: string; - }; - actions: { - retry: () => unknown; - abort: () => unknown; - }; - } - | { - type: LoginQRCallbackEventType.GotLoginInfo; - data: { - cookie: unknown; - imei: string; - userAgent: string; - }; - actions: null; - }; - - export type Listener = { - on(event: "message", callback: (message: Message) => void): void; - on(event: "error", callback: (error: unknown) => void): void; - on(event: "closed", callback: (code: number, reason: string) => void): void; - off(event: "message", callback: (message: Message) => void): void; - off(event: "error", callback: (error: unknown) => void): void; - off(event: "closed", callback: (code: number, reason: string) => void): void; - start(opts?: { retryOnClose?: boolean }): void; - stop(): void; - }; - - export type ZaloEventMessageParams = { - msgId: string; - cliMsgId: string; - uidFrom: string; - idTo: string; - msgType: string; - st: number; - at: number; - cmd: number; - ts: string | number; - }; - - export type AddReactionDestination = { - data: { - msgId: string; - cliMsgId: string; - }; - threadId: string; - type: ThreadType; - }; - - export class API { - listener: Listener; - getContext(): { - imei: string; - userAgent: string; - language?: string; - }; - getCookie(): { - toJSON(): { - cookies: unknown[]; - }; - }; - fetchAccountInfo(): Promise<{ profile: User } | User>; - getAllFriends(): Promise; - getOwnId(): string; - getAllGroups(): Promise<{ - gridVerMap: Record; - }>; - getGroupInfo(groupId: string | string[]): Promise<{ - gridInfoMap: Record; - }>; - getGroupMembersInfo(memberId: string | string[]): Promise<{ - profiles: Record< - string, - { - id?: string; - displayName?: string; - zaloName?: string; - avatar?: string; - } - >; - }>; - sendMessage( - message: string | Record, - threadId: string, - type?: ThreadType, - ): Promise<{ - message?: { msgId?: string | number } | null; - attachment?: Array<{ msgId?: string | number }>; - }>; - sendLink( - payload: { link: string; msg?: string }, - threadId: string, - type?: ThreadType, - ): Promise<{ msgId?: string | number }>; - sendTypingEvent( - threadId: string, - type?: ThreadType, - destType?: number, - ): Promise<{ status: number }>; - addReaction( - icon: Reactions | string | { rType: number; source: number; icon: string }, - dest: AddReactionDestination, - ): Promise; - sendDeliveredEvent( - isSeen: boolean, - messages: ZaloEventMessageParams | ZaloEventMessageParams[], - type?: ThreadType, - ): Promise; - sendSeenEvent( - messages: ZaloEventMessageParams | ZaloEventMessageParams[], - type?: ThreadType, - ): Promise; - } + export const Reactions: Record; export class Zalo { constructor(options?: { logging?: boolean; selfListen?: boolean }); - login(credentials: Credentials): Promise; - loginQR( - options?: { userAgent?: string; language?: string; qrPath?: string }, - callback?: (event: LoginQRCallbackEvent) => unknown, - ): Promise; + login(credentials: unknown): Promise; + loginQR(options?: unknown, callback?: (event: unknown) => unknown): Promise; } }