@kodelyth/nextcloud-talk 2026.5.42 → 2026.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/klaw.plugin.json +799 -2
  2. package/package.json +16 -4
  3. package/api.ts +0 -1
  4. package/channel-plugin-api.ts +0 -1
  5. package/contract-api.ts +0 -4
  6. package/doctor-contract-api.ts +0 -1
  7. package/index.ts +0 -20
  8. package/runtime-api.ts +0 -29
  9. package/secret-contract-api.ts +0 -5
  10. package/setup-entry.ts +0 -13
  11. package/src/accounts.test.ts +0 -31
  12. package/src/accounts.ts +0 -149
  13. package/src/api-credentials.ts +0 -31
  14. package/src/approval-auth.test.ts +0 -17
  15. package/src/approval-auth.ts +0 -27
  16. package/src/bot-preflight.test.ts +0 -135
  17. package/src/bot-preflight.ts +0 -183
  18. package/src/channel-api.ts +0 -5
  19. package/src/channel.adapters.ts +0 -52
  20. package/src/channel.core.test.ts +0 -75
  21. package/src/channel.lifecycle.test.ts +0 -91
  22. package/src/channel.status.test.ts +0 -28
  23. package/src/channel.ts +0 -225
  24. package/src/config-schema.ts +0 -79
  25. package/src/core.test.ts +0 -325
  26. package/src/doctor-contract.ts +0 -9
  27. package/src/doctor.test.ts +0 -87
  28. package/src/doctor.ts +0 -40
  29. package/src/gateway.ts +0 -109
  30. package/src/inbound.authz.test.ts +0 -146
  31. package/src/inbound.behavior.test.ts +0 -309
  32. package/src/inbound.ts +0 -392
  33. package/src/message-actions.test.ts +0 -270
  34. package/src/message-actions.ts +0 -82
  35. package/src/message-adapter.ts +0 -28
  36. package/src/monitor-runtime.ts +0 -138
  37. package/src/monitor.replay.test.ts +0 -276
  38. package/src/monitor.test-fixtures.ts +0 -30
  39. package/src/monitor.test-harness.ts +0 -59
  40. package/src/monitor.ts +0 -385
  41. package/src/normalize.ts +0 -44
  42. package/src/policy.ts +0 -111
  43. package/src/replay-guard.ts +0 -128
  44. package/src/room-info.test.ts +0 -160
  45. package/src/room-info.ts +0 -130
  46. package/src/runtime.ts +0 -9
  47. package/src/secret-contract.ts +0 -103
  48. package/src/secret-input.ts +0 -4
  49. package/src/send.cfg-threading.test.ts +0 -359
  50. package/src/send.runtime.ts +0 -8
  51. package/src/send.ts +0 -269
  52. package/src/session-route.ts +0 -40
  53. package/src/setup-core.ts +0 -250
  54. package/src/setup-surface.ts +0 -195
  55. package/src/setup.test.ts +0 -445
  56. package/src/signature.ts +0 -82
  57. package/src/types.ts +0 -195
  58. package/tsconfig.json +0 -16
package/src/inbound.ts DELETED
@@ -1,392 +0,0 @@
1
- import {
2
- channelIngressRoutes,
3
- resolveStableChannelMessageIngress,
4
- } from "klaw/plugin-sdk/channel-ingress-runtime";
5
- import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "klaw/plugin-sdk/inbound-envelope";
6
- import {
7
- normalizeOptionalString,
8
- normalizeStringEntries,
9
- } from "klaw/plugin-sdk/string-coerce-runtime";
10
- import {
11
- GROUP_POLICY_BLOCKED_LABEL,
12
- resolveAllowlistProviderRuntimeGroupPolicy,
13
- createChannelPairingController,
14
- deliverFormattedTextWithAttachments,
15
- logInboundDrop,
16
- resolveDefaultGroupPolicy,
17
- warnMissingProviderGroupPolicyFallbackOnce,
18
- type GroupPolicy,
19
- type KlawConfig,
20
- type OutboundReplyPayload,
21
- type RuntimeEnv,
22
- } from "../runtime-api.js";
23
- import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
24
- import {
25
- normalizeNextcloudTalkAllowEntry,
26
- normalizeNextcloudTalkAllowlist,
27
- resolveNextcloudTalkAllowlistMatch,
28
- resolveNextcloudTalkRequireMention,
29
- resolveNextcloudTalkRoomMatch,
30
- } from "./policy.js";
31
- import { resolveNextcloudTalkRoomKind } from "./room-info.js";
32
- import { getNextcloudTalkRuntime } from "./runtime.js";
33
- import { sendMessageNextcloudTalk } from "./send.js";
34
- import type { CoreConfig, NextcloudTalkInboundMessage, NextcloudTalkRoomConfig } from "./types.js";
35
-
36
- const CHANNEL_ID = "nextcloud-talk" as const;
37
-
38
- type NextcloudTalkRoomMatch = ReturnType<typeof resolveNextcloudTalkRoomMatch>;
39
-
40
- function hasAllowEntries(entries: string[]): boolean {
41
- return normalizeNextcloudTalkAllowlist(entries).length > 0;
42
- }
43
-
44
- function roomRoutes(params: {
45
- isGroup: boolean;
46
- groupPolicy: GroupPolicy;
47
- roomMatch: NextcloudTalkRoomMatch;
48
- roomConfig?: NextcloudTalkRoomConfig;
49
- senderId: string;
50
- outerGroupAllowFrom: string[];
51
- roomAllowFrom: string[];
52
- }) {
53
- if (!params.isGroup) {
54
- return [];
55
- }
56
- const roomSenderConfigured =
57
- params.groupPolicy === "allowlist" && hasAllowEntries(params.roomAllowFrom);
58
- return channelIngressRoutes(
59
- params.roomMatch.allowlistConfigured && {
60
- id: "nextcloud-talk:room",
61
- allowed: params.roomMatch.allowed,
62
- precedence: 0,
63
- matchId: "nextcloud-talk-room",
64
- blockReason: "room_not_allowlisted",
65
- },
66
- params.roomConfig?.enabled === false && {
67
- id: "nextcloud-talk:room-enabled",
68
- enabled: false,
69
- precedence: 10,
70
- blockReason: "room_disabled",
71
- },
72
- roomSenderConfigured && {
73
- id: "nextcloud-talk:room-sender",
74
- kind: "nestedAllowlist",
75
- precedence: 20,
76
- blockReason: "room_sender_not_allowlisted",
77
- ...(!hasAllowEntries(params.outerGroupAllowFrom)
78
- ? {
79
- senderPolicy: "replace" as const,
80
- senderAllowFrom: params.roomAllowFrom,
81
- }
82
- : {
83
- allowed: resolveNextcloudTalkAllowlistMatch({
84
- allowFrom: params.roomAllowFrom,
85
- senderId: params.senderId,
86
- }).allowed,
87
- matchId: "nextcloud-talk-room-sender",
88
- }),
89
- },
90
- );
91
- }
92
-
93
- async function deliverNextcloudTalkReply(params: {
94
- cfg: CoreConfig;
95
- payload: OutboundReplyPayload;
96
- roomToken: string;
97
- accountId: string;
98
- statusSink?: (patch: { lastOutboundAt?: number }) => void;
99
- }): Promise<void> {
100
- const { cfg, payload, roomToken, accountId, statusSink } = params;
101
- await deliverFormattedTextWithAttachments({
102
- payload,
103
- send: async ({ text, replyToId }) => {
104
- await sendMessageNextcloudTalk(roomToken, text, {
105
- cfg,
106
- accountId,
107
- replyTo: replyToId,
108
- });
109
- statusSink?.({ lastOutboundAt: Date.now() });
110
- },
111
- });
112
- }
113
-
114
- export async function handleNextcloudTalkInbound(params: {
115
- message: NextcloudTalkInboundMessage;
116
- account: ResolvedNextcloudTalkAccount;
117
- config: CoreConfig;
118
- runtime: RuntimeEnv;
119
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
120
- }): Promise<void> {
121
- const { message, account, config, runtime, statusSink } = params;
122
- const core = getNextcloudTalkRuntime();
123
- const pairing = createChannelPairingController({
124
- core,
125
- channel: CHANNEL_ID,
126
- accountId: account.accountId,
127
- });
128
-
129
- const rawBody = message.text?.trim() ?? "";
130
- if (!rawBody) {
131
- return;
132
- }
133
-
134
- const roomKind = await resolveNextcloudTalkRoomKind({
135
- account,
136
- roomToken: message.roomToken,
137
- runtime,
138
- });
139
- const isGroup = roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
140
- const senderId = message.senderId;
141
- const senderName = message.senderName;
142
- const roomToken = message.roomToken;
143
- const roomName = message.roomName;
144
-
145
- statusSink?.({ lastInboundAt: message.timestamp });
146
-
147
- const roomMatch = resolveNextcloudTalkRoomMatch({
148
- rooms: account.config.rooms,
149
- roomToken,
150
- });
151
- const roomConfig = roomMatch.roomConfig;
152
- const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
153
- cfg: config as KlawConfig,
154
- surface: CHANNEL_ID,
155
- });
156
- const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as KlawConfig);
157
- const shouldRequireMention = isGroup
158
- ? resolveNextcloudTalkRequireMention({
159
- roomConfig,
160
- wildcardConfig: roomMatch.wildcardConfig,
161
- })
162
- : false;
163
- const { groupPolicy, providerMissingFallbackApplied } =
164
- resolveAllowlistProviderRuntimeGroupPolicy({
165
- providerConfigPresent:
166
- ((config.channels as Record<string, unknown> | undefined)?.[CHANNEL_ID] ?? undefined) !==
167
- undefined,
168
- groupPolicy: account.config.groupPolicy,
169
- defaultGroupPolicy: resolveDefaultGroupPolicy(config as KlawConfig),
170
- });
171
- const allowFrom = normalizeStringEntries(account.config.allowFrom);
172
- const outerGroupAllowFrom = account.config.groupAllowFrom?.length
173
- ? normalizeStringEntries(account.config.groupAllowFrom)
174
- : allowFrom;
175
- const roomAllowFrom = normalizeStringEntries(roomConfig?.allowFrom);
176
- const resolveAccess = async (wasMentioned?: boolean) =>
177
- await resolveStableChannelMessageIngress({
178
- channelId: CHANNEL_ID,
179
- accountId: account.accountId,
180
- identity: {
181
- key: "nextcloud-talk-user-id",
182
- normalize: (value) => normalizeNextcloudTalkAllowEntry(value) || null,
183
- sensitivity: "pii",
184
- entryIdPrefix: "nextcloud-talk-entry",
185
- },
186
- cfg: config as KlawConfig,
187
- readStoreAllowFrom: async () =>
188
- await pairing.readStoreForDmPolicy(CHANNEL_ID, account.accountId),
189
- subject: { stableId: senderId },
190
- conversation: {
191
- kind: isGroup ? "group" : "direct",
192
- id: isGroup ? roomToken : senderId,
193
- },
194
- route: roomRoutes({
195
- isGroup,
196
- groupPolicy,
197
- roomMatch,
198
- roomConfig,
199
- senderId,
200
- outerGroupAllowFrom,
201
- roomAllowFrom,
202
- }),
203
- dmPolicy: account.config.dmPolicy ?? "pairing",
204
- groupPolicy,
205
- policy: {
206
- groupAllowFromFallbackToAllowFrom: true,
207
- activation: {
208
- requireMention: isGroup && shouldRequireMention,
209
- allowTextCommands,
210
- },
211
- },
212
- mentionFacts:
213
- isGroup && wasMentioned !== undefined
214
- ? {
215
- canDetectMention: true,
216
- wasMentioned,
217
- hasAnyMention: wasMentioned,
218
- }
219
- : undefined,
220
- allowFrom,
221
- groupAllowFrom: account.config.groupAllowFrom,
222
- command: {
223
- allowTextCommands,
224
- hasControlCommand,
225
- },
226
- });
227
- let access = await resolveAccess();
228
- warnMissingProviderGroupPolicyFallbackOnce({
229
- providerMissingFallbackApplied,
230
- providerKey: "nextcloud-talk",
231
- accountId: account.accountId,
232
- blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
233
- log: (message) => runtime.log?.(message),
234
- });
235
- const commandAuthorized = access.commandAccess.authorized;
236
- const accessReason =
237
- access.ingress.reasonCode === "route_blocked"
238
- ? "route blocked"
239
- : access.senderAccess.reasonCode;
240
-
241
- if (isGroup) {
242
- if (access.routeAccess.reason === "room_not_allowlisted") {
243
- runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
244
- return;
245
- }
246
- if (access.routeAccess.reason === "room_disabled") {
247
- runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`);
248
- return;
249
- }
250
- if (access.routeAccess.reason === "room_sender_not_allowlisted") {
251
- runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`);
252
- return;
253
- }
254
- if (access.senderAccess.decision !== "allow") {
255
- runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${accessReason})`);
256
- return;
257
- }
258
- } else {
259
- if (access.senderAccess.decision !== "allow") {
260
- if (access.senderAccess.decision === "pairing") {
261
- await pairing.issueChallenge({
262
- senderId,
263
- senderIdLine: `Your Nextcloud user id: ${senderId}`,
264
- meta: { name: senderName || undefined },
265
- sendPairingReply: async (text) => {
266
- await sendMessageNextcloudTalk(roomToken, text, {
267
- cfg: config,
268
- accountId: account.accountId,
269
- });
270
- statusSink?.({ lastOutboundAt: Date.now() });
271
- },
272
- onReplyError: (err) => {
273
- runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
274
- },
275
- });
276
- }
277
- runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${accessReason})`);
278
- return;
279
- }
280
- }
281
-
282
- if (access.commandAccess.shouldBlockControlCommand) {
283
- logInboundDrop({
284
- log: (message) => runtime.log?.(message),
285
- channel: CHANNEL_ID,
286
- reason: "control command (unauthorized)",
287
- target: senderId,
288
- });
289
- return;
290
- }
291
-
292
- const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as KlawConfig);
293
- const wasMentioned = mentionRegexes.length
294
- ? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes)
295
- : false;
296
- if (isGroup) {
297
- access = await resolveAccess(wasMentioned);
298
- }
299
-
300
- if (isGroup && access.activationAccess.shouldSkip) {
301
- runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
302
- return;
303
- }
304
- const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
305
- cfg: config as KlawConfig,
306
- channel: CHANNEL_ID,
307
- accountId: account.accountId,
308
- peer: {
309
- kind: isGroup ? "group" : "direct",
310
- id: isGroup ? roomToken : senderId,
311
- },
312
- runtime: core.channel,
313
- sessionStore: (config.session as Record<string, unknown> | undefined)?.store as
314
- | string
315
- | undefined,
316
- });
317
-
318
- const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
319
- const { storePath, body } = buildEnvelope({
320
- channel: "Nextcloud Talk",
321
- from: fromLabel,
322
- timestamp: message.timestamp,
323
- body: rawBody,
324
- });
325
-
326
- const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
327
-
328
- const ctxPayload = core.channel.reply.finalizeInboundContext({
329
- Body: body,
330
- BodyForAgent: rawBody,
331
- RawBody: rawBody,
332
- CommandBody: rawBody,
333
- From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
334
- To: `nextcloud-talk:${roomToken}`,
335
- SessionKey: route.sessionKey,
336
- AccountId: route.accountId,
337
- ChatType: isGroup ? "group" : "direct",
338
- ConversationLabel: fromLabel,
339
- SenderName: senderName || undefined,
340
- SenderId: senderId,
341
- GroupSubject: isGroup ? roomName || roomToken : undefined,
342
- GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
343
- Provider: CHANNEL_ID,
344
- Surface: CHANNEL_ID,
345
- WasMentioned: isGroup ? wasMentioned : undefined,
346
- MessageSid: message.messageId,
347
- Timestamp: message.timestamp,
348
- OriginatingChannel: CHANNEL_ID,
349
- OriginatingTo: `nextcloud-talk:${roomToken}`,
350
- CommandAuthorized: commandAuthorized,
351
- });
352
-
353
- await core.channel.turn.runAssembled({
354
- cfg: config as KlawConfig,
355
- channel: CHANNEL_ID,
356
- accountId: account.accountId,
357
- agentId: route.agentId,
358
- routeSessionKey: route.sessionKey,
359
- storePath,
360
- ctxPayload,
361
- recordInboundSession: core.channel.session.recordInboundSession,
362
- dispatchReplyWithBufferedBlockDispatcher:
363
- core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
364
- delivery: {
365
- deliver: async (payload) => {
366
- await deliverNextcloudTalkReply({
367
- cfg: config,
368
- payload,
369
- roomToken,
370
- accountId: account.accountId,
371
- statusSink,
372
- });
373
- },
374
- onError: (err, info) => {
375
- runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
376
- },
377
- },
378
- replyPipeline: {},
379
- replyOptions: {
380
- skillFilter: roomConfig?.skills,
381
- disableBlockStreaming:
382
- typeof account.config.blockStreaming === "boolean"
383
- ? !account.config.blockStreaming
384
- : undefined,
385
- },
386
- record: {
387
- onRecordError: (err) => {
388
- runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
389
- },
390
- },
391
- });
392
- }
@@ -1,270 +0,0 @@
1
- import type { KlawConfig } from "klaw/plugin-sdk/config-types";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import type { CoreConfig } from "./types.js";
4
-
5
- const hoisted = vi.hoisted(() => ({
6
- sendReactionNextcloudTalk: vi.fn(),
7
- sendMessageNextcloudTalk: vi.fn(),
8
- listNextcloudTalkAccountIds: vi.fn(),
9
- resolveNextcloudTalkAccount: vi.fn(),
10
- }));
11
-
12
- vi.mock("./send.js", () => ({
13
- sendReactionNextcloudTalk: hoisted.sendReactionNextcloudTalk,
14
- sendMessageNextcloudTalk: hoisted.sendMessageNextcloudTalk,
15
- }));
16
-
17
- vi.mock("./accounts.js", () => ({
18
- listNextcloudTalkAccountIds: hoisted.listNextcloudTalkAccountIds,
19
- resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
20
- }));
21
-
22
- const { nextcloudTalkMessageActions } = await import("./message-actions.js");
23
-
24
- const configuredAccount = {
25
- accountId: "default",
26
- enabled: true,
27
- baseUrl: "https://nc.example.com",
28
- secret: "bot-secret",
29
- } as const;
30
-
31
- const unconfiguredAccount = {
32
- accountId: "default",
33
- enabled: true,
34
- baseUrl: "",
35
- secret: null,
36
- } as const;
37
-
38
- const disabledAccount = {
39
- accountId: "default",
40
- enabled: false,
41
- baseUrl: "https://nc.example.com",
42
- secret: "bot-secret",
43
- } as const;
44
-
45
- describe("nextcloudTalkMessageActions", () => {
46
- beforeEach(() => {
47
- hoisted.sendReactionNextcloudTalk.mockReset();
48
- hoisted.sendReactionNextcloudTalk.mockResolvedValue({ ok: true });
49
- hoisted.sendMessageNextcloudTalk.mockReset();
50
- hoisted.listNextcloudTalkAccountIds.mockReset();
51
- hoisted.resolveNextcloudTalkAccount.mockReset();
52
- });
53
-
54
- describe("describeMessageTool", () => {
55
- it("returns null when no accounts are configured", () => {
56
- hoisted.listNextcloudTalkAccountIds.mockReturnValue([]);
57
-
58
- const result = nextcloudTalkMessageActions.describeMessageTool?.({
59
- cfg: {} as KlawConfig,
60
- });
61
-
62
- expect(result).toBeNull();
63
- });
64
-
65
- it("returns null when configured account has no secret/baseUrl", () => {
66
- hoisted.listNextcloudTalkAccountIds.mockReturnValue([unconfiguredAccount.accountId]);
67
- hoisted.resolveNextcloudTalkAccount.mockReturnValue(unconfiguredAccount);
68
-
69
- const result = nextcloudTalkMessageActions.describeMessageTool?.({
70
- cfg: {} as KlawConfig,
71
- });
72
-
73
- expect(result).toBeNull();
74
- });
75
-
76
- it("returns null when the only listed account is disabled", () => {
77
- hoisted.listNextcloudTalkAccountIds.mockReturnValue([disabledAccount.accountId]);
78
- hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
79
-
80
- const result = nextcloudTalkMessageActions.describeMessageTool?.({
81
- cfg: {} as KlawConfig,
82
- });
83
-
84
- expect(result).toBeNull();
85
- });
86
-
87
- it("advertises send + react when an account is configured", () => {
88
- hoisted.listNextcloudTalkAccountIds.mockReturnValue([configuredAccount.accountId]);
89
- hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
90
-
91
- const result = nextcloudTalkMessageActions.describeMessageTool?.({
92
- cfg: {} as KlawConfig,
93
- });
94
-
95
- expect(result?.actions).toEqual(["send", "react"]);
96
- });
97
-
98
- it("scopes discovery to a specific accountId when provided", () => {
99
- hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
100
-
101
- const result = nextcloudTalkMessageActions.describeMessageTool?.({
102
- cfg: {} as KlawConfig,
103
- accountId: "work",
104
- });
105
-
106
- expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
107
- cfg: {},
108
- accountId: "work",
109
- });
110
- expect(hoisted.listNextcloudTalkAccountIds).not.toHaveBeenCalled();
111
- expect(result?.actions).toEqual(["send", "react"]);
112
- });
113
-
114
- it("returns null when the targeted account is disabled", () => {
115
- hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
116
-
117
- const result = nextcloudTalkMessageActions.describeMessageTool?.({
118
- cfg: {} as KlawConfig,
119
- accountId: "work",
120
- });
121
-
122
- expect(result).toBeNull();
123
- });
124
- });
125
-
126
- describe("supportsAction", () => {
127
- it("delegates send back to outbound", () => {
128
- expect(nextcloudTalkMessageActions.supportsAction?.({ action: "send" })).toBe(false);
129
- });
130
-
131
- it("handles react locally", () => {
132
- expect(nextcloudTalkMessageActions.supportsAction?.({ action: "react" })).toBe(true);
133
- });
134
- });
135
-
136
- describe("handleAction", () => {
137
- const cfg = {} as CoreConfig;
138
-
139
- it("invokes sendReactionNextcloudTalk with normalized params for the react action", async () => {
140
- const result = await nextcloudTalkMessageActions.handleAction?.({
141
- channel: "nextcloud-talk",
142
- action: "react",
143
- params: { to: "room:abc123", messageId: "42", emoji: "👍" },
144
- cfg,
145
- accountId: "work",
146
- });
147
-
148
- expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
149
- expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "42", "👍", {
150
- accountId: "work",
151
- cfg,
152
- });
153
- expect(result).toMatchObject({
154
- details: { ok: true, added: "👍" },
155
- });
156
- });
157
-
158
- it("uses toolContext.currentMessageId when params.messageId is missing", async () => {
159
- await nextcloudTalkMessageActions.handleAction?.({
160
- channel: "nextcloud-talk",
161
- action: "react",
162
- params: { to: "room:abc123", emoji: "✅" },
163
- cfg,
164
- accountId: null,
165
- toolContext: { currentMessageId: 99 },
166
- });
167
-
168
- expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "99", "✅", {
169
- accountId: undefined,
170
- cfg,
171
- });
172
- });
173
-
174
- it("requires a target room token", async () => {
175
- await expect(
176
- nextcloudTalkMessageActions.handleAction?.({
177
- channel: "nextcloud-talk",
178
- action: "react",
179
- params: { messageId: "1", emoji: "👍" },
180
- cfg,
181
- }),
182
- ).rejects.toThrow(/to \(room token\) required/);
183
- expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
184
- });
185
-
186
- it("requires a messageId (explicit or via toolContext)", async () => {
187
- await expect(
188
- nextcloudTalkMessageActions.handleAction?.({
189
- channel: "nextcloud-talk",
190
- action: "react",
191
- params: { to: "room:abc123", emoji: "👍" },
192
- cfg,
193
- }),
194
- ).rejects.toThrow(/messageId required/);
195
- expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
196
- });
197
-
198
- it("requires an emoji", async () => {
199
- await expect(
200
- nextcloudTalkMessageActions.handleAction?.({
201
- channel: "nextcloud-talk",
202
- action: "react",
203
- params: { to: "room:abc123", messageId: "1" },
204
- cfg,
205
- }),
206
- ).rejects.toThrow(/emoji required/);
207
- expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
208
- });
209
-
210
- it("rejects send through the action handler (outbound owns send)", async () => {
211
- await expect(
212
- nextcloudTalkMessageActions.handleAction?.({
213
- channel: "nextcloud-talk",
214
- action: "send",
215
- params: { to: "room:abc123", text: "hi" },
216
- cfg,
217
- }),
218
- ).rejects.toThrow(/handled by outbound/);
219
- });
220
-
221
- it("rejects unsupported actions", async () => {
222
- await expect(
223
- nextcloudTalkMessageActions.handleAction?.({
224
- channel: "nextcloud-talk",
225
- action: "delete",
226
- params: {},
227
- cfg,
228
- }),
229
- ).rejects.toThrow(/Action delete not supported for nextcloud-talk/);
230
- });
231
-
232
- it("rejects reaction removal requests without calling the add-reaction sender", async () => {
233
- await expect(
234
- nextcloudTalkMessageActions.handleAction?.({
235
- channel: "nextcloud-talk",
236
- action: "react",
237
- params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: true },
238
- cfg,
239
- }),
240
- ).rejects.toThrow(/removal is not supported/);
241
- expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
242
- });
243
-
244
- it("still adds the reaction when remove is explicitly false", async () => {
245
- await nextcloudTalkMessageActions.handleAction?.({
246
- channel: "nextcloud-talk",
247
- action: "react",
248
- params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: false },
249
- cfg,
250
- });
251
-
252
- expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
253
- });
254
-
255
- it("propagates errors from sendReactionNextcloudTalk", async () => {
256
- hoisted.sendReactionNextcloudTalk.mockRejectedValueOnce(
257
- new Error("Nextcloud Talk reaction failed: 403 forbidden"),
258
- );
259
-
260
- await expect(
261
- nextcloudTalkMessageActions.handleAction?.({
262
- channel: "nextcloud-talk",
263
- action: "react",
264
- params: { to: "room:abc123", messageId: "1", emoji: "👍" },
265
- cfg,
266
- }),
267
- ).rejects.toThrow(/403 forbidden/);
268
- });
269
- });
270
- });