Files
openclaw/extensions/nextcloud-talk/src/message-actions.test.ts
nitinjwadhawan 9995e1b4d5 fix(nextcloud-talk): dispatch react action so agents can send reactions (#70110) (#72348)
Summary:
- This PR adds and registers a Nextcloud Talk message action adapter for add-only reactions, updates reaction docs, and adds adapter plus sender tests.
- Reproducibility: yes. Source inspection on current main shows Nextcloud Talk advertises reactions and has a  ... ion sender, but the plugin lacks `actions.handleAction`, so shared `react` dispatch has no channel handler.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(nextcloud-talk): cover reaction sender request path
- PR branch already contained follow-up commit before automerge: fix(nextcloud-talk): harden react null-guard; fix disabled-account te…
- PR branch already contained follow-up commit before automerge: fix(nextcloud-talk): reject react remove requests instead of silently…
- PR branch already contained follow-up commit before automerge: fix(nextcloud-talk): inline listEnabledAccounts helper after main cle…
- PR branch already contained follow-up commit before automerge: docs(nextcloud-talk): note add-only react support in reactions and me…

Validation:
- ClawSweeper review passed for head 9817fed842.
- Required merge gates passed before the squash merge.

Prepared head SHA: 9817fed842
Review: https://github.com/openclaw/openclaw/pull/72348#issuecomment-4323046928

Co-authored-by: NW <nitinwadhawan66@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 18:54:44 +00:00

271 lines
8.7 KiB
TypeScript

import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "./types.js";
const hoisted = vi.hoisted(() => ({
sendReactionNextcloudTalk: vi.fn(),
sendMessageNextcloudTalk: vi.fn(),
listNextcloudTalkAccountIds: vi.fn(),
resolveNextcloudTalkAccount: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendReactionNextcloudTalk: hoisted.sendReactionNextcloudTalk,
sendMessageNextcloudTalk: hoisted.sendMessageNextcloudTalk,
}));
vi.mock("./accounts.js", () => ({
listNextcloudTalkAccountIds: hoisted.listNextcloudTalkAccountIds,
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
}));
const { nextcloudTalkMessageActions } = await import("./message-actions.js");
const configuredAccount = {
accountId: "default",
enabled: true,
baseUrl: "https://nc.example.com",
secret: "bot-secret",
} as const;
const unconfiguredAccount = {
accountId: "default",
enabled: true,
baseUrl: "",
secret: null,
} as const;
const disabledAccount = {
accountId: "default",
enabled: false,
baseUrl: "https://nc.example.com",
secret: "bot-secret",
} as const;
describe("nextcloudTalkMessageActions", () => {
beforeEach(() => {
hoisted.sendReactionNextcloudTalk.mockReset();
hoisted.sendReactionNextcloudTalk.mockResolvedValue({ ok: true });
hoisted.sendMessageNextcloudTalk.mockReset();
hoisted.listNextcloudTalkAccountIds.mockReset();
hoisted.resolveNextcloudTalkAccount.mockReset();
});
describe("describeMessageTool", () => {
it("returns null when no accounts are configured", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([]);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result).toBeNull();
});
it("returns null when configured account has no secret/baseUrl", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([unconfiguredAccount.accountId]);
hoisted.resolveNextcloudTalkAccount.mockReturnValue(unconfiguredAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result).toBeNull();
});
it("returns null when the only listed account is disabled", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([disabledAccount.accountId]);
hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result).toBeNull();
});
it("advertises send + react when an account is configured", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([configuredAccount.accountId]);
hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result?.actions).toEqual(["send", "react"]);
});
it("scopes discovery to a specific accountId when provided", () => {
hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
accountId: "work",
});
expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
cfg: {},
accountId: "work",
});
expect(hoisted.listNextcloudTalkAccountIds).not.toHaveBeenCalled();
expect(result?.actions).toEqual(["send", "react"]);
});
it("returns null when the targeted account is disabled", () => {
hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
accountId: "work",
});
expect(result).toBeNull();
});
});
describe("supportsAction", () => {
it("delegates send back to outbound", () => {
expect(nextcloudTalkMessageActions.supportsAction?.({ action: "send" })).toBe(false);
});
it("handles react locally", () => {
expect(nextcloudTalkMessageActions.supportsAction?.({ action: "react" })).toBe(true);
});
});
describe("handleAction", () => {
const cfg = {} as CoreConfig;
it("invokes sendReactionNextcloudTalk with normalized params for the react action", async () => {
const result = await nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "42", emoji: "👍" },
cfg,
accountId: "work",
});
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "42", "👍", {
accountId: "work",
cfg,
});
expect(result).toMatchObject({
details: { ok: true, added: "👍" },
});
});
it("uses toolContext.currentMessageId when params.messageId is missing", async () => {
await nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", emoji: "✅" },
cfg,
accountId: null,
toolContext: { currentMessageId: 99 },
});
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "99", "✅", {
accountId: undefined,
cfg,
});
});
it("requires a target room token", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { messageId: "1", emoji: "👍" },
cfg,
}),
).rejects.toThrow(/to \(room token\) required/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("requires a messageId (explicit or via toolContext)", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", emoji: "👍" },
cfg,
}),
).rejects.toThrow(/messageId required/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("requires an emoji", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1" },
cfg,
}),
).rejects.toThrow(/emoji required/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("rejects send through the action handler (outbound owns send)", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "send",
params: { to: "room:abc123", text: "hi" },
cfg,
}),
).rejects.toThrow(/handled by outbound/);
});
it("rejects unsupported actions", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "delete",
params: {},
cfg,
}),
).rejects.toThrow(/Action delete not supported for nextcloud-talk/);
});
it("rejects reaction removal requests without calling the add-reaction sender", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: true },
cfg,
}),
).rejects.toThrow(/removal is not supported/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("still adds the reaction when remove is explicitly false", async () => {
await nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: false },
cfg,
});
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
});
it("propagates errors from sendReactionNextcloudTalk", async () => {
hoisted.sendReactionNextcloudTalk.mockRejectedValueOnce(
new Error("Nextcloud Talk reaction failed: 403 forbidden"),
);
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1", emoji: "👍" },
cfg,
}),
).rejects.toThrow(/403 forbidden/);
});
});
});