mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(zalouser): enforce group mention gating and typing
This commit is contained in:
@@ -18,6 +18,29 @@ describe("zalouser outbound chunker", () => {
|
||||
});
|
||||
|
||||
describe("zalouser channel policies", () => {
|
||||
it("resolves requireMention from group config", () => {
|
||||
const resolveRequireMention = zalouserPlugin.groups?.resolveRequireMention;
|
||||
expect(resolveRequireMention).toBeTypeOf("function");
|
||||
if (!resolveRequireMention) {
|
||||
return;
|
||||
}
|
||||
const requireMention = resolveRequireMention({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"123": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId: "123",
|
||||
groupChannel: "123",
|
||||
});
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves group tool policy by explicit group id", () => {
|
||||
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
|
||||
expect(resolveToolPolicy).toBeTypeOf("function");
|
||||
|
||||
@@ -135,6 +135,27 @@ function resolveZalouserGroupToolPolicy(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
const candidates = [params.groupId?.trim(), params.groupChannel?.trim()].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const zalouserDock: ChannelDock = {
|
||||
id: "zalouser",
|
||||
capabilities: {
|
||||
@@ -152,7 +173,7 @@ export const zalouserDock: ChannelDock = {
|
||||
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveRequireMention: resolveZalouserRequireMention,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
@@ -235,7 +256,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveRequireMention: resolveZalouserRequireMention,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
const groupConfigSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import { setZalouserRuntime } from "./runtime.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
}));
|
||||
|
||||
describe("zalouser monitor pairing account scoping", () => {
|
||||
|
||||
210
extensions/zalouser/src/monitor.group-gating.test.ts
Normal file
210
extensions/zalouser/src/monitor.group-gating.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __testing } from "./monitor.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
}));
|
||||
|
||||
function createAccount(): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
authenticated: true,
|
||||
config: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
function installRuntime(params: { commandAuthorized: boolean }) {
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
|
||||
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
|
||||
});
|
||||
|
||||
setZalouserRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })),
|
||||
buildPairingReply: vi.fn(() => "pair"),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => params.commandAuthorized),
|
||||
isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
shouldHandleTextCommands: vi.fn(() => true),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: vi.fn(() => []),
|
||||
matchesMentionWithExplicit: vi.fn(
|
||||
(input) => input.explicit?.isExplicitlyMentioned === true,
|
||||
),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: vi.fn((input) => {
|
||||
const cfg = input.cfg as OpenClawConfig;
|
||||
const groupCfg = cfg.channels?.zalouser?.groups ?? {};
|
||||
const groupEntry = input.groupId ? groupCfg[input.groupId] : undefined;
|
||||
const defaultEntry = groupCfg["*"];
|
||||
if (typeof groupEntry?.requireMention === "boolean") {
|
||||
return groupEntry.requireMention;
|
||||
}
|
||||
if (typeof defaultEntry?.requireMention === "boolean") {
|
||||
return defaultEntry.requireMention;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: vi.fn(() => ({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:zalouser:group:1",
|
||||
accountId: "default",
|
||||
mainSessionKey: "agent:main:main",
|
||||
})),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp"),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordInboundSession: vi.fn(async () => {}),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => undefined),
|
||||
formatAgentEnvelope: vi.fn(({ body }) => body),
|
||||
finalizeInboundContext: vi.fn((ctx) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
},
|
||||
text: {
|
||||
resolveMarkdownTableMode: vi.fn(() => "code"),
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
resolveChunkMode: vi.fn(() => "line"),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
return { dispatchReplyWithBufferedBlockDispatcher };
|
||||
}
|
||||
|
||||
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
|
||||
return {
|
||||
threadId: "g-1",
|
||||
isGroup: true,
|
||||
senderId: "123",
|
||||
senderName: "Alice",
|
||||
groupName: "Team",
|
||||
content: "hello",
|
||||
timestampMs: Date.now(),
|
||||
msgId: "m-1",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
canResolveExplicitMention: true,
|
||||
implicitMention: false,
|
||||
raw: { source: "test" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zalouser monitor group mention gating", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageZalouserMock.mockClear();
|
||||
sendTypingZalouserMock.mockClear();
|
||||
});
|
||||
|
||||
it("skips unmentioned group messages when requireMention=true", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage(),
|
||||
account: createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
content: "ping @bot",
|
||||
}),
|
||||
account: createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows authorized control commands to bypass mention gating", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: true,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "/status",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
}),
|
||||
account: createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createTypingCallbacks,
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
resolveOutboundMediaUrls,
|
||||
mergeAllowlist,
|
||||
resolveMentionGatingWithBypass,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveSenderCommandAuthorization,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import { sendMessageZalouser, sendTypingZalouser } from "./send.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
import { listZaloFriends, listZaloGroups, startZaloListener } from "./zalo-js.js";
|
||||
|
||||
@@ -89,7 +91,7 @@ function normalizeGroupSlug(raw?: string | null): string {
|
||||
function isGroupAllowed(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
|
||||
}): boolean {
|
||||
const groups = params.groups ?? {};
|
||||
const keys = Object.keys(groups);
|
||||
@@ -116,6 +118,30 @@ function isGroupAllowed(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveGroupRequireMention(params: {
|
||||
groupId: string;
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: ZaloInboundMessage,
|
||||
account: ResolvedZalouserAccount,
|
||||
@@ -238,11 +264,8 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
const hasControlCommand = core.channel.commands.isControlCommandMessage(rawBody, config);
|
||||
if (isGroup && hasControlCommand && commandAuthorized !== true) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
@@ -266,6 +289,42 @@ async function processMessage(
|
||||
},
|
||||
});
|
||||
|
||||
const requireMention = isGroup
|
||||
? resolveGroupRequireMention({
|
||||
groupId: chatId,
|
||||
groupName,
|
||||
groups,
|
||||
})
|
||||
: false;
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
||||
const explicitMention = {
|
||||
hasAnyMention: message.hasAnyMention === true,
|
||||
isExplicitlyMentioned: message.wasExplicitlyMentioned === true,
|
||||
canResolveExplicit: message.canResolveExplicitMention === true,
|
||||
};
|
||||
const wasMentioned = isGroup
|
||||
? core.channel.mentions.matchesMentionWithExplicit({
|
||||
text: rawBody,
|
||||
mentionRegexes,
|
||||
explicit: explicitMention,
|
||||
})
|
||||
: true;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup,
|
||||
requireMention,
|
||||
canDetectMention: mentionRegexes.length > 0 || explicitMention.canResolveExplicit,
|
||||
wasMentioned,
|
||||
implicitMention: message.implicitMention === true,
|
||||
hasAnyMention: explicitMention.hasAnyMention,
|
||||
allowTextCommands: core.channel.commands.shouldHandleTextCommands(config),
|
||||
hasControlCommand,
|
||||
commandAuthorized: commandAuthorized === true,
|
||||
});
|
||||
if (isGroup && mentionGate.shouldSkip) {
|
||||
logVerbose(core, runtime, `zalouser: skip group ${chatId} (mention required, not mentioned)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
@@ -297,6 +356,7 @@ async function processMessage(
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
@@ -320,12 +380,24 @@ async function processMessage(
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
await sendTypingZalouser(chatId, {
|
||||
profile: account.profile,
|
||||
isGroup,
|
||||
});
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
await deliverZalouserReply({
|
||||
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
|
||||
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
|
||||
import {
|
||||
sendImageZalouser,
|
||||
sendLinkZalouser,
|
||||
sendMessageZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import { sendZaloLink, sendZaloTextMessage, sendZaloTypingEvent } from "./zalo-js.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
sendZaloTextMessage: vi.fn(),
|
||||
sendZaloLink: vi.fn(),
|
||||
sendZaloTypingEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSendText = vi.mocked(sendZaloTextMessage);
|
||||
const mockSendLink = vi.mocked(sendZaloLink);
|
||||
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
|
||||
|
||||
describe("zalouser send helpers", () => {
|
||||
beforeEach(() => {
|
||||
mockSendText.mockReset();
|
||||
mockSendLink.mockReset();
|
||||
mockSendTyping.mockReset();
|
||||
});
|
||||
|
||||
it("delegates text send to JS transport", async () => {
|
||||
@@ -62,4 +70,13 @@ describe("zalouser send helpers", () => {
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "boom" });
|
||||
});
|
||||
|
||||
it("delegates typing helper to JS transport", async () => {
|
||||
await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
|
||||
|
||||
expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
|
||||
profile: "p4",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
|
||||
import { sendZaloLink, sendZaloTextMessage, sendZaloTypingEvent } from "./zalo-js.js";
|
||||
|
||||
export type ZalouserSendOptions = ZaloSendOptions;
|
||||
export type ZalouserSendResult = ZaloSendResult;
|
||||
@@ -30,3 +30,10 @@ export async function sendLinkZalouser(
|
||||
): Promise<ZalouserSendResult> {
|
||||
return await sendZaloLink(threadId, url, options);
|
||||
}
|
||||
|
||||
export async function sendTypingZalouser(
|
||||
threadId: string,
|
||||
options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
|
||||
): Promise<void> {
|
||||
await sendZaloTypingEvent(threadId, options);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ export type ZaloInboundMessage = {
|
||||
timestampMs: number;
|
||||
msgId?: string;
|
||||
cliMsgId?: string;
|
||||
hasAnyMention?: boolean;
|
||||
wasExplicitlyMentioned?: boolean;
|
||||
canResolveExplicitMention?: boolean;
|
||||
implicitMention?: boolean;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
@@ -59,6 +63,7 @@ type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
|
||||
type ZalouserGroupConfig = {
|
||||
allow?: boolean;
|
||||
enabled?: boolean;
|
||||
requireMention?: boolean;
|
||||
tools?: ZalouserToolConfig;
|
||||
};
|
||||
|
||||
|
||||
@@ -165,6 +165,20 @@ function resolveInboundTimestamp(rawTs: unknown): number {
|
||||
return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
|
||||
}
|
||||
|
||||
function extractMentionIds(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return "";
|
||||
}
|
||||
return toNumberId((entry as { uid?: unknown }).uid);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractSendMessageId(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
@@ -422,7 +436,7 @@ async function fetchGroupsByIds(api: API, ids: string[]): Promise<Map<string, Gr
|
||||
return result;
|
||||
}
|
||||
|
||||
function toInboundMessage(message: Message): ZaloInboundMessage | null {
|
||||
function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMessage | null {
|
||||
const data = message.data as Record<string, unknown>;
|
||||
const isGroup = message.type === ThreadType.Group;
|
||||
const senderId = toNumberId(data.uidFrom);
|
||||
@@ -433,6 +447,20 @@ function toInboundMessage(message: Message): ZaloInboundMessage | null {
|
||||
return null;
|
||||
}
|
||||
const content = normalizeMessageContent(data.content);
|
||||
const normalizedOwnUserId = toNumberId(ownUserId);
|
||||
const mentionIds = extractMentionIds(data.mentions);
|
||||
const quoteOwnerId =
|
||||
data.quote && typeof data.quote === "object"
|
||||
? toNumberId((data.quote as { ownerId?: unknown }).ownerId)
|
||||
: "";
|
||||
const hasAnyMention = mentionIds.length > 0;
|
||||
const canResolveExplicitMention = Boolean(normalizedOwnUserId);
|
||||
const wasExplicitlyMentioned = Boolean(
|
||||
normalizedOwnUserId && mentionIds.some((id) => id === normalizedOwnUserId),
|
||||
);
|
||||
const implicitMention = Boolean(
|
||||
normalizedOwnUserId && quoteOwnerId && quoteOwnerId === normalizedOwnUserId,
|
||||
);
|
||||
return {
|
||||
threadId,
|
||||
isGroup,
|
||||
@@ -442,6 +470,10 @@ function toInboundMessage(message: Message): ZaloInboundMessage | null {
|
||||
timestampMs: resolveInboundTimestamp(data.ts),
|
||||
msgId: typeof data.msgId === "string" ? data.msgId : undefined,
|
||||
cliMsgId: typeof data.cliMsgId === "string" ? data.cliMsgId : undefined,
|
||||
hasAnyMention,
|
||||
canResolveExplicitMention,
|
||||
wasExplicitlyMentioned,
|
||||
implicitMention,
|
||||
raw: message,
|
||||
};
|
||||
}
|
||||
@@ -670,6 +702,20 @@ export async function sendZaloTextMessage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendZaloTypingEvent(
|
||||
threadId: string,
|
||||
options: Pick<ZaloSendOptions, "profile" | "isGroup"> = {},
|
||||
): Promise<void> {
|
||||
const profile = normalizeProfile(options.profile);
|
||||
const trimmedThreadId = threadId.trim();
|
||||
if (!trimmedThreadId) {
|
||||
throw new Error("No threadId provided");
|
||||
}
|
||||
const api = await ensureApi(profile);
|
||||
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
await api.sendTypingEvent(trimmedThreadId, type);
|
||||
}
|
||||
|
||||
export async function sendZaloLink(
|
||||
threadId: string,
|
||||
url: string,
|
||||
@@ -956,6 +1002,7 @@ export async function startZaloListener(params: {
|
||||
}
|
||||
|
||||
const api = await ensureApi(profile);
|
||||
const ownUserId = toNumberId(api.getOwnId());
|
||||
let stopped = false;
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -982,7 +1029,7 @@ export async function startZaloListener(params: {
|
||||
if (incoming.isSelf) {
|
||||
return;
|
||||
}
|
||||
const normalized = toInboundMessage(incoming);
|
||||
const normalized = toInboundMessage(incoming, ownUserId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user