// Nextcloud Talk tests cover inbound.behavior plugin behavior. import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { handleNextcloudTalkInbound } from "./inbound.js"; import { setNextcloudTalkRuntime } from "./runtime.js"; import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; const { createChannelPairingControllerMock, resolveAllowlistProviderRuntimeGroupPolicyMock, resolveDefaultGroupPolicyMock, warnMissingProviderGroupPolicyFallbackOnceMock, } = vi.hoisted(() => { return { createChannelPairingControllerMock: vi.fn(), resolveAllowlistProviderRuntimeGroupPolicyMock: vi.fn(), resolveDefaultGroupPolicyMock: vi.fn(), warnMissingProviderGroupPolicyFallbackOnceMock: vi.fn(), }; }); const sendMessageNextcloudTalkMock = vi.hoisted(() => vi.fn()); const resolveNextcloudTalkRoomKindMock = vi.hoisted(() => vi.fn()); vi.mock("../runtime-api.js", async () => { const actual = await vi.importActual("../runtime-api.js"); return { ...actual, createChannelPairingController: createChannelPairingControllerMock, resolveAllowlistProviderRuntimeGroupPolicy: resolveAllowlistProviderRuntimeGroupPolicyMock, resolveDefaultGroupPolicy: resolveDefaultGroupPolicyMock, warnMissingProviderGroupPolicyFallbackOnce: warnMissingProviderGroupPolicyFallbackOnceMock, }; }); vi.mock("./send.js", () => ({ sendMessageNextcloudTalk: sendMessageNextcloudTalkMock, })); vi.mock("./room-info.js", async () => { const actual = await vi.importActual("./room-info.js"); return { ...actual, resolveNextcloudTalkRoomKind: resolveNextcloudTalkRoomKindMock, }; }); function installRuntime(params?: { buildMentionRegexes?: () => RegExp[]; hasControlCommand?: (body: string) => boolean; matchesMentionPatterns?: (body: string, regexes: RegExp[]) => boolean; shouldHandleTextCommands?: () => boolean; }) { const runtime = { channel: { inbound: { dispatchReply: vi.fn(async () => undefined), }, pairing: { readAllowFromStore: vi.fn(async () => []), upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })), }, commands: { shouldHandleTextCommands: params?.shouldHandleTextCommands ?? vi.fn(() => false), }, text: { hasControlCommand: params?.hasControlCommand ?? vi.fn(() => false), }, mentions: { buildMentionRegexes: params?.buildMentionRegexes ?? vi.fn(() => []), matchesMentionPatterns: params?.matchesMentionPatterns ?? vi.fn(() => false), }, }, }; setNextcloudTalkRuntime(runtime as unknown as PluginRuntime); return runtime; } function createRuntimeEnv() { return { log: vi.fn(), error: vi.fn(), } as unknown as RuntimeEnv; } function requireFirstMockArg(mock: ReturnType, label: string): unknown { const [call] = mock.mock.calls; if (!call) { throw new Error(`expected ${label}`); } return call[0]; } function requireFirstSendMessageCall(): [unknown, unknown, unknown] { const [call] = sendMessageNextcloudTalkMock.mock.calls; if (!call) { throw new Error("expected Nextcloud Talk send call"); } return call as [unknown, unknown, unknown]; } function createAccount( overrides?: Partial, ): ResolvedNextcloudTalkAccount { return { accountId: "default", enabled: true, baseUrl: "https://cloud.example.com", secret: "secret", secretSource: "config", config: { dmPolicy: "pairing", allowFrom: [], groupPolicy: "allowlist", groupAllowFrom: [], }, ...overrides, }; } function createMessage( overrides?: Partial, ): NextcloudTalkInboundMessage { return { messageId: "msg-1", roomToken: "room-1", roomName: "Room 1", senderId: "user-1", senderName: "Alice", text: "hello", mediaType: "text/plain", timestamp: Date.now(), isGroupChat: false, ...overrides, }; } describe("nextcloud-talk inbound behavior", () => { beforeEach(() => { vi.clearAllMocks(); installRuntime(); resolveNextcloudTalkRoomKindMock.mockResolvedValue("direct"); resolveDefaultGroupPolicyMock.mockReturnValue("allowlist"); resolveAllowlistProviderRuntimeGroupPolicyMock.mockReturnValue({ groupPolicy: "allowlist", providerMissingFallbackApplied: false, }); warnMissingProviderGroupPolicyFallbackOnceMock.mockReturnValue(undefined); }); it("issues a DM pairing challenge and sends the challenge text", async () => { const issueChallenge = vi.fn( async (params: { sendPairingReply: (text: string) => Promise }) => { await params.sendPairingReply("Pair with code 123456"); }, ); createChannelPairingControllerMock.mockReturnValue({ readStoreForDmPolicy: vi.fn(), issueChallenge, }); sendMessageNextcloudTalkMock.mockResolvedValue(undefined); const statusSink = vi.fn(); await handleNextcloudTalkInbound({ message: createMessage({ timestamp: 1_736_380_800_000 }), account: createAccount(), config: { channels: { "nextcloud-talk": {} } } as CoreConfig, runtime: createRuntimeEnv(), statusSink, }); const challengeParams = requireFirstMockArg( issueChallenge, "Nextcloud Talk pairing challenge", ) as { meta?: { name?: string }; senderId?: string; senderIdLine?: string; }; expect(challengeParams.senderId).toBe("user-1"); expect(challengeParams.senderIdLine).toBe("Your Nextcloud user id: user-1"); expect(challengeParams.meta).toEqual({ name: "Alice" }); expect(sendMessageNextcloudTalkMock).toHaveBeenCalledTimes(1); const sendArgs = requireFirstSendMessageCall(); expect(sendArgs[0]).toBe("room-1"); expect(sendArgs[1]).toBe("Pair with code 123456"); expect(sendArgs[2]).toEqual({ cfg: { channels: { "nextcloud-talk": {} } }, accountId: "default", }); expect(statusSink).toHaveBeenCalledWith({ lastInboundAt: 1_736_380_800_000 }); const outboundStatus = statusSink.mock.calls .map(([status]) => status as { lastOutboundAt?: unknown }) .find((status) => status.lastOutboundAt !== undefined); expect(typeof outboundStatus?.lastOutboundAt).toBe("number"); expect(outboundStatus?.lastOutboundAt).toBeGreaterThanOrEqual(1_736_380_800_000); expect(sendMessageNextcloudTalkMock).toHaveBeenCalledTimes(1); }); it("drops unmentioned group traffic before dispatch", async () => { installRuntime({ buildMentionRegexes: vi.fn(() => [/@openclaw/i]), matchesMentionPatterns: vi.fn(() => false), }); createChannelPairingControllerMock.mockReturnValue({ readStoreForDmPolicy: vi.fn(), issueChallenge: vi.fn(), }); resolveNextcloudTalkRoomKindMock.mockResolvedValue("group"); const runtime = createRuntimeEnv(); await handleNextcloudTalkInbound({ message: createMessage({ roomToken: "room-group", roomName: "Ops", isGroupChat: true, }), account: createAccount({ config: { dmPolicy: "pairing", allowFrom: [], groupPolicy: "allowlist", groupAllowFrom: ["user-1"], }, }), config: { channels: { "nextcloud-talk": {} } } as CoreConfig, runtime, }); expect(sendMessageNextcloudTalkMock).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("nextcloud-talk: drop room room-group (no mention)"); }); it("blocks unauthorized group text control commands even when room sender access allows chat", async () => { const buildMentionRegexes = vi.fn(() => [/@openclaw/i]); const coreRuntime = installRuntime({ buildMentionRegexes, hasControlCommand: vi.fn(() => true), shouldHandleTextCommands: vi.fn(() => true), }); createChannelPairingControllerMock.mockReturnValue({ readStoreForDmPolicy: vi.fn(), issueChallenge: vi.fn(), }); resolveNextcloudTalkRoomKindMock.mockResolvedValue("group"); const runtime = createRuntimeEnv(); await handleNextcloudTalkInbound({ message: createMessage({ roomToken: "room-group", roomName: "Ops", isGroupChat: true, text: "/openclaw reload", }), account: createAccount({ config: { dmPolicy: "pairing", allowFrom: [], groupPolicy: "allowlist", groupAllowFrom: [], rooms: { "room-group": { allowFrom: ["user-1"], requireMention: false, }, }, }, }), config: { channels: { "nextcloud-talk": {} } } as CoreConfig, runtime, }); expect(coreRuntime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); expect(buildMentionRegexes).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith( "nextcloud-talk: drop control command (unauthorized) target=user-1", ); }); it("passes the shared reply pipeline for dispatched replies", async () => { const coreRuntime = createPluginRuntimeMock(); setNextcloudTalkRuntime(coreRuntime as unknown as PluginRuntime); createChannelPairingControllerMock.mockReturnValue({ readStoreForDmPolicy: vi.fn(async () => []), issueChallenge: vi.fn(), }); await handleNextcloudTalkInbound({ message: createMessage(), account: createAccount({ config: { dmPolicy: "allowlist", allowFrom: ["user-1"], groupPolicy: "allowlist", groupAllowFrom: [], }, }), config: { channels: { "nextcloud-talk": {} } } as CoreConfig, runtime: createRuntimeEnv(), }); const assembledRequest = requireFirstMockArg( coreRuntime.channel.inbound.dispatchReply as ReturnType, "Nextcloud Talk assembled request", ) as { replyPipeline?: unknown }; expect(assembledRequest.replyPipeline).toEqual({}); }); });