@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.
Files changed (78) hide show
  1. package/api.ts +1 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +4 -0
  4. package/dist/api.js +2 -0
  5. package/dist/channel-ej3z6XJ5.js +2094 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/contract-api.js +2 -0
  8. package/dist/doctor-contract-Dia7keG4.js +7 -0
  9. package/dist/doctor-contract-api.js +2 -0
  10. package/dist/index.js +22 -0
  11. package/dist/runtime-api-DCIDXlUd.js +14 -0
  12. package/dist/runtime-api.js +2 -0
  13. package/dist/secret-contract-DQ2wQ4m1.js +86 -0
  14. package/dist/secret-contract-api.js +2 -0
  15. package/dist/setup-entry.js +15 -0
  16. package/doctor-contract-api.ts +1 -0
  17. package/index.ts +20 -0
  18. package/klaw.plugin.json +2 -799
  19. package/package.json +4 -4
  20. package/runtime-api.ts +29 -0
  21. package/secret-contract-api.ts +5 -0
  22. package/setup-entry.ts +13 -0
  23. package/src/accounts.test.ts +31 -0
  24. package/src/accounts.ts +149 -0
  25. package/src/api-credentials.ts +31 -0
  26. package/src/approval-auth.test.ts +17 -0
  27. package/src/approval-auth.ts +27 -0
  28. package/src/bot-preflight.test.ts +135 -0
  29. package/src/bot-preflight.ts +183 -0
  30. package/src/channel-api.ts +5 -0
  31. package/src/channel.adapters.ts +52 -0
  32. package/src/channel.core.test.ts +75 -0
  33. package/src/channel.lifecycle.test.ts +91 -0
  34. package/src/channel.status.test.ts +28 -0
  35. package/src/channel.ts +225 -0
  36. package/src/config-schema.ts +79 -0
  37. package/src/core.test.ts +325 -0
  38. package/src/doctor-contract.ts +9 -0
  39. package/src/doctor.test.ts +87 -0
  40. package/src/doctor.ts +40 -0
  41. package/src/gateway.ts +109 -0
  42. package/src/inbound.authz.test.ts +146 -0
  43. package/src/inbound.behavior.test.ts +309 -0
  44. package/src/inbound.ts +392 -0
  45. package/src/message-actions.test.ts +270 -0
  46. package/src/message-actions.ts +82 -0
  47. package/src/message-adapter.ts +28 -0
  48. package/src/monitor-runtime.ts +138 -0
  49. package/src/monitor.replay.test.ts +276 -0
  50. package/src/monitor.test-fixtures.ts +30 -0
  51. package/src/monitor.test-harness.ts +59 -0
  52. package/src/monitor.ts +385 -0
  53. package/src/normalize.ts +44 -0
  54. package/src/policy.ts +111 -0
  55. package/src/replay-guard.ts +128 -0
  56. package/src/room-info.test.ts +160 -0
  57. package/src/room-info.ts +130 -0
  58. package/src/runtime.ts +9 -0
  59. package/src/secret-contract.ts +103 -0
  60. package/src/secret-input.ts +4 -0
  61. package/src/send.cfg-threading.test.ts +359 -0
  62. package/src/send.runtime.ts +8 -0
  63. package/src/send.ts +269 -0
  64. package/src/session-route.ts +40 -0
  65. package/src/setup-core.ts +250 -0
  66. package/src/setup-surface.ts +195 -0
  67. package/src/setup.test.ts +445 -0
  68. package/src/signature.ts +82 -0
  69. package/src/types.ts +195 -0
  70. package/tsconfig.json +16 -0
  71. package/api.js +0 -7
  72. package/channel-plugin-api.js +0 -7
  73. package/contract-api.js +0 -7
  74. package/doctor-contract-api.js +0 -7
  75. package/index.js +0 -7
  76. package/runtime-api.js +0 -7
  77. package/secret-contract-api.js +0 -7
  78. package/setup-entry.js +0 -7
@@ -0,0 +1,2094 @@
1
+ import { a as fetchWithSsrFGuard, c as resolveDefaultGroupPolicy, i as deliverFormattedTextWithAttachments, l as warnMissingProviderGroupPolicyFallbackOnce, o as logInboundDrop, r as createChannelPairingController, s as resolveAllowlistProviderRuntimeGroupPolicy, t as GROUP_POLICY_BLOCKED_LABEL, u as getNextcloudTalkRuntime } from "./runtime-api-DCIDXlUd.js";
2
+ import { n as normalizeCompatibilityConfig, t as legacyConfigRules } from "./doctor-contract-Dia7keG4.js";
3
+ import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries } from "./secret-contract-DQ2wQ4m1.js";
4
+ import { describeWebhookAccountSnapshot } from "klaw/plugin-sdk/account-helpers";
5
+ import { createChatChannelPlugin } from "klaw/plugin-sdk/channel-core";
6
+ import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from "klaw/plugin-sdk/channel-pairing";
7
+ import { createAllowlistProviderRouteAllowlistWarningCollector } from "klaw/plugin-sdk/channel-policy";
8
+ import { buildWebhookChannelStatusSummary, createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "klaw/plugin-sdk/status-helpers";
9
+ import { DEFAULT_ACCOUNT_ID, createAccountListHelpers, hasConfiguredAccountValue, normalizeAccountId, resolveAccountWithDefaultFallback, resolveMergedAccountConfig } from "klaw/plugin-sdk/account-core";
10
+ import { tryReadSecretFileSync } from "klaw/plugin-sdk/secret-file-runtime";
11
+ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "klaw/plugin-sdk/string-coerce-runtime";
12
+ import { buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString } from "klaw/plugin-sdk/secret-input";
13
+ import { createResolvedApproverActionAuthAdapter, resolveApprovalApprovers } from "klaw/plugin-sdk/approval-auth-runtime";
14
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
15
+ import { readProviderJsonResponse } from "klaw/plugin-sdk/provider-http";
16
+ import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter } from "klaw/plugin-sdk/channel-message";
17
+ import { ssrfPolicyFromPrivateNetworkOptIn, ssrfPolicyFromPrivateNetworkOptIn as ssrfPolicyFromPrivateNetworkOptIn$1 } from "klaw/plugin-sdk/ssrf-runtime";
18
+ import { readFileSync } from "node:fs";
19
+ import { requireRuntimeConfig } from "klaw/plugin-sdk/plugin-config-runtime";
20
+ import { resolveMarkdownTableMode } from "klaw/plugin-sdk/markdown-table-runtime";
21
+ import { convertMarkdownTables } from "klaw/plugin-sdk/text-chunking";
22
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
23
+ import { clearAccountEntryFields } from "klaw/plugin-sdk/channel-plugin-common";
24
+ import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$2 } from "klaw/plugin-sdk/account-id";
25
+ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, ReplyRuntimeConfigSchemaShape, ToolPolicySchema, buildChannelConfigSchema, requireOpenAllowFrom } from "klaw/plugin-sdk/channel-config-schema";
26
+ import { formatAllowFromLowercase } from "klaw/plugin-sdk/allow-from";
27
+ import { adaptScopedAccountAccessor, createScopedChannelConfigAdapter, createScopedDmSecurityResolver } from "klaw/plugin-sdk/channel-config-helpers";
28
+ import { requireChannelOpenAllowFrom, resolveLoggerBackedRuntime, runStoppablePassiveMonitor, safeParseJsonWithSchema } from "klaw/plugin-sdk/extension-shared";
29
+ import { z } from "zod";
30
+ import { createAccountStatusSink } from "klaw/plugin-sdk/channel-lifecycle";
31
+ import os from "node:os";
32
+ import { channelIngressRoutes, resolveStableChannelMessageIngress } from "klaw/plugin-sdk/channel-ingress-runtime";
33
+ import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "klaw/plugin-sdk/inbound-envelope";
34
+ import { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision } from "klaw/plugin-sdk/channel-targets";
35
+ import { createServer } from "node:http";
36
+ import { WEBHOOK_RATE_LIMIT_DEFAULTS, createAuthRateLimiter, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "klaw/plugin-sdk/webhook-ingress";
37
+ import path from "node:path";
38
+ import { createClaimableDedupe } from "klaw/plugin-sdk/persistent-dedupe";
39
+ import { jsonResult, readStringParam, resolveReactionMessageId } from "klaw/plugin-sdk/channel-actions";
40
+ import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, buildOutboundBaseSessionKey, normalizeAccountId as normalizeAccountId$1 } from "klaw/plugin-sdk/routing";
41
+ import { applyAccountNameToChannelSection, createSetupTranslator, createStandardChannelSetupStatus, formatDocsLink, patchScopedAccountConfig, setSetupChannelEnabled } from "klaw/plugin-sdk/setup";
42
+ import { createSetupInputPresenceValidator, createSetupTranslator as createSetupTranslator$1, mergeAllowFromEntries, promptParsedAllowFromForAccount, resolveSetupAccountId } from "klaw/plugin-sdk/setup-runtime";
43
+ import { formatDocsLink as formatDocsLink$1 } from "klaw/plugin-sdk/setup-tools";
44
+ //#region extensions/nextcloud-talk/src/accounts.ts
45
+ function isTruthyEnvValue(value) {
46
+ const normalized = normalizeLowercaseStringOrEmpty(value);
47
+ return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
48
+ }
49
+ const debugAccounts = (...args) => {
50
+ if (isTruthyEnvValue(process.env.KLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) console.warn("[nextcloud-talk:accounts]", ...args);
51
+ };
52
+ const { listAccountIds: listNextcloudTalkAccountIdsInternal, resolveDefaultAccountId: resolveDefaultNextcloudTalkAccountId } = createAccountListHelpers("nextcloud-talk", {
53
+ normalizeAccountId,
54
+ hasImplicitDefaultAccount: (cfg) => {
55
+ const channel = cfg.channels?.["nextcloud-talk"];
56
+ return Boolean(channel?.baseUrl?.trim() && (hasConfiguredAccountValue(channel.botSecret) || channel.botSecretFile?.trim() || process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()));
57
+ }
58
+ });
59
+ function listNextcloudTalkAccountIds(cfg) {
60
+ const ids = listNextcloudTalkAccountIdsInternal(cfg);
61
+ debugAccounts("listNextcloudTalkAccountIds", ids);
62
+ return ids;
63
+ }
64
+ function mergeNextcloudTalkAccountConfig(cfg, accountId) {
65
+ return resolveMergedAccountConfig({
66
+ channelConfig: cfg.channels?.["nextcloud-talk"],
67
+ accounts: cfg.channels?.["nextcloud-talk"]?.accounts,
68
+ accountId,
69
+ omitKeys: ["defaultAccount"],
70
+ normalizeAccountId
71
+ });
72
+ }
73
+ function resolveNextcloudTalkSecret(cfg, opts) {
74
+ const resolvedAccountId = opts.accountId ?? resolveDefaultNextcloudTalkAccountId(cfg);
75
+ const merged = mergeNextcloudTalkAccountConfig(cfg, resolvedAccountId);
76
+ const envSecret = normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET);
77
+ if (envSecret && resolvedAccountId === DEFAULT_ACCOUNT_ID) return {
78
+ secret: envSecret,
79
+ source: "env"
80
+ };
81
+ if (merged.botSecretFile) {
82
+ const fileSecret = tryReadSecretFileSync(merged.botSecretFile, "Nextcloud Talk bot secret file", { rejectSymlink: true });
83
+ if (fileSecret) return {
84
+ secret: fileSecret,
85
+ source: "secretFile"
86
+ };
87
+ }
88
+ const inlineSecret = normalizeResolvedSecretInputString({
89
+ value: merged.botSecret,
90
+ path: `channels.nextcloud-talk.accounts.${resolvedAccountId}.botSecret`
91
+ });
92
+ if (inlineSecret) return {
93
+ secret: inlineSecret,
94
+ source: "config"
95
+ };
96
+ return {
97
+ secret: "",
98
+ source: "none"
99
+ };
100
+ }
101
+ function resolveNextcloudTalkAccount(params) {
102
+ const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
103
+ const resolvedAccountId = params.accountId ?? resolveDefaultNextcloudTalkAccountId(params.cfg);
104
+ const resolve = (accountId) => {
105
+ const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId);
106
+ const accountEnabled = merged.enabled !== false;
107
+ const enabled = baseEnabled && accountEnabled;
108
+ const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId });
109
+ const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? "";
110
+ debugAccounts("resolve", {
111
+ accountId,
112
+ enabled,
113
+ secretSource: secretResolution.source,
114
+ baseUrl: baseUrl ? "[set]" : "[missing]"
115
+ });
116
+ return {
117
+ accountId,
118
+ enabled,
119
+ name: normalizeOptionalString(merged.name),
120
+ baseUrl,
121
+ secret: secretResolution.secret,
122
+ secretSource: secretResolution.source,
123
+ config: merged
124
+ };
125
+ };
126
+ return resolveAccountWithDefaultFallback({
127
+ accountId: resolvedAccountId,
128
+ normalizeAccountId,
129
+ resolvePrimary: resolve,
130
+ hasCredential: (account) => account.secretSource !== "none",
131
+ resolveDefaultAccountId: () => resolveDefaultNextcloudTalkAccountId(params.cfg)
132
+ });
133
+ }
134
+ //#endregion
135
+ //#region extensions/nextcloud-talk/src/approval-auth.ts
136
+ function normalizeNextcloudTalkApproverId(value) {
137
+ return normalizeOptionalLowercaseString(String(value).trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, ""));
138
+ }
139
+ const nextcloudTalkApprovalAuth = createResolvedApproverActionAuthAdapter({
140
+ channelLabel: "Nextcloud Talk",
141
+ resolveApprovers: ({ cfg, accountId }) => {
142
+ return resolveApprovalApprovers({
143
+ allowFrom: resolveNextcloudTalkAccount({
144
+ cfg,
145
+ accountId
146
+ }).config.allowFrom,
147
+ normalizeApprover: normalizeNextcloudTalkApproverId
148
+ });
149
+ },
150
+ normalizeSenderId: (value) => normalizeNextcloudTalkApproverId(value)
151
+ });
152
+ //#endregion
153
+ //#region extensions/nextcloud-talk/src/api-credentials.ts
154
+ function resolveNextcloudTalkApiCredentials(params) {
155
+ const apiUser = params.apiUser?.trim();
156
+ if (!apiUser) return;
157
+ const inlinePassword = normalizeResolvedSecretInputString({
158
+ value: params.apiPassword,
159
+ path: "channels.nextcloud-talk.apiPassword"
160
+ });
161
+ if (inlinePassword) return {
162
+ apiUser,
163
+ apiPassword: inlinePassword
164
+ };
165
+ if (!params.apiPasswordFile) return;
166
+ try {
167
+ const filePassword = readFileSync(params.apiPasswordFile, "utf-8").trim();
168
+ return filePassword ? {
169
+ apiUser,
170
+ apiPassword: filePassword
171
+ } : void 0;
172
+ } catch {
173
+ return;
174
+ }
175
+ }
176
+ //#endregion
177
+ //#region extensions/nextcloud-talk/src/signature.ts
178
+ const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
179
+ const RANDOM_HEADER = "x-nextcloud-talk-random";
180
+ const BACKEND_HEADER = "x-nextcloud-talk-backend";
181
+ /**
182
+ * Verify the HMAC-SHA256 signature of an incoming webhook request.
183
+ * Signature is calculated as: HMAC-SHA256(random + body, secret)
184
+ */
185
+ function verifyNextcloudTalkSignature(params) {
186
+ const { signature, random, body, secret } = params;
187
+ if (!signature || !random || !secret) return false;
188
+ const expected = createHmac("sha256", secret).update(random + body).digest("hex");
189
+ const expectedBuf = Buffer.from(expected, "utf8");
190
+ const signatureBuf = Buffer.from(signature, "utf8");
191
+ const maxLen = Math.max(expectedBuf.length, signatureBuf.length);
192
+ const paddedExpected = Buffer.alloc(maxLen);
193
+ const paddedSignature = Buffer.alloc(maxLen);
194
+ expectedBuf.copy(paddedExpected);
195
+ signatureBuf.copy(paddedSignature);
196
+ const timingResult = timingSafeEqual(paddedExpected, paddedSignature);
197
+ return expectedBuf.length === signatureBuf.length && timingResult;
198
+ }
199
+ /**
200
+ * Extract webhook headers from an incoming request.
201
+ */
202
+ function extractNextcloudTalkHeaders(headers) {
203
+ const getHeader = (name) => {
204
+ const value = headers[name] ?? headers[normalizeLowercaseStringOrEmpty(name)];
205
+ return Array.isArray(value) ? value[0] : value;
206
+ };
207
+ const signature = getHeader(SIGNATURE_HEADER);
208
+ const random = getHeader(RANDOM_HEADER);
209
+ const backend = getHeader(BACKEND_HEADER);
210
+ if (!signature || !random || !backend) return null;
211
+ return {
212
+ signature,
213
+ random,
214
+ backend
215
+ };
216
+ }
217
+ /**
218
+ * Generate signature headers for an outbound request to Nextcloud Talk.
219
+ */
220
+ function generateNextcloudTalkSignature(params) {
221
+ const { body, secret } = params;
222
+ const random = randomBytes(32).toString("hex");
223
+ return {
224
+ random,
225
+ signature: createHmac("sha256", secret).update(random + body).digest("hex")
226
+ };
227
+ }
228
+ //#endregion
229
+ //#region extensions/nextcloud-talk/src/bot-preflight.ts
230
+ const BOT_FEATURE_RESPONSE = 2;
231
+ function normalizeUrlForMatch(value) {
232
+ if (!value?.trim()) return "";
233
+ try {
234
+ const url = new URL(value.trim());
235
+ url.hash = "";
236
+ return url.toString().replace(/\/$/, "");
237
+ } catch {
238
+ return value.trim().replace(/\/$/, "");
239
+ }
240
+ }
241
+ function coerceFeatureMask(value) {
242
+ if (typeof value === "number" && Number.isFinite(value)) return value;
243
+ if (typeof value === "string" && value.trim()) {
244
+ const parsed = Number.parseInt(value, 10);
245
+ return Number.isFinite(parsed) ? parsed : void 0;
246
+ }
247
+ }
248
+ function formatMissingResponseFeatureMessage(bot, features) {
249
+ const id = bot.id == null ? "unknown" : String(bot.id);
250
+ return `Nextcloud Talk bot "${bot.name?.trim() || "matching bot"}" (${id}) is missing the response feature${typeof features === "number" ? ` (features=${features})` : ""}; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction ${id} 1 or reinstall the bot with --feature response.`;
251
+ }
252
+ async function probeNextcloudTalkBotResponseFeature(params) {
253
+ const { account, timeoutMs } = params;
254
+ const baseUrl = account.baseUrl?.trim();
255
+ if (!baseUrl) return {
256
+ ok: true,
257
+ skipped: true,
258
+ code: "missing_base_url",
259
+ message: "Nextcloud Talk bot response feature probe skipped: baseUrl is not configured."
260
+ };
261
+ const webhookUrl = normalizeUrlForMatch(account.config.webhookPublicUrl);
262
+ if (!webhookUrl) return {
263
+ ok: true,
264
+ skipped: true,
265
+ code: "missing_webhook_url",
266
+ message: "Nextcloud Talk bot response feature probe skipped: webhookPublicUrl is not configured."
267
+ };
268
+ const credentials = resolveNextcloudTalkApiCredentials({
269
+ apiUser: account.config.apiUser,
270
+ apiPassword: account.config.apiPassword,
271
+ apiPasswordFile: account.config.apiPasswordFile
272
+ });
273
+ if (!credentials) return {
274
+ ok: true,
275
+ skipped: true,
276
+ code: "missing_api_credentials",
277
+ message: "Nextcloud Talk bot response feature probe skipped: apiUser/apiPassword are not configured."
278
+ };
279
+ const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/admin`;
280
+ const auth = Buffer.from(`${credentials.apiUser}:${credentials.apiPassword}`, "utf-8").toString("base64");
281
+ try {
282
+ const { response, release } = await fetchWithSsrFGuard({
283
+ url,
284
+ init: {
285
+ method: "GET",
286
+ headers: {
287
+ Authorization: `Basic ${auth}`,
288
+ "OCS-APIRequest": "true",
289
+ Accept: "application/json"
290
+ }
291
+ },
292
+ auditContext: "nextcloud-talk.bot-response-preflight",
293
+ policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config),
294
+ timeoutMs
295
+ });
296
+ try {
297
+ if (!response.ok) {
298
+ const body = await response.text().catch(() => "");
299
+ return {
300
+ ok: false,
301
+ code: "api_error",
302
+ status: response.status,
303
+ message: `Nextcloud Talk bot response feature probe failed (${response.status})${body ? `: ${body}` : ""}`
304
+ };
305
+ }
306
+ const payload = await readProviderJsonResponse(response, "Nextcloud Talk bot response feature probe failed");
307
+ const bot = (Array.isArray(payload.ocs?.data) ? payload.ocs.data : []).find((entry) => normalizeUrlForMatch(entry.url) === webhookUrl);
308
+ if (!bot) return {
309
+ ok: false,
310
+ code: "bot_not_found",
311
+ message: `Nextcloud Talk bot response feature probe could not find a bot with webhook URL ${webhookUrl}.`
312
+ };
313
+ const features = coerceFeatureMask(bot.features);
314
+ if (features == null || (features & BOT_FEATURE_RESPONSE) !== BOT_FEATURE_RESPONSE) return {
315
+ ok: false,
316
+ code: "missing_response_feature",
317
+ botId: bot.id == null ? void 0 : String(bot.id),
318
+ botName: bot.name,
319
+ features,
320
+ message: formatMissingResponseFeatureMessage(bot, features)
321
+ };
322
+ return {
323
+ ok: true,
324
+ code: "ok",
325
+ botId: bot.id == null ? void 0 : String(bot.id),
326
+ botName: bot.name,
327
+ features,
328
+ message: `Nextcloud Talk bot "${bot.name ?? bot.id ?? "matching bot"}" has the response feature.`
329
+ };
330
+ } finally {
331
+ await release();
332
+ }
333
+ } catch (error) {
334
+ return {
335
+ ok: false,
336
+ code: "request_failed",
337
+ message: `Nextcloud Talk bot response feature probe failed: ${error instanceof Error ? error.message : formatErrorMessage(error)}`
338
+ };
339
+ }
340
+ }
341
+ //#endregion
342
+ //#region extensions/nextcloud-talk/src/channel.adapters.ts
343
+ const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter({
344
+ sectionKey: "nextcloud-talk",
345
+ listAccountIds: listNextcloudTalkAccountIds,
346
+ resolveAccount: adaptScopedAccountAccessor(resolveNextcloudTalkAccount),
347
+ defaultAccountId: resolveDefaultNextcloudTalkAccountId,
348
+ clearBaseFields: [
349
+ "botSecret",
350
+ "botSecretFile",
351
+ "baseUrl",
352
+ "name"
353
+ ],
354
+ resolveAllowFrom: (account) => account.config.allowFrom,
355
+ formatAllowFrom: (allowFrom) => formatAllowFromLowercase({
356
+ allowFrom,
357
+ stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i
358
+ })
359
+ });
360
+ const nextcloudTalkSecurityAdapter = { resolveDmPolicy: createScopedDmSecurityResolver({
361
+ channelKey: "nextcloud-talk",
362
+ resolvePolicy: (account) => account.config.dmPolicy,
363
+ resolveAllowFrom: (account) => account.config.allowFrom,
364
+ policyPathSuffix: "dmPolicy",
365
+ normalizeEntry: (raw) => normalizeLowercaseStringOrEmpty(raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
366
+ }) };
367
+ const nextcloudTalkPairingTextAdapter = {
368
+ idLabel: "nextcloudUserId",
369
+ message: "Klaw: your access has been approved.",
370
+ normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => normalizeLowercaseStringOrEmpty(entry))
371
+ };
372
+ //#endregion
373
+ //#region extensions/nextcloud-talk/src/config-schema.ts
374
+ const NextcloudTalkRoomSchema = z.object({
375
+ requireMention: z.boolean().optional(),
376
+ tools: ToolPolicySchema,
377
+ skills: z.array(z.string()).optional(),
378
+ enabled: z.boolean().optional(),
379
+ allowFrom: z.array(z.string()).optional(),
380
+ systemPrompt: z.string().optional()
381
+ }).strict();
382
+ const NextcloudTalkNetworkSchema = z.object({
383
+ /** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
384
+ dangerouslyAllowPrivateNetwork: z.boolean().optional() }).strict().optional();
385
+ const NextcloudTalkAccountSchemaBase = z.object({
386
+ name: z.string().optional(),
387
+ enabled: z.boolean().optional(),
388
+ markdown: MarkdownConfigSchema,
389
+ baseUrl: z.string().optional(),
390
+ botSecret: buildSecretInputSchema().optional(),
391
+ botSecretFile: z.string().optional(),
392
+ apiUser: z.string().optional(),
393
+ apiPassword: buildSecretInputSchema().optional(),
394
+ apiPasswordFile: z.string().optional(),
395
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
396
+ webhookPort: z.number().int().positive().optional(),
397
+ webhookHost: z.string().optional(),
398
+ webhookPath: z.string().optional(),
399
+ webhookPublicUrl: z.string().optional(),
400
+ allowFrom: z.array(z.string()).optional(),
401
+ groupAllowFrom: z.array(z.string()).optional(),
402
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
403
+ rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
404
+ /** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
405
+ network: NextcloudTalkNetworkSchema,
406
+ ...ReplyRuntimeConfigSchemaShape
407
+ }).strict();
408
+ const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine((value, ctx) => {
409
+ requireChannelOpenAllowFrom({
410
+ channel: "nextcloud-talk",
411
+ policy: value.dmPolicy,
412
+ allowFrom: value.allowFrom,
413
+ ctx,
414
+ requireOpenAllowFrom
415
+ });
416
+ });
417
+ const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
418
+ accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
419
+ defaultAccount: z.string().optional()
420
+ }).superRefine((value, ctx) => {
421
+ requireChannelOpenAllowFrom({
422
+ channel: "nextcloud-talk",
423
+ policy: value.dmPolicy,
424
+ allowFrom: value.allowFrom,
425
+ ctx,
426
+ requireOpenAllowFrom
427
+ });
428
+ });
429
+ //#endregion
430
+ //#region extensions/nextcloud-talk/src/doctor.ts
431
+ async function collectNextcloudTalkBotResponseWarnings(params) {
432
+ const warnings = [];
433
+ for (const accountId of listNextcloudTalkAccountIds(params.cfg)) {
434
+ const account = resolveNextcloudTalkAccount({
435
+ cfg: params.cfg,
436
+ accountId
437
+ });
438
+ if (!account.enabled || !account.secret || !account.baseUrl) continue;
439
+ const result = await probeNextcloudTalkBotResponseFeature({
440
+ account,
441
+ timeoutMs: 5e3
442
+ });
443
+ if (result.code === "missing_response_feature" || result.code === "bot_not_found" || result.code === "api_error" || result.code === "request_failed") warnings.push(`- channels.nextcloud-talk.${account.accountId}: ${result.message}`);
444
+ }
445
+ return warnings;
446
+ }
447
+ const nextcloudTalkDoctor = {
448
+ legacyConfigRules,
449
+ normalizeCompatibilityConfig,
450
+ collectPreviewWarnings: async ({ cfg }) => await collectNextcloudTalkBotResponseWarnings({ cfg })
451
+ };
452
+ //#endregion
453
+ //#region extensions/nextcloud-talk/src/policy.ts
454
+ function normalizeNextcloudTalkAllowEntry(raw) {
455
+ return raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase();
456
+ }
457
+ function normalizeNextcloudTalkAllowlist(values) {
458
+ return (values ?? []).map((value) => normalizeNextcloudTalkAllowEntry(String(value))).filter(Boolean);
459
+ }
460
+ function resolveNextcloudTalkAllowlistMatch(params) {
461
+ const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
462
+ if (allowFrom.length === 0) return { allowed: false };
463
+ if (allowFrom.includes("*")) return {
464
+ allowed: true,
465
+ matchKey: "*",
466
+ matchSource: "wildcard"
467
+ };
468
+ const senderId = normalizeNextcloudTalkAllowEntry(params.senderId);
469
+ if (allowFrom.includes(senderId)) return {
470
+ allowed: true,
471
+ matchKey: senderId,
472
+ matchSource: "id"
473
+ };
474
+ return { allowed: false };
475
+ }
476
+ function resolveNextcloudTalkRoomMatch(params) {
477
+ const rooms = params.rooms ?? {};
478
+ const allowlistConfigured = Object.keys(rooms).length > 0;
479
+ const match = resolveChannelEntryMatchWithFallback({
480
+ entries: rooms,
481
+ keys: buildChannelKeyCandidates(params.roomToken),
482
+ wildcardKey: "*",
483
+ normalizeKey: normalizeChannelSlug
484
+ });
485
+ const roomConfig = match.entry;
486
+ const allowed = resolveNestedAllowlistDecision({
487
+ outerConfigured: allowlistConfigured,
488
+ outerMatched: Boolean(roomConfig),
489
+ innerConfigured: false,
490
+ innerMatched: false
491
+ });
492
+ return {
493
+ roomConfig,
494
+ wildcardConfig: match.wildcardEntry,
495
+ roomKey: match.matchKey ?? match.key,
496
+ matchSource: match.matchSource,
497
+ allowed,
498
+ allowlistConfigured
499
+ };
500
+ }
501
+ function resolveNextcloudTalkGroupToolPolicy(params) {
502
+ const cfg = params.cfg;
503
+ const roomToken = params.groupId?.trim();
504
+ if (!roomToken) return;
505
+ const match = resolveNextcloudTalkRoomMatch({
506
+ rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
507
+ roomToken
508
+ });
509
+ return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
510
+ }
511
+ function resolveNextcloudTalkRequireMention(params) {
512
+ if (typeof params.roomConfig?.requireMention === "boolean") return params.roomConfig.requireMention;
513
+ if (typeof params.wildcardConfig?.requireMention === "boolean") return params.wildcardConfig.requireMention;
514
+ return true;
515
+ }
516
+ //#endregion
517
+ //#region extensions/nextcloud-talk/src/room-info.ts
518
+ const ROOM_CACHE_TTL_MS = 300 * 1e3;
519
+ const ROOM_CACHE_ERROR_TTL_MS = 30 * 1e3;
520
+ const roomCache = /* @__PURE__ */ new Map();
521
+ function resolveRoomCacheKey(params) {
522
+ return `${params.accountId}:${params.roomToken}`;
523
+ }
524
+ function coerceRoomType(value) {
525
+ if (typeof value === "number" && Number.isFinite(value)) return value;
526
+ if (typeof value === "string" && value.trim()) {
527
+ const parsed = Number.parseInt(value, 10);
528
+ return Number.isFinite(parsed) ? parsed : void 0;
529
+ }
530
+ }
531
+ function resolveRoomKindFromType(type) {
532
+ if (!type) return;
533
+ if (type === 1 || type === 5 || type === 6) return "direct";
534
+ return "group";
535
+ }
536
+ async function resolveNextcloudTalkRoomKind(params) {
537
+ const { account, roomToken, runtime } = params;
538
+ const key = resolveRoomCacheKey({
539
+ accountId: account.accountId,
540
+ roomToken
541
+ });
542
+ const cached = roomCache.get(key);
543
+ if (cached) {
544
+ const age = Date.now() - cached.fetchedAt;
545
+ if (cached.kind && age < ROOM_CACHE_TTL_MS) return cached.kind;
546
+ if (cached.error && age < ROOM_CACHE_ERROR_TTL_MS) return;
547
+ }
548
+ const apiCredentials = resolveNextcloudTalkApiCredentials({
549
+ apiUser: account.config.apiUser,
550
+ apiPassword: account.config.apiPassword,
551
+ apiPasswordFile: account.config.apiPasswordFile
552
+ });
553
+ if (!apiCredentials) return;
554
+ const baseUrl = account.baseUrl?.trim();
555
+ if (!baseUrl) return;
556
+ const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`;
557
+ const auth = Buffer.from(`${apiCredentials.apiUser}:${apiCredentials.apiPassword}`, "utf-8").toString("base64");
558
+ try {
559
+ const { response, release } = await fetchWithSsrFGuard({
560
+ url,
561
+ init: {
562
+ method: "GET",
563
+ headers: {
564
+ Authorization: `Basic ${auth}`,
565
+ "OCS-APIRequest": "true",
566
+ Accept: "application/json"
567
+ }
568
+ },
569
+ auditContext: "nextcloud-talk.room-info",
570
+ policy: ssrfPolicyFromPrivateNetworkOptIn(account.config)
571
+ });
572
+ try {
573
+ if (!response.ok) {
574
+ roomCache.set(key, {
575
+ fetchedAt: Date.now(),
576
+ error: `status:${response.status}`
577
+ });
578
+ runtime?.log?.(`nextcloud-talk: room lookup failed (${response.status}) token=${roomToken}`);
579
+ return;
580
+ }
581
+ const kind = resolveRoomKindFromType(coerceRoomType((await readProviderJsonResponse(response, "Nextcloud Talk room info failed")).ocs?.data?.type));
582
+ roomCache.set(key, {
583
+ fetchedAt: Date.now(),
584
+ kind
585
+ });
586
+ return kind;
587
+ } finally {
588
+ await release();
589
+ }
590
+ } catch (err) {
591
+ roomCache.set(key, {
592
+ fetchedAt: Date.now(),
593
+ error: formatErrorMessage(err)
594
+ });
595
+ runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`);
596
+ return;
597
+ }
598
+ }
599
+ //#endregion
600
+ //#region extensions/nextcloud-talk/src/normalize.ts
601
+ function stripNextcloudTalkTargetPrefix(raw) {
602
+ const trimmed = raw.trim();
603
+ if (!trimmed) return;
604
+ let normalized = trimmed;
605
+ if (normalized.startsWith("nextcloud-talk:")) normalized = normalized.slice(15).trim();
606
+ else if (normalized.startsWith("nc-talk:")) normalized = normalized.slice(8).trim();
607
+ else if (normalized.startsWith("nc:")) normalized = normalized.slice(3).trim();
608
+ if (normalized.startsWith("room:")) normalized = normalized.slice(5).trim();
609
+ if (!normalized) return;
610
+ return normalized;
611
+ }
612
+ function normalizeNextcloudTalkMessagingTarget(raw) {
613
+ const normalized = stripNextcloudTalkTargetPrefix(raw);
614
+ return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : void 0;
615
+ }
616
+ function looksLikeNextcloudTalkTargetId(raw) {
617
+ const trimmed = raw.trim();
618
+ if (!trimmed) return false;
619
+ if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) return true;
620
+ return /^[a-z0-9]{8,}$/i.test(trimmed);
621
+ }
622
+ //#endregion
623
+ //#region extensions/nextcloud-talk/src/send.ts
624
+ function resolveCredentials(explicit, account) {
625
+ const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl;
626
+ const secret = explicit.secret?.trim() ?? account.secret;
627
+ if (!baseUrl) throw new Error(`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`);
628
+ if (!secret) throw new Error(`Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`);
629
+ return {
630
+ baseUrl,
631
+ secret
632
+ };
633
+ }
634
+ function normalizeRoomToken(to) {
635
+ const normalized = stripNextcloudTalkTargetPrefix(to);
636
+ if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends");
637
+ return normalized;
638
+ }
639
+ function resolveNextcloudTalkSendContext(opts) {
640
+ const cfg = requireRuntimeConfig(opts.cfg, "Nextcloud Talk send");
641
+ const account = resolveNextcloudTalkAccount({
642
+ cfg,
643
+ accountId: opts.accountId
644
+ });
645
+ const { baseUrl, secret } = resolveCredentials({
646
+ baseUrl: opts.baseUrl,
647
+ secret: opts.secret
648
+ }, account);
649
+ return {
650
+ cfg,
651
+ account,
652
+ baseUrl,
653
+ secret
654
+ };
655
+ }
656
+ function recordNextcloudTalkOutboundActivity(accountId) {
657
+ try {
658
+ getNextcloudTalkRuntime().channel.activity.record({
659
+ channel: "nextcloud-talk",
660
+ accountId,
661
+ direction: "outbound"
662
+ });
663
+ } catch (error) {
664
+ if (!(error instanceof Error) || error.message !== "Nextcloud Talk runtime not initialized") throw error;
665
+ }
666
+ }
667
+ function createNextcloudTalkSendReceipt(params) {
668
+ const messageId = params.messageId.trim();
669
+ return createMessageReceiptFromOutboundResults({
670
+ results: messageId && messageId !== "unknown" ? [{
671
+ channel: "nextcloud-talk",
672
+ messageId,
673
+ conversationId: params.roomToken
674
+ }] : [],
675
+ kind: "text",
676
+ ...params.replyTo ? { replyToId: params.replyTo } : {}
677
+ });
678
+ }
679
+ async function sendMessageNextcloudTalk(to, text, opts) {
680
+ const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
681
+ const roomToken = normalizeRoomToken(to);
682
+ if (!text?.trim()) throw new Error("Message must be non-empty for Nextcloud Talk sends");
683
+ const tableMode = resolveMarkdownTableMode({
684
+ cfg,
685
+ channel: "nextcloud-talk",
686
+ accountId: account.accountId
687
+ });
688
+ const message = convertMarkdownTables(text.trim(), tableMode);
689
+ const body = { message };
690
+ if (opts.replyTo) body.replyTo = opts.replyTo;
691
+ const bodyStr = JSON.stringify(body);
692
+ const { random, signature } = generateNextcloudTalkSignature({
693
+ body: message,
694
+ secret
695
+ });
696
+ const { response, release } = await fetchWithSsrFGuard({
697
+ url: `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`,
698
+ init: {
699
+ method: "POST",
700
+ headers: {
701
+ "Content-Type": "application/json",
702
+ "OCS-APIRequest": "true",
703
+ "X-Nextcloud-Talk-Bot-Random": random,
704
+ "X-Nextcloud-Talk-Bot-Signature": signature
705
+ },
706
+ body: bodyStr
707
+ },
708
+ auditContext: "nextcloud-talk-send",
709
+ policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config)
710
+ });
711
+ try {
712
+ if (!response.ok) {
713
+ const errorBody = await response.text().catch(() => "");
714
+ const status = response.status;
715
+ let errorMsg = `Nextcloud Talk send failed (${status})`;
716
+ if (status === 400) errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`;
717
+ else if (status === 401) errorMsg = "Nextcloud Talk: bot send was rejected - check the bot secret and ensure the bot was installed with --feature response";
718
+ else if (status === 403) errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room";
719
+ else if (status === 404) errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`;
720
+ else if (errorBody) errorMsg = `Nextcloud Talk send failed: ${errorBody}`;
721
+ throw new Error(errorMsg);
722
+ }
723
+ let messageId = "unknown";
724
+ let timestamp;
725
+ try {
726
+ const data = await response.json();
727
+ if (data.ocs?.data?.id != null) messageId = String(data.ocs.data.id);
728
+ if (typeof data.ocs?.data?.timestamp === "number") timestamp = data.ocs.data.timestamp;
729
+ } catch {}
730
+ if (opts.verbose) console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`);
731
+ recordNextcloudTalkOutboundActivity(account.accountId);
732
+ return {
733
+ messageId,
734
+ roomToken,
735
+ receipt: createNextcloudTalkSendReceipt({
736
+ messageId,
737
+ roomToken,
738
+ ...opts.replyTo ? { replyTo: opts.replyTo } : {}
739
+ }),
740
+ timestamp
741
+ };
742
+ } finally {
743
+ await release();
744
+ }
745
+ }
746
+ async function sendReactionNextcloudTalk(roomToken, messageId, reaction, opts) {
747
+ const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
748
+ const normalizedToken = normalizeRoomToken(roomToken);
749
+ const body = JSON.stringify({ reaction });
750
+ const { random, signature } = generateNextcloudTalkSignature({
751
+ body: reaction,
752
+ secret
753
+ });
754
+ const { response, release } = await fetchWithSsrFGuard({
755
+ url: `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`,
756
+ init: {
757
+ method: "POST",
758
+ headers: {
759
+ "Content-Type": "application/json",
760
+ "OCS-APIRequest": "true",
761
+ "X-Nextcloud-Talk-Bot-Random": random,
762
+ "X-Nextcloud-Talk-Bot-Signature": signature
763
+ },
764
+ body
765
+ },
766
+ auditContext: "nextcloud-talk-reaction",
767
+ policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config)
768
+ });
769
+ try {
770
+ if (!response.ok) {
771
+ const errorBody = await response.text().catch(() => "");
772
+ throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
773
+ }
774
+ return { ok: true };
775
+ } finally {
776
+ await release();
777
+ }
778
+ }
779
+ //#endregion
780
+ //#region extensions/nextcloud-talk/src/inbound.ts
781
+ const CHANNEL_ID = "nextcloud-talk";
782
+ function hasAllowEntries(entries) {
783
+ return normalizeNextcloudTalkAllowlist(entries).length > 0;
784
+ }
785
+ function roomRoutes(params) {
786
+ if (!params.isGroup) return [];
787
+ const roomSenderConfigured = params.groupPolicy === "allowlist" && hasAllowEntries(params.roomAllowFrom);
788
+ return channelIngressRoutes(params.roomMatch.allowlistConfigured && {
789
+ id: "nextcloud-talk:room",
790
+ allowed: params.roomMatch.allowed,
791
+ precedence: 0,
792
+ matchId: "nextcloud-talk-room",
793
+ blockReason: "room_not_allowlisted"
794
+ }, params.roomConfig?.enabled === false && {
795
+ id: "nextcloud-talk:room-enabled",
796
+ enabled: false,
797
+ precedence: 10,
798
+ blockReason: "room_disabled"
799
+ }, roomSenderConfigured && {
800
+ id: "nextcloud-talk:room-sender",
801
+ kind: "nestedAllowlist",
802
+ precedence: 20,
803
+ blockReason: "room_sender_not_allowlisted",
804
+ ...!hasAllowEntries(params.outerGroupAllowFrom) ? {
805
+ senderPolicy: "replace",
806
+ senderAllowFrom: params.roomAllowFrom
807
+ } : {
808
+ allowed: resolveNextcloudTalkAllowlistMatch({
809
+ allowFrom: params.roomAllowFrom,
810
+ senderId: params.senderId
811
+ }).allowed,
812
+ matchId: "nextcloud-talk-room-sender"
813
+ }
814
+ });
815
+ }
816
+ async function deliverNextcloudTalkReply(params) {
817
+ const { cfg, payload, roomToken, accountId, statusSink } = params;
818
+ await deliverFormattedTextWithAttachments({
819
+ payload,
820
+ send: async ({ text, replyToId }) => {
821
+ await sendMessageNextcloudTalk(roomToken, text, {
822
+ cfg,
823
+ accountId,
824
+ replyTo: replyToId
825
+ });
826
+ statusSink?.({ lastOutboundAt: Date.now() });
827
+ }
828
+ });
829
+ }
830
+ async function handleNextcloudTalkInbound(params) {
831
+ const { message, account, config, runtime, statusSink } = params;
832
+ const core = getNextcloudTalkRuntime();
833
+ const pairing = createChannelPairingController({
834
+ core,
835
+ channel: CHANNEL_ID,
836
+ accountId: account.accountId
837
+ });
838
+ const rawBody = message.text?.trim() ?? "";
839
+ if (!rawBody) return;
840
+ const roomKind = await resolveNextcloudTalkRoomKind({
841
+ account,
842
+ roomToken: message.roomToken,
843
+ runtime
844
+ });
845
+ const isGroup = roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
846
+ const senderId = message.senderId;
847
+ const senderName = message.senderName;
848
+ const roomToken = message.roomToken;
849
+ const roomName = message.roomName;
850
+ statusSink?.({ lastInboundAt: message.timestamp });
851
+ const roomMatch = resolveNextcloudTalkRoomMatch({
852
+ rooms: account.config.rooms,
853
+ roomToken
854
+ });
855
+ const roomConfig = roomMatch.roomConfig;
856
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
857
+ cfg: config,
858
+ surface: CHANNEL_ID
859
+ });
860
+ const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config);
861
+ const shouldRequireMention = isGroup ? resolveNextcloudTalkRequireMention({
862
+ roomConfig,
863
+ wildcardConfig: roomMatch.wildcardConfig
864
+ }) : false;
865
+ const { groupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({
866
+ providerConfigPresent: (config.channels?.[CHANNEL_ID] ?? void 0) !== void 0,
867
+ groupPolicy: account.config.groupPolicy,
868
+ defaultGroupPolicy: resolveDefaultGroupPolicy(config)
869
+ });
870
+ const allowFrom = normalizeStringEntries(account.config.allowFrom);
871
+ const outerGroupAllowFrom = account.config.groupAllowFrom?.length ? normalizeStringEntries(account.config.groupAllowFrom) : allowFrom;
872
+ const roomAllowFrom = normalizeStringEntries(roomConfig?.allowFrom);
873
+ const resolveAccess = async (wasMentioned) => await resolveStableChannelMessageIngress({
874
+ channelId: CHANNEL_ID,
875
+ accountId: account.accountId,
876
+ identity: {
877
+ key: "nextcloud-talk-user-id",
878
+ normalize: (value) => normalizeNextcloudTalkAllowEntry(value) || null,
879
+ sensitivity: "pii",
880
+ entryIdPrefix: "nextcloud-talk-entry"
881
+ },
882
+ cfg: config,
883
+ readStoreAllowFrom: async () => await pairing.readStoreForDmPolicy(CHANNEL_ID, account.accountId),
884
+ subject: { stableId: senderId },
885
+ conversation: {
886
+ kind: isGroup ? "group" : "direct",
887
+ id: isGroup ? roomToken : senderId
888
+ },
889
+ route: roomRoutes({
890
+ isGroup,
891
+ groupPolicy,
892
+ roomMatch,
893
+ roomConfig,
894
+ senderId,
895
+ outerGroupAllowFrom,
896
+ roomAllowFrom
897
+ }),
898
+ dmPolicy: account.config.dmPolicy ?? "pairing",
899
+ groupPolicy,
900
+ policy: {
901
+ groupAllowFromFallbackToAllowFrom: true,
902
+ activation: {
903
+ requireMention: isGroup && shouldRequireMention,
904
+ allowTextCommands
905
+ }
906
+ },
907
+ mentionFacts: isGroup && wasMentioned !== void 0 ? {
908
+ canDetectMention: true,
909
+ wasMentioned,
910
+ hasAnyMention: wasMentioned
911
+ } : void 0,
912
+ allowFrom,
913
+ groupAllowFrom: account.config.groupAllowFrom,
914
+ command: {
915
+ allowTextCommands,
916
+ hasControlCommand
917
+ }
918
+ });
919
+ let access = await resolveAccess();
920
+ warnMissingProviderGroupPolicyFallbackOnce({
921
+ providerMissingFallbackApplied,
922
+ providerKey: "nextcloud-talk",
923
+ accountId: account.accountId,
924
+ blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
925
+ log: (message) => runtime.log?.(message)
926
+ });
927
+ const commandAuthorized = access.commandAccess.authorized;
928
+ const accessReason = access.ingress.reasonCode === "route_blocked" ? "route blocked" : access.senderAccess.reasonCode;
929
+ if (isGroup) {
930
+ if (access.routeAccess.reason === "room_not_allowlisted") {
931
+ runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
932
+ return;
933
+ }
934
+ if (access.routeAccess.reason === "room_disabled") {
935
+ runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`);
936
+ return;
937
+ }
938
+ if (access.routeAccess.reason === "room_sender_not_allowlisted") {
939
+ runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`);
940
+ return;
941
+ }
942
+ if (access.senderAccess.decision !== "allow") {
943
+ runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${accessReason})`);
944
+ return;
945
+ }
946
+ } else if (access.senderAccess.decision !== "allow") {
947
+ if (access.senderAccess.decision === "pairing") await pairing.issueChallenge({
948
+ senderId,
949
+ senderIdLine: `Your Nextcloud user id: ${senderId}`,
950
+ meta: { name: senderName || void 0 },
951
+ sendPairingReply: async (text) => {
952
+ await sendMessageNextcloudTalk(roomToken, text, {
953
+ cfg: config,
954
+ accountId: account.accountId
955
+ });
956
+ statusSink?.({ lastOutboundAt: Date.now() });
957
+ },
958
+ onReplyError: (err) => {
959
+ runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
960
+ }
961
+ });
962
+ runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${accessReason})`);
963
+ return;
964
+ }
965
+ if (access.commandAccess.shouldBlockControlCommand) {
966
+ logInboundDrop({
967
+ log: (message) => runtime.log?.(message),
968
+ channel: CHANNEL_ID,
969
+ reason: "control command (unauthorized)",
970
+ target: senderId
971
+ });
972
+ return;
973
+ }
974
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(config);
975
+ const wasMentioned = mentionRegexes.length ? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) : false;
976
+ if (isGroup) access = await resolveAccess(wasMentioned);
977
+ if (isGroup && access.activationAccess.shouldSkip) {
978
+ runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
979
+ return;
980
+ }
981
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
982
+ cfg: config,
983
+ channel: CHANNEL_ID,
984
+ accountId: account.accountId,
985
+ peer: {
986
+ kind: isGroup ? "group" : "direct",
987
+ id: isGroup ? roomToken : senderId
988
+ },
989
+ runtime: core.channel,
990
+ sessionStore: config.session?.store
991
+ });
992
+ const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
993
+ const { storePath, body } = buildEnvelope({
994
+ channel: "Nextcloud Talk",
995
+ from: fromLabel,
996
+ timestamp: message.timestamp,
997
+ body: rawBody
998
+ });
999
+ const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
1000
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
1001
+ Body: body,
1002
+ BodyForAgent: rawBody,
1003
+ RawBody: rawBody,
1004
+ CommandBody: rawBody,
1005
+ From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
1006
+ To: `nextcloud-talk:${roomToken}`,
1007
+ SessionKey: route.sessionKey,
1008
+ AccountId: route.accountId,
1009
+ ChatType: isGroup ? "group" : "direct",
1010
+ ConversationLabel: fromLabel,
1011
+ SenderName: senderName || void 0,
1012
+ SenderId: senderId,
1013
+ GroupSubject: isGroup ? roomName || roomToken : void 0,
1014
+ GroupSystemPrompt: isGroup ? groupSystemPrompt : void 0,
1015
+ Provider: CHANNEL_ID,
1016
+ Surface: CHANNEL_ID,
1017
+ WasMentioned: isGroup ? wasMentioned : void 0,
1018
+ MessageSid: message.messageId,
1019
+ Timestamp: message.timestamp,
1020
+ OriginatingChannel: CHANNEL_ID,
1021
+ OriginatingTo: `nextcloud-talk:${roomToken}`,
1022
+ CommandAuthorized: commandAuthorized
1023
+ });
1024
+ await core.channel.turn.runAssembled({
1025
+ cfg: config,
1026
+ channel: CHANNEL_ID,
1027
+ accountId: account.accountId,
1028
+ agentId: route.agentId,
1029
+ routeSessionKey: route.sessionKey,
1030
+ storePath,
1031
+ ctxPayload,
1032
+ recordInboundSession: core.channel.session.recordInboundSession,
1033
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
1034
+ delivery: {
1035
+ deliver: async (payload) => {
1036
+ await deliverNextcloudTalkReply({
1037
+ cfg: config,
1038
+ payload,
1039
+ roomToken,
1040
+ accountId: account.accountId,
1041
+ statusSink
1042
+ });
1043
+ },
1044
+ onError: (err, info) => {
1045
+ runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
1046
+ }
1047
+ },
1048
+ replyPipeline: {},
1049
+ replyOptions: {
1050
+ skillFilter: roomConfig?.skills,
1051
+ disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming : void 0
1052
+ },
1053
+ record: { onRecordError: (err) => {
1054
+ runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
1055
+ } }
1056
+ });
1057
+ }
1058
+ //#endregion
1059
+ //#region extensions/nextcloud-talk/src/monitor.ts
1060
+ const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
1061
+ const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
1062
+ const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5e3;
1063
+ const HEALTH_PATH = "/healthz";
1064
+ const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
1065
+ const NextcloudTalkWebhookPayloadSchema = z.object({
1066
+ type: z.enum([
1067
+ "Create",
1068
+ "Update",
1069
+ "Delete"
1070
+ ]),
1071
+ actor: z.object({
1072
+ type: z.literal("Person"),
1073
+ id: z.string().min(1),
1074
+ name: z.string()
1075
+ }),
1076
+ object: z.object({
1077
+ type: z.literal("Note"),
1078
+ id: z.string().min(1),
1079
+ name: z.string(),
1080
+ content: z.string(),
1081
+ mediaType: z.string()
1082
+ }),
1083
+ target: z.object({
1084
+ type: z.literal("Collection"),
1085
+ id: z.string().min(1),
1086
+ name: z.string()
1087
+ })
1088
+ });
1089
+ const WEBHOOK_ERRORS = {
1090
+ missingSignatureHeaders: "Missing signature headers",
1091
+ invalidBackend: "Invalid backend",
1092
+ invalidSignature: "Invalid signature",
1093
+ invalidPayloadFormat: "Invalid payload format",
1094
+ payloadTooLarge: "Payload too large",
1095
+ internalServerError: "Internal server error"
1096
+ };
1097
+ var NextcloudTalkRetryableWebhookError = class extends Error {
1098
+ constructor(message, options) {
1099
+ super(message, options);
1100
+ this.name = "NextcloudTalkRetryableWebhookError";
1101
+ }
1102
+ };
1103
+ async function processNextcloudTalkReplayGuardedMessage(params) {
1104
+ if (await params.replayGuard.claimMessage({
1105
+ accountId: params.accountId,
1106
+ roomToken: params.message.roomToken,
1107
+ messageId: params.message.messageId
1108
+ }) !== "claimed") return "duplicate";
1109
+ try {
1110
+ await params.handleMessage();
1111
+ await params.replayGuard.commitMessage({
1112
+ accountId: params.accountId,
1113
+ roomToken: params.message.roomToken,
1114
+ messageId: params.message.messageId
1115
+ });
1116
+ return "processed";
1117
+ } catch (error) {
1118
+ if (error instanceof NextcloudTalkRetryableWebhookError) params.replayGuard.releaseMessage({
1119
+ accountId: params.accountId,
1120
+ roomToken: params.message.roomToken,
1121
+ messageId: params.message.messageId,
1122
+ error
1123
+ });
1124
+ else await params.replayGuard.commitMessage({
1125
+ accountId: params.accountId,
1126
+ roomToken: params.message.roomToken,
1127
+ messageId: params.message.messageId
1128
+ });
1129
+ throw error;
1130
+ }
1131
+ }
1132
+ function formatError(err) {
1133
+ if (err instanceof Error) return err.message;
1134
+ return typeof err === "string" ? err : JSON.stringify(err);
1135
+ }
1136
+ function parseWebhookPayload(body) {
1137
+ return safeParseJsonWithSchema(NextcloudTalkWebhookPayloadSchema, body);
1138
+ }
1139
+ function writeJsonResponse(res, status, body) {
1140
+ if (body) {
1141
+ res.writeHead(status, { "Content-Type": "application/json" });
1142
+ res.end(JSON.stringify(body));
1143
+ return;
1144
+ }
1145
+ res.writeHead(status);
1146
+ res.end();
1147
+ }
1148
+ function writeWebhookError(res, status, error) {
1149
+ if (res.headersSent) return;
1150
+ writeJsonResponse(res, status, { error });
1151
+ }
1152
+ function validateWebhookHeaders(params) {
1153
+ const headers = extractNextcloudTalkHeaders(params.req.headers);
1154
+ if (!headers) {
1155
+ writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders);
1156
+ return null;
1157
+ }
1158
+ if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) {
1159
+ writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend);
1160
+ return null;
1161
+ }
1162
+ return headers;
1163
+ }
1164
+ function verifyWebhookSignature(params) {
1165
+ if (!verifyNextcloudTalkSignature({
1166
+ signature: params.headers.signature,
1167
+ random: params.headers.random,
1168
+ body: params.body,
1169
+ secret: params.secret
1170
+ })) {
1171
+ params.authRateLimiter.recordFailure(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
1172
+ writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
1173
+ return false;
1174
+ }
1175
+ params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
1176
+ return true;
1177
+ }
1178
+ function decodeWebhookCreateMessage(params) {
1179
+ const payload = parseWebhookPayload(params.body);
1180
+ if (!payload) {
1181
+ writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
1182
+ return { kind: "invalid" };
1183
+ }
1184
+ if (payload.type !== "Create") return { kind: "ignore" };
1185
+ return {
1186
+ kind: "message",
1187
+ message: payloadToInboundMessage(payload)
1188
+ };
1189
+ }
1190
+ function payloadToInboundMessage(payload) {
1191
+ return {
1192
+ messageId: payload.object.id,
1193
+ roomToken: payload.target.id,
1194
+ roomName: payload.target.name,
1195
+ senderId: payload.actor.id,
1196
+ senderName: payload.actor.name ?? "",
1197
+ text: payload.object.content || payload.object.name || "",
1198
+ mediaType: payload.object.mediaType || "text/plain",
1199
+ timestamp: Date.now(),
1200
+ isGroupChat: true
1201
+ };
1202
+ }
1203
+ function readNextcloudTalkWebhookBody(req, maxBodyBytes) {
1204
+ return readRequestBodyWithLimit(req, {
1205
+ maxBytes: Math.min(maxBodyBytes, PREAUTH_WEBHOOK_MAX_BODY_BYTES),
1206
+ timeoutMs: PREAUTH_WEBHOOK_BODY_TIMEOUT_MS
1207
+ });
1208
+ }
1209
+ function createNextcloudTalkWebhookServer(opts) {
1210
+ const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
1211
+ const maxBodyBytes = typeof opts.maxBodyBytes === "number" && Number.isFinite(opts.maxBodyBytes) && opts.maxBodyBytes > 0 ? Math.floor(opts.maxBodyBytes) : DEFAULT_WEBHOOK_MAX_BODY_BYTES;
1212
+ const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
1213
+ const isBackendAllowed = opts.isBackendAllowed;
1214
+ const shouldProcessMessage = opts.shouldProcessMessage;
1215
+ const processMessage = opts.processMessage;
1216
+ const authRateLimitMaxRequests = typeof opts.authRateLimit?.maxRequests === "number" ? opts.authRateLimit.maxRequests : WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests;
1217
+ const authRateLimitWindowMs = typeof opts.authRateLimit?.windowMs === "number" ? opts.authRateLimit.windowMs : WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs;
1218
+ const webhookAuthRateLimiter = createAuthRateLimiter({
1219
+ maxAttempts: authRateLimitMaxRequests,
1220
+ windowMs: authRateLimitWindowMs,
1221
+ lockoutMs: authRateLimitWindowMs,
1222
+ exemptLoopback: false,
1223
+ pruneIntervalMs: authRateLimitWindowMs
1224
+ });
1225
+ const server = createServer(async (req, res) => {
1226
+ if (req.url === HEALTH_PATH) {
1227
+ res.writeHead(200, { "Content-Type": "text/plain" });
1228
+ res.end("ok");
1229
+ return;
1230
+ }
1231
+ if (req.url !== path || req.method !== "POST") {
1232
+ res.writeHead(404);
1233
+ res.end();
1234
+ return;
1235
+ }
1236
+ const clientIp = req.socket.remoteAddress ?? "unknown";
1237
+ if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
1238
+ res.writeHead(429);
1239
+ res.end("Too Many Requests");
1240
+ return;
1241
+ }
1242
+ try {
1243
+ const headers = validateWebhookHeaders({
1244
+ req,
1245
+ res,
1246
+ isBackendAllowed
1247
+ });
1248
+ if (!headers) return;
1249
+ const body = await readBody(req, maxBodyBytes);
1250
+ if (!verifyWebhookSignature({
1251
+ headers,
1252
+ body,
1253
+ secret,
1254
+ res,
1255
+ clientIp,
1256
+ authRateLimiter: webhookAuthRateLimiter
1257
+ })) return;
1258
+ const decoded = decodeWebhookCreateMessage({
1259
+ body,
1260
+ res
1261
+ });
1262
+ if (decoded.kind === "invalid") return;
1263
+ if (decoded.kind === "ignore") {
1264
+ writeJsonResponse(res, 200);
1265
+ return;
1266
+ }
1267
+ const message = decoded.message;
1268
+ if (processMessage) {
1269
+ writeJsonResponse(res, 200);
1270
+ try {
1271
+ await processMessage(message);
1272
+ } catch (err) {
1273
+ onError?.(err instanceof Error ? err : new Error(formatError(err)));
1274
+ }
1275
+ return;
1276
+ }
1277
+ if (shouldProcessMessage) {
1278
+ if (!await shouldProcessMessage(message)) {
1279
+ writeJsonResponse(res, 200);
1280
+ return;
1281
+ }
1282
+ }
1283
+ writeJsonResponse(res, 200);
1284
+ try {
1285
+ await onMessage(message);
1286
+ } catch (err) {
1287
+ onError?.(err instanceof Error ? err : new Error(formatError(err)));
1288
+ }
1289
+ } catch (err) {
1290
+ if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
1291
+ writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
1292
+ return;
1293
+ }
1294
+ if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
1295
+ writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
1296
+ return;
1297
+ }
1298
+ const error = err instanceof Error ? err : new Error(formatError(err));
1299
+ onError?.(error);
1300
+ writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
1301
+ }
1302
+ });
1303
+ const start = () => {
1304
+ return new Promise((resolve) => {
1305
+ server.listen(port, host, () => resolve());
1306
+ });
1307
+ };
1308
+ let stopped = false;
1309
+ const stop = () => {
1310
+ if (stopped) return;
1311
+ stopped = true;
1312
+ try {
1313
+ server.close();
1314
+ } catch {}
1315
+ };
1316
+ if (abortSignal) if (abortSignal.aborted) stop();
1317
+ else abortSignal.addEventListener("abort", stop, { once: true });
1318
+ return {
1319
+ server,
1320
+ start,
1321
+ stop
1322
+ };
1323
+ }
1324
+ //#endregion
1325
+ //#region extensions/nextcloud-talk/src/replay-guard.ts
1326
+ const DEFAULT_REPLAY_TTL_MS = 1440 * 60 * 1e3;
1327
+ const DEFAULT_MEMORY_MAX_SIZE = 1e3;
1328
+ const DEFAULT_FILE_MAX_ENTRIES = 1e4;
1329
+ function sanitizeSegment(value) {
1330
+ const trimmed = value.trim();
1331
+ if (!trimmed) return "default";
1332
+ return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
1333
+ }
1334
+ function buildReplayKey(params) {
1335
+ const roomToken = params.roomToken.trim();
1336
+ const messageId = params.messageId.trim();
1337
+ if (!roomToken || !messageId) return null;
1338
+ return `${roomToken}:${messageId}`;
1339
+ }
1340
+ function createNextcloudTalkReplayGuard(options) {
1341
+ const stateDir = options.stateDir?.trim();
1342
+ const baseOptions = {
1343
+ ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
1344
+ memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE
1345
+ };
1346
+ const dedupe = createClaimableDedupe(stateDir ? {
1347
+ ...baseOptions,
1348
+ fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
1349
+ resolveFilePath: (namespace) => path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`),
1350
+ onDiskError: options.onDiskError
1351
+ } : baseOptions);
1352
+ return {
1353
+ claimMessage: async ({ accountId, roomToken, messageId }) => {
1354
+ const replayKey = buildReplayKey({
1355
+ roomToken,
1356
+ messageId
1357
+ });
1358
+ if (!replayKey) return "invalid";
1359
+ return (await dedupe.claim(replayKey, { namespace: accountId })).kind;
1360
+ },
1361
+ commitMessage: async ({ accountId, roomToken, messageId }) => {
1362
+ const replayKey = buildReplayKey({
1363
+ roomToken,
1364
+ messageId
1365
+ });
1366
+ if (!replayKey) return true;
1367
+ return await dedupe.commit(replayKey, { namespace: accountId });
1368
+ },
1369
+ releaseMessage: ({ accountId, roomToken, messageId, error }) => {
1370
+ const replayKey = buildReplayKey({
1371
+ roomToken,
1372
+ messageId
1373
+ });
1374
+ if (!replayKey) return;
1375
+ dedupe.release(replayKey, {
1376
+ namespace: accountId,
1377
+ error
1378
+ });
1379
+ },
1380
+ shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
1381
+ const replayKey = buildReplayKey({
1382
+ roomToken,
1383
+ messageId
1384
+ });
1385
+ if (!replayKey) return true;
1386
+ if ((await dedupe.claim(replayKey, { namespace: accountId })).kind !== "claimed") return false;
1387
+ return await dedupe.commit(replayKey, { namespace: accountId });
1388
+ }
1389
+ };
1390
+ }
1391
+ //#endregion
1392
+ //#region extensions/nextcloud-talk/src/monitor-runtime.ts
1393
+ const DEFAULT_WEBHOOK_PORT = 8788;
1394
+ const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
1395
+ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
1396
+ function normalizeOrigin(value) {
1397
+ try {
1398
+ return normalizeLowercaseStringOrEmpty(new URL(value).origin);
1399
+ } catch {
1400
+ return null;
1401
+ }
1402
+ }
1403
+ async function monitorNextcloudTalkProvider(opts) {
1404
+ const core = getNextcloudTalkRuntime();
1405
+ const cfg = opts.config ?? core.config.current();
1406
+ const account = resolveNextcloudTalkAccount({
1407
+ cfg,
1408
+ accountId: opts.accountId
1409
+ });
1410
+ const runtime = resolveLoggerBackedRuntime(opts.runtime, core.logging.getChildLogger());
1411
+ if (!account.secret) throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
1412
+ const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
1413
+ const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
1414
+ const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
1415
+ const logger = core.logging.getChildLogger({
1416
+ channel: "nextcloud-talk",
1417
+ accountId: account.accountId
1418
+ });
1419
+ const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
1420
+ const replayGuard = createNextcloudTalkReplayGuard({
1421
+ stateDir: core.state.resolveStateDir(process.env, os.homedir),
1422
+ onDiskError: (error) => {
1423
+ logger.warn(`[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`);
1424
+ }
1425
+ });
1426
+ const { start, stop } = createNextcloudTalkWebhookServer({
1427
+ port,
1428
+ host,
1429
+ path,
1430
+ secret: account.secret,
1431
+ isBackendAllowed: (backend) => {
1432
+ if (!expectedBackendOrigin) return true;
1433
+ return normalizeOrigin(backend) === expectedBackendOrigin;
1434
+ },
1435
+ processMessage: async (message) => {
1436
+ if (await processNextcloudTalkReplayGuardedMessage({
1437
+ replayGuard,
1438
+ accountId: account.accountId,
1439
+ message,
1440
+ handleMessage: async () => {
1441
+ core.channel.activity.record({
1442
+ channel: "nextcloud-talk",
1443
+ accountId: account.accountId,
1444
+ direction: "inbound",
1445
+ at: message.timestamp
1446
+ });
1447
+ if (opts.onMessage) await opts.onMessage(message);
1448
+ else await handleNextcloudTalkInbound({
1449
+ message,
1450
+ account,
1451
+ config: cfg,
1452
+ runtime,
1453
+ statusSink: opts.statusSink
1454
+ });
1455
+ }
1456
+ }) === "duplicate") {
1457
+ logger.warn(`[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`);
1458
+ return;
1459
+ }
1460
+ },
1461
+ onMessage: async () => {},
1462
+ onError: (error) => {
1463
+ logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
1464
+ },
1465
+ abortSignal: opts.abortSignal
1466
+ });
1467
+ if (opts.abortSignal?.aborted) return { stop };
1468
+ await start();
1469
+ if (opts.abortSignal?.aborted) {
1470
+ stop();
1471
+ return { stop };
1472
+ }
1473
+ const publicUrl = account.config.webhookPublicUrl ?? `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
1474
+ logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
1475
+ return { stop };
1476
+ }
1477
+ //#endregion
1478
+ //#region extensions/nextcloud-talk/src/gateway.ts
1479
+ const nextcloudTalkGatewayAdapter = {
1480
+ startAccount: async (ctx) => {
1481
+ const account = ctx.account;
1482
+ if (!account.secret || !account.baseUrl) throw new Error(`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`);
1483
+ ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
1484
+ const statusSink = createAccountStatusSink({
1485
+ accountId: ctx.accountId,
1486
+ setStatus: ctx.setStatus
1487
+ });
1488
+ await runStoppablePassiveMonitor({
1489
+ abortSignal: ctx.abortSignal,
1490
+ start: async () => await monitorNextcloudTalkProvider({
1491
+ accountId: account.accountId,
1492
+ config: ctx.cfg,
1493
+ runtime: ctx.runtime,
1494
+ abortSignal: ctx.abortSignal,
1495
+ statusSink
1496
+ })
1497
+ });
1498
+ },
1499
+ logoutAccount: async ({ accountId, cfg }) => {
1500
+ const nextCfg = { ...cfg };
1501
+ const nextSection = cfg.channels?.["nextcloud-talk"] ? { ...cfg.channels["nextcloud-talk"] } : void 0;
1502
+ let cleared = false;
1503
+ let changed = false;
1504
+ if (nextSection) {
1505
+ if (accountId === DEFAULT_ACCOUNT_ID$2 && nextSection.botSecret) {
1506
+ delete nextSection.botSecret;
1507
+ cleared = true;
1508
+ changed = true;
1509
+ }
1510
+ const accountCleanup = clearAccountEntryFields({
1511
+ accounts: nextSection.accounts,
1512
+ accountId,
1513
+ fields: ["botSecret"]
1514
+ });
1515
+ if (accountCleanup.changed) {
1516
+ changed = true;
1517
+ if (accountCleanup.cleared) cleared = true;
1518
+ if (accountCleanup.nextAccounts) nextSection.accounts = accountCleanup.nextAccounts;
1519
+ else delete nextSection.accounts;
1520
+ }
1521
+ }
1522
+ if (changed) if (nextSection && Object.keys(nextSection).length > 0) nextCfg.channels = {
1523
+ ...nextCfg.channels,
1524
+ "nextcloud-talk": nextSection
1525
+ };
1526
+ else {
1527
+ const nextChannels = { ...nextCfg.channels };
1528
+ delete nextChannels["nextcloud-talk"];
1529
+ if (Object.keys(nextChannels).length > 0) nextCfg.channels = nextChannels;
1530
+ else delete nextCfg.channels;
1531
+ }
1532
+ const loggedOut = resolveNextcloudTalkAccount({
1533
+ cfg: changed ? nextCfg : cfg,
1534
+ accountId
1535
+ }).secretSource === "none";
1536
+ if (changed) await getNextcloudTalkRuntime().config.replaceConfigFile({
1537
+ nextConfig: nextCfg,
1538
+ afterWrite: { mode: "auto" }
1539
+ });
1540
+ return {
1541
+ cleared,
1542
+ envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
1543
+ loggedOut
1544
+ };
1545
+ }
1546
+ };
1547
+ //#endregion
1548
+ //#region extensions/nextcloud-talk/src/message-actions.ts
1549
+ const providerId = "nextcloud-talk";
1550
+ function isAccountConfigured(account) {
1551
+ return Boolean(account.enabled && account.secret?.trim() && account.baseUrl?.trim());
1552
+ }
1553
+ function hasConfiguredAccount(cfg, accountId) {
1554
+ if (accountId) return isAccountConfigured(resolveNextcloudTalkAccount({
1555
+ cfg,
1556
+ accountId
1557
+ }));
1558
+ return listNextcloudTalkAccountIds(cfg).map((id) => resolveNextcloudTalkAccount({
1559
+ cfg,
1560
+ accountId: id
1561
+ })).some(isAccountConfigured);
1562
+ }
1563
+ const nextcloudTalkMessageActions = {
1564
+ describeMessageTool: ({ cfg, accountId }) => {
1565
+ if (!hasConfiguredAccount(cfg, accountId)) return null;
1566
+ return { actions: ["send", "react"] };
1567
+ },
1568
+ supportsAction: ({ action }) => action !== "send",
1569
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
1570
+ if (action === "send") throw new Error("Send should be handled by outbound, not actions handler.");
1571
+ if (action === "react") {
1572
+ const target = readStringParam(params, "to", {
1573
+ required: true,
1574
+ label: "to (room token)"
1575
+ });
1576
+ const messageIdRaw = resolveReactionMessageId({
1577
+ args: params,
1578
+ toolContext
1579
+ });
1580
+ if (messageIdRaw == null) throw new Error("messageId required");
1581
+ const messageId = String(messageIdRaw);
1582
+ const emoji = readStringParam(params, "emoji", { required: true });
1583
+ if (params.remove === true) throw new Error("Nextcloud Talk reaction removal is not supported yet; only adding reactions is implemented.");
1584
+ await sendReactionNextcloudTalk(target, messageId, emoji, {
1585
+ accountId: accountId ?? void 0,
1586
+ cfg
1587
+ });
1588
+ return jsonResult({
1589
+ ok: true,
1590
+ added: emoji
1591
+ });
1592
+ }
1593
+ throw new Error(`Action ${action} not supported for ${providerId}.`);
1594
+ }
1595
+ };
1596
+ //#endregion
1597
+ //#region extensions/nextcloud-talk/src/message-adapter.ts
1598
+ const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
1599
+ id: "nextcloud-talk",
1600
+ durableFinal: { capabilities: {
1601
+ text: true,
1602
+ media: true,
1603
+ replyTo: true
1604
+ } },
1605
+ send: {
1606
+ text: async ({ cfg, to, text, accountId, replyToId }) => await sendMessageNextcloudTalk(to, text, {
1607
+ accountId: accountId ?? void 0,
1608
+ replyTo: replyToId ?? void 0,
1609
+ cfg
1610
+ }),
1611
+ media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
1612
+ accountId: accountId ?? void 0,
1613
+ replyTo: replyToId ?? void 0,
1614
+ cfg
1615
+ })
1616
+ }
1617
+ });
1618
+ //#endregion
1619
+ //#region extensions/nextcloud-talk/src/session-route.ts
1620
+ function resolveNextcloudTalkOutboundSessionRoute(params) {
1621
+ const roomId = stripNextcloudTalkTargetPrefix(params.target);
1622
+ if (!roomId) return null;
1623
+ const baseSessionKey = buildOutboundBaseSessionKey({
1624
+ cfg: params.cfg,
1625
+ agentId: params.agentId,
1626
+ channel: "nextcloud-talk",
1627
+ accountId: params.accountId,
1628
+ peer: {
1629
+ kind: "group",
1630
+ id: roomId
1631
+ }
1632
+ });
1633
+ return {
1634
+ sessionKey: baseSessionKey,
1635
+ baseSessionKey,
1636
+ peer: {
1637
+ kind: "group",
1638
+ id: roomId
1639
+ },
1640
+ chatType: "group",
1641
+ from: `nextcloud-talk:room:${roomId}`,
1642
+ to: `nextcloud-talk:${roomId}`
1643
+ };
1644
+ }
1645
+ //#endregion
1646
+ //#region extensions/nextcloud-talk/src/setup-core.ts
1647
+ const t$1 = createSetupTranslator$1();
1648
+ const channel$1 = "nextcloud-talk";
1649
+ function addWildcardAllowFrom(allowFrom) {
1650
+ return mergeAllowFromEntries(allowFrom, ["*"]);
1651
+ }
1652
+ function normalizeNextcloudTalkBaseUrl(value) {
1653
+ return value?.trim().replace(/\/+$/, "") ?? "";
1654
+ }
1655
+ function validateNextcloudTalkBaseUrl(value) {
1656
+ if (!value) return "Required";
1657
+ if (!value.startsWith("http://") && !value.startsWith("https://")) return "URL must start with http:// or https://";
1658
+ }
1659
+ function setNextcloudTalkAccountConfig(cfg, accountId, updates) {
1660
+ return patchScopedAccountConfig({
1661
+ cfg,
1662
+ channelKey: channel$1,
1663
+ accountId,
1664
+ patch: updates
1665
+ });
1666
+ }
1667
+ function clearNextcloudTalkAccountFields(cfg, accountId, fields) {
1668
+ const section = cfg.channels?.["nextcloud-talk"];
1669
+ if (!section) return cfg;
1670
+ if (accountId === DEFAULT_ACCOUNT_ID$1) {
1671
+ const nextSection = { ...section };
1672
+ for (const field of fields) delete nextSection[field];
1673
+ return {
1674
+ ...cfg,
1675
+ channels: {
1676
+ ...cfg.channels,
1677
+ "nextcloud-talk": nextSection
1678
+ }
1679
+ };
1680
+ }
1681
+ const currentAccount = section.accounts?.[accountId];
1682
+ if (!currentAccount) return cfg;
1683
+ const nextAccount = { ...currentAccount };
1684
+ for (const field of fields) delete nextAccount[field];
1685
+ return {
1686
+ ...cfg,
1687
+ channels: {
1688
+ ...cfg.channels,
1689
+ "nextcloud-talk": {
1690
+ ...section,
1691
+ accounts: {
1692
+ ...section.accounts,
1693
+ [accountId]: nextAccount
1694
+ }
1695
+ }
1696
+ }
1697
+ };
1698
+ }
1699
+ async function promptNextcloudTalkAllowFrom(params) {
1700
+ return await promptParsedAllowFromForAccount({
1701
+ cfg: params.cfg,
1702
+ accountId: params.accountId,
1703
+ defaultAccountId: params.accountId,
1704
+ prompter: params.prompter,
1705
+ noteTitle: t$1("wizard.nextcloudTalk.userIdTitle"),
1706
+ noteLines: [
1707
+ t$1("wizard.nextcloudTalk.userIdHelpAdmin"),
1708
+ t$1("wizard.nextcloudTalk.userIdHelpLogs"),
1709
+ t$1("wizard.nextcloudTalk.userIdHelpLowercase"),
1710
+ t$1("wizard.channels.docs", { link: formatDocsLink$1("/channels/nextcloud-talk", "nextcloud-talk") })
1711
+ ],
1712
+ message: t$1("wizard.nextcloudTalk.allowFromPrompt"),
1713
+ placeholder: "username",
1714
+ parseEntries: (raw) => ({ entries: raw.split(/[\n,;]+/g).map(normalizeLowercaseStringOrEmpty).filter(Boolean) }),
1715
+ getExistingAllowFrom: ({ cfg, accountId }) => resolveNextcloudTalkAccount({
1716
+ cfg,
1717
+ accountId
1718
+ }).config.allowFrom ?? [],
1719
+ mergeEntries: ({ existing, parsed }) => mergeAllowFromEntries(existing.map((value) => normalizeLowercaseStringOrEmpty(String(value))), parsed),
1720
+ applyAllowFrom: ({ cfg, accountId, allowFrom }) => setNextcloudTalkAccountConfig(cfg, accountId, {
1721
+ dmPolicy: "allowlist",
1722
+ allowFrom
1723
+ })
1724
+ });
1725
+ }
1726
+ async function promptNextcloudTalkAllowFromForAccount(params) {
1727
+ const accountId = resolveSetupAccountId({
1728
+ accountId: params.accountId,
1729
+ defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg)
1730
+ });
1731
+ return await promptNextcloudTalkAllowFrom({
1732
+ cfg: params.cfg,
1733
+ prompter: params.prompter,
1734
+ accountId
1735
+ });
1736
+ }
1737
+ const nextcloudTalkDmPolicy = {
1738
+ label: "Nextcloud Talk",
1739
+ channel: channel$1,
1740
+ policyKey: "channels.nextcloud-talk.dmPolicy",
1741
+ allowFromKey: "channels.nextcloud-talk.allowFrom",
1742
+ resolveConfigKeys: (cfg, accountId) => (accountId ?? resolveDefaultNextcloudTalkAccountId(cfg)) !== DEFAULT_ACCOUNT_ID$1 ? {
1743
+ policyKey: `channels.nextcloud-talk.accounts.${accountId ?? resolveDefaultNextcloudTalkAccountId(cfg)}.dmPolicy`,
1744
+ allowFromKey: `channels.nextcloud-talk.accounts.${accountId ?? resolveDefaultNextcloudTalkAccountId(cfg)}.allowFrom`
1745
+ } : {
1746
+ policyKey: "channels.nextcloud-talk.dmPolicy",
1747
+ allowFromKey: "channels.nextcloud-talk.allowFrom"
1748
+ },
1749
+ getCurrent: (cfg, accountId) => resolveNextcloudTalkAccount({
1750
+ cfg,
1751
+ accountId: accountId ?? resolveDefaultNextcloudTalkAccountId(cfg)
1752
+ }).config.dmPolicy ?? "pairing",
1753
+ setPolicy: (cfg, policy, accountId) => {
1754
+ const resolvedAccountId = accountId ?? resolveDefaultNextcloudTalkAccountId(cfg);
1755
+ const resolved = resolveNextcloudTalkAccount({
1756
+ cfg,
1757
+ accountId: resolvedAccountId
1758
+ });
1759
+ return setNextcloudTalkAccountConfig(cfg, resolvedAccountId, {
1760
+ dmPolicy: policy,
1761
+ ...policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}
1762
+ });
1763
+ },
1764
+ promptAllowFrom: promptNextcloudTalkAllowFromForAccount
1765
+ };
1766
+ const nextcloudTalkSetupAdapter = {
1767
+ resolveAccountId: ({ accountId }) => normalizeAccountId$1(accountId),
1768
+ applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
1769
+ cfg,
1770
+ channelKey: channel$1,
1771
+ accountId,
1772
+ name
1773
+ }),
1774
+ validateInput: createSetupInputPresenceValidator({
1775
+ defaultAccountOnlyEnvError: "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.",
1776
+ validate: ({ input }) => {
1777
+ const setupInput = input;
1778
+ if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) return "Nextcloud Talk requires bot secret or --secret-file (or --use-env).";
1779
+ if (!setupInput.baseUrl) return "Nextcloud Talk requires --base-url.";
1780
+ return null;
1781
+ }
1782
+ }),
1783
+ applyAccountConfig: ({ cfg, accountId, input }) => {
1784
+ const setupInput = input;
1785
+ const namedConfig = applyAccountNameToChannelSection({
1786
+ cfg,
1787
+ channelKey: channel$1,
1788
+ accountId,
1789
+ name: setupInput.name
1790
+ });
1791
+ return setNextcloudTalkAccountConfig(setupInput.useEnv ? clearNextcloudTalkAccountFields(namedConfig, accountId, ["botSecret", "botSecretFile"]) : namedConfig, accountId, {
1792
+ baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl),
1793
+ ...setupInput.useEnv ? {} : setupInput.secretFile ? { botSecretFile: setupInput.secretFile } : setupInput.secret ? { botSecret: setupInput.secret } : {}
1794
+ });
1795
+ }
1796
+ };
1797
+ //#endregion
1798
+ //#region extensions/nextcloud-talk/src/setup-surface.ts
1799
+ const t = createSetupTranslator();
1800
+ const channel = "nextcloud-talk";
1801
+ const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
1802
+ const nextcloudTalkSetupWizard = {
1803
+ channel,
1804
+ stepOrder: "text-first",
1805
+ status: createStandardChannelSetupStatus({
1806
+ channelLabel: "Nextcloud Talk",
1807
+ configuredLabel: t("wizard.channels.statusConfigured"),
1808
+ unconfiguredLabel: t("wizard.channels.statusNeedsSetup"),
1809
+ configuredHint: t("wizard.channels.statusConfigured"),
1810
+ unconfiguredHint: t("wizard.channels.statusSelfHostedChat"),
1811
+ configuredScore: 1,
1812
+ unconfiguredScore: 5,
1813
+ resolveConfigured: ({ cfg, accountId }) => {
1814
+ const account = resolveNextcloudTalkAccount({
1815
+ cfg,
1816
+ accountId
1817
+ });
1818
+ return Boolean(account.secret && account.baseUrl);
1819
+ }
1820
+ }),
1821
+ introNote: {
1822
+ title: t("wizard.nextcloudTalk.setupTitle"),
1823
+ lines: [
1824
+ t("wizard.nextcloudTalk.helpSsh"),
1825
+ t("wizard.nextcloudTalk.helpInstallCommand"),
1826
+ t("wizard.nextcloudTalk.helpCopySecret"),
1827
+ t("wizard.nextcloudTalk.helpEnableRoom"),
1828
+ t("wizard.nextcloudTalk.helpEnvTip"),
1829
+ t("wizard.channels.docs", { link: formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk") })
1830
+ ],
1831
+ shouldShow: ({ cfg, accountId }) => {
1832
+ const account = resolveNextcloudTalkAccount({
1833
+ cfg,
1834
+ accountId
1835
+ });
1836
+ return !account.secret || !account.baseUrl;
1837
+ }
1838
+ },
1839
+ prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
1840
+ const resolvedAccount = resolveNextcloudTalkAccount({
1841
+ cfg,
1842
+ accountId
1843
+ });
1844
+ const hasApiCredentials = Boolean(resolvedAccount.config.apiUser?.trim() && (hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || resolvedAccount.config.apiPasswordFile));
1845
+ if (!await prompter.confirm({
1846
+ message: t("wizard.nextcloudTalk.configureApiCredentials"),
1847
+ initialValue: hasApiCredentials
1848
+ })) return;
1849
+ return { credentialValues: {
1850
+ ...credentialValues,
1851
+ [CONFIGURE_API_FLAG]: "1"
1852
+ } };
1853
+ },
1854
+ credentials: [{
1855
+ inputKey: "token",
1856
+ providerHint: channel,
1857
+ credentialLabel: t("wizard.nextcloudTalk.botSecret"),
1858
+ preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
1859
+ envPrompt: t("wizard.nextcloudTalk.botSecretEnvPrompt"),
1860
+ keepPrompt: t("wizard.nextcloudTalk.botSecretKeep"),
1861
+ inputPrompt: t("wizard.nextcloudTalk.botSecretInput"),
1862
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$1,
1863
+ inspect: ({ cfg, accountId }) => {
1864
+ const resolvedAccount = resolveNextcloudTalkAccount({
1865
+ cfg,
1866
+ accountId
1867
+ });
1868
+ return {
1869
+ accountConfigured: Boolean(resolvedAccount.secret && resolvedAccount.baseUrl),
1870
+ hasConfiguredValue: Boolean(hasConfiguredSecretInput(resolvedAccount.config.botSecret) || resolvedAccount.config.botSecretFile),
1871
+ resolvedValue: resolvedAccount.secret || void 0,
1872
+ envValue: accountId === DEFAULT_ACCOUNT_ID$1 ? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET) : void 0
1873
+ };
1874
+ },
1875
+ applyUseEnv: async (params) => {
1876
+ const resolvedAccount = resolveNextcloudTalkAccount({
1877
+ cfg: params.cfg,
1878
+ accountId: params.accountId
1879
+ });
1880
+ return setNextcloudTalkAccountConfig(clearNextcloudTalkAccountFields(params.cfg, params.accountId, ["botSecret", "botSecretFile"]), params.accountId, { baseUrl: resolvedAccount.baseUrl });
1881
+ },
1882
+ applySet: async (params) => setNextcloudTalkAccountConfig(clearNextcloudTalkAccountFields(params.cfg, params.accountId, ["botSecret", "botSecretFile"]), params.accountId, { botSecret: params.value })
1883
+ }, {
1884
+ inputKey: "password",
1885
+ providerHint: "nextcloud-talk-api",
1886
+ credentialLabel: t("wizard.nextcloudTalk.apiPassword"),
1887
+ preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
1888
+ envPrompt: "",
1889
+ keepPrompt: t("wizard.nextcloudTalk.apiPasswordKeep"),
1890
+ inputPrompt: t("wizard.nextcloudTalk.apiPasswordInput"),
1891
+ inspect: ({ cfg, accountId }) => {
1892
+ const resolvedAccount = resolveNextcloudTalkAccount({
1893
+ cfg,
1894
+ accountId
1895
+ });
1896
+ const apiUser = resolvedAccount.config.apiUser?.trim();
1897
+ const apiPasswordConfigured = Boolean(hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || resolvedAccount.config.apiPasswordFile);
1898
+ return {
1899
+ accountConfigured: Boolean(apiUser && apiPasswordConfigured),
1900
+ hasConfiguredValue: apiPasswordConfigured
1901
+ };
1902
+ },
1903
+ shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
1904
+ applySet: async (params) => setNextcloudTalkAccountConfig(clearNextcloudTalkAccountFields(params.cfg, params.accountId, ["apiPassword", "apiPasswordFile"]), params.accountId, { apiPassword: params.value })
1905
+ }],
1906
+ textInputs: [{
1907
+ inputKey: "httpUrl",
1908
+ message: t("wizard.nextcloudTalk.instanceUrlPrompt"),
1909
+ currentValue: ({ cfg, accountId }) => resolveNextcloudTalkAccount({
1910
+ cfg,
1911
+ accountId
1912
+ }).baseUrl || void 0,
1913
+ shouldPrompt: ({ currentValue }) => !currentValue,
1914
+ validate: ({ value }) => validateNextcloudTalkBaseUrl(value),
1915
+ normalizeValue: ({ value }) => normalizeNextcloudTalkBaseUrl(value),
1916
+ applySet: async (params) => setNextcloudTalkAccountConfig(params.cfg, params.accountId, { baseUrl: params.value })
1917
+ }, {
1918
+ inputKey: "userId",
1919
+ message: t("wizard.nextcloudTalk.apiUserPrompt"),
1920
+ currentValue: ({ cfg, accountId }) => resolveNextcloudTalkAccount({
1921
+ cfg,
1922
+ accountId
1923
+ }).config.apiUser?.trim() || void 0,
1924
+ shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
1925
+ validate: ({ value }) => value ? void 0 : t("common.required"),
1926
+ applySet: async (params) => setNextcloudTalkAccountConfig(params.cfg, params.accountId, { apiUser: params.value })
1927
+ }],
1928
+ dmPolicy: nextcloudTalkDmPolicy,
1929
+ disable: (cfg) => setSetupChannelEnabled(cfg, channel, false)
1930
+ };
1931
+ //#endregion
1932
+ //#region extensions/nextcloud-talk/src/channel.ts
1933
+ const meta = {
1934
+ id: "nextcloud-talk",
1935
+ label: "Nextcloud Talk",
1936
+ selectionLabel: "Nextcloud Talk (self-hosted)",
1937
+ docsPath: "/channels/nextcloud-talk",
1938
+ docsLabel: "nextcloud-talk",
1939
+ blurb: "Self-hosted chat via Nextcloud Talk webhook bots.",
1940
+ aliases: ["nc-talk", "nc"],
1941
+ order: 65,
1942
+ quickstartAllowFrom: true
1943
+ };
1944
+ const collectNextcloudTalkSecurityWarnings = createAllowlistProviderRouteAllowlistWarningCollector({
1945
+ providerConfigPresent: (cfg) => cfg.channels?.["nextcloud-talk"] !== void 0,
1946
+ resolveGroupPolicy: (account) => account.config.groupPolicy,
1947
+ resolveRouteAllowlistConfigured: (account) => Boolean(account.config.rooms) && Object.keys(account.config.rooms ?? {}).length > 0,
1948
+ restrictSenders: {
1949
+ surface: "Nextcloud Talk rooms",
1950
+ openScope: "any member in allowed rooms",
1951
+ groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
1952
+ groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom"
1953
+ },
1954
+ noRouteAllowlist: {
1955
+ surface: "Nextcloud Talk rooms",
1956
+ routeAllowlistPath: "channels.nextcloud-talk.rooms",
1957
+ routeScope: "room",
1958
+ groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
1959
+ groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom"
1960
+ }
1961
+ });
1962
+ const nextcloudTalkPlugin = createChatChannelPlugin({
1963
+ base: {
1964
+ id: "nextcloud-talk",
1965
+ meta,
1966
+ setupWizard: nextcloudTalkSetupWizard,
1967
+ capabilities: {
1968
+ chatTypes: ["direct", "group"],
1969
+ reactions: true,
1970
+ threads: false,
1971
+ media: true,
1972
+ nativeCommands: false,
1973
+ blockStreaming: true
1974
+ },
1975
+ reload: { configPrefixes: ["channels.nextcloud-talk"] },
1976
+ configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
1977
+ config: {
1978
+ ...nextcloudTalkConfigAdapter,
1979
+ isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
1980
+ describeAccount: (account) => describeWebhookAccountSnapshot({
1981
+ account,
1982
+ configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
1983
+ extra: {
1984
+ secretSource: account.secretSource,
1985
+ baseUrl: account.baseUrl ? "[set]" : "[missing]"
1986
+ }
1987
+ })
1988
+ },
1989
+ approvalCapability: nextcloudTalkApprovalAuth,
1990
+ doctor: nextcloudTalkDoctor,
1991
+ groups: {
1992
+ resolveRequireMention: ({ cfg, accountId, groupId }) => {
1993
+ const rooms = resolveNextcloudTalkAccount({
1994
+ cfg,
1995
+ accountId
1996
+ }).config.rooms;
1997
+ if (!rooms || !groupId) return true;
1998
+ const roomConfig = rooms[groupId];
1999
+ if (roomConfig?.requireMention !== void 0) return roomConfig.requireMention;
2000
+ const wildcardConfig = rooms["*"];
2001
+ if (wildcardConfig?.requireMention !== void 0) return wildcardConfig.requireMention;
2002
+ return true;
2003
+ },
2004
+ resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy
2005
+ },
2006
+ messaging: {
2007
+ targetPrefixes: [
2008
+ "nextcloud-talk",
2009
+ "nc-talk",
2010
+ "nc"
2011
+ ],
2012
+ normalizeTarget: normalizeNextcloudTalkMessagingTarget,
2013
+ resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params),
2014
+ targetResolver: {
2015
+ looksLikeId: looksLikeNextcloudTalkTargetId,
2016
+ hint: "<roomToken>"
2017
+ }
2018
+ },
2019
+ secrets: {
2020
+ secretTargetRegistryEntries,
2021
+ collectRuntimeConfigAssignments
2022
+ },
2023
+ setup: nextcloudTalkSetupAdapter,
2024
+ status: createComputedAccountStatusAdapter({
2025
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID$2),
2026
+ buildChannelSummary: ({ snapshot }) => buildWebhookChannelStatusSummary(snapshot, { secretSource: snapshot.secretSource ?? "none" }),
2027
+ collectStatusIssues: (accounts) => accounts.flatMap((account) => {
2028
+ const probe = account.probe;
2029
+ if (!probe || probe.ok !== false || probe.code !== "missing_response_feature" || !probe.message) return [];
2030
+ return [{
2031
+ channel: "nextcloud-talk",
2032
+ accountId: account.accountId ?? DEFAULT_ACCOUNT_ID$2,
2033
+ kind: "config",
2034
+ message: probe.message,
2035
+ fix: "Add --feature response to the Talk bot."
2036
+ }];
2037
+ }),
2038
+ probeAccount: async ({ account, timeoutMs }) => await probeNextcloudTalkBotResponseFeature({
2039
+ account,
2040
+ timeoutMs
2041
+ }),
2042
+ resolveAccountSnapshot: ({ account }) => ({
2043
+ accountId: account.accountId,
2044
+ name: account.name,
2045
+ enabled: account.enabled,
2046
+ configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
2047
+ extra: {
2048
+ secretSource: account.secretSource,
2049
+ baseUrl: account.baseUrl ? "[set]" : "[missing]",
2050
+ mode: "webhook"
2051
+ }
2052
+ })
2053
+ }),
2054
+ gateway: nextcloudTalkGatewayAdapter,
2055
+ message: nextcloudTalkMessageAdapter,
2056
+ actions: nextcloudTalkMessageActions
2057
+ },
2058
+ pairing: { text: {
2059
+ ...nextcloudTalkPairingTextAdapter,
2060
+ notify: createLoggedPairingApprovalNotifier(({ id }) => `[nextcloud-talk] User ${id} approved for pairing`)
2061
+ } },
2062
+ security: {
2063
+ ...nextcloudTalkSecurityAdapter,
2064
+ collectWarnings: collectNextcloudTalkSecurityWarnings
2065
+ },
2066
+ outbound: {
2067
+ base: {
2068
+ deliveryMode: "direct",
2069
+ chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
2070
+ chunkerMode: "markdown",
2071
+ textChunkLimit: 4e3
2072
+ },
2073
+ attachedResults: {
2074
+ channel: "nextcloud-talk",
2075
+ sendText: async ({ cfg, to, text, accountId, replyToId }) => await nextcloudTalkMessageAdapter.send.text({
2076
+ cfg,
2077
+ to,
2078
+ text,
2079
+ accountId,
2080
+ replyToId
2081
+ }),
2082
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => await nextcloudTalkMessageAdapter.send.media({
2083
+ cfg,
2084
+ to,
2085
+ text,
2086
+ mediaUrl: mediaUrl ?? "",
2087
+ accountId,
2088
+ replyToId
2089
+ })
2090
+ }
2091
+ }
2092
+ });
2093
+ //#endregion
2094
+ export { nextcloudTalkPlugin as t };