@kodelyth/nextcloud-talk 2026.5.39 → 2026.5.42
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.
- package/api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +4 -0
- package/dist/api.js +2 -0
- package/dist/channel-ej3z6XJ5.js +2094 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/contract-api.js +2 -0
- package/dist/doctor-contract-Dia7keG4.js +7 -0
- package/dist/doctor-contract-api.js +2 -0
- package/dist/index.js +22 -0
- package/dist/runtime-api-DCIDXlUd.js +14 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-DQ2wQ4m1.js +86 -0
- package/dist/secret-contract-api.js +2 -0
- package/dist/setup-entry.js +15 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +20 -0
- package/klaw.plugin.json +2 -799
- package/package.json +4 -4
- package/runtime-api.ts +29 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +31 -0
- package/src/accounts.ts +149 -0
- package/src/api-credentials.ts +31 -0
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +27 -0
- package/src/bot-preflight.test.ts +135 -0
- package/src/bot-preflight.ts +183 -0
- package/src/channel-api.ts +5 -0
- package/src/channel.adapters.ts +52 -0
- package/src/channel.core.test.ts +75 -0
- package/src/channel.lifecycle.test.ts +91 -0
- package/src/channel.status.test.ts +28 -0
- package/src/channel.ts +225 -0
- package/src/config-schema.ts +79 -0
- package/src/core.test.ts +325 -0
- package/src/doctor-contract.ts +9 -0
- package/src/doctor.test.ts +87 -0
- package/src/doctor.ts +40 -0
- package/src/gateway.ts +109 -0
- package/src/inbound.authz.test.ts +146 -0
- package/src/inbound.behavior.test.ts +309 -0
- package/src/inbound.ts +392 -0
- package/src/message-actions.test.ts +270 -0
- package/src/message-actions.ts +82 -0
- package/src/message-adapter.ts +28 -0
- package/src/monitor-runtime.ts +138 -0
- package/src/monitor.replay.test.ts +276 -0
- package/src/monitor.test-fixtures.ts +30 -0
- package/src/monitor.test-harness.ts +59 -0
- package/src/monitor.ts +385 -0
- package/src/normalize.ts +44 -0
- package/src/policy.ts +111 -0
- package/src/replay-guard.ts +128 -0
- package/src/room-info.test.ts +160 -0
- package/src/room-info.ts +130 -0
- package/src/runtime.ts +9 -0
- package/src/secret-contract.ts +103 -0
- package/src/secret-input.ts +4 -0
- package/src/send.cfg-threading.test.ts +359 -0
- package/src/send.runtime.ts +8 -0
- package/src/send.ts +269 -0
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +250 -0
- package/src/setup-surface.ts +195 -0
- package/src/setup.test.ts +445 -0
- package/src/signature.ts +82 -0
- package/src/types.ts +195 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/contract-api.js +0 -7
- package/doctor-contract-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/secret-contract-api.js +0 -7
- package/setup-entry.js +0 -7
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { verifyChannelMessageAdapterCapabilityProofs } from "klaw/plugin-sdk/channel-message";
|
|
2
|
+
import {
|
|
3
|
+
createSendCfgThreadingRuntime,
|
|
4
|
+
expectProvidedCfgSkipsRuntimeLoad,
|
|
5
|
+
} from "klaw/plugin-sdk/channel-test-helpers";
|
|
6
|
+
import type { KlawConfig as CoreConfig } from "klaw/plugin-sdk/config-contracts";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
const hoisted = vi.hoisted(() => ({
|
|
10
|
+
loadConfig: vi.fn(),
|
|
11
|
+
resolveMarkdownTableMode: vi.fn(() => "preserve"),
|
|
12
|
+
convertMarkdownTables: vi.fn((text: string) => text),
|
|
13
|
+
record: vi.fn(),
|
|
14
|
+
resolveNextcloudTalkAccount: vi.fn(),
|
|
15
|
+
ssrfPolicyFromPrivateNetworkOptIn: vi.fn(() => undefined),
|
|
16
|
+
generateNextcloudTalkSignature: vi.fn(() => ({
|
|
17
|
+
random: "r",
|
|
18
|
+
signature: "s",
|
|
19
|
+
})),
|
|
20
|
+
mockFetchGuard: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("./send.runtime.js", () => {
|
|
24
|
+
return {
|
|
25
|
+
convertMarkdownTables: hoisted.convertMarkdownTables,
|
|
26
|
+
fetchWithSsrFGuard: hoisted.mockFetchGuard,
|
|
27
|
+
generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature,
|
|
28
|
+
getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted),
|
|
29
|
+
requireRuntimeConfig: (cfg: unknown, context: string) => {
|
|
30
|
+
if (cfg) {
|
|
31
|
+
return cfg;
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`${context} requires a resolved runtime config`);
|
|
34
|
+
},
|
|
35
|
+
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
|
|
36
|
+
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
|
37
|
+
ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const { nextcloudTalkMessageAdapter } = await import("./message-adapter.js");
|
|
42
|
+
const { sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js");
|
|
43
|
+
|
|
44
|
+
function expectProvidedMessageCfgThreading(cfg: unknown): void {
|
|
45
|
+
expectProvidedCfgSkipsRuntimeLoad({
|
|
46
|
+
loadConfig: hoisted.loadConfig,
|
|
47
|
+
resolveAccount: hoisted.resolveNextcloudTalkAccount,
|
|
48
|
+
cfg,
|
|
49
|
+
accountId: "work",
|
|
50
|
+
});
|
|
51
|
+
expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({
|
|
52
|
+
cfg,
|
|
53
|
+
channel: "nextcloud-talk",
|
|
54
|
+
accountId: "default",
|
|
55
|
+
});
|
|
56
|
+
expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("nextcloud-talk send cfg threading", () => {
|
|
60
|
+
const fetchMock = vi.fn<typeof fetch>();
|
|
61
|
+
const fixedSentAt = 1_800_000_000_000;
|
|
62
|
+
const defaultAccount = {
|
|
63
|
+
accountId: "default",
|
|
64
|
+
baseUrl: "https://nextcloud.example.com",
|
|
65
|
+
secret: "secret-value",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function mockNextcloudMessageResponse(messageId: number, timestamp: number): void {
|
|
69
|
+
fetchMock.mockResolvedValueOnce(
|
|
70
|
+
new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
ocs: { data: { id: messageId, timestamp } },
|
|
73
|
+
}),
|
|
74
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.setSystemTime(fixedSentAt);
|
|
81
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
82
|
+
// Route the SSRF guard mock through the global fetch mock.
|
|
83
|
+
hoisted.mockFetchGuard.mockImplementation(async (p: { url: string; init?: RequestInit }) => {
|
|
84
|
+
const response = await globalThis.fetch(p.url, p.init);
|
|
85
|
+
return { response, release: async () => {}, finalUrl: p.url };
|
|
86
|
+
});
|
|
87
|
+
hoisted.loadConfig.mockReset();
|
|
88
|
+
hoisted.resolveMarkdownTableMode.mockClear();
|
|
89
|
+
hoisted.convertMarkdownTables.mockClear();
|
|
90
|
+
hoisted.record.mockReset();
|
|
91
|
+
hoisted.ssrfPolicyFromPrivateNetworkOptIn.mockClear();
|
|
92
|
+
hoisted.generateNextcloudTalkSignature.mockClear();
|
|
93
|
+
hoisted.resolveNextcloudTalkAccount.mockReset();
|
|
94
|
+
hoisted.resolveNextcloudTalkAccount.mockReturnValue(defaultAccount);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
fetchMock.mockReset();
|
|
99
|
+
hoisted.mockFetchGuard.mockReset();
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
vi.unstubAllGlobals();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => {
|
|
105
|
+
const cfg = { source: "provided" } as const;
|
|
106
|
+
mockNextcloudMessageResponse(12345, 1_706_000_000);
|
|
107
|
+
|
|
108
|
+
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
|
109
|
+
cfg,
|
|
110
|
+
accountId: "work",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expectProvidedMessageCfgThreading(cfg);
|
|
114
|
+
expect(hoisted.record).toHaveBeenCalledWith({
|
|
115
|
+
channel: "nextcloud-talk",
|
|
116
|
+
accountId: "default",
|
|
117
|
+
direction: "outbound",
|
|
118
|
+
});
|
|
119
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
messageId: "12345",
|
|
122
|
+
receipt: {
|
|
123
|
+
platformMessageIds: ["12345"],
|
|
124
|
+
primaryPlatformMessageId: "12345",
|
|
125
|
+
parts: [
|
|
126
|
+
{
|
|
127
|
+
index: 0,
|
|
128
|
+
kind: "text",
|
|
129
|
+
platformMessageId: "12345",
|
|
130
|
+
raw: {
|
|
131
|
+
channel: "nextcloud-talk",
|
|
132
|
+
conversationId: "abc123",
|
|
133
|
+
messageId: "12345",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
raw: [
|
|
138
|
+
{
|
|
139
|
+
channel: "nextcloud-talk",
|
|
140
|
+
conversationId: "abc123",
|
|
141
|
+
messageId: "12345",
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
sentAt: fixedSentAt,
|
|
145
|
+
},
|
|
146
|
+
roomToken: "abc123",
|
|
147
|
+
timestamp: 1_706_000_000,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("sends with provided cfg even when the runtime store is not initialized", async () => {
|
|
152
|
+
const cfg = { source: "provided" } as const;
|
|
153
|
+
hoisted.record.mockImplementation(() => {
|
|
154
|
+
throw new Error("Nextcloud Talk runtime not initialized");
|
|
155
|
+
});
|
|
156
|
+
mockNextcloudMessageResponse(12346, 1_706_000_001);
|
|
157
|
+
|
|
158
|
+
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
|
159
|
+
cfg,
|
|
160
|
+
accountId: "work",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expectProvidedMessageCfgThreading(cfg);
|
|
164
|
+
expect(result).toEqual({
|
|
165
|
+
messageId: "12346",
|
|
166
|
+
receipt: {
|
|
167
|
+
platformMessageIds: ["12346"],
|
|
168
|
+
primaryPlatformMessageId: "12346",
|
|
169
|
+
parts: [
|
|
170
|
+
{
|
|
171
|
+
index: 0,
|
|
172
|
+
kind: "text",
|
|
173
|
+
platformMessageId: "12346",
|
|
174
|
+
raw: {
|
|
175
|
+
channel: "nextcloud-talk",
|
|
176
|
+
conversationId: "abc123",
|
|
177
|
+
messageId: "12346",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
raw: [
|
|
182
|
+
{
|
|
183
|
+
channel: "nextcloud-talk",
|
|
184
|
+
conversationId: "abc123",
|
|
185
|
+
messageId: "12346",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
sentAt: fixedSentAt,
|
|
189
|
+
},
|
|
190
|
+
roomToken: "abc123",
|
|
191
|
+
timestamp: 1_706_000_001,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("preserves reply ids in receipts", async () => {
|
|
196
|
+
const cfg = { source: "provided" } as const;
|
|
197
|
+
mockNextcloudMessageResponse(12347, 1_706_000_002);
|
|
198
|
+
|
|
199
|
+
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
|
200
|
+
cfg,
|
|
201
|
+
accountId: "work",
|
|
202
|
+
replyTo: "parent-1",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(result.receipt).toEqual({
|
|
206
|
+
platformMessageIds: ["12347"],
|
|
207
|
+
primaryPlatformMessageId: "12347",
|
|
208
|
+
replyToId: "parent-1",
|
|
209
|
+
parts: [
|
|
210
|
+
{
|
|
211
|
+
index: 0,
|
|
212
|
+
kind: "text",
|
|
213
|
+
replyToId: "parent-1",
|
|
214
|
+
platformMessageId: "12347",
|
|
215
|
+
raw: {
|
|
216
|
+
channel: "nextcloud-talk",
|
|
217
|
+
conversationId: "abc123",
|
|
218
|
+
messageId: "12347",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
raw: [
|
|
223
|
+
{
|
|
224
|
+
channel: "nextcloud-talk",
|
|
225
|
+
conversationId: "abc123",
|
|
226
|
+
messageId: "12347",
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
sentAt: fixedSentAt,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("explains that 401 sends can mean the response feature is missing", async () => {
|
|
234
|
+
const cfg = { source: "provided" } as const;
|
|
235
|
+
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 }));
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
sendMessageNextcloudTalk("room:abc123", "hello", {
|
|
239
|
+
cfg,
|
|
240
|
+
accountId: "work",
|
|
241
|
+
}),
|
|
242
|
+
).rejects.toThrow("--feature response");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("declares message adapter durable text, media, and reply with receipt proofs", async () => {
|
|
246
|
+
const cfg = { source: "provided" } as const;
|
|
247
|
+
mockNextcloudMessageResponse(22345, 1_706_000_003);
|
|
248
|
+
mockNextcloudMessageResponse(22346, 1_706_000_004);
|
|
249
|
+
mockNextcloudMessageResponse(22347, 1_706_000_005);
|
|
250
|
+
|
|
251
|
+
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
|
|
252
|
+
adapterName: "nextcloud-talk",
|
|
253
|
+
adapter: nextcloudTalkMessageAdapter,
|
|
254
|
+
proofs: {
|
|
255
|
+
text: async () => {
|
|
256
|
+
const result = await nextcloudTalkMessageAdapter.send?.text?.({
|
|
257
|
+
cfg: cfg as CoreConfig,
|
|
258
|
+
to: "room:abc123",
|
|
259
|
+
text: "hello",
|
|
260
|
+
accountId: "work",
|
|
261
|
+
});
|
|
262
|
+
expect(result?.receipt.platformMessageIds).toEqual(["22345"]);
|
|
263
|
+
},
|
|
264
|
+
media: async () => {
|
|
265
|
+
const result = await nextcloudTalkMessageAdapter.send?.media?.({
|
|
266
|
+
cfg: cfg as CoreConfig,
|
|
267
|
+
to: "room:abc123",
|
|
268
|
+
text: "image",
|
|
269
|
+
mediaUrl: "https://example.com/image.png",
|
|
270
|
+
accountId: "work",
|
|
271
|
+
});
|
|
272
|
+
expect(result?.receipt.platformMessageIds).toEqual(["22346"]);
|
|
273
|
+
const mediaSendCall = fetchMock.mock.calls.at(1);
|
|
274
|
+
expect(mediaSendCall?.[0]).toBe(
|
|
275
|
+
"https://nextcloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/abc123/message",
|
|
276
|
+
);
|
|
277
|
+
expect(mediaSendCall?.[1]?.body).toBe(
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
message: "image\n\nAttachment: https://example.com/image.png",
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
replyTo: async () => {
|
|
284
|
+
const result = await nextcloudTalkMessageAdapter.send?.text?.({
|
|
285
|
+
cfg: cfg as CoreConfig,
|
|
286
|
+
to: "room:abc123",
|
|
287
|
+
text: "threaded",
|
|
288
|
+
replyToId: "parent-1",
|
|
289
|
+
accountId: "work",
|
|
290
|
+
});
|
|
291
|
+
expect(result?.receipt.replyToId).toBe("parent-1");
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(proofResults.find((result) => result.capability === "text")?.status).toBe("verified");
|
|
297
|
+
expect(proofResults.find((result) => result.capability === "media")?.status).toBe("verified");
|
|
298
|
+
expect(proofResults.find((result) => result.capability === "replyTo")?.status).toBe("verified");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("fails hard for sendReaction when cfg is omitted", async () => {
|
|
302
|
+
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
|
306
|
+
accountId: "default",
|
|
307
|
+
} as never),
|
|
308
|
+
).rejects.toThrow("Nextcloud Talk send requires a resolved runtime config");
|
|
309
|
+
|
|
310
|
+
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
|
311
|
+
expect(hoisted.resolveNextcloudTalkAccount).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("uses provided cfg for sendReaction and posts the reaction payload", async () => {
|
|
315
|
+
const cfg = { source: "provided" } as const;
|
|
316
|
+
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
|
317
|
+
|
|
318
|
+
const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
|
319
|
+
cfg,
|
|
320
|
+
accountId: "work",
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expectProvidedCfgSkipsRuntimeLoad({
|
|
324
|
+
loadConfig: hoisted.loadConfig,
|
|
325
|
+
resolveAccount: hoisted.resolveNextcloudTalkAccount,
|
|
326
|
+
cfg,
|
|
327
|
+
accountId: "work",
|
|
328
|
+
});
|
|
329
|
+
expect(hoisted.generateNextcloudTalkSignature).toHaveBeenCalledWith({
|
|
330
|
+
body: "👍",
|
|
331
|
+
secret: "secret-value",
|
|
332
|
+
});
|
|
333
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
334
|
+
"https://nextcloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/ops/reaction/m-1",
|
|
335
|
+
{
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
"Content-Type": "application/json",
|
|
339
|
+
"OCS-APIRequest": "true",
|
|
340
|
+
"X-Nextcloud-Talk-Bot-Random": "r",
|
|
341
|
+
"X-Nextcloud-Talk-Bot-Signature": "s",
|
|
342
|
+
},
|
|
343
|
+
body: JSON.stringify({ reaction: "👍" }),
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
expect(result).toEqual({ ok: true });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("surfaces sendReaction HTTP failures", async () => {
|
|
350
|
+
fetchMock.mockResolvedValueOnce(new Response("forbidden", { status: 403 }));
|
|
351
|
+
|
|
352
|
+
await expect(
|
|
353
|
+
sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
|
354
|
+
cfg: { source: "provided" },
|
|
355
|
+
accountId: "work",
|
|
356
|
+
}),
|
|
357
|
+
).rejects.toThrow("Nextcloud Talk reaction failed: 403 forbidden");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { requireRuntimeConfig } from "klaw/plugin-sdk/plugin-config-runtime";
|
|
2
|
+
export { resolveMarkdownTableMode } from "klaw/plugin-sdk/markdown-table-runtime";
|
|
3
|
+
export { ssrfPolicyFromPrivateNetworkOptIn } from "klaw/plugin-sdk/ssrf-runtime";
|
|
4
|
+
export { convertMarkdownTables } from "klaw/plugin-sdk/text-chunking";
|
|
5
|
+
export { fetchWithSsrFGuard } from "../runtime-api.js";
|
|
6
|
+
export { resolveNextcloudTalkAccount } from "./accounts.js";
|
|
7
|
+
export { getNextcloudTalkRuntime } from "./runtime.js";
|
|
8
|
+
export { generateNextcloudTalkSignature } from "./signature.js";
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createMessageReceiptFromOutboundResults } from "klaw/plugin-sdk/channel-message";
|
|
2
|
+
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
|
|
3
|
+
import {
|
|
4
|
+
convertMarkdownTables,
|
|
5
|
+
fetchWithSsrFGuard,
|
|
6
|
+
generateNextcloudTalkSignature,
|
|
7
|
+
getNextcloudTalkRuntime,
|
|
8
|
+
requireRuntimeConfig,
|
|
9
|
+
resolveMarkdownTableMode,
|
|
10
|
+
resolveNextcloudTalkAccount,
|
|
11
|
+
ssrfPolicyFromPrivateNetworkOptIn,
|
|
12
|
+
} from "./send.runtime.js";
|
|
13
|
+
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
|
14
|
+
|
|
15
|
+
type NextcloudTalkSendOpts = {
|
|
16
|
+
cfg: CoreConfig;
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
secret?: string;
|
|
19
|
+
accountId?: string;
|
|
20
|
+
replyTo?: string;
|
|
21
|
+
verbose?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function resolveCredentials(
|
|
25
|
+
explicit: { baseUrl?: string; secret?: string },
|
|
26
|
+
account: { baseUrl: string; secret: string; accountId: string },
|
|
27
|
+
): { baseUrl: string; secret: string } {
|
|
28
|
+
const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl;
|
|
29
|
+
const secret = explicit.secret?.trim() ?? account.secret;
|
|
30
|
+
|
|
31
|
+
if (!baseUrl) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (!secret) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { baseUrl, secret };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeRoomToken(to: string): string {
|
|
46
|
+
const normalized = stripNextcloudTalkTargetPrefix(to);
|
|
47
|
+
if (!normalized) {
|
|
48
|
+
throw new Error("Room token is required for Nextcloud Talk sends");
|
|
49
|
+
}
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): {
|
|
54
|
+
cfg: CoreConfig;
|
|
55
|
+
account: ReturnType<typeof resolveNextcloudTalkAccount>;
|
|
56
|
+
baseUrl: string;
|
|
57
|
+
secret: string;
|
|
58
|
+
} {
|
|
59
|
+
const cfg = requireRuntimeConfig(opts.cfg, "Nextcloud Talk send") as CoreConfig;
|
|
60
|
+
const account = resolveNextcloudTalkAccount({
|
|
61
|
+
cfg,
|
|
62
|
+
accountId: opts.accountId,
|
|
63
|
+
});
|
|
64
|
+
const { baseUrl, secret } = resolveCredentials(
|
|
65
|
+
{ baseUrl: opts.baseUrl, secret: opts.secret },
|
|
66
|
+
account,
|
|
67
|
+
);
|
|
68
|
+
return { cfg, account, baseUrl, secret };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function recordNextcloudTalkOutboundActivity(accountId: string): void {
|
|
72
|
+
try {
|
|
73
|
+
getNextcloudTalkRuntime().channel.activity.record({
|
|
74
|
+
channel: "nextcloud-talk",
|
|
75
|
+
accountId,
|
|
76
|
+
direction: "outbound",
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!(error instanceof Error) || error.message !== "Nextcloud Talk runtime not initialized") {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createNextcloudTalkSendReceipt(params: {
|
|
86
|
+
messageId: string;
|
|
87
|
+
roomToken: string;
|
|
88
|
+
replyTo?: string;
|
|
89
|
+
}) {
|
|
90
|
+
const messageId = params.messageId.trim();
|
|
91
|
+
return createMessageReceiptFromOutboundResults({
|
|
92
|
+
results:
|
|
93
|
+
messageId && messageId !== "unknown"
|
|
94
|
+
? [
|
|
95
|
+
{
|
|
96
|
+
channel: "nextcloud-talk",
|
|
97
|
+
messageId,
|
|
98
|
+
conversationId: params.roomToken,
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
: [],
|
|
102
|
+
kind: "text",
|
|
103
|
+
...(params.replyTo ? { replyToId: params.replyTo } : {}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function sendMessageNextcloudTalk(
|
|
108
|
+
to: string,
|
|
109
|
+
text: string,
|
|
110
|
+
opts: NextcloudTalkSendOpts,
|
|
111
|
+
): Promise<NextcloudTalkSendResult> {
|
|
112
|
+
const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
|
|
113
|
+
const roomToken = normalizeRoomToken(to);
|
|
114
|
+
|
|
115
|
+
if (!text?.trim()) {
|
|
116
|
+
throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tableMode = resolveMarkdownTableMode({
|
|
120
|
+
cfg,
|
|
121
|
+
channel: "nextcloud-talk",
|
|
122
|
+
accountId: account.accountId,
|
|
123
|
+
});
|
|
124
|
+
const message = convertMarkdownTables(text.trim(), tableMode);
|
|
125
|
+
|
|
126
|
+
const body: Record<string, unknown> = {
|
|
127
|
+
message,
|
|
128
|
+
};
|
|
129
|
+
if (opts.replyTo) {
|
|
130
|
+
body.replyTo = opts.replyTo;
|
|
131
|
+
}
|
|
132
|
+
const bodyStr = JSON.stringify(body);
|
|
133
|
+
|
|
134
|
+
// Nextcloud Talk verifies signature against the extracted message text,
|
|
135
|
+
// not the full JSON body. See ChecksumVerificationService.php:
|
|
136
|
+
// hash_hmac('sha256', $random . $data, $secret)
|
|
137
|
+
// where $data is the "message" parameter, not the raw request body.
|
|
138
|
+
const { random, signature } = generateNextcloudTalkSignature({
|
|
139
|
+
body: message,
|
|
140
|
+
secret,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`;
|
|
144
|
+
|
|
145
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
146
|
+
url,
|
|
147
|
+
init: {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
"OCS-APIRequest": "true",
|
|
152
|
+
"X-Nextcloud-Talk-Bot-Random": random,
|
|
153
|
+
"X-Nextcloud-Talk-Bot-Signature": signature,
|
|
154
|
+
},
|
|
155
|
+
body: bodyStr,
|
|
156
|
+
},
|
|
157
|
+
auditContext: "nextcloud-talk-send",
|
|
158
|
+
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const errorBody = await response.text().catch(() => "");
|
|
164
|
+
const status = response.status;
|
|
165
|
+
let errorMsg = `Nextcloud Talk send failed (${status})`;
|
|
166
|
+
|
|
167
|
+
if (status === 400) {
|
|
168
|
+
errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`;
|
|
169
|
+
} else if (status === 401) {
|
|
170
|
+
errorMsg =
|
|
171
|
+
"Nextcloud Talk: bot send was rejected - check the bot secret and ensure the bot was installed with --feature response";
|
|
172
|
+
} else if (status === 403) {
|
|
173
|
+
errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room";
|
|
174
|
+
} else if (status === 404) {
|
|
175
|
+
errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`;
|
|
176
|
+
} else if (errorBody) {
|
|
177
|
+
errorMsg = `Nextcloud Talk send failed: ${errorBody}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw new Error(errorMsg);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let messageId = "unknown";
|
|
184
|
+
let timestamp: number | undefined;
|
|
185
|
+
try {
|
|
186
|
+
const data = (await response.json()) as {
|
|
187
|
+
ocs?: {
|
|
188
|
+
data?: {
|
|
189
|
+
id?: number | string;
|
|
190
|
+
timestamp?: number;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
if (data.ocs?.data?.id != null) {
|
|
195
|
+
messageId = String(data.ocs.data.id);
|
|
196
|
+
}
|
|
197
|
+
if (typeof data.ocs?.data?.timestamp === "number") {
|
|
198
|
+
timestamp = data.ocs.data.timestamp;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Response parsing failed, but message was sent.
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (opts.verbose) {
|
|
205
|
+
console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
recordNextcloudTalkOutboundActivity(account.accountId);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
messageId,
|
|
212
|
+
roomToken,
|
|
213
|
+
receipt: createNextcloudTalkSendReceipt({
|
|
214
|
+
messageId,
|
|
215
|
+
roomToken,
|
|
216
|
+
...(opts.replyTo ? { replyTo: opts.replyTo } : {}),
|
|
217
|
+
}),
|
|
218
|
+
timestamp,
|
|
219
|
+
};
|
|
220
|
+
} finally {
|
|
221
|
+
await release();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function sendReactionNextcloudTalk(
|
|
226
|
+
roomToken: string,
|
|
227
|
+
messageId: string,
|
|
228
|
+
reaction: string,
|
|
229
|
+
opts: Omit<NextcloudTalkSendOpts, "replyTo">,
|
|
230
|
+
): Promise<{ ok: true }> {
|
|
231
|
+
const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
|
|
232
|
+
const normalizedToken = normalizeRoomToken(roomToken);
|
|
233
|
+
|
|
234
|
+
const body = JSON.stringify({ reaction });
|
|
235
|
+
// Sign only the reaction string, not the full JSON body
|
|
236
|
+
const { random, signature } = generateNextcloudTalkSignature({
|
|
237
|
+
body: reaction,
|
|
238
|
+
secret,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`;
|
|
242
|
+
|
|
243
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
244
|
+
url,
|
|
245
|
+
init: {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: {
|
|
248
|
+
"Content-Type": "application/json",
|
|
249
|
+
"OCS-APIRequest": "true",
|
|
250
|
+
"X-Nextcloud-Talk-Bot-Random": random,
|
|
251
|
+
"X-Nextcloud-Talk-Bot-Signature": signature,
|
|
252
|
+
},
|
|
253
|
+
body,
|
|
254
|
+
},
|
|
255
|
+
auditContext: "nextcloud-talk-reaction",
|
|
256
|
+
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
const errorBody = await response.text().catch(() => "");
|
|
262
|
+
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { ok: true };
|
|
266
|
+
} finally {
|
|
267
|
+
await release();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
|
|
2
|
+
import { buildOutboundBaseSessionKey } from "klaw/plugin-sdk/routing";
|
|
3
|
+
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
|
|
4
|
+
|
|
5
|
+
type NextcloudTalkOutboundSessionRouteParams = {
|
|
6
|
+
cfg: KlawConfig;
|
|
7
|
+
agentId: string;
|
|
8
|
+
accountId?: string | null;
|
|
9
|
+
target: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function resolveNextcloudTalkOutboundSessionRoute(
|
|
13
|
+
params: NextcloudTalkOutboundSessionRouteParams,
|
|
14
|
+
) {
|
|
15
|
+
const roomId = stripNextcloudTalkTargetPrefix(params.target);
|
|
16
|
+
if (!roomId) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const baseSessionKey = buildOutboundBaseSessionKey({
|
|
20
|
+
cfg: params.cfg,
|
|
21
|
+
agentId: params.agentId,
|
|
22
|
+
channel: "nextcloud-talk",
|
|
23
|
+
accountId: params.accountId,
|
|
24
|
+
peer: {
|
|
25
|
+
kind: "group",
|
|
26
|
+
id: roomId,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
sessionKey: baseSessionKey,
|
|
31
|
+
baseSessionKey,
|
|
32
|
+
peer: {
|
|
33
|
+
kind: "group" as const,
|
|
34
|
+
id: roomId,
|
|
35
|
+
},
|
|
36
|
+
chatType: "group" as const,
|
|
37
|
+
from: `nextcloud-talk:room:${roomId}`,
|
|
38
|
+
to: `nextcloud-talk:${roomId}`,
|
|
39
|
+
};
|
|
40
|
+
}
|