mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(zalouser): extract policy and message helpers
This commit is contained in:
@@ -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)
|
||||
|
||||
49
extensions/zalouser/src/group-policy.test.ts
Normal file
49
extensions/zalouser/src/group-policy.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
78
extensions/zalouser/src/group-policy.ts
Normal file
78
extensions/zalouser/src/group-policy.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ZalouserGroupConfig } from "./types.js";
|
||||
|
||||
type ZalouserGroups = Record<string, ZalouserGroupConfig>;
|
||||
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
66
extensions/zalouser/src/message-sid.test.ts
Normal file
66
extensions/zalouser/src/message-sid.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
80
extensions/zalouser/src/message-sid.ts
Normal file
80
extensions/zalouser/src/message-sid.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
|
||||
}): 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}`,
|
||||
});
|
||||
|
||||
19
extensions/zalouser/src/reaction.test.ts
Normal file
19
extensions/zalouser/src/reaction.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
29
extensions/zalouser/src/reaction.ts
Normal file
29
extensions/zalouser/src/reaction.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Reactions } from "./zca-client.js";
|
||||
|
||||
const REACTION_ALIAS_MAP = new Map<string, string>([
|
||||
["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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, API>();
|
||||
const apiInitByProfile = new Map<string, Promise<API>>();
|
||||
@@ -241,33 +242,6 @@ function buildEventMessage(data: Record<string, unknown>): 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,
|
||||
|
||||
249
extensions/zalouser/src/zca-client.ts
Normal file
249
extensions/zalouser/src/zca-client.ts
Normal file
@@ -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<string, string> & {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
export type LoginQRCallbackEvent =
|
||||
| {
|
||||
type: 0;
|
||||
data: {
|
||||
code: string;
|
||||
image: string;
|
||||
};
|
||||
actions: {
|
||||
saveToFile: (qrPath?: string) => Promise<unknown>;
|
||||
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<User[]>;
|
||||
getOwnId(): string;
|
||||
getAllGroups(): Promise<{
|
||||
gridVerMap: Record<string, string>;
|
||||
}>;
|
||||
getGroupInfo(groupId: string | string[]): Promise<{
|
||||
gridInfoMap: Record<string, GroupInfo & { memVerList?: unknown }>;
|
||||
}>;
|
||||
getGroupMembersInfo(memberId: string | string[]): Promise<{
|
||||
profiles: Record<
|
||||
string,
|
||||
{
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
zaloName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
sendMessage(
|
||||
message: string | Record<string, unknown>,
|
||||
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<unknown>;
|
||||
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<unknown>;
|
||||
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<unknown>;
|
||||
};
|
||||
|
||||
type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => {
|
||||
login(credentials: Credentials): Promise<API>;
|
||||
loginQR(
|
||||
options?: { userAgent?: string; language?: string; qrPath?: string },
|
||||
callback?: (event: LoginQRCallbackEvent) => unknown,
|
||||
): Promise<API>;
|
||||
};
|
||||
|
||||
export const Zalo = ZaloRuntime as unknown as ZaloCtor;
|
||||
221
extensions/zalouser/src/zca-js-exports.d.ts
vendored
221
extensions/zalouser/src/zca-js-exports.d.ts
vendored
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export type LoginQRCallbackEvent =
|
||||
| {
|
||||
type: LoginQRCallbackEventType.QRCodeGenerated;
|
||||
data: {
|
||||
code: string;
|
||||
image: string;
|
||||
};
|
||||
actions: {
|
||||
saveToFile: (qrPath?: string) => Promise<unknown>;
|
||||
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<User[]>;
|
||||
getOwnId(): string;
|
||||
getAllGroups(): Promise<{
|
||||
gridVerMap: Record<string, string>;
|
||||
}>;
|
||||
getGroupInfo(groupId: string | string[]): Promise<{
|
||||
gridInfoMap: Record<string, GroupInfo & { memVerList?: unknown }>;
|
||||
}>;
|
||||
getGroupMembersInfo(memberId: string | string[]): Promise<{
|
||||
profiles: Record<
|
||||
string,
|
||||
{
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
zaloName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
sendMessage(
|
||||
message: string | Record<string, unknown>,
|
||||
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<unknown>;
|
||||
sendDeliveredEvent(
|
||||
isSeen: boolean,
|
||||
messages: ZaloEventMessageParams | ZaloEventMessageParams[],
|
||||
type?: ThreadType,
|
||||
): Promise<unknown>;
|
||||
sendSeenEvent(
|
||||
messages: ZaloEventMessageParams | ZaloEventMessageParams[],
|
||||
type?: ThreadType,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
export const Reactions: Record<string, string>;
|
||||
|
||||
export class Zalo {
|
||||
constructor(options?: { logging?: boolean; selfListen?: boolean });
|
||||
login(credentials: Credentials): Promise<API>;
|
||||
loginQR(
|
||||
options?: { userAgent?: string; language?: string; qrPath?: string },
|
||||
callback?: (event: LoginQRCallbackEvent) => unknown,
|
||||
): Promise<API>;
|
||||
login(credentials: unknown): Promise<unknown>;
|
||||
loginQR(options?: unknown, callback?: (event: unknown) => unknown): Promise<unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user