diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 005113e1ee6..ca22b702f22 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -1,3 +1,5 @@ +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { fetchWithSsrFGuard } from "../runtime-api.js"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { stripNextcloudTalkTargetPrefix } from "./normalize.js"; @@ -61,6 +63,20 @@ function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): { return { cfg, account, baseUrl, secret }; } +function recordNextcloudTalkOutboundActivity(accountId: string): void { + try { + getNextcloudTalkRuntime().channel.activity.record({ + channel: "nextcloud-talk", + accountId, + direction: "outbound", + }); + } catch (error) { + if (!(error instanceof Error) || error.message !== "Nextcloud Talk runtime not initialized") { + throw error; + } + } +} + export async function sendMessageNextcloudTalk( to: string, text: string, @@ -73,15 +89,12 @@ export async function sendMessageNextcloudTalk( throw new Error("Message must be non-empty for Nextcloud Talk sends"); } - const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({ + const tableMode = resolveMarkdownTableMode({ cfg, channel: "nextcloud-talk", accountId: account.accountId, }); - const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables( - text.trim(), - tableMode, - ); + const message = convertMarkdownTables(text.trim(), tableMode); const body: Record = { message, @@ -164,11 +177,7 @@ export async function sendMessageNextcloudTalk( console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`); } - getNextcloudTalkRuntime().channel.activity.record({ - channel: "nextcloud-talk", - accountId: account.accountId, - direction: "outbound", - }); + recordNextcloudTalkOutboundActivity(account.accountId); return { messageId, roomToken, timestamp }; } finally { diff --git a/extensions/nextcloud-talk/src/setup.test.ts b/extensions/nextcloud-talk/src/setup.test.ts index be710b5691a..d0818597660 100644 --- a/extensions/nextcloud-talk/src/setup.test.ts +++ b/extensions/nextcloud-talk/src/setup.test.ts @@ -76,6 +76,30 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { }; }); +vi.mock("../../../src/infra/net/fetch-guard.js", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + fetchWithSsrFGuard: hoisted.mockFetchGuard, + }; +}); + +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, + }; +}); + +vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + convertMarkdownTables: hoisted.convertMarkdownTables, + }; +}); + const accountsActual = await vi.importActual("./accounts.js"); hoisted.resolveNextcloudTalkAccount.mockImplementation(accountsActual.resolveNextcloudTalkAccount); @@ -452,13 +476,18 @@ describe("resolveNextcloudTalkAccount", () => { describe("nextcloud-talk send cfg threading", () => { const fetchMock = vi.fn(); - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js")); vi.stubGlobal("fetch", fetchMock); // Wire the SSRF guard mock to delegate to the global fetch mock hoisted.mockFetchGuard.mockImplementation(async (p: { url: string; init?: RequestInit }) => { const response = await globalThis.fetch(p.url, p.init); return { response, release: async () => {}, finalUrl: p.url }; }); + hoisted.resolveNextcloudTalkAccount.mockImplementation( + accountsActual.resolveNextcloudTalkAccount, + ); }); afterEach(() => { @@ -494,6 +523,17 @@ describe("nextcloud-talk send cfg threading", () => { cfg, accountId: "work", }); + expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({ + cfg, + channel: "nextcloud-talk", + accountId: "default", + }); + expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve"); + expect(hoisted.record).toHaveBeenCalledWith({ + channel: "nextcloud-talk", + accountId: "default", + direction: "outbound", + }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(result).toEqual({ messageId: "12345", @@ -502,6 +542,49 @@ describe("nextcloud-talk send cfg threading", () => { }); }); + it("sends with provided cfg even when the runtime store is not initialized", async () => { + const cfg = { source: "provided" } as const; + hoisted.resolveNextcloudTalkAccount.mockReturnValue({ + accountId: "default", + baseUrl: "https://nextcloud.example.com", + secret: "secret-value", + }); + hoisted.record.mockImplementation(() => { + throw new Error("Nextcloud Talk runtime not initialized"); + }); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ocs: { data: { id: 12346, timestamp: 1_706_000_001 } }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await sendMessageNextcloudTalk("room:abc123", "hello", { + cfg, + accountId: "work", + }); + + expectProvidedCfgSkipsRuntimeLoad({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, + cfg, + accountId: "work", + }); + expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({ + cfg, + channel: "nextcloud-talk", + accountId: "default", + }); + expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve"); + expect(result).toEqual({ + messageId: "12346", + roomToken: "abc123", + timestamp: 1_706_000_001, + }); + }); + it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { const runtimeCfg = { source: "runtime" } as const; hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);