@openclaw/nextcloud-talk 2026.10.10 → 2026.11.5-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/.setup/api-BvrRSilb.mjs +2 -0
  2. package/dist/.setup/{channel-D9tHRtkv.mjs → channel-BA1-OoqO.mjs} +1695 -934
  3. package/dist/.setup/outbound-media-CpFe1Xgz.mjs +303 -0
  4. package/dist/.setup/reply-BEkv387l.mjs +64 -0
  5. package/dist/.setup/{runtime-api-DSz4xBAm.mjs → runtime-api-CGy_KpdV.mjs} +1 -2
  6. package/dist/.setup/{secret-contract-api-CKCDZ3m8.mjs → secret-contract-api-QMihLdr1.mjs} +1 -1
  7. package/dist/.setup/secret-contract-yjCLewtg.mjs +19 -0
  8. package/dist/.source-module-urls/3332e832383d54fca83a3cfe8961ecdecf361c1eec0972c99f00a876d9481d98.json +1 -1
  9. package/dist/api.js +1 -1
  10. package/dist/channel-plugin-api.js +1 -1
  11. package/dist/configured-state.js +2 -5
  12. package/dist/runtime-api.js +1 -1
  13. package/dist/secret-contract-api.js +1 -1
  14. package/dist/setup-entry.js +2 -2
  15. package/node_modules/saxes/README.md +323 -0
  16. package/node_modules/saxes/node_modules/xmlchars/LICENSE +18 -0
  17. package/node_modules/saxes/node_modules/xmlchars/README.md +33 -0
  18. package/node_modules/saxes/node_modules/xmlchars/package.json +51 -0
  19. package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed4.d.ts +31 -0
  20. package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed4.js +44 -0
  21. package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed4.js.map +1 -0
  22. package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed5.d.ts +51 -0
  23. package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed5.js +105 -0
  24. package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed5.js.map +1 -0
  25. package/node_modules/saxes/node_modules/xmlchars/xml/1.1/ed2.d.ts +73 -0
  26. package/node_modules/saxes/node_modules/xmlchars/xml/1.1/ed2.js +145 -0
  27. package/node_modules/saxes/node_modules/xmlchars/xml/1.1/ed2.js.map +1 -0
  28. package/node_modules/saxes/node_modules/xmlchars/xmlchars.d.ts +170 -0
  29. package/node_modules/saxes/node_modules/xmlchars/xmlchars.js +191 -0
  30. package/node_modules/saxes/node_modules/xmlchars/xmlchars.js.map +1 -0
  31. package/node_modules/saxes/node_modules/xmlchars/xmlns/1.0/ed3.d.ts +28 -0
  32. package/node_modules/saxes/node_modules/xmlchars/xmlns/1.0/ed3.js +65 -0
  33. package/node_modules/saxes/node_modules/xmlchars/xmlns/1.0/ed3.js.map +1 -0
  34. package/node_modules/saxes/package.json +71 -0
  35. package/node_modules/saxes/saxes.d.ts +635 -0
  36. package/node_modules/saxes/saxes.js +2053 -0
  37. package/node_modules/saxes/saxes.js.map +1 -0
  38. package/node_modules/zod/package.json +1 -1
  39. package/node_modules/zod/src/v4/classic/tests/array.test.ts +27 -0
  40. package/node_modules/zod/src/v4/classic/tests/map.test.ts +7 -0
  41. package/node_modules/zod/src/v4/classic/tests/set.test.ts +6 -0
  42. package/node_modules/zod/src/v4/core/compile.ts +9 -0
  43. package/node_modules/zod/src/v4/core/schemas.ts +35 -1
  44. package/node_modules/zod/src/v4/core/util.ts +13 -0
  45. package/node_modules/zod/src/v4/core/versions.ts +1 -1
  46. package/node_modules/zod/v4/core/compile.cjs +8 -0
  47. package/node_modules/zod/v4/core/compile.js +8 -0
  48. package/node_modules/zod/v4/core/schemas.cjs +30 -0
  49. package/node_modules/zod/v4/core/schemas.d.cts +3 -1
  50. package/node_modules/zod/v4/core/schemas.d.ts +3 -1
  51. package/node_modules/zod/v4/core/schemas.js +30 -0
  52. package/node_modules/zod/v4/core/util.cjs +15 -0
  53. package/node_modules/zod/v4/core/util.d.cts +2 -0
  54. package/node_modules/zod/v4/core/util.d.ts +2 -0
  55. package/node_modules/zod/v4/core/util.js +14 -0
  56. package/node_modules/zod/v4/core/versions.cjs +1 -1
  57. package/node_modules/zod/v4/core/versions.js +1 -1
  58. package/openclaw.plugin.json +23 -1
  59. package/package.json +8 -6
  60. package/dist/.setup/api-BgnUYkO3.mjs +0 -2
  61. package/dist/.setup/secret-contract-BHyPrmz3.mjs +0 -14
@@ -1,57 +1,186 @@
1
1
  import { a as __exportAll, n as legacyConfigRules, o as __reExport, r as normalizeCompatibilityConfig } from "./doctor-contract-CkWQa5uZ.mjs";
2
2
  import { listNextcloudTalkAccountIds, mergeNextcloudTalkAccountConfig, resolveDefaultNextcloudTalkAccountId } from "../configured-state.js";
3
- import { a as logInboundDrop, c as warnMissingProviderGroupPolicyFallbackOnce, i as fetchWithSsrFGuard, l as getNextcloudTalkRuntime, n as createChannelPairingController, o as resolveAllowlistProviderRuntimeGroupPolicy, r as deliverFormattedTextWithAttachments, s as resolveDefaultGroupPolicy, t as GROUP_POLICY_BLOCKED_LABEL, u as getOptionalNextcloudTalkRuntime } from "./runtime-api-DSz4xBAm.mjs";
4
- import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries } from "./secret-contract-BHyPrmz3.mjs";
5
- import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
3
+ import { a as resolveAllowlistProviderRuntimeGroupPolicy, c as getNextcloudTalkRuntime, i as logInboundDrop, l as getOptionalNextcloudTalkRuntime, n as createChannelPairingController, o as resolveDefaultGroupPolicy, r as fetchWithSsrFGuard, s as warnMissingProviderGroupPolicyFallbackOnce, t as GROUP_POLICY_BLOCKED_LABEL } from "./runtime-api-CGy_KpdV.mjs";
4
+ import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries } from "./secret-contract-yjCLewtg.mjs";
5
+ import { describeWebhookAccountSnapshot, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/account-helpers";
6
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
7
+ import { createChannelApprovalAuth } from "openclaw/plugin-sdk/approval-auth-runtime";
8
+ import { adaptScopedAccountAccessor, clearAccountFieldsFromConfigSection, createScopedChannelConfigAdapter, createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
6
9
  import { buildChannelOutboundSessionRoute, createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
10
+ import { bindIngressLifecycleToReplyOptions, createAccountStatusSink, createChannelIngressMonitor, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, resolveChannelStreamingBlockEnabled, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-outbound";
7
11
  import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
8
12
  import { createAllowlistProviderRouteAllowlistWarningCollector, createConditionalWarningCollector, resolveScopeRequireMention, resolveScopeToolsPolicy } from "openclaw/plugin-sdk/channel-policy";
13
+ import { createDeferred, requireChannelOpenAllowFrom, resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
14
+ import { channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime";
9
15
  import { buildWebhookChannelStatusSummary, createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
16
+ import { isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, normalizeStringEntries, readNonEmptyStringPreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
10
17
  import { convertMarkdownTables, sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
11
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core";
18
+ import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, normalizeAccountId, resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core";
12
19
  import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
13
20
  import { buildSecretInputSchema, hasConfiguredSecretInput, resolveSecretInputString } from "openclaw/plugin-sdk/secret-input";
14
- import { isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries, readNonEmptyStringPreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
15
- import { createChannelApprovalAuth } from "openclaw/plugin-sdk/approval-auth-runtime";
16
21
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
17
22
  import { parseStrictNonNegativeInteger, parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
18
23
  import { readProviderJsonResponse, readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
19
- import { createChannelInboundEnvelopeBuilderAsync } from "openclaw/plugin-sdk/channel-inbound";
20
- import { SsrFBlockedError, ssrfPolicyFromPrivateNetworkOptIn, ssrfPolicyFromPrivateNetworkOptIn as ssrfPolicyFromPrivateNetworkOptIn$1 } from "openclaw/plugin-sdk/ssrf-runtime";
24
+ import { createChannelInboundEnvelopeBuilderAsync, filterChannelInboundQuoteContext, formatInboundMediaUnavailableText, resolveInboundSupplementalSenderAllowed } from "openclaw/plugin-sdk/channel-inbound";
25
+ import { SsrFBlockedError, ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
21
26
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
22
27
  import { redactToolPayloadText } from "openclaw/plugin-sdk/logging-core";
23
- import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
24
- import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
25
- import { createHmac, randomBytes } from "node:crypto";
26
- import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
27
- import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$2 } from "openclaw/plugin-sdk/account-id";
28
28
  import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, ReplyRuntimeConfigSchemaShape, ReplyToModeSchema, buildChannelConfigSchema, buildGroupEntrySchema, buildMultiAccountChannelSchema, requireOpenAllowFrom } from "openclaw/plugin-sdk/channel-config-schema";
29
- import { formatAllowFromLowercase, resolveAllowlistMatchByCandidates } from "openclaw/plugin-sdk/allow-from";
30
- import { adaptScopedAccountAccessor, clearAccountFieldsFromConfigSection, createScopedChannelConfigAdapter, createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
31
- import { createDeferred, requireChannelOpenAllowFrom, resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
32
29
  import { z } from "zod";
33
30
  import os from "node:os";
34
31
  import path from "node:path";
35
32
  import { fileExists } from "openclaw/plugin-sdk/file-access-runtime";
36
33
  import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
37
- import { bindIngressLifecycleToReplyOptions, createAccountStatusSink, createChannelIngressMonitor, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, resolveChannelStreamingBlockEnabled, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-outbound";
38
- import { channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime";
39
34
  import { channelIngressRoutes } from "openclaw/plugin-sdk/channel-ingress-runtime";
40
- import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, normalizeAccountId as normalizeAccountId$1, resolveAgentRoute } from "openclaw/plugin-sdk/routing";
35
+ import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
36
+ import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$2, normalizeAccountId as normalizeAccountId$1, resolveAgentRoute } from "openclaw/plugin-sdk/routing";
37
+ import { expandAllowFromWithAccessGroups, safeEqualSecret, sanitizeUntrustedFileName } from "openclaw/plugin-sdk/security-runtime";
41
38
  import { safeParseJson, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
39
+ import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
40
+ import { containsAsciiControlCharacter } from "openclaw/plugin-sdk/string-normalization-runtime";
41
+ import { resolveAllowlistMatchByCandidates } from "openclaw/plugin-sdk/allow-from";
42
42
  import { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback } from "openclaw/plugin-sdk/channel-targets";
43
+ import { SaxesParser } from "saxes";
43
44
  import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
45
+ import { sha256Hex } from "openclaw/plugin-sdk/node-crypto-runtime";
46
+ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
47
+ import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
48
+ import { createHmac, randomBytes } from "node:crypto";
49
+ import { constants, promises } from "node:fs";
50
+ import { jsonResult, readStringParam, resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
51
+ import { writeJsonWithoutCharset } from "openclaw/plugin-sdk/http-response-runtime";
44
52
  import { WEBHOOK_RATE_LIMIT_DEFAULTS, createAuthRateLimiter, createWebhookInFlightLimiter, getWebhookLegacyListener, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, resolveRequestClientIp } from "openclaw/plugin-sdk/webhook-ingress";
45
53
  import { sendHttpRequestRejection } from "openclaw/plugin-sdk/webhook-request-guards";
46
54
  import { canonicalizeWebhookRouteKey, registerPluginHttpRoute, registerWebhookTarget, resolveSingleWebhookTarget } from "openclaw/plugin-sdk/webhook-targets";
47
55
  import { isRecord as isRecord$1 } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
48
- import { resolvePersistentDedupePluginStateNamespace } from "openclaw/plugin-sdk/persistent-dedupe";
49
- import { jsonResult, readStringParam, resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
50
56
  import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
51
57
  import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
52
58
  import { applyAccountNameToChannelSection, baseUrlTextInput, createPromptParsedAllowFromForAccount, createSetupTranslator, createStandardChannelSetupStatus, defineTokenCredential, formatDocsLink, patchScopedAccountConfig, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
53
59
  import { createSetupInputPresenceValidator, createSetupTranslator as createSetupTranslator$1, mergeAllowFromEntries } from "openclaw/plugin-sdk/setup-runtime";
54
60
  import { formatDocsLink as formatDocsLink$1 } from "openclaw/plugin-sdk/setup-tools";
61
+ import { resolvePersistentDedupePluginStateNamespace } from "openclaw/plugin-sdk/persistent-dedupe";
62
+ // src/gateway/gateway-http-route-contracts.ts
63
+ const GATEWAY_PROBE_ROUTES = /* @__PURE__ */ new Map([
64
+ ["/health", "live"],
65
+ ["/healthz", "live"],
66
+ ["/ready", "ready"],
67
+ ["/readyz", "ready"],
68
+ ["/startup", "startup"],
69
+ ["/startupz", "startup"]
70
+ ]);
71
+ function classifyGatewayProbePath(pathname) {
72
+ for (const [root, status] of GATEWAY_PROBE_ROUTES) {
73
+ if (pathname === root) return status;
74
+ if (pathname.startsWith(`${root}/`)) return "namespace";
75
+ }
76
+ return "outside";
77
+ }
78
+ // packages/normalization-core/src/string-coerce.ts
79
+ /** Trims string input and returns null for non-strings or empty strings. */
80
+ function normalizeNullableString(value) {
81
+ if (typeof value !== "string") return null;
82
+ const trimmed = value.trim();
83
+ return trimmed ? trimmed : null;
84
+ }
85
+ /** Trims string input and returns undefined for non-strings or empty strings. */
86
+ function normalizeOptionalString$1(value) {
87
+ return normalizeNullableString(value) ?? void 0;
88
+ }
89
+ /** Lowercases a normalized optional string. */
90
+ function normalizeOptionalLowercaseString(value) {
91
+ return normalizeOptionalString$1(value)?.toLowerCase();
92
+ }
93
+ /** Lowercases a normalized string or returns an empty string when absent. */
94
+ function normalizeLowercaseStringOrEmpty$1(value) {
95
+ return normalizeOptionalLowercaseString(value) ?? "";
96
+ }
97
+ // src/plugins/http-path-prefix.ts
98
+ function prefixMatchPath(pathname, prefix) {
99
+ return pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`);
100
+ }
101
+ // src/gateway/security-path.ts
102
+ const MAX_PATH_DECODE_PASSES = 32;
103
+ function normalizePathSeparators(pathname) {
104
+ const collapsed = pathname.replace(/\/{2,}/g, "/");
105
+ if (collapsed.length <= 1) return collapsed;
106
+ return collapsed.replace(/\/+$/, "");
107
+ }
108
+ function normalizePathForSecurity(pathname) {
109
+ return normalizePathSeparators(normalizeLowercaseStringOrEmpty$1(URL.parse(pathname, "http://localhost")?.pathname ?? pathname)) || "/";
110
+ }
111
+ function buildCanonicalPathCandidates(pathname) {
112
+ const candidates = /* @__PURE__ */ new Set([normalizePathForSecurity(pathname)]);
113
+ let decoded = pathname;
114
+ let malformedEncoding = false;
115
+ for (let pass = 0; pass < MAX_PATH_DECODE_PASSES; pass++) {
116
+ let nextDecoded;
117
+ try {
118
+ nextDecoded = decodeURIComponent(decoded);
119
+ } catch {
120
+ malformedEncoding = true;
121
+ break;
122
+ }
123
+ if (nextDecoded === decoded) break;
124
+ decoded = nextDecoded;
125
+ candidates.add(normalizePathForSecurity(decoded));
126
+ }
127
+ let decodePassLimitReached = false;
128
+ if (!malformedEncoding) try {
129
+ decodePassLimitReached = decodeURIComponent(decoded) !== decoded;
130
+ } catch {
131
+ malformedEncoding = true;
132
+ }
133
+ return {
134
+ candidates: [...candidates],
135
+ decodePassLimitReached,
136
+ malformedEncoding
137
+ };
138
+ }
139
+ function canonicalizePathForSecurity(pathname) {
140
+ const { candidates, decodePassLimitReached, malformedEncoding } = buildCanonicalPathCandidates(pathname);
141
+ return {
142
+ canonicalPath: candidates[candidates.length - 1] ?? "/",
143
+ candidates,
144
+ decodePassLimitReached,
145
+ malformedEncoding,
146
+ rawNormalizedPath: normalizePathSeparators(normalizeLowercaseStringOrEmpty$1(pathname)) || "/"
147
+ };
148
+ }
149
+ const PROTECTED_PLUGIN_ROUTE_PREFIXES = ["/api/channels"];
150
+ // src/gateway/server/plugins-http/path-context.ts
151
+ function normalizeProtectedPrefix(prefix) {
152
+ const collapsed = normalizeLowercaseStringOrEmpty$1(prefix).replace(/\/{2,}/g, "/");
153
+ if (collapsed.length <= 1) return collapsed || "/";
154
+ return collapsed.replace(/\/+$/, "");
155
+ }
156
+ const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES = PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix);
157
+ /** Returns true when any decoded path candidate targets a protected route. */
158
+ function isProtectedPluginRoutePathFromContext(context) {
159
+ if (context.candidates.some((candidate) => NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatchPath(candidate, prefix)))) return true;
160
+ if (context.decodePassLimitReached) return true;
161
+ if (!context.malformedEncoding) return false;
162
+ return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatchPath(context.rawNormalizedPath, prefix));
163
+ }
164
+ /** Builds all security-relevant decoded path candidates for a request path. */
165
+ function resolvePluginRoutePathContext(pathname) {
166
+ const canonical = canonicalizePathForSecurity(pathname);
167
+ return {
168
+ pathname,
169
+ canonicalPath: canonical.canonicalPath,
170
+ candidates: canonical.candidates,
171
+ malformedEncoding: canonical.malformedEncoding,
172
+ decodePassLimitReached: canonical.decodePassLimitReached,
173
+ rawNormalizedPath: canonical.rawNormalizedPath
174
+ };
175
+ }
176
+ // \0openclaw:bundled-sdk:openclaw/plugin-sdk/gateway-config-runtime
177
+ var gateway_config_runtime_exports = /* @__PURE__ */ __exportAll({
178
+ classifyGatewayProbePath: () => classifyGatewayProbePath,
179
+ isProtectedPluginRoutePathFromContext: () => isProtectedPluginRoutePathFromContext,
180
+ resolvePluginRoutePathContext: () => resolvePluginRoutePathContext
181
+ });
182
+ import * as import_openclaw_plugin_sdk_gateway_config_runtime from "openclaw/plugin-sdk/gateway-config-runtime";
183
+ __reExport(gateway_config_runtime_exports, import_openclaw_plugin_sdk_gateway_config_runtime);
55
184
  // extensions/nextcloud-talk/src/api-credentials.ts
56
185
  function resolveNextcloudTalkApiCredentialsResult(params) {
57
186
  const apiUser = params.apiUser?.trim();
@@ -97,7 +226,7 @@ function resolveNextcloudTalkSecret(accountId, merged) {
97
226
  status: "configured_unavailable"
98
227
  };
99
228
  const envSecret = normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET);
100
- if (envSecret && accountId === DEFAULT_ACCOUNT_ID) return {
229
+ if (envSecret && accountId === DEFAULT_ACCOUNT_ID$1) return {
101
230
  secret: envSecret,
102
231
  source: "env",
103
232
  status: "available"
@@ -177,21 +306,11 @@ function inspectNextcloudTalkAccount(params) {
177
306
  function isNextcloudTalkAccountConfigured(account) {
178
307
  return Boolean(account.tokenStatus !== "missing" && account.baseUrl?.trim());
179
308
  }
180
- // extensions/nextcloud-talk/src/approval-auth.ts
181
- function normalizeNextcloudTalkApproverId(value) {
182
- return normalizeOptionalLowercaseString(String(value).trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, ""));
183
- }
184
- const nextcloudTalkApprovalAuth = createChannelApprovalAuth({
185
- channelLabel: "Nextcloud Talk",
186
- resolveInputs: ({ cfg, accountId }) => {
187
- return { allowFrom: resolveNextcloudTalkAccount({
188
- cfg,
189
- accountId
190
- }).config.allowFrom };
191
- },
192
- normalizeApprover: normalizeNextcloudTalkApproverId
193
- }).approvalAuth;
194
309
  // extensions/nextcloud-talk/src/guarded-response.ts
310
+ async function releaseNextcloudTalkGuardedResponse(params) {
311
+ if (!params.response.bodyUsed) await params.response.body?.cancel().catch(() => void 0);
312
+ await params.release();
313
+ }
195
314
  async function readNextcloudTalkErrorBody(response, ...credentials) {
196
315
  try {
197
316
  let body = await readProviderTextResponse(response, "Nextcloud Talk error", {
@@ -204,41 +323,17 @@ async function readNextcloudTalkErrorBody(response, ...credentials) {
204
323
  return "";
205
324
  }
206
325
  }
207
- // extensions/nextcloud-talk/src/signature.ts
208
- const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
209
- const RANDOM_HEADER = "x-nextcloud-talk-random";
210
- const BACKEND_HEADER = "x-nextcloud-talk-backend";
211
- /**
212
- * Verify the HMAC-SHA256 signature of an incoming webhook request.
213
- * Signature is calculated as: HMAC-SHA256(random + body, secret)
214
- */
215
- function verifyNextcloudTalkSignature(params) {
216
- const { signature, random, body, secret } = params;
217
- if (!signature || !random || !secret) return false;
218
- const expected = createHmac("sha256", secret).update(random + body).digest("hex");
219
- return safeEqualSecret(signature, expected);
220
- }
221
- function extractNextcloudTalkHeaders(headers) {
222
- const getHeader = (name) => {
223
- const value = headers[name];
224
- return Array.isArray(value) ? value[0] : value;
225
- };
226
- const signature = getHeader(SIGNATURE_HEADER);
227
- const random = getHeader(RANDOM_HEADER);
228
- const backend = getHeader(BACKEND_HEADER);
229
- if (!signature || !random || !backend) return null;
230
- return {
231
- signature,
232
- random,
233
- backend
234
- };
235
- }
236
- function generateNextcloudTalkSignature(params) {
237
- const { body, secret } = params;
238
- const random = randomBytes(32).toString("hex");
326
+ function buildNextcloudTalkOcsOptions(auth, allowPrivateNetwork) {
239
327
  return {
240
- random,
241
- signature: createHmac("sha256", secret).update(random + body).digest("hex")
328
+ init: {
329
+ method: "GET",
330
+ headers: {
331
+ Authorization: `Basic ${auth}`,
332
+ "OCS-APIRequest": "true",
333
+ Accept: "application/json"
334
+ }
335
+ },
336
+ policy: ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork)
242
337
  };
243
338
  }
244
339
  // extensions/nextcloud-talk/src/bot-preflight.ts
@@ -285,16 +380,8 @@ async function probeNextcloudTalkBotResponseFeature(params) {
285
380
  try {
286
381
  const { response, release } = await fetchWithSsrFGuard({
287
382
  url,
288
- init: {
289
- method: "GET",
290
- headers: {
291
- Authorization: `Basic ${auth}`,
292
- "OCS-APIRequest": "true",
293
- Accept: "application/json"
294
- }
295
- },
383
+ ...buildNextcloudTalkOcsOptions(auth, account.config.network?.dangerouslyAllowPrivateNetwork),
296
384
  auditContext: "nextcloud-talk.bot-response-preflight",
297
- policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config.network?.dangerouslyAllowPrivateNetwork),
298
385
  timeoutMs
299
386
  });
300
387
  try {
@@ -342,46 +429,6 @@ async function probeNextcloudTalkBotResponseFeature(params) {
342
429
  };
343
430
  }
344
431
  }
345
- // extensions/nextcloud-talk/src/channel.adapters.ts
346
- const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter({
347
- sectionKey: "nextcloud-talk",
348
- listAccountIds: listNextcloudTalkAccountIds,
349
- resolveAccount: adaptScopedAccountAccessor(resolveNextcloudTalkAccount),
350
- inspectAccount: (cfg, accountId) => {
351
- const account = inspectNextcloudTalkAccount({
352
- cfg,
353
- accountId
354
- });
355
- return {
356
- ...account,
357
- baseUrl: account.baseUrl ? "[set]" : "[missing]"
358
- };
359
- },
360
- defaultAccountId: resolveDefaultNextcloudTalkAccountId,
361
- clearBaseFields: [
362
- "botSecret",
363
- "botSecretFile",
364
- "baseUrl",
365
- "name"
366
- ],
367
- resolveAllowFrom: (account) => account.config.allowFrom,
368
- formatAllowFrom: (allowFrom) => formatAllowFromLowercase({
369
- allowFrom,
370
- stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i
371
- })
372
- });
373
- const nextcloudTalkSecurityAdapter = { resolveDmPolicy: createScopedDmSecurityResolver({
374
- channelKey: "nextcloud-talk",
375
- resolvePolicy: (account) => account.config.dmPolicy,
376
- resolveAllowFrom: (account) => account.config.allowFrom,
377
- policyPathSuffix: "dmPolicy",
378
- normalizeEntry: (raw) => normalizeLowercaseStringOrEmpty(raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
379
- }) };
380
- const nextcloudTalkPairingTextAdapter = {
381
- idLabel: "nextcloudUserId",
382
- message: "OpenClaw: your access has been approved.",
383
- normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => normalizeLowercaseStringOrEmpty(entry))
384
- };
385
432
  // extensions/nextcloud-talk/src/config-schema.ts
386
433
  const NextcloudTalkRoomSchema = buildGroupEntrySchema({ allowFrom: z.array(z.string()).optional() }).omit({ toolsBySender: true });
387
434
  const NextcloudTalkNetworkSchema = z.object({
@@ -407,6 +454,7 @@ const NextcloudTalkAccountSchemaBase = z.object({
407
454
  webhookPath: z.string().optional(),
408
455
  webhookPublicUrl: z.string().optional(),
409
456
  allowFrom: z.array(z.string()).optional(),
457
+ mediaAllowFrom: z.array(z.string()).optional(),
410
458
  groupAllowFrom: z.array(z.string()).optional(),
411
459
  groupPolicy: GroupPolicySchema.optional().default("allowlist"),
412
460
  rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
@@ -426,153 +474,37 @@ const NextcloudTalkConfigSchema = buildMultiAccountChannelSchema(NextcloudTalkAc
426
474
  });
427
475
  }
428
476
  });
429
- // src/gateway/gateway-http-route-contracts.ts
430
- const GATEWAY_PROBE_ROUTES = /* @__PURE__ */ new Map([
431
- ["/health", "live"],
432
- ["/healthz", "live"],
433
- ["/ready", "ready"],
434
- ["/readyz", "ready"],
435
- ["/startup", "startup"],
436
- ["/startupz", "startup"]
437
- ]);
438
- function classifyGatewayProbePath(pathname) {
439
- for (const [root, status] of GATEWAY_PROBE_ROUTES) {
440
- if (pathname === root) return status;
441
- if (pathname.startsWith(`${root}/`)) return "namespace";
477
+ const NextcloudTalkChannelConfigSchema = buildChannelConfigSchema(NextcloudTalkConfigSchema, { uiHints: {
478
+ mediaAllowFrom: {
479
+ label: "Nextcloud Talk Inbound Media Allowlist",
480
+ help: "User IDs whose attachments may be processed after ordinary access and mention checks. Omitted or empty denies all media; use [\"*\"] for every otherwise-authorized sender."
481
+ },
482
+ "accounts.*.mediaAllowFrom": {
483
+ label: "Nextcloud Talk Account Inbound Media Allowlist",
484
+ help: "Per-account attachment allowlist. Omitted inherits the root allowlist; an empty list denies all media; use [\"*\"] for every otherwise-authorized sender."
442
485
  }
443
- return "outside";
444
- }
445
- // packages/normalization-core/src/string-coerce.ts
446
- /** Trims string input and returns null for non-strings or empty strings. */
447
- function normalizeNullableString(value) {
448
- if (typeof value !== "string") return null;
449
- const trimmed = value.trim();
450
- return trimmed ? trimmed : null;
451
- }
452
- /** Trims string input and returns undefined for non-strings or empty strings. */
453
- function normalizeOptionalString$1(value) {
454
- return normalizeNullableString(value) ?? void 0;
486
+ } });
487
+ // extensions/nextcloud-talk/src/webhook-route.ts
488
+ const DEFAULT_NEXTCLOUD_TALK_WEBHOOK_PATH = "/nextcloud-talk-webhook";
489
+ function resolveNextcloudTalkLegacyWebhook(config) {
490
+ const listener = config.legacyWebhook;
491
+ return listener ? {
492
+ port: listener.port,
493
+ host: listener.host ?? "0.0.0.0"
494
+ } : void 0;
455
495
  }
456
- /** Lowercases a normalized optional string. */
457
- function normalizeOptionalLowercaseString$1(value) {
458
- return normalizeOptionalString$1(value)?.toLowerCase();
496
+ function describeNextcloudTalkWebhookRouteConflict(path, gatewayPort) {
497
+ const pathname = URL.parse(path, "http://localhost")?.pathname ?? path;
498
+ const probe = classifyGatewayProbePath(pathname);
499
+ const reason = probe !== "outside" && probe !== "namespace" ? "is reserved for Gateway checks" : isProtectedPluginRoutePathFromContext(resolvePluginRoutePathContext(pathname)) ? "requires Gateway authentication" : void 0;
500
+ if (!reason) return;
501
+ return `Webhook path "${path}" ${reason} and cannot receive Nextcloud callbacks on the Gateway port. Set webhookPath to "${DEFAULT_NEXTCLOUD_TALK_WEBHOOK_PATH}" and update the Nextcloud bot callback and reverse-proxy upstream to Gateway port ${gatewayPort}${DEFAULT_NEXTCLOUD_TALK_WEBHOOK_PATH}.`;
459
502
  }
460
- /** Lowercases a normalized string or returns an empty string when absent. */
461
- function normalizeLowercaseStringOrEmpty$1(value) {
462
- return normalizeOptionalLowercaseString$1(value) ?? "";
463
- }
464
- // src/plugins/http-path-prefix.ts
465
- function prefixMatchPath(pathname, prefix) {
466
- return pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`);
467
- }
468
- // src/gateway/security-path.ts
469
- const MAX_PATH_DECODE_PASSES = 32;
470
- function normalizePathSeparators(pathname) {
471
- const collapsed = pathname.replace(/\/{2,}/g, "/");
472
- if (collapsed.length <= 1) return collapsed;
473
- return collapsed.replace(/\/+$/, "");
474
- }
475
- function normalizePathForSecurity(pathname) {
476
- return normalizePathSeparators(normalizeLowercaseStringOrEmpty$1(URL.parse(pathname, "http://localhost")?.pathname ?? pathname)) || "/";
477
- }
478
- function buildCanonicalPathCandidates(pathname) {
479
- const candidates = /* @__PURE__ */ new Set([normalizePathForSecurity(pathname)]);
480
- let decoded = pathname;
481
- let malformedEncoding = false;
482
- let decodePasses = 0;
483
- for (let pass = 0; pass < MAX_PATH_DECODE_PASSES; pass++) {
484
- let nextDecoded;
485
- try {
486
- nextDecoded = decodeURIComponent(decoded);
487
- } catch {
488
- malformedEncoding = true;
489
- break;
490
- }
491
- if (nextDecoded === decoded) break;
492
- decodePasses += 1;
493
- decoded = nextDecoded;
494
- candidates.add(normalizePathForSecurity(decoded));
495
- }
496
- let decodePassLimitReached = false;
497
- if (!malformedEncoding) try {
498
- decodePassLimitReached = decodeURIComponent(decoded) !== decoded;
499
- } catch {
500
- malformedEncoding = true;
501
- }
502
- return {
503
- candidates: [...candidates],
504
- decodePasses,
505
- decodePassLimitReached,
506
- malformedEncoding
507
- };
508
- }
509
- function canonicalizePathForSecurity(pathname) {
510
- const { candidates, decodePasses, decodePassLimitReached, malformedEncoding } = buildCanonicalPathCandidates(pathname);
511
- return {
512
- canonicalPath: candidates[candidates.length - 1] ?? "/",
513
- candidates,
514
- decodePasses,
515
- decodePassLimitReached,
516
- malformedEncoding,
517
- rawNormalizedPath: normalizePathSeparators(normalizeLowercaseStringOrEmpty$1(pathname)) || "/"
518
- };
519
- }
520
- const PROTECTED_PLUGIN_ROUTE_PREFIXES = ["/api/channels"];
521
- // src/gateway/server/plugins-http/path-context.ts
522
- function normalizeProtectedPrefix(prefix) {
523
- const collapsed = normalizeLowercaseStringOrEmpty$1(prefix).replace(/\/{2,}/g, "/");
524
- if (collapsed.length <= 1) return collapsed || "/";
525
- return collapsed.replace(/\/+$/, "");
526
- }
527
- const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES = PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix);
528
- /** Returns true when any decoded path candidate targets a protected route. */
529
- function isProtectedPluginRoutePathFromContext(context) {
530
- if (context.candidates.some((candidate) => NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatchPath(candidate, prefix)))) return true;
531
- if (context.decodePassLimitReached) return true;
532
- if (!context.malformedEncoding) return false;
533
- return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatchPath(context.rawNormalizedPath, prefix));
534
- }
535
- /** Builds all security-relevant decoded path candidates for a request path. */
536
- function resolvePluginRoutePathContext(pathname) {
537
- const canonical = canonicalizePathForSecurity(pathname);
538
- return {
539
- pathname,
540
- canonicalPath: canonical.canonicalPath,
541
- candidates: canonical.candidates,
542
- malformedEncoding: canonical.malformedEncoding,
543
- decodePassLimitReached: canonical.decodePassLimitReached,
544
- rawNormalizedPath: canonical.rawNormalizedPath
545
- };
546
- }
547
- // \0openclaw:bundled-sdk:openclaw/plugin-sdk/gateway-config-runtime
548
- var gateway_config_runtime_exports = /* @__PURE__ */ __exportAll({
549
- classifyGatewayProbePath: () => classifyGatewayProbePath,
550
- isProtectedPluginRoutePathFromContext: () => isProtectedPluginRoutePathFromContext,
551
- resolvePluginRoutePathContext: () => resolvePluginRoutePathContext
552
- });
553
- import * as import_openclaw_plugin_sdk_gateway_config_runtime from "openclaw/plugin-sdk/gateway-config-runtime";
554
- __reExport(gateway_config_runtime_exports, import_openclaw_plugin_sdk_gateway_config_runtime);
555
- // extensions/nextcloud-talk/src/webhook-route.ts
556
- const DEFAULT_NEXTCLOUD_TALK_WEBHOOK_PATH = "/nextcloud-talk-webhook";
557
- function resolveNextcloudTalkLegacyWebhook(config) {
558
- const listener = config.legacyWebhook;
559
- return listener ? {
560
- port: listener.port,
561
- host: listener.host ?? "0.0.0.0"
562
- } : void 0;
563
- }
564
- function describeNextcloudTalkWebhookRouteConflict(path, gatewayPort) {
565
- const pathname = URL.parse(path, "http://localhost")?.pathname ?? path;
566
- const probe = classifyGatewayProbePath(pathname);
567
- const reason = probe !== "outside" && probe !== "namespace" ? "is reserved for Gateway checks" : isProtectedPluginRoutePathFromContext(resolvePluginRoutePathContext(pathname)) ? "requires Gateway authentication" : void 0;
568
- if (!reason) return;
569
- return `Webhook path "${path}" ${reason} and cannot receive Nextcloud callbacks on the Gateway port. Set webhookPath to "${DEFAULT_NEXTCLOUD_TALK_WEBHOOK_PATH}" and update the Nextcloud bot callback and reverse-proxy upstream to Gateway port ${gatewayPort}${DEFAULT_NEXTCLOUD_TALK_WEBHOOK_PATH}.`;
570
- }
571
- // extensions/nextcloud-talk/src/doctor.ts
572
- function sanitizeLegacyReplaySegment(value) {
573
- const trimmed = value.trim();
574
- if (!trimmed) return "default";
575
- return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
503
+ // extensions/nextcloud-talk/src/doctor.ts
504
+ function sanitizeLegacyReplaySegment(value) {
505
+ const trimmed = value.trim();
506
+ if (!trimmed) return "default";
507
+ return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
576
508
  }
577
509
  function runNextcloudTalkDoctorSequence(params) {
578
510
  const infoNotes = [];
@@ -633,7 +565,8 @@ const nextcloudTalkDoctor = {
633
565
  };
634
566
  // extensions/nextcloud-talk/src/policy.ts
635
567
  function normalizeNextcloudTalkAllowEntry(raw) {
636
- return raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase();
568
+ const normalized = raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, "").trim().toLowerCase();
569
+ return normalized && normalized !== "*" && !normalized.includes("/") ? `users/${normalized}` : normalized;
637
570
  }
638
571
  function normalizeNextcloudTalkAllowlist(values) {
639
572
  return (values ?? []).map((value) => normalizeNextcloudTalkAllowEntry(String(value))).filter(Boolean);
@@ -652,20 +585,15 @@ function resolveNextcloudTalkAllowlistMatch(params) {
652
585
  function resolveNextcloudTalkRoomMatch(params) {
653
586
  const rooms = params.rooms ?? {};
654
587
  const allowlistConfigured = Object.keys(rooms).length > 0;
655
- const match = resolveChannelEntryMatchWithFallback({
588
+ const roomConfig = resolveChannelEntryMatchWithFallback({
656
589
  entries: rooms,
657
590
  keys: buildChannelKeyCandidates(params.roomToken),
658
591
  wildcardKey: "*",
659
592
  normalizeKey: normalizeChannelSlug
660
- });
661
- const roomConfig = match.entry;
662
- const allowed = !allowlistConfigured || Boolean(roomConfig);
593
+ }).entry;
663
594
  return {
664
595
  roomConfig,
665
- wildcardConfig: match.wildcardEntry,
666
- roomKey: match.matchKey ?? match.key,
667
- matchSource: match.matchSource,
668
- allowed,
596
+ allowed: !allowlistConfigured || Boolean(roomConfig),
669
597
  allowlistConfigured
670
598
  };
671
599
  }
@@ -710,6 +638,333 @@ function resolveNextcloudTalkGroupRequireMention(params) {
710
638
  path: exactPath
711
639
  });
712
640
  }
641
+ // extensions/nextcloud-talk/src/webdav.ts
642
+ function parseWebDavFileId(xml) {
643
+ const parser = new SaxesParser({ xmlns: true });
644
+ const ids = [];
645
+ let readingFileId = false;
646
+ let fileIdText = "";
647
+ let invalid = false;
648
+ parser.on("opentag", (tag) => {
649
+ if (readingFileId) invalid = true;
650
+ if (tag.uri === "http://owncloud.org/ns" && tag.local === "fileid") {
651
+ readingFileId = true;
652
+ fileIdText = "";
653
+ }
654
+ });
655
+ parser.on("text", (value) => {
656
+ if (readingFileId) fileIdText += value;
657
+ });
658
+ parser.on("closetag", (tag) => {
659
+ if (tag.uri === "http://owncloud.org/ns" && tag.local === "fileid") {
660
+ ids.push(fileIdText.trim());
661
+ readingFileId = false;
662
+ }
663
+ });
664
+ try {
665
+ parser.write(xml).close();
666
+ } catch {
667
+ return;
668
+ }
669
+ const id = ids[0];
670
+ return !invalid && ids.length === 1 && id && /^\d+$/u.test(id) ? id : void 0;
671
+ }
672
+ // extensions/nextcloud-talk/src/inbound-media.ts
673
+ const DEFAULT_NEXTCLOUD_TALK_MEDIA_MAX_BYTES = 20971520;
674
+ const NEXTCLOUD_TALK_MEDIA_RESPONSE_HEADER_TIMEOUT_MS = 12e4;
675
+ const NEXTCLOUD_TALK_MEDIA_READ_IDLE_TIMEOUT_MS = 3e4;
676
+ const NEXTCLOUD_TALK_WEBDAV_FILE_ID_REQUEST = "<d:propfind xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\"><d:prop><oc:fileid/></d:prop></d:propfind>";
677
+ function boundedLogField(value) {
678
+ return value.replace(/[^a-z0-9._:/-]/giu, "_").slice(0, 128) || "unknown";
679
+ }
680
+ function isNextcloudTalkMediaSenderAllowed(params) {
681
+ return resolveNextcloudTalkAllowlistMatch({
682
+ allowFrom: params.mediaAllowFrom,
683
+ senderId: params.senderId
684
+ }).allowed;
685
+ }
686
+ function resolveNextcloudTalkMediaMaxBytes(params) {
687
+ return resolveChannelMediaMaxBytes({
688
+ cfg: params.cfg,
689
+ accountId: params.accountId,
690
+ resolveChannelLimitMb: () => params.mediaMaxMb
691
+ }) ?? DEFAULT_NEXTCLOUD_TALK_MEDIA_MAX_BYTES;
692
+ }
693
+ function resolveNextcloudTalkAttachmentReference(params) {
694
+ let base;
695
+ let share;
696
+ try {
697
+ base = new URL(params.baseUrl);
698
+ share = new URL(params.shareUrl);
699
+ } catch {
700
+ return {
701
+ ok: false,
702
+ reason: "media_invalid_link"
703
+ };
704
+ }
705
+ const fileName = sanitizeUntrustedFileName(params.fileName, "");
706
+ if (base.protocol !== "http:" && base.protocol !== "https:" || share.protocol !== "http:" && share.protocol !== "https:" || base.username || base.password || base.search || base.hash || share.username || share.password || share.search || share.hash || !fileName) return {
707
+ ok: false,
708
+ reason: "media_invalid_link"
709
+ };
710
+ if (share.origin !== base.origin) return {
711
+ ok: false,
712
+ reason: "media_origin_mismatch"
713
+ };
714
+ const basePath = base.pathname.replace(/\/+$/u, "");
715
+ const prefix = [
716
+ "/s/",
717
+ "/index.php/s/",
718
+ "/index.php/f/",
719
+ "/f/"
720
+ ].map((route) => `${basePath}${route}`).find((route) => share.pathname.startsWith(route));
721
+ const suffix = prefix ? share.pathname.slice(prefix.length) : "";
722
+ if (!suffix || suffix.includes("/")) return {
723
+ ok: false,
724
+ reason: "media_invalid_link"
725
+ };
726
+ return {
727
+ ok: true,
728
+ origin: base.origin,
729
+ hostname: base.hostname,
730
+ fileName
731
+ };
732
+ }
733
+ function buildBasicAuthorization(apiUser, apiPassword) {
734
+ return `Basic ${Buffer.from(`${apiUser}:${apiPassword}`, "utf8").toString("base64")}`;
735
+ }
736
+ function buildSameOriginUrl(base, pathname) {
737
+ const url = new URL(base.origin);
738
+ url.pathname = `${base.pathname.replace(/\/+$/u, "")}${pathname}`;
739
+ return url;
740
+ }
741
+ function resolveCanonicalUserId(payload) {
742
+ if (!isRecord(payload) || !isRecord(payload.ocs) || !isRecord(payload.ocs.data)) return;
743
+ return normalizeOptionalString(payload.ocs.data.id);
744
+ }
745
+ function parseHistorySize(value) {
746
+ if (typeof value !== "string" && typeof value !== "number") return;
747
+ const normalized = String(value).trim();
748
+ if (!/^\d+$/u.test(normalized)) return;
749
+ const parsed = Number(normalized);
750
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : void 0;
751
+ }
752
+ async function resolveNextcloudTalkAuthenticatedMediaSource(params) {
753
+ params.signal?.throwIfAborted();
754
+ const credentials = resolveNextcloudTalkApiCredentials({
755
+ apiUser: params.accountConfig.apiUser,
756
+ apiPassword: params.accountConfig.apiPassword,
757
+ apiPasswordFile: params.accountConfig.apiPasswordFile
758
+ });
759
+ if (!credentials) return {
760
+ ok: false,
761
+ reason: "media_auth_unavailable"
762
+ };
763
+ const base = new URL(params.baseUrl);
764
+ const authorization = buildBasicAuthorization(credentials.apiUser, credentials.apiPassword);
765
+ async function fetchLookup(url, auditContext, init, readResponse, expectedStatus) {
766
+ const { origin, hostname } = params.reference;
767
+ const { accountConfig, signal } = params;
768
+ try {
769
+ signal?.throwIfAborted();
770
+ const privateNetworkPolicy = ssrfPolicyFromPrivateNetworkOptIn(accountConfig);
771
+ const { response, release } = await fetchWithSsrFGuard({
772
+ url,
773
+ init,
774
+ maxRedirects: 0,
775
+ requireHttps: new URL(origin).protocol === "https:",
776
+ timeoutMs: NEXTCLOUD_TALK_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
777
+ ...signal ? { signal } : {},
778
+ policy: {
779
+ ...privateNetworkPolicy,
780
+ hostnameAllowlist: [hostname]
781
+ },
782
+ auditContext
783
+ });
784
+ try {
785
+ if (expectedStatus === void 0 ? !response.ok : response.status !== expectedStatus) return {
786
+ ok: false,
787
+ status: response.status
788
+ };
789
+ return await readResponse(response);
790
+ } finally {
791
+ await releaseNextcloudTalkGuardedResponse({
792
+ response,
793
+ release
794
+ });
795
+ }
796
+ } catch {
797
+ signal?.throwIfAborted();
798
+ return { ok: false };
799
+ }
800
+ }
801
+ async function fetchJson(url, auditContext) {
802
+ return fetchLookup(url, auditContext, {
803
+ method: "GET",
804
+ headers: {
805
+ Authorization: authorization,
806
+ "OCS-APIRequest": "true",
807
+ Accept: "application/json"
808
+ }
809
+ }, async (response) => {
810
+ const payload = await readProviderJsonResponse(response, "Nextcloud Talk media lookup", {
811
+ maxBytes: 65536,
812
+ chunkTimeoutMs: NEXTCLOUD_TALK_MEDIA_READ_IDLE_TIMEOUT_MS
813
+ });
814
+ params.signal?.throwIfAborted();
815
+ return {
816
+ ok: true,
817
+ payload
818
+ };
819
+ });
820
+ }
821
+ const userUrl = buildSameOriginUrl(base, "/ocs/v1.php/cloud/user");
822
+ userUrl.searchParams.set("format", "json");
823
+ const user = await fetchJson(userUrl.toString(), "nextcloud-talk.inbound-media-user");
824
+ if (!user.ok) return {
825
+ ok: false,
826
+ reason: user.status === 401 || user.status === 403 ? "media_auth_unavailable" : "media_fetch_failed",
827
+ ...user.status === void 0 ? {} : { status: user.status }
828
+ };
829
+ const canonicalUserId = resolveCanonicalUserId(user.payload);
830
+ if (!canonicalUserId) return {
831
+ ok: false,
832
+ reason: "media_auth_unavailable"
833
+ };
834
+ params.signal?.throwIfAborted();
835
+ const historyUrl = buildSameOriginUrl(base, `/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(params.roomToken)}`);
836
+ historyUrl.searchParams.set("lookIntoFuture", "0");
837
+ historyUrl.searchParams.set("limit", "1");
838
+ historyUrl.searchParams.set("lastKnownMessageId", params.messageId);
839
+ historyUrl.searchParams.set("includeLastKnown", "1");
840
+ historyUrl.searchParams.set("setReadMarker", "0");
841
+ historyUrl.searchParams.set("noStatusUpdate", "1");
842
+ historyUrl.searchParams.set("markNotificationsAsRead", "0");
843
+ const history = await fetchJson(historyUrl.toString(), "nextcloud-talk.inbound-media-message");
844
+ if (!history.ok) return {
845
+ ok: false,
846
+ reason: history.status === 401 || history.status === 403 ? "media_auth_unavailable" : history.status === 404 ? "media_unavailable" : "media_fetch_failed",
847
+ ...history.status === void 0 ? {} : { status: history.status }
848
+ };
849
+ const exactHistoryMedia = (() => {
850
+ const payload = history.payload;
851
+ if (!isRecord(payload) || !isRecord(payload.ocs)) return;
852
+ const data = payload.ocs.data;
853
+ if (!Array.isArray(data)) return;
854
+ const message = data.find((candidate) => isRecord(candidate) && String(candidate.id) === params.messageId);
855
+ if (!isRecord(message) || message.token !== params.roomToken) return;
856
+ if (normalizeNextcloudTalkAllowEntry(`${typeof message.actorType === "string" ? message.actorType : ""}/${typeof message.actorId === "string" ? message.actorId : ""}`) !== normalizeNextcloudTalkAllowEntry(params.senderId)) return;
857
+ if (!isRecord(message.messageParameters) || !isRecord(message.messageParameters.file)) return;
858
+ const file = message.messageParameters.file;
859
+ const fileId = typeof file.id === "string" ? file.id.trim() : typeof file.id === "number" && Number.isSafeInteger(file.id) ? String(file.id) : "";
860
+ const name = typeof file.name === "string" ? file.name.trim() : "";
861
+ const mimeType = typeof file.mimetype === "string" ? file.mimetype.trim() : "";
862
+ const path = typeof file.path === "string" ? file.path.trim() : "";
863
+ const hideDownload = file["hide-download"];
864
+ if (!/^\d+$/u.test(fileId) || params.attachment.fileId && fileId !== params.attachment.fileId || !name || mimeType !== params.attachment.mimeType || parseHistorySize(file.size) !== params.attachment.declaredSizeBytes || hideDownload !== void 0 && hideDownload !== "yes" && hideDownload !== "no" || hideDownload === "yes" !== params.attachment.hideDownload || !path) return;
865
+ const segments = path.split("/");
866
+ if (segments.some((segment) => !segment || segment === "." || segment === ".." || containsAsciiControlCharacter(segment)) || segments.at(-1) !== name) return;
867
+ const encodedFilePath = segments.map((segment) => encodeURIComponent(segment)).join("/");
868
+ const messageType = typeof message.messageType === "string" ? message.messageType : "";
869
+ const contentTypeOverride = messageType === "voice-message" && /^video\//iu.test(mimeType) ? mimeType.replace(/^video\//iu, "audio/") : void 0;
870
+ return {
871
+ fileId,
872
+ encodedFilePath,
873
+ ...contentTypeOverride ? { contentTypeOverride } : {},
874
+ ...messageType === "voice-message" ? { sourceModality: "voice" } : {}
875
+ };
876
+ })();
877
+ if (!exactHistoryMedia) return {
878
+ ok: false,
879
+ reason: "media_message_mismatch"
880
+ };
881
+ const webDavUrl = buildSameOriginUrl(base, `/remote.php/dav/files/${encodeURIComponent(canonicalUserId)}/${exactHistoryMedia.encodedFilePath}`);
882
+ const webDavFile = await fetchLookup(webDavUrl.toString(), "nextcloud-talk.inbound-media-file-id", {
883
+ method: "PROPFIND",
884
+ headers: {
885
+ Authorization: authorization,
886
+ Depth: "0",
887
+ "Content-Type": "application/xml; charset=utf-8",
888
+ Accept: "application/xml"
889
+ },
890
+ body: NEXTCLOUD_TALK_WEBDAV_FILE_ID_REQUEST
891
+ }, async (response) => {
892
+ const xml = await readProviderTextResponse(response, "Nextcloud Talk file identity", {
893
+ maxBytes: 65536,
894
+ chunkTimeoutMs: NEXTCLOUD_TALK_MEDIA_READ_IDLE_TIMEOUT_MS
895
+ });
896
+ params.signal?.throwIfAborted();
897
+ const fileId = parseWebDavFileId(xml);
898
+ return fileId ? {
899
+ ok: true,
900
+ fileId
901
+ } : { ok: false };
902
+ }, 207);
903
+ if (!webDavFile.ok) return {
904
+ ok: false,
905
+ reason: webDavFile.status === 404 ? "media_unavailable" : "media_fetch_failed",
906
+ ...webDavFile.status === void 0 ? {} : { status: webDavFile.status }
907
+ };
908
+ if (webDavFile.fileId !== exactHistoryMedia.fileId) return {
909
+ ok: false,
910
+ reason: "media_message_mismatch"
911
+ };
912
+ return {
913
+ ok: true,
914
+ url: webDavUrl.toString(),
915
+ origin: params.reference.origin,
916
+ hostname: params.reference.hostname,
917
+ fileName: params.reference.fileName,
918
+ authorization,
919
+ ...exactHistoryMedia.contentTypeOverride ? { contentTypeOverride: exactHistoryMedia.contentTypeOverride } : {},
920
+ ...exactHistoryMedia.sourceModality ? { sourceModality: exactHistoryMedia.sourceModality } : {}
921
+ };
922
+ }
923
+ function classifyNextcloudTalkMediaFailure(error) {
924
+ if (!(error instanceof MediaFetchError)) return { reason: "media_stage_failed" };
925
+ if (error.code === "max_bytes") return { reason: "media_download_oversize" };
926
+ if (error.code === "http_error") return {
927
+ reason: "media_unavailable",
928
+ ...error.status === void 0 ? {} : { status: error.status }
929
+ };
930
+ return { reason: "media_fetch_failed" };
931
+ }
932
+ async function saveNextcloudTalkInboundMedia(params) {
933
+ params.signal?.throwIfAborted();
934
+ const originProtocol = new URL(params.origin).protocol;
935
+ const privateNetworkPolicy = ssrfPolicyFromPrivateNetworkOptIn(params.accountConfig);
936
+ return await params.saveRemoteMedia({
937
+ url: params.url,
938
+ maxBytes: params.maxBytes,
939
+ maxRedirects: 0,
940
+ requestInit: {
941
+ headers: { Authorization: params.authorization },
942
+ ...params.signal ? { signal: params.signal } : {}
943
+ },
944
+ requireHttps: originProtocol === "https:",
945
+ responseHeaderTimeoutMs: NEXTCLOUD_TALK_MEDIA_RESPONSE_HEADER_TIMEOUT_MS,
946
+ readIdleTimeoutMs: NEXTCLOUD_TALK_MEDIA_READ_IDLE_TIMEOUT_MS,
947
+ filePathHint: params.fileName,
948
+ fallbackContentType: params.mimeType,
949
+ originalFilename: params.fileName,
950
+ ssrfPolicy: {
951
+ ...privateNetworkPolicy,
952
+ hostnameAllowlist: [params.hostname]
953
+ }
954
+ });
955
+ }
956
+ function logNextcloudTalkMediaNonOutcome(params) {
957
+ const fields = [
958
+ `reason=${params.reason}`,
959
+ `account=${boundedLogField(params.accountId)}`,
960
+ `message=${boundedLogField(params.messageId)}`,
961
+ `sender=${boundedLogField(normalizeNextcloudTalkAllowEntry(params.senderId))}`,
962
+ ...params.status === void 0 ? [] : [`status=${params.status}`],
963
+ ...params.sizeBytes === void 0 ? [] : [`sizeBytes=${params.sizeBytes}`],
964
+ ...params.maxBytes === void 0 ? [] : [`maxBytes=${params.maxBytes}`]
965
+ ];
966
+ params.log?.(`nextcloud-talk: inbound media non-outcome ${fields.join(" ")}`);
967
+ }
713
968
  // extensions/nextcloud-talk/src/room-info.ts
714
969
  const ROOM_CACHE_TTL_MS = 3e5;
715
970
  const ROOM_CACHE_ERROR_TTL_MS = 3e4;
@@ -731,10 +986,6 @@ function resolveRoomKindFromType(type) {
731
986
  }
732
987
  async function resolveNextcloudTalkRoomKind(params) {
733
988
  const { account, roomToken, runtime } = params;
734
- const key = `${account.accountId}:${roomToken}`;
735
- const cached = roomCache.get(key);
736
- if (cached && Date.now() < cached.expiresAt) return cached.kind;
737
- roomCache.delete(key);
738
989
  const apiCredentials = resolveNextcloudTalkApiCredentials({
739
990
  apiUser: account.config.apiUser,
740
991
  apiPassword: account.config.apiPassword,
@@ -742,6 +993,17 @@ async function resolveNextcloudTalkRoomKind(params) {
742
993
  });
743
994
  const baseUrl = account.baseUrl?.trim();
744
995
  if (!apiCredentials || !baseUrl) return;
996
+ const key = JSON.stringify([
997
+ account.accountId,
998
+ baseUrl,
999
+ roomToken,
1000
+ apiCredentials.apiUser,
1001
+ sha256Hex(apiCredentials.apiPassword),
1002
+ account.config.network?.dangerouslyAllowPrivateNetwork === true
1003
+ ]);
1004
+ const cached = roomCache.get(key);
1005
+ if (cached && Date.now() < cached.expiresAt) return cached.kind;
1006
+ roomCache.delete(key);
745
1007
  const fallback = (error) => {
746
1008
  runtime?.error?.(`nextcloud-talk: room lookup error: ${String(error)}`);
747
1009
  return cacheRoomInfo(key, void 0, ROOM_CACHE_ERROR_TTL_MS);
@@ -753,16 +1015,8 @@ async function resolveNextcloudTalkRoomKind(params) {
753
1015
  try {
754
1016
  guarded = await fetchWithSsrFGuard({
755
1017
  url: `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`,
756
- init: {
757
- method: "GET",
758
- headers: {
759
- Authorization: `Basic ${auth}`,
760
- "OCS-APIRequest": "true",
761
- Accept: "application/json"
762
- }
763
- },
1018
+ ...buildNextcloudTalkOcsOptions(auth, account.config.network?.dangerouslyAllowPrivateNetwork),
764
1019
  auditContext: "nextcloud-talk.room-info",
765
- policy: ssrfPolicyFromPrivateNetworkOptIn(account.config.network?.dangerouslyAllowPrivateNetwork),
766
1020
  timeoutMs: params.timeoutMs ?? NEXTCLOUD_TALK_ROOM_INFO_TIMEOUT_MS
767
1021
  });
768
1022
  } catch (error) {
@@ -798,10 +1052,46 @@ function normalizeNextcloudTalkMessagingTarget(raw) {
798
1052
  }
799
1053
  function looksLikeNextcloudTalkTargetId(raw) {
800
1054
  const trimmed = raw.trim();
801
- if (!trimmed) return false;
802
1055
  if (/^(nextcloud-talk|nc-talk|nc|room):/i.test(trimmed)) return true;
803
1056
  return /^[a-z0-9]{8,}$/i.test(trimmed);
804
1057
  }
1058
+ // extensions/nextcloud-talk/src/signature.ts
1059
+ const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
1060
+ const RANDOM_HEADER = "x-nextcloud-talk-random";
1061
+ const BACKEND_HEADER = "x-nextcloud-talk-backend";
1062
+ /**
1063
+ * Verify the HMAC-SHA256 signature of an incoming webhook request.
1064
+ * Signature is calculated as: HMAC-SHA256(random + body, secret)
1065
+ */
1066
+ function verifyNextcloudTalkSignature(params) {
1067
+ const { signature, random, body, secret } = params;
1068
+ if (!signature || !random || !secret) return false;
1069
+ const expected = createHmac("sha256", secret).update(random + body).digest("hex");
1070
+ return safeEqualSecret(signature, expected);
1071
+ }
1072
+ function extractNextcloudTalkHeaders(headers) {
1073
+ const getHeader = (name) => {
1074
+ const value = headers[name];
1075
+ return Array.isArray(value) ? value[0] : value;
1076
+ };
1077
+ const signature = getHeader(SIGNATURE_HEADER);
1078
+ const random = getHeader(RANDOM_HEADER);
1079
+ const backend = getHeader(BACKEND_HEADER);
1080
+ if (!signature || !random || !backend) return null;
1081
+ return {
1082
+ signature,
1083
+ random,
1084
+ backend
1085
+ };
1086
+ }
1087
+ function generateNextcloudTalkSignature(params) {
1088
+ const { body, secret } = params;
1089
+ const random = randomBytes(32).toString("hex");
1090
+ return {
1091
+ random,
1092
+ signature: createHmac("sha256", secret).update(random + body).digest("hex")
1093
+ };
1094
+ }
805
1095
  // extensions/nextcloud-talk/src/send.ts
806
1096
  const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS = 200;
807
1097
  const NEXTCLOUD_TALK_SEND_TIMEOUT_MS = 3e4;
@@ -818,7 +1108,7 @@ function resolveNextcloudTalkSendContext(opts) {
818
1108
  accountId: opts.accountId
819
1109
  });
820
1110
  const baseUrl = account.baseUrl;
821
- const secret = opts.secret?.trim() ?? account.secret;
1111
+ const secret = account.secret;
822
1112
  if (!baseUrl) throw new Error(`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`);
823
1113
  if (!secret) throw new Error(account.tokenStatus === "configured_unavailable" ? `Nextcloud Talk bot secret is configured but unavailable for account "${account.accountId}" (check the configured channels.nextcloud-talk.botSecret/botSecretFile).` : `Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`);
824
1114
  return {
@@ -833,8 +1123,36 @@ function normalizeRoomToken(to) {
833
1123
  if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends");
834
1124
  return normalized;
835
1125
  }
1126
+ async function postNextcloudTalkBot(params) {
1127
+ const { account, baseUrl, secret } = params.context;
1128
+ const { random, signature } = generateNextcloudTalkSignature({
1129
+ body: params.signedValue,
1130
+ secret
1131
+ });
1132
+ return {
1133
+ ...await fetchWithSsrFGuard({
1134
+ url: `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${params.path}`,
1135
+ beforeRequest: params.beforeRequest,
1136
+ init: {
1137
+ method: "POST",
1138
+ headers: {
1139
+ "Content-Type": "application/json",
1140
+ "OCS-APIRequest": "true",
1141
+ "X-Nextcloud-Talk-Bot-Random": random,
1142
+ "X-Nextcloud-Talk-Bot-Signature": signature
1143
+ },
1144
+ body: JSON.stringify(params.payload)
1145
+ },
1146
+ auditContext: params.auditContext,
1147
+ policy: ssrfPolicyFromPrivateNetworkOptIn(account.config.network?.dangerouslyAllowPrivateNetwork),
1148
+ timeoutMs: NEXTCLOUD_TALK_SEND_TIMEOUT_MS
1149
+ }),
1150
+ signature
1151
+ };
1152
+ }
836
1153
  async function sendMessageNextcloudTalk(to, text, opts) {
837
- const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
1154
+ const context = resolveNextcloudTalkSendContext(opts);
1155
+ const { cfg, account } = context;
838
1156
  const roomToken = normalizeRoomToken(to);
839
1157
  if (!text?.trim()) throw new Error("Message must be non-empty for Nextcloud Talk sends");
840
1158
  const tableMode = resolveMarkdownTableMode({
@@ -843,32 +1161,18 @@ async function sendMessageNextcloudTalk(to, text, opts) {
843
1161
  accountId: account.accountId
844
1162
  });
845
1163
  const message = convertMarkdownTables(text.trim(), tableMode);
846
- const body = JSON.stringify({
847
- message,
848
- replyTo: opts.replyTo || void 0
849
- });
850
- const { random, signature } = generateNextcloudTalkSignature({
851
- body: message,
852
- secret
853
- });
854
- const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`;
855
1164
  await opts.onPlatformSendDispatch?.();
856
- const { response, release } = await fetchWithSsrFGuard({
857
- url,
858
- beforeRequest: opts.assertDirectAdapterHandoff,
859
- init: {
860
- method: "POST",
861
- headers: {
862
- "Content-Type": "application/json",
863
- "OCS-APIRequest": "true",
864
- "X-Nextcloud-Talk-Bot-Random": random,
865
- "X-Nextcloud-Talk-Bot-Signature": signature
866
- },
867
- body
1165
+ const { response, release, signature } = await postNextcloudTalkBot({
1166
+ context,
1167
+ path: `${roomToken}/message`,
1168
+ payload: {
1169
+ message,
1170
+ replyTo: opts.replyTo || void 0,
1171
+ ...opts.silent ? { silent: true } : {}
868
1172
  },
869
- auditContext: "nextcloud-talk-send",
870
- policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config.network?.dangerouslyAllowPrivateNetwork),
871
- timeoutMs: opts.timeoutMs ?? NEXTCLOUD_TALK_SEND_TIMEOUT_MS
1173
+ signedValue: message,
1174
+ beforeRequest: opts.assertDirectAdapterHandoff,
1175
+ auditContext: "nextcloud-talk-send"
872
1176
  });
873
1177
  try {
874
1178
  if (!response.ok) {
@@ -914,40 +1218,158 @@ async function sendMessageNextcloudTalk(to, text, opts) {
914
1218
  }
915
1219
  }
916
1220
  async function sendReactionNextcloudTalk(roomToken, messageId, reaction, opts) {
917
- const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
918
- const normalizedToken = normalizeRoomToken(roomToken);
919
- const body = JSON.stringify({ reaction });
920
- const { random, signature } = generateNextcloudTalkSignature({
921
- body: reaction,
922
- secret
923
- });
924
- const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`;
925
- const { response, release } = await fetchWithSsrFGuard({
926
- url,
927
- init: {
928
- method: "POST",
929
- headers: {
930
- "Content-Type": "application/json",
931
- "OCS-APIRequest": "true",
932
- "X-Nextcloud-Talk-Bot-Random": random,
933
- "X-Nextcloud-Talk-Bot-Signature": signature
934
- },
935
- body
936
- },
937
- auditContext: "nextcloud-talk-reaction",
938
- policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config.network?.dangerouslyAllowPrivateNetwork),
939
- timeoutMs: opts.timeoutMs ?? NEXTCLOUD_TALK_SEND_TIMEOUT_MS
1221
+ const { response, release, signature } = await postNextcloudTalkBot({
1222
+ context: resolveNextcloudTalkSendContext(opts),
1223
+ path: `${normalizeRoomToken(roomToken)}/reaction/${messageId}`,
1224
+ payload: { reaction },
1225
+ signedValue: reaction,
1226
+ auditContext: "nextcloud-talk-reaction"
940
1227
  });
941
1228
  try {
942
1229
  if (!response.ok) {
943
1230
  const errorBody = collapseErrorSnippet(await readNextcloudTalkErrorBody(response, signature));
944
1231
  throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
945
1232
  }
946
- return { ok: true };
947
1233
  } finally {
948
1234
  await release();
949
1235
  }
950
1236
  }
1237
+ // extensions/nextcloud-talk/src/voice-media.ts
1238
+ const BOX_HEADER_BYTES = 8;
1239
+ const LARGE_BOX_HEADER_BYTES = 16;
1240
+ const MAX_BOX_COUNT = 4096;
1241
+ const MAX_BOX_DEPTH = 8;
1242
+ const CONTAINER_BOX_TYPES = /* @__PURE__ */ new Set([
1243
+ "moov",
1244
+ "mdia",
1245
+ "minf",
1246
+ "stbl"
1247
+ ]);
1248
+ async function readExactly(handle, position, length) {
1249
+ const buffer = Buffer.alloc(length);
1250
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
1251
+ if (bytesRead !== length) throw new Error("truncated ISO-BMFF box");
1252
+ return buffer;
1253
+ }
1254
+ async function readBox(handle, offset, parentEnd) {
1255
+ if (parentEnd - offset < BOX_HEADER_BYTES) throw new Error("truncated ISO-BMFF box header");
1256
+ const header = await readExactly(handle, offset, BOX_HEADER_BYTES);
1257
+ const size32 = header.readUInt32BE(0);
1258
+ const type = header.toString("ascii", 4, 8);
1259
+ let headerBytes = BOX_HEADER_BYTES;
1260
+ let size;
1261
+ if (size32 === 0) size = parentEnd - offset;
1262
+ else if (size32 === 1) {
1263
+ if (parentEnd - offset < LARGE_BOX_HEADER_BYTES) throw new Error("truncated ISO-BMFF large box header");
1264
+ const largeSize = (await readExactly(handle, offset + BOX_HEADER_BYTES, 8)).readBigUInt64BE(0);
1265
+ if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("oversized ISO-BMFF box");
1266
+ size = Number(largeSize);
1267
+ headerBytes = LARGE_BOX_HEADER_BYTES;
1268
+ } else size = size32;
1269
+ if (size < headerBytes || offset + size > parentEnd) throw new Error("invalid ISO-BMFF box bounds");
1270
+ return {
1271
+ type,
1272
+ payloadStart: offset + headerBytes,
1273
+ end: offset + size
1274
+ };
1275
+ }
1276
+ async function readSampleEntryTypes(handle, box, state) {
1277
+ if (box.end - box.payloadStart < 8) throw new Error("invalid ISO-BMFF sample description");
1278
+ const entryCount = (await readExactly(handle, box.payloadStart, 8)).readUInt32BE(4);
1279
+ if (entryCount === 0 || entryCount > MAX_BOX_COUNT) throw new Error("invalid ISO-BMFF sample entry count");
1280
+ const entryTypes = [];
1281
+ let offset = box.payloadStart + 8;
1282
+ for (let index = 0; index < entryCount; index += 1) {
1283
+ state.boxCount += 1;
1284
+ if (state.boxCount > MAX_BOX_COUNT) throw new Error("ISO-BMFF box count exceeded");
1285
+ const entry = await readBox(handle, offset, box.end);
1286
+ entryTypes.push(entry.type);
1287
+ offset = entry.end;
1288
+ }
1289
+ if (offset !== box.end) throw new Error("invalid ISO-BMFF sample description bounds");
1290
+ return entryTypes;
1291
+ }
1292
+ async function inspectBoxes(params) {
1293
+ if (params.depth > MAX_BOX_DEPTH) throw new Error("ISO-BMFF box depth exceeded");
1294
+ let offset = params.start;
1295
+ while (offset < params.end) {
1296
+ params.state.boxCount += 1;
1297
+ if (params.state.boxCount > MAX_BOX_COUNT) throw new Error("ISO-BMFF box count exceeded");
1298
+ const box = await readBox(params.handle, offset, params.end);
1299
+ if (box.type === "trak") {
1300
+ const track = { sampleEntryTypes: [] };
1301
+ await inspectBoxes({
1302
+ ...params,
1303
+ start: box.payloadStart,
1304
+ end: box.end,
1305
+ depth: params.depth + 1,
1306
+ track
1307
+ });
1308
+ params.state.tracks.push(track);
1309
+ } else if (box.type === "hdlr" && params.track) {
1310
+ if (box.end - box.payloadStart < 12) throw new Error("invalid ISO-BMFF handler box");
1311
+ params.track.handlerType = (await readExactly(params.handle, box.payloadStart + 8, 4)).toString("ascii");
1312
+ } else if (box.type === "stsd" && params.track) params.track.sampleEntryTypes = await readSampleEntryTypes(params.handle, box, params.state);
1313
+ else if (CONTAINER_BOX_TYPES.has(box.type)) await inspectBoxes({
1314
+ ...params,
1315
+ start: box.payloadStart,
1316
+ end: box.end,
1317
+ depth: params.depth + 1
1318
+ });
1319
+ offset = box.end;
1320
+ }
1321
+ }
1322
+ async function inspectIsoBmff(filePath, expectedSize) {
1323
+ let handle;
1324
+ try {
1325
+ handle = await promises.open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
1326
+ const stat = await handle.stat();
1327
+ if (!stat.isFile() || stat.size !== expectedSize || stat.size < BOX_HEADER_BYTES) return "ambiguous";
1328
+ if ((await readExactly(handle, 0, BOX_HEADER_BYTES)).toString("ascii", 4, 8) !== "ftyp") return "not-isobmff";
1329
+ const state = {
1330
+ boxCount: 0,
1331
+ tracks: []
1332
+ };
1333
+ await inspectBoxes({
1334
+ handle,
1335
+ start: 0,
1336
+ end: stat.size,
1337
+ depth: 0,
1338
+ state
1339
+ });
1340
+ if (state.tracks.length === 0) return "ambiguous";
1341
+ return state.tracks.every((track) => track.handlerType === "soun" && track.sampleEntryTypes.length > 0 && track.sampleEntryTypes.every((entryType) => entryType === "mp4a")) ? "audio-only-aac" : "ambiguous";
1342
+ } catch {
1343
+ return "ambiguous";
1344
+ } finally {
1345
+ await handle?.close().catch(() => void 0);
1346
+ }
1347
+ }
1348
+ function replaceExtension(value, extension) {
1349
+ const currentExtension = path.extname(value);
1350
+ return `${currentExtension ? value.slice(0, -currentExtension.length) : value}${extension}`;
1351
+ }
1352
+ async function canonicalizeNextcloudTalkNativeVoiceMedia(media) {
1353
+ if (path.basename(media.path) !== media.id) return { ok: false };
1354
+ const inspection = await inspectIsoBmff(media.path, media.size);
1355
+ if (inspection === "not-isobmff") return media.contentType?.startsWith("audio/") ? {
1356
+ ok: true,
1357
+ media
1358
+ } : { ok: false };
1359
+ if (inspection !== "audio-only-aac") return { ok: false };
1360
+ const canonicalId = replaceExtension(media.id, ".m4a");
1361
+ const canonicalPath = path.join(path.dirname(media.path), canonicalId);
1362
+ if (canonicalPath !== media.path) await promises.rename(media.path, canonicalPath);
1363
+ return {
1364
+ ok: true,
1365
+ media: {
1366
+ ...media,
1367
+ id: canonicalId,
1368
+ path: canonicalPath,
1369
+ contentType: "audio/mp4"
1370
+ }
1371
+ };
1372
+ }
951
1373
  // extensions/nextcloud-talk/src/inbound.ts
952
1374
  const CHANNEL_ID = "nextcloud-talk";
953
1375
  function hasAllowEntries(entries) {
@@ -955,6 +1377,8 @@ function hasAllowEntries(entries) {
955
1377
  }
956
1378
  async function handleNextcloudTalkInbound(params) {
957
1379
  const { message, account, config, runtime, statusSink } = params;
1380
+ const ingressAbortSignal = params.turnAdoptionLifecycle?.abortSignal;
1381
+ ingressAbortSignal?.throwIfAborted();
958
1382
  const core = getNextcloudTalkRuntime();
959
1383
  const pairing = createChannelPairingController({
960
1384
  core,
@@ -962,7 +1386,8 @@ async function handleNextcloudTalkInbound(params) {
962
1386
  accountId: account.accountId
963
1387
  });
964
1388
  const rawBody = message.text?.trim() ?? "";
965
- if (!rawBody) {
1389
+ const hasInboundMedia = Boolean(message.attachment || message.attachmentIssue);
1390
+ if (!rawBody && !hasInboundMedia) {
966
1391
  logInboundDrop({
967
1392
  log: (messageLocal) => runtime.log?.(messageLocal),
968
1393
  channel: CHANNEL_ID,
@@ -977,10 +1402,7 @@ async function handleNextcloudTalkInbound(params) {
977
1402
  runtime
978
1403
  });
979
1404
  const isGroup = roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
980
- const senderId = message.senderId;
981
- const senderName = message.senderName;
982
- const roomToken = message.roomToken;
983
- const roomName = message.roomName;
1405
+ const { senderId, senderName, roomToken, roomName } = message;
984
1406
  statusSink?.({ lastInboundAt: message.timestamp });
985
1407
  const roomMatch = resolveNextcloudTalkRoomMatch({
986
1408
  rooms: account.config.rooms,
@@ -1074,6 +1496,9 @@ async function handleNextcloudTalkInbound(params) {
1074
1496
  }
1075
1497
  });
1076
1498
  let access = await resolveAccess();
1499
+ const logAuthorizationChanged = () => {
1500
+ runtime.log?.(isGroup && access.activationAccess.shouldSkip ? `nextcloud-talk: drop room ${roomToken} (no mention)` : `nextcloud-talk: drop ${isGroup ? "room" : "DM"} ${roomToken} (authorization changed)`);
1501
+ };
1077
1502
  warnMissingProviderGroupPolicyFallbackOnce({
1078
1503
  providerMissingFallbackApplied,
1079
1504
  providerKey: "nextcloud-talk",
@@ -1081,8 +1506,18 @@ async function handleNextcloudTalkInbound(params) {
1081
1506
  blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
1082
1507
  log: (messageValue) => runtime.log?.(messageValue)
1083
1508
  });
1084
- const commandAuthorized = access.commandAccess.authorized;
1085
1509
  const accessReason = access.ingress.reasonCode === "route_blocked" ? "route blocked" : access.senderAccess.reasonCode;
1510
+ const ignoreOwnOutboundFileShare = async () => {
1511
+ if (!message.attachment) return false;
1512
+ const { isNextcloudTalkOutboundFileEcho } = await import("./outbound-media-CpFe1Xgz.mjs");
1513
+ const echo = await isNextcloudTalkOutboundFileEcho({
1514
+ account,
1515
+ message,
1516
+ signal: ingressAbortSignal
1517
+ });
1518
+ if (echo) runtime.log?.("nextcloud-talk: ignored own outbound file share");
1519
+ return echo;
1520
+ };
1086
1521
  if (isGroup) {
1087
1522
  if (access.routeAccess.reason === "room_not_allowlisted") {
1088
1523
  runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
@@ -1101,21 +1536,25 @@ async function handleNextcloudTalkInbound(params) {
1101
1536
  return;
1102
1537
  }
1103
1538
  } else if (access.senderAccess.decision !== "allow") {
1104
- if (access.senderAccess.decision === "pairing") await pairing.issueChallenge({
1105
- senderId,
1106
- senderIdLine: `Your Nextcloud user id: ${senderId}`,
1107
- meta: { name: senderName || void 0 },
1108
- sendPairingReply: async (text) => {
1109
- await sendMessageNextcloudTalk(roomToken, text, {
1110
- cfg: config,
1111
- accountId: account.accountId
1112
- });
1113
- statusSink?.({ lastOutboundAt: Date.now() });
1114
- },
1115
- onReplyError: (err) => {
1116
- runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
1117
- }
1118
- });
1539
+ if (access.senderAccess.decision === "pairing") {
1540
+ if (await ignoreOwnOutboundFileShare()) return;
1541
+ ingressAbortSignal?.throwIfAborted();
1542
+ await pairing.issueChallenge({
1543
+ senderId,
1544
+ senderIdLine: `Your Nextcloud user id: ${senderId}`,
1545
+ meta: { name: senderName || void 0 },
1546
+ sendPairingReply: async (text) => {
1547
+ await sendMessageNextcloudTalk(roomToken, text, {
1548
+ cfg: config,
1549
+ accountId: account.accountId
1550
+ });
1551
+ statusSink?.({ lastOutboundAt: Date.now() });
1552
+ },
1553
+ onReplyError: (err) => {
1554
+ runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
1555
+ }
1556
+ });
1557
+ }
1119
1558
  runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${accessReason})`);
1120
1559
  return;
1121
1560
  }
@@ -1129,7 +1568,7 @@ async function handleNextcloudTalkInbound(params) {
1129
1568
  return;
1130
1569
  }
1131
1570
  const mentionRegexes = core.channel.mentions.buildMentionRegexes(config);
1132
- const wasMentioned = mentionRegexes.length ? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) : false;
1571
+ const wasMentioned = mentionRegexes.length ? core.channel.mentions.matchesMentionPatterns(message.mentionText ?? rawBody, mentionRegexes) : false;
1133
1572
  const route = resolveAgentRoute({
1134
1573
  cfg: config,
1135
1574
  channel: CHANNEL_ID,
@@ -1139,114 +1578,375 @@ async function handleNextcloudTalkInbound(params) {
1139
1578
  id: isGroup ? roomToken : senderId
1140
1579
  }
1141
1580
  });
1142
- access = await resolveAccess(isGroup ? wasMentioned : void 0, {
1581
+ const contextBinding = {
1143
1582
  agentId: route.agentId,
1144
1583
  sessionKey: route.sessionKey,
1145
1584
  messageId: message.messageId,
1146
1585
  inboundEventKind: "user_request"
1586
+ };
1587
+ const buildEnvelope = await createChannelInboundEnvelopeBuilderAsync({
1588
+ cfg: config,
1589
+ route
1147
1590
  });
1591
+ ingressAbortSignal?.throwIfAborted();
1592
+ access = await resolveAccess(isGroup ? wasMentioned : void 0, contextBinding);
1148
1593
  if (access.ingress.admission !== "dispatch") {
1149
- runtime.log?.(isGroup && access.activationAccess.shouldSkip ? `nextcloud-talk: drop room ${roomToken} (no mention)` : `nextcloud-talk: drop ${isGroup ? "room" : "DM"} ${roomToken} (authorization changed)`);
1594
+ logAuthorizationChanged();
1150
1595
  return;
1151
1596
  }
1152
- const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
1153
- const body = (await createChannelInboundEnvelopeBuilderAsync({
1154
- cfg: config,
1155
- route
1156
- }))({
1157
- channel: "Nextcloud Talk",
1158
- from: fromLabel,
1159
- timestamp: message.timestamp,
1160
- body: rawBody
1161
- });
1162
- const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
1163
- const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
1164
- const ctxPayload = core.channel.inbound.buildContext({
1165
- channelIngress: access,
1166
- channel: CHANNEL_ID,
1167
- accountId: route.accountId,
1597
+ if (await ignoreOwnOutboundFileShare()) return;
1598
+ let stagedMedia;
1599
+ let stagedMediaId;
1600
+ let sourceModality;
1601
+ let authorizedMediaUnavailable = false;
1602
+ let performedAsyncMediaWork = false;
1603
+ const logMediaNonOutcome = (reason, details) => logNextcloudTalkMediaNonOutcome({
1604
+ log: (messageLocal) => runtime.log?.(messageLocal),
1605
+ reason,
1606
+ accountId: account.accountId,
1168
1607
  messageId: message.messageId,
1169
- timestamp: message.timestamp,
1170
- from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
1171
- sender: {
1172
- id: senderId,
1173
- name: senderName || void 0
1174
- },
1175
- conversation: {
1176
- kind: isGroup ? "group" : "direct",
1177
- id: isGroup ? roomToken : senderId,
1178
- label: fromLabel
1179
- },
1180
- route: {
1181
- agentId: route.agentId,
1182
- dmScope: route.dmScope,
1183
- accountId: route.accountId,
1184
- routeSessionKey: route.sessionKey
1185
- },
1186
- reply: {
1187
- to: `nextcloud-talk:${roomToken}`,
1188
- originatingTo: `nextcloud-talk:${roomToken}`
1189
- },
1190
- message: {
1191
- body,
1192
- bodyForAgent: rawBody,
1193
- rawBody,
1194
- commandBody
1195
- },
1196
- access: {
1197
- commands: { authorized: commandAuthorized },
1198
- mentions: {
1199
- canDetectMention: isGroup,
1200
- wasMentioned: isGroup && wasMentioned
1201
- }
1202
- },
1203
- extra: {
1204
- GroupSubject: isGroup ? roomName || roomToken : void 0,
1205
- GroupSystemPrompt: isGroup ? groupSystemPrompt : void 0
1608
+ senderId,
1609
+ ...details
1610
+ });
1611
+ const deleteStagedMedia = async () => {
1612
+ const mediaId = stagedMediaId;
1613
+ stagedMediaId = void 0;
1614
+ stagedMedia = void 0;
1615
+ if (!mediaId) return;
1616
+ try {
1617
+ await core.channel.media.deleteMediaBuffer(mediaId);
1618
+ } catch {
1619
+ logMediaNonOutcome("media_cleanup_failed");
1206
1620
  }
1621
+ };
1622
+ const throwIfIngressAborted = () => ingressAbortSignal?.throwIfAborted();
1623
+ const mediaSenderAllowed = hasInboundMedia && isNextcloudTalkMediaSenderAllowed({
1624
+ mediaAllowFrom: account.config.mediaAllowFrom,
1625
+ senderId
1207
1626
  });
1208
- await core.channel.inbound.dispatch({
1209
- cfg: config,
1210
- channel: CHANNEL_ID,
1211
- accountId: account.accountId,
1212
- route: {
1213
- agentId: route.agentId,
1214
- sessionKey: route.sessionKey
1215
- },
1216
- ctxPayload,
1217
- delivery: {
1218
- preparePayload: (payload) => payload.text === void 0 ? payload : {
1219
- ...payload,
1220
- text: sanitizeAssistantVisibleText(payload.text)
1221
- },
1222
- deliver: async (payload) => {
1223
- return { visibleReplySent: await deliverFormattedTextWithAttachments({
1224
- payload,
1225
- send: async ({ text, replyToId }) => {
1226
- await sendMessageNextcloudTalk(roomToken, text, {
1227
- cfg: config,
1228
- accountId: account.accountId,
1229
- replyTo: replyToId
1230
- });
1231
- statusSink?.({ lastOutboundAt: Date.now() });
1627
+ let mediaHandedOff = false;
1628
+ try {
1629
+ if (hasInboundMedia && !mediaSenderAllowed) {
1630
+ logMediaNonOutcome("media_sender_not_allowlisted");
1631
+ if (!rawBody) return;
1632
+ } else if (message.attachmentIssue) {
1633
+ authorizedMediaUnavailable = true;
1634
+ logMediaNonOutcome(message.attachmentIssue);
1635
+ } else if (message.attachment?.hideDownload) {
1636
+ authorizedMediaUnavailable = true;
1637
+ logMediaNonOutcome("media_hidden_download");
1638
+ } else if (message.attachment) {
1639
+ const mediaMaxBytes = resolveNextcloudTalkMediaMaxBytes({
1640
+ cfg: config,
1641
+ accountId: account.accountId,
1642
+ mediaMaxMb: account.config.mediaMaxMb
1643
+ });
1644
+ if (message.attachment.declaredSizeBytes > mediaMaxBytes) {
1645
+ authorizedMediaUnavailable = true;
1646
+ logMediaNonOutcome("media_declared_oversize", {
1647
+ sizeBytes: message.attachment.declaredSizeBytes,
1648
+ maxBytes: mediaMaxBytes
1649
+ });
1650
+ } else {
1651
+ const attachmentReference = resolveNextcloudTalkAttachmentReference({
1652
+ baseUrl: account.baseUrl,
1653
+ shareUrl: message.attachment.shareUrl,
1654
+ fileName: message.attachment.name
1655
+ });
1656
+ if (!attachmentReference.ok) {
1657
+ authorizedMediaUnavailable = true;
1658
+ logMediaNonOutcome(attachmentReference.reason);
1659
+ } else {
1660
+ performedAsyncMediaWork = true;
1661
+ throwIfIngressAborted();
1662
+ const authenticatedSource = await resolveNextcloudTalkAuthenticatedMediaSource({
1663
+ baseUrl: account.baseUrl,
1664
+ roomToken,
1665
+ messageId: message.messageId,
1666
+ senderId,
1667
+ attachment: message.attachment,
1668
+ accountConfig: account.config,
1669
+ reference: attachmentReference,
1670
+ signal: ingressAbortSignal
1671
+ });
1672
+ throwIfIngressAborted();
1673
+ if (!authenticatedSource.ok) {
1674
+ authorizedMediaUnavailable = true;
1675
+ logMediaNonOutcome(authenticatedSource.reason, { status: authenticatedSource.status });
1676
+ } else {
1677
+ sourceModality = authenticatedSource.sourceModality;
1678
+ access = await resolveAccess(isGroup ? wasMentioned : void 0, contextBinding);
1679
+ throwIfIngressAborted();
1680
+ if (access.ingress.admission !== "dispatch") {
1681
+ logAuthorizationChanged();
1682
+ return;
1683
+ }
1684
+ try {
1685
+ throwIfIngressAborted();
1686
+ const saved = await saveNextcloudTalkInboundMedia({
1687
+ saveRemoteMedia: core.channel.media.saveRemoteMedia,
1688
+ url: authenticatedSource.url,
1689
+ origin: authenticatedSource.origin,
1690
+ hostname: authenticatedSource.hostname,
1691
+ accountConfig: account.config,
1692
+ maxBytes: mediaMaxBytes,
1693
+ fileName: authenticatedSource.fileName,
1694
+ mimeType: authenticatedSource.contentTypeOverride ?? message.attachment.mimeType,
1695
+ authorization: authenticatedSource.authorization,
1696
+ signal: ingressAbortSignal
1697
+ });
1698
+ stagedMediaId = saved.id;
1699
+ const canonicalized = sourceModality === "voice" ? await canonicalizeNextcloudTalkNativeVoiceMedia(saved) : {
1700
+ ok: true,
1701
+ media: saved
1702
+ };
1703
+ if (!canonicalized.ok) {
1704
+ await deleteStagedMedia();
1705
+ authorizedMediaUnavailable = true;
1706
+ logMediaNonOutcome("media_unsupported");
1707
+ } else {
1708
+ stagedMediaId = canonicalized.media.id;
1709
+ stagedMedia = {
1710
+ path: canonicalized.media.path,
1711
+ contentType: canonicalized.media.contentType
1712
+ };
1713
+ if (!sourceModality && stagedMedia.contentType?.startsWith("audio/")) sourceModality = "audio";
1714
+ }
1715
+ throwIfIngressAborted();
1716
+ } catch (error) {
1717
+ await deleteStagedMedia();
1718
+ throwIfIngressAborted();
1719
+ authorizedMediaUnavailable = true;
1720
+ const failure = classifyNextcloudTalkMediaFailure(error);
1721
+ logMediaNonOutcome(failure.reason, {
1722
+ status: failure.status,
1723
+ ...failure.reason === "media_download_oversize" ? { maxBytes: mediaMaxBytes } : {}
1724
+ });
1725
+ }
1232
1726
  }
1233
- }) };
1727
+ }
1728
+ }
1729
+ }
1730
+ const quote = message.quote;
1731
+ let quoteAllowFrom = hasAllowEntries(outerGroupAllowFrom) ? outerGroupAllowFrom : roomAllowFrom;
1732
+ let quoteRoomAllowFrom = roomAllowFrom;
1733
+ const hasQuotePolicyWork = quote !== void 0 && isGroup && groupPolicy === "allowlist";
1734
+ if (hasQuotePolicyWork) {
1735
+ const expandQuoteAllowFrom = (entries) => expandAllowFromWithAccessGroups({
1736
+ cfg: config,
1737
+ allowFrom: entries,
1738
+ channel: CHANNEL_ID,
1739
+ accountId: account.accountId,
1740
+ senderId: quote.senderId,
1741
+ isSenderAllowed: (id, allow) => resolveNextcloudTalkAllowlistMatch({
1742
+ senderId: id,
1743
+ allowFrom: allow
1744
+ }).allowed
1745
+ });
1746
+ quoteAllowFrom = await expandQuoteAllowFrom(quoteAllowFrom);
1747
+ quoteRoomAllowFrom = await expandQuoteAllowFrom(roomAllowFrom);
1748
+ }
1749
+ if (performedAsyncMediaWork || hasQuotePolicyWork) {
1750
+ throwIfIngressAborted();
1751
+ access = await resolveAccess(isGroup ? wasMentioned : void 0, contextBinding);
1752
+ throwIfIngressAborted();
1753
+ if (access.ingress.admission !== "dispatch") {
1754
+ await deleteStagedMedia();
1755
+ logAuthorizationChanged();
1756
+ return;
1757
+ }
1758
+ }
1759
+ throwIfIngressAborted();
1760
+ const agentBody = authorizedMediaUnavailable ? formatInboundMediaUnavailableText({
1761
+ body: rawBody,
1762
+ notice: "[Nextcloud Talk attachment unavailable]"
1763
+ }) : rawBody;
1764
+ const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
1765
+ const body = buildEnvelope({
1766
+ channel: "Nextcloud Talk",
1767
+ from: fromLabel,
1768
+ timestamp: message.timestamp,
1769
+ body: agentBody
1770
+ });
1771
+ const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
1772
+ const visibleQuote = quote ? filterChannelInboundQuoteContext(resolveChannelContextVisibilityMode({
1773
+ cfg: config,
1774
+ channel: CHANNEL_ID,
1775
+ accountId: account.accountId
1776
+ }), {
1777
+ id: quote.id,
1778
+ body: quote.body,
1779
+ sender: quote.senderName || quote.senderId,
1780
+ senderAllowed: resolveInboundSupplementalSenderAllowed({
1781
+ isGroup,
1782
+ groupPolicy,
1783
+ allowFrom: quoteAllowFrom,
1784
+ isSenderAllowed: (candidateAllowFrom) => resolveNextcloudTalkAllowlistMatch({
1785
+ allowFrom: [...candidateAllowFrom],
1786
+ senderId: quote.senderId
1787
+ }).allowed && (!hasAllowEntries(quoteRoomAllowFrom) || resolveNextcloudTalkAllowlistMatch({
1788
+ allowFrom: quoteRoomAllowFrom,
1789
+ senderId: quote.senderId
1790
+ }).allowed)
1791
+ })
1792
+ }) : void 0;
1793
+ const ctxPayload = core.channel.inbound.buildContext({
1794
+ channelIngress: access,
1795
+ channel: CHANNEL_ID,
1796
+ accountId: route.accountId,
1797
+ messageId: message.messageId,
1798
+ timestamp: message.timestamp,
1799
+ from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
1800
+ sender: {
1801
+ id: senderId,
1802
+ name: senderName || void 0
1234
1803
  },
1235
- onError: (err, info) => {
1236
- runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
1804
+ conversation: {
1805
+ kind: isGroup ? "group" : "direct",
1806
+ id: isGroup ? roomToken : senderId,
1807
+ label: fromLabel
1808
+ },
1809
+ route: {
1810
+ agentId: route.agentId,
1811
+ dmScope: route.dmScope,
1812
+ accountId: route.accountId,
1813
+ routeSessionKey: route.sessionKey
1814
+ },
1815
+ reply: {
1816
+ to: `nextcloud-talk:${roomToken}`,
1817
+ originatingTo: `nextcloud-talk:${roomToken}`
1818
+ },
1819
+ message: {
1820
+ body,
1821
+ bodyForAgent: agentBody,
1822
+ rawBody,
1823
+ commandBody,
1824
+ ...sourceModality ? { sourceModality } : {}
1825
+ },
1826
+ access: {
1827
+ commands: { authorized: access.commandAccess.authorized },
1828
+ mentions: {
1829
+ canDetectMention: isGroup,
1830
+ wasMentioned: isGroup && wasMentioned
1831
+ }
1832
+ },
1833
+ ...stagedMedia ? { media: [stagedMedia] } : {},
1834
+ extra: {
1835
+ GroupSubject: isGroup ? roomName || roomToken : void 0,
1836
+ GroupSystemPrompt: isGroup ? normalizeOptionalString(roomConfig?.systemPrompt) : void 0,
1837
+ ReplyChain: quote && visibleQuote ? [{
1838
+ messageId: visibleQuote.id,
1839
+ senderId: quote.senderId,
1840
+ sender: visibleQuote.sender,
1841
+ body: visibleQuote.body,
1842
+ isQuote: true
1843
+ }] : void 0
1237
1844
  }
1238
- },
1239
- replyPipeline: {},
1240
- replyOptions: {
1241
- ...params.turnAdoptionLifecycle ? bindIngressLifecycleToReplyOptions(params.turnAdoptionLifecycle) : {},
1242
- skillFilter: roomConfig?.skills,
1243
- disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : void 0
1244
- },
1245
- record: { onRecordError: (err) => {
1246
- runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
1247
- } }
1248
- });
1845
+ });
1846
+ throwIfIngressAborted();
1847
+ const dispatchParams = {
1848
+ cfg: config,
1849
+ channel: CHANNEL_ID,
1850
+ accountId: account.accountId,
1851
+ route: {
1852
+ agentId: route.agentId,
1853
+ sessionKey: route.sessionKey
1854
+ },
1855
+ ctxPayload,
1856
+ delivery: {
1857
+ preparePayload: (payload) => payload.text === void 0 ? payload : {
1858
+ ...payload,
1859
+ text: sanitizeAssistantVisibleText(payload.text)
1860
+ },
1861
+ deliverWithProviderMessageSending: async (payload, info) => {
1862
+ const { deliverNextcloudTalkReply } = await import("./reply-BEkv387l.mjs");
1863
+ return await deliverNextcloudTalkReply({
1864
+ cfg: config,
1865
+ accountId: account.accountId,
1866
+ agentId: route.agentId,
1867
+ ctxPayload,
1868
+ payload,
1869
+ info,
1870
+ to: roomToken,
1871
+ signal: ingressAbortSignal
1872
+ });
1873
+ },
1874
+ onDelivered: (_payload, _info, result) => {
1875
+ if (result?.visibleReplySent) statusSink?.({ lastOutboundAt: Date.now() });
1876
+ },
1877
+ onError: (err, info) => {
1878
+ runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
1879
+ }
1880
+ },
1881
+ replyPipeline: {},
1882
+ replyOptions: {
1883
+ ...params.turnAdoptionLifecycle ? bindIngressLifecycleToReplyOptions(params.turnAdoptionLifecycle) : {},
1884
+ skillFilter: roomConfig?.skills,
1885
+ disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : void 0
1886
+ },
1887
+ record: { onRecordError: (err) => {
1888
+ runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
1889
+ } }
1890
+ };
1891
+ mediaHandedOff = true;
1892
+ await core.channel.inbound.dispatch(dispatchParams);
1893
+ } finally {
1894
+ if (!mediaHandedOff) await deleteStagedMedia();
1895
+ }
1896
+ }
1897
+ // extensions/nextcloud-talk/src/message-actions.ts
1898
+ const providerId = "nextcloud-talk";
1899
+ function isAccountConfigured(account) {
1900
+ return Boolean(account.enabled && account.secret?.trim() && account.baseUrl?.trim());
1249
1901
  }
1902
+ function hasConfiguredAccount(cfg, accountId) {
1903
+ if (accountId) return isAccountConfigured(resolveNextcloudTalkAccount({
1904
+ cfg,
1905
+ accountId
1906
+ }));
1907
+ return listNextcloudTalkAccountIds(cfg).map((id) => resolveNextcloudTalkAccount({
1908
+ cfg,
1909
+ accountId: id
1910
+ })).some(isAccountConfigured);
1911
+ }
1912
+ const nextcloudTalkMessageActions = {
1913
+ describeMessageTool: ({ cfg, accountId }) => {
1914
+ if (!hasConfiguredAccount(cfg, accountId)) return null;
1915
+ return { actions: ["send", "react"] };
1916
+ },
1917
+ supportsAction: ({ action }) => action === "react",
1918
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
1919
+ if (action === "send") throw new Error("Send should be handled by outbound, not actions handler.");
1920
+ if (action === "react") {
1921
+ const account = resolveNextcloudTalkAccount({
1922
+ cfg,
1923
+ accountId
1924
+ });
1925
+ if (!isAccountConfigured(account)) throw new Error(`Nextcloud Talk account "${account.accountId}" is disabled or not configured.`);
1926
+ const target = readStringParam(params, "to", {
1927
+ required: true,
1928
+ label: "to (room token)"
1929
+ });
1930
+ const messageIdRaw = resolveReactionMessageId({
1931
+ args: params,
1932
+ toolContext
1933
+ });
1934
+ if (messageIdRaw == null) throw new Error("messageId required");
1935
+ const messageId = String(messageIdRaw);
1936
+ const emoji = readStringParam(params, "emoji", { required: true });
1937
+ if (params.remove === true) throw new Error("Nextcloud Talk reaction removal is not supported yet; only adding reactions is implemented.");
1938
+ await sendReactionNextcloudTalk(target, messageId, emoji, {
1939
+ accountId: accountId ?? void 0,
1940
+ cfg
1941
+ });
1942
+ return jsonResult({
1943
+ ok: true,
1944
+ added: emoji
1945
+ });
1946
+ }
1947
+ throw new Error(`Action ${action} not supported for ${providerId}.`);
1948
+ }
1949
+ };
1250
1950
  var NextcloudTalkWebhookPayloadError = class extends Error {
1251
1951
  constructor(message, options) {
1252
1952
  super(message, options);
@@ -1267,9 +1967,91 @@ function requiredString(value, field) {
1267
1967
  if (typeof value === "string" && value.trim()) return value.trim();
1268
1968
  throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk webhook is missing ${field}.`);
1269
1969
  }
1970
+ /** Read one optional signed parent without rejecting an otherwise valid current message. */
1971
+ function parseNextcloudTalkQuotedReply(envelope) {
1972
+ const object = isRecord$1(envelope.object) ? envelope.object : null;
1973
+ const quote = isRecord$1(object?.inReplyTo) ? object.inReplyTo : null;
1974
+ const actor = isRecord$1(quote?.actor) ? quote.actor : null;
1975
+ const parent = isRecord$1(quote?.object) ? quote.object : null;
1976
+ if (quote?.type !== "Note" || actor?.type !== "Person" || typeof actor.id !== "string" || !actor.id.trim() || typeof actor.name !== "string" || parent?.type !== "Note" || typeof parent.id !== "string" || !parent.id.trim() || typeof parent.content !== "string") return;
1977
+ let body = parent.content;
1978
+ let structured;
1979
+ try {
1980
+ structured = JSON.parse(body);
1981
+ } catch {}
1982
+ if (isRecord$1(structured)) {
1983
+ const parameters = isRecord$1(structured.parameters) ? structured.parameters : Array.isArray(structured.parameters) && structured.parameters.length === 0 ? {} : void 0;
1984
+ if (typeof structured.message !== "string" || !parameters) return;
1985
+ body = structured.message.replace(/\{([^{}]+)\}/gu, (placeholder, key) => {
1986
+ const parameter = Object.hasOwn(parameters, key) ? parameters[key] : void 0;
1987
+ return isRecord$1(parameter) && typeof parameter.name === "string" ? parameter.name : placeholder;
1988
+ });
1989
+ } else if (Array.isArray(structured)) return;
1990
+ return {
1991
+ id: parent.id.trim(),
1992
+ senderId: actor.id.trim(),
1993
+ senderName: actor.name,
1994
+ body
1995
+ };
1996
+ }
1997
+ function parseDeclaredSizeBytes(value) {
1998
+ const parsed = typeof value === "number" ? value : typeof value === "string" && /^\d+$/u.test(value.trim()) ? Number(value.trim()) : NaN;
1999
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : void 0;
2000
+ }
2001
+ function parseFileAttachment(value) {
2002
+ if (!isRecord$1(value) || value.type !== "file") return;
2003
+ const name = typeof value.name === "string" ? value.name.trim() : "";
2004
+ const mimeType = typeof value.mimetype === "string" ? value.mimetype.trim() : "";
2005
+ const shareUrl = typeof value.link === "string" ? value.link.trim() : "";
2006
+ const declaredSizeBytes = parseDeclaredSizeBytes(value.size);
2007
+ const hideDownload = value["hide-download"];
2008
+ if (!name || !mimeType || !shareUrl || declaredSizeBytes === void 0 || hideDownload !== void 0 && hideDownload !== "yes" && hideDownload !== "no") return;
2009
+ const fileId = typeof value.id === "string" && value.id.trim() ? value.id.trim() : void 0;
2010
+ return {
2011
+ ...fileId ? { fileId } : {},
2012
+ name,
2013
+ mimeType,
2014
+ declaredSizeBytes,
2015
+ shareUrl,
2016
+ hideDownload: hideDownload === "yes"
2017
+ };
2018
+ }
2019
+ /** Recognize Talk's file-bearing message Activity without admitting other system events. */
2020
+ function parseNextcloudTalkFileSharedActivity(envelope) {
2021
+ if (envelope.type !== "Activity") return null;
2022
+ const object = isRecord$1(envelope.object) ? envelope.object : null;
2023
+ if (object?.type !== "Note" || object.name !== "file_shared" && object.name !== "message" && object.name !== "") return null;
2024
+ let content;
2025
+ try {
2026
+ content = typeof object.content === "string" ? JSON.parse(object.content) : null;
2027
+ } catch {
2028
+ content = null;
2029
+ }
2030
+ const contentRecord = isRecord$1(content) ? content : null;
2031
+ const parameters = isRecord$1(contentRecord?.parameters) ? contentRecord.parameters : null;
2032
+ if (!parameters || !Object.hasOwn(parameters, "file")) return null;
2033
+ const message = typeof contentRecord?.message === "string" ? contentRecord.message : "";
2034
+ const text = message.trim() === "{file}" ? "" : message;
2035
+ const mentionText = text.replace(/\{([^{}]+)\}/gu, (placeholder, key) => {
2036
+ const parameter = Object.hasOwn(parameters, key) ? parameters[key] : void 0;
2037
+ return isRecord$1(parameter) && parameter.type === "user" && typeof parameter.name === "string" ? `@${parameter.name}` : placeholder;
2038
+ });
2039
+ const attachment = parseFileAttachment(parameters.file);
2040
+ return attachment ? {
2041
+ text,
2042
+ mentionText,
2043
+ attachment
2044
+ } : {
2045
+ text,
2046
+ mentionText,
2047
+ attachmentIssue: "media_missing_metadata"
2048
+ };
2049
+ }
1270
2050
  function inspectNextcloudTalkWebhookEnvelope(rawEvent) {
1271
2051
  const envelope = parseRawObject(rawEvent);
1272
- if (envelope.type !== "Create") return null;
2052
+ const isCreateMessage = envelope.type === "Create";
2053
+ const isFileShare = parseNextcloudTalkFileSharedActivity(envelope) !== null;
2054
+ if (!isCreateMessage && !isFileShare) return null;
1273
2055
  const object = isRecord$1(envelope.object) ? envelope.object : null;
1274
2056
  if (object?.type !== void 0 && object.type !== "Note") return null;
1275
2057
  if (!object) throw new NextcloudTalkWebhookPayloadError("Nextcloud Talk webhook is missing object.");
@@ -1291,7 +2073,6 @@ function parseLegacyReplayKey(key) {
1291
2073
  /** Convert the shipped replay guard's live window into durable completion tombstones. */
1292
2074
  async function migrateNextcloudTalkLegacyReplayState(params) {
1293
2075
  const entries = await params.store.entries();
1294
- let migrated = 0;
1295
2076
  for (const entry of entries) {
1296
2077
  const identity = parseLegacyReplayKey(entry.value.key);
1297
2078
  if (!identity || !Number.isFinite(entry.value.seenAt)) continue;
@@ -1307,10 +2088,8 @@ async function migrateNextcloudTalkLegacyReplayState(params) {
1307
2088
  if (result.kind === "accepted" || result.kind === "pending" && result.record.payload.rawEvent === "") {
1308
2089
  if (!await params.queue.complete(identity.messageId, { completedAt: entry.value.seenAt })) throw new Error(`Failed to migrate Nextcloud Talk replay key ${entry.value.key}.`);
1309
2090
  }
1310
- migrated += 1;
1311
2091
  }
1312
2092
  await params.store.clear();
1313
- return migrated;
1314
2093
  }
1315
2094
  // extensions/nextcloud-talk/src/monitor.ts
1316
2095
  const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 65536;
@@ -1322,12 +2101,7 @@ const WEBHOOK_IN_FLIGHT_KEY = "nextcloud-talk-webhook";
1322
2101
  const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
1323
2102
  function writeWebhookError(res, status, error) {
1324
2103
  if (res.headersSent) return;
1325
- res.writeHead(status, { "Content-Type": "application/json" });
1326
- res.end(JSON.stringify({ error }));
1327
- }
1328
- async function rejectWebhookRequest(req, res, status, error) {
1329
- if (res.headersSent) return;
1330
- await sendHttpRequestRejection(req, res, status, JSON.stringify({ error }), "application/json");
2104
+ writeJsonWithoutCharset(res, status, { error });
1331
2105
  }
1332
2106
  function createWebhookGuards() {
1333
2107
  return {
@@ -1439,12 +2213,8 @@ async function handleWebhookRequest(req, res, targetsByPath, path) {
1439
2213
  }
1440
2214
  webhookAuthRateLimiter.reset(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
1441
2215
  } catch (err) {
1442
- if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
1443
- await rejectWebhookRequest(req, res, 413, "Payload too large");
1444
- return;
1445
- }
1446
- if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
1447
- await rejectWebhookRequest(req, res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
2216
+ if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE") || isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
2217
+ if (!res.headersSent) await sendHttpRequestRejection(req, res, err.statusCode, JSON.stringify({ error: requestBodyErrorToText(err.code) }), "application/json");
1448
2218
  return;
1449
2219
  }
1450
2220
  const error = err instanceof Error ? err : new Error(formatErrorMessage(err));
@@ -1507,423 +2277,32 @@ function registerNextcloudTalkWebhook(target) {
1507
2277
  legacyListener: target.legacyListener ? {
1508
2278
  ...target.legacyListener,
1509
2279
  health: {
1510
- path: "/healthz",
1511
- contentType: "text/plain"
1512
- }
1513
- } : void 0,
1514
- reuseExistingSameOwner: true,
1515
- throwOnFailure: true
1516
- });
1517
- let stopPromise;
1518
- return () => {
1519
- if (stopPromise) return stopPromise;
1520
- registration.target.stopping = true;
1521
- const release = () => {
1522
- removeTarget();
1523
- unregister();
1524
- };
1525
- const pending = registration.target.pendingResponses;
1526
- if (pending.size === 0) {
1527
- release();
1528
- stopPromise = Promise.resolve();
1529
- } else stopPromise = Promise.all(pending).then(release);
1530
- return stopPromise;
1531
- };
1532
- } catch (error) {
1533
- removeTarget();
1534
- throw error;
1535
- }
1536
- }
1537
- // extensions/nextcloud-talk/src/webhook-spool.ts
1538
- const NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS = 500;
1539
- function describeIgnoredWebhookEvent(rawEvent) {
1540
- const envelope = parseRawObject(rawEvent);
1541
- const type = typeof envelope.type === "string" ? envelope.type : "unknown";
1542
- const object = isRecord$1(envelope.object) ? envelope.object : null;
1543
- return `type=${type} objectType=${typeof object?.type === "string" ? object.type : "unknown"}`;
1544
- }
1545
- const NextcloudTalkWebhookPayloadSchema = z.object({
1546
- type: z.enum([
1547
- "Create",
1548
- "Update",
1549
- "Delete"
1550
- ]),
1551
- actor: z.object({
1552
- type: z.literal("Person"),
1553
- id: z.string().min(1),
1554
- name: z.string()
1555
- }),
1556
- object: z.object({
1557
- type: z.literal("Note"),
1558
- id: z.string().min(1),
1559
- name: z.string(),
1560
- content: z.string(),
1561
- mediaType: z.string()
1562
- }),
1563
- target: z.object({
1564
- type: z.literal("Collection"),
1565
- id: z.string().min(1),
1566
- name: z.string()
1567
- })
1568
- });
1569
- function parseClaimedMessage(payload, claimedId) {
1570
- const result = NextcloudTalkWebhookPayloadSchema.safeParse(parseRawObject(payload.rawEvent));
1571
- if (!result.success || result.data.type !== "Create" || result.data.object.id !== claimedId) throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk ingress row ${claimedId} has invalid message identity.`);
1572
- const webhook = result.data;
1573
- const roomId = requiredString(webhook.target.id, "target.id");
1574
- return {
1575
- messageId: webhook.object.id,
1576
- roomToken: roomId,
1577
- roomName: webhook.target.name,
1578
- senderId: webhook.actor.id,
1579
- senderName: webhook.actor.name,
1580
- text: webhook.object.content || webhook.object.name,
1581
- mediaType: webhook.object.mediaType || "text/plain",
1582
- timestamp: payload.receivedAt,
1583
- isGroupChat: true
1584
- };
1585
- }
1586
- function resolveNonRetryableFailure(error) {
1587
- if (error instanceof NextcloudTalkWebhookPayloadError) return {
1588
- reason: "invalid-event",
1589
- message: error.message
1590
- };
1591
- const message = formatErrorMessage(error);
1592
- if (message.includes("Nextcloud Talk: bot send was rejected") || message.includes("Nextcloud Talk: forbidden")) return {
1593
- reason: "nextcloud-talk-auth",
1594
- message
1595
- };
1596
- return null;
1597
- }
1598
- function createNextcloudTalkWebhookSpool(options) {
1599
- let queue = options.queue;
1600
- const getQueue = () => {
1601
- queue ??= getNextcloudTalkRuntime().state.openChannelIngressQueue({ accountId: options.accountId });
1602
- return queue;
1603
- };
1604
- const legacyReplayStore = options.legacyReplayStore === null ? null : options.legacyReplayStore ?? getNextcloudTalkRuntime().state.openKeyedStore({
1605
- namespace: resolvePersistentDedupePluginStateNamespace({
1606
- namespace: options.accountId,
1607
- namespacePrefix: "replay-dedupe"
1608
- }),
1609
- maxEntries: 1e4,
1610
- defaultTtlMs: 864e5
1611
- });
1612
- const legacyMigration = legacyReplayStore ? migrateNextcloudTalkLegacyReplayState({
1613
- queue: getQueue(),
1614
- store: legacyReplayStore
1615
- }) : Promise.resolve();
1616
- const monitor = createChannelIngressMonitor({
1617
- queue: getQueue,
1618
- inspect: (rawEvent) => inspectNextcloudTalkWebhookEnvelope(rawEvent),
1619
- payload: {
1620
- version: 1,
1621
- serialize: (rawEvent, { receivedAt }) => ({
1622
- receivedAt,
1623
- rawEvent
1624
- }),
1625
- deserialize: (body) => body.rawEvent,
1626
- encode: ({ body }) => ({
1627
- version: 1,
1628
- ...body
1629
- }),
1630
- decode: (payload) => ({
1631
- version: payload.version,
1632
- body: {
1633
- receivedAt: payload.receivedAt,
1634
- rawEvent: payload.rawEvent
1635
- }
1636
- }),
1637
- createClaimError: (kind, claim) => new NextcloudTalkWebhookPayloadError(kind === "invalid-version" ? `Nextcloud Talk ingress row ${claim.id} has an unsupported version.` : `Nextcloud Talk ingress row ${claim.id} has invalid message identity.`)
1638
- },
1639
- deliver: async (_rawEvent, lifecycle, claim) => {
1640
- const message = parseClaimedMessage(claim.payload, claim.id);
1641
- await options.deliver(message, lifecycle);
1642
- },
1643
- pollIntervalMs: options.pollIntervalMs ?? NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS,
1644
- retention: {
1645
- completedMaxEntries: 1e4,
1646
- failedMaxEntries: 1e4
1647
- },
1648
- drain: {
1649
- startLimit: 32,
1650
- resolveNonRetryableFailure,
1651
- ...options.adoptionStallTimeoutMs === void 0 ? {} : { adoptionStallTimeoutMs: options.adoptionStallTimeoutMs },
1652
- onLog: (message) => options.runtime.log?.(`nextcloud-talk ${message}`)
1653
- },
1654
- ...options.abortSignal ? { abortSignal: options.abortSignal } : {},
1655
- createStoppedError: () => /* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"),
1656
- onError: (error) => options.runtime.error?.(`nextcloud-talk ingress drain failed: ${formatErrorMessage(error)}`)
1657
- });
1658
- let stopping = false;
1659
- const inFlightReceives = /* @__PURE__ */ new Set();
1660
- const startAfterMigration = legacyMigration.then(() => {
1661
- if (!stopping) monitor.start();
1662
- });
1663
- return {
1664
- ready: async () => await startAfterMigration,
1665
- receive: (rawEvent) => {
1666
- if (stopping) return Promise.reject(/* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"));
1667
- const receiveTask = (async () => {
1668
- await startAfterMigration;
1669
- if ((await monitor.admit(rawEvent)).kind === "ignored") {
1670
- options.runtime.log?.(`nextcloud-talk: ignored non-message webhook event (${describeIgnoredWebhookEvent(rawEvent)})`);
1671
- return "ignored";
1672
- }
1673
- return "accepted";
1674
- })();
1675
- inFlightReceives.add(receiveTask);
1676
- receiveTask.then(() => inFlightReceives.delete(receiveTask), () => inFlightReceives.delete(receiveTask));
1677
- return receiveTask;
1678
- },
1679
- stop: async () => {
1680
- stopping = true;
1681
- const pendingReceives = [...inFlightReceives];
1682
- const pauseTask = monitor.pause();
1683
- await startAfterMigration.catch(() => void 0);
1684
- await Promise.allSettled(pendingReceives);
1685
- await monitor.stop();
1686
- await pauseTask;
1687
- },
1688
- waitForIdle: async () => {
1689
- await startAfterMigration.catch(() => void 0);
1690
- await monitor.waitForIdle();
1691
- }
1692
- };
1693
- }
1694
- // extensions/nextcloud-talk/src/monitor-runtime.ts
1695
- function normalizeOrigin(value) {
1696
- return URL.parse(value)?.origin.toLowerCase() ?? null;
1697
- }
1698
- async function monitorNextcloudTalkProvider(opts) {
1699
- const core = getNextcloudTalkRuntime();
1700
- const cfg = opts.config;
1701
- const account = resolveNextcloudTalkAccount({
1702
- cfg,
1703
- accountId: opts.accountId
1704
- });
1705
- const runtime = resolveLoggerBackedRuntime(opts.runtime, core.logging.getChildLogger());
1706
- if (!account.secret) throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
1707
- const path = account.config.webhookPath ?? "/nextcloud-talk-webhook";
1708
- const gatewayPort = (0, gateway_config_runtime_exports.resolveGatewayPort)({ gateway: cfg.gateway });
1709
- const legacyListener = resolveNextcloudTalkLegacyWebhook(account.config);
1710
- const routeConflict = describeNextcloudTalkWebhookRouteConflict(path, gatewayPort);
1711
- if (routeConflict && !legacyListener) throw new Error(`[nextcloud-talk:${account.accountId}] ${routeConflict}`);
1712
- const logger = core.logging.getChildLogger({
1713
- channel: "nextcloud-talk",
1714
- accountId: account.accountId
1715
- });
1716
- const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
1717
- const spool = createNextcloudTalkWebhookSpool({
1718
- accountId: account.accountId,
1719
- runtime,
1720
- abortSignal: opts.abortSignal,
1721
- deliver: async (message, lifecycle) => {
1722
- core.channel.activity.record({
1723
- channel: "nextcloud-talk",
1724
- accountId: account.accountId,
1725
- direction: "inbound",
1726
- at: message.timestamp
1727
- });
1728
- await handleNextcloudTalkInbound({
1729
- message,
1730
- account,
1731
- config: cfg,
1732
- runtime,
1733
- statusSink: opts.statusSink,
1734
- turnAdoptionLifecycle: lifecycle
1735
- });
1736
- }
1737
- });
1738
- let unregister;
1739
- let stopPromise;
1740
- const stop = () => {
1741
- stopPromise ??= (async () => {
1742
- await unregister?.();
1743
- unregister = void 0;
1744
- await spool.stop();
1745
- })();
1746
- return stopPromise;
1747
- };
1748
- if (opts.abortSignal && !opts.abortSignal.aborted) opts.abortSignal.addEventListener("abort", () => void stop(), { once: true });
1749
- if (opts.abortSignal?.aborted) {
1750
- await stop();
1751
- return { stop };
1752
- }
1753
- try {
1754
- await spool.ready();
1755
- if (opts.abortSignal?.aborted) {
1756
- await stop();
1757
- return { stop };
1758
- }
1759
- unregister = registerNextcloudTalkWebhook({
1760
- accountId: account.accountId,
1761
- legacyListener,
1762
- path,
1763
- secret: account.secret,
1764
- isBackendAllowed: (backend) => {
1765
- if (!expectedBackendOrigin) return true;
1766
- return normalizeOrigin(backend) === expectedBackendOrigin;
1767
- },
1768
- onWebhook: spool.receive,
1769
- onError: (error) => {
1770
- logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
1771
- },
1772
- trustedProxies: cfg.gateway?.trustedProxies,
1773
- allowRealIpFallback: cfg.gateway?.allowRealIpFallback
1774
- });
1775
- } catch (error) {
1776
- await stop();
1777
- throw error;
1778
- }
1779
- opts.statusSink?.(channelReadyPatch());
1780
- if (routeConflict && legacyListener) {
1781
- logger.warn(`[nextcloud-talk:${account.accountId}] ${routeConflict} Legacy webhook listener ${legacyListener.host}:${legacyListener.port} remains available; verify the new route before removing the legacyWebhook pin.`);
1782
- return { stop };
1783
- }
1784
- logger.info(`[nextcloud-talk:${account.accountId}] Gateway webhook route ready at port ${gatewayPort}${path}; point the Nextcloud bot callback or reverse-proxy upstream here.`);
1785
- if (legacyListener) logger.info(`[nextcloud-talk:${account.accountId}] legacy webhook listener ${legacyListener.host}:${legacyListener.port} forwards to the Gateway route. After verifying the callback or proxy upstream uses the Gateway port, remove the legacyWebhook pin; use legacyWebhook: false to override an inherited endpoint.`);
1786
- return { stop };
1787
- }
1788
- // extensions/nextcloud-talk/src/gateway.ts
1789
- const nextcloudTalkGatewayAdapter = {
1790
- startAccount: async (ctx) => {
1791
- const account = ctx.account;
1792
- if (!account.secret || !account.baseUrl) throw new Error(`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`);
1793
- ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk Gateway webhook route`);
1794
- const statusSink = createAccountStatusSink({
1795
- accountId: ctx.accountId,
1796
- setStatus: ctx.setStatus
1797
- });
1798
- await runPassiveAccountLifecycle({
1799
- abortSignal: ctx.abortSignal,
1800
- start: async () => await monitorNextcloudTalkProvider({
1801
- accountId: account.accountId,
1802
- config: ctx.cfg,
1803
- runtime: ctx.runtime,
1804
- abortSignal: ctx.abortSignal,
1805
- statusSink
1806
- }),
1807
- stop: async (monitor) => {
1808
- await monitor.stop();
1809
- }
1810
- });
1811
- },
1812
- logoutAccount: async ({ accountId, cfg }) => {
1813
- const { nextConfig, changed, cleared } = clearAccountFieldsFromConfigSection({
1814
- cfg,
1815
- sectionKey: "nextcloud-talk",
1816
- accountId,
1817
- fields: ["botSecret"]
1818
- });
1819
- const loggedOut = resolveNextcloudTalkAccount({
1820
- cfg: nextConfig,
1821
- accountId
1822
- }).secretSource === "none";
1823
- if (changed) await getNextcloudTalkRuntime().config.replaceConfigFile({
1824
- nextConfig,
1825
- afterWrite: { mode: "auto" }
1826
- });
1827
- return {
1828
- cleared,
1829
- envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
1830
- loggedOut
1831
- };
1832
- }
1833
- };
1834
- // extensions/nextcloud-talk/src/message-actions.ts
1835
- const providerId = "nextcloud-talk";
1836
- function isAccountConfigured(account) {
1837
- return Boolean(account.enabled && account.secret?.trim() && account.baseUrl?.trim());
1838
- }
1839
- function hasConfiguredAccount(cfg, accountId) {
1840
- if (accountId) return isAccountConfigured(resolveNextcloudTalkAccount({
1841
- cfg,
1842
- accountId
1843
- }));
1844
- return listNextcloudTalkAccountIds(cfg).map((id) => resolveNextcloudTalkAccount({
1845
- cfg,
1846
- accountId: id
1847
- })).some(isAccountConfigured);
1848
- }
1849
- const nextcloudTalkMessageActions = {
1850
- describeMessageTool: ({ cfg, accountId }) => {
1851
- if (!hasConfiguredAccount(cfg, accountId)) return null;
1852
- return { actions: ["send", "react"] };
1853
- },
1854
- supportsAction: ({ action }) => action === "react",
1855
- handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
1856
- if (action === "send") throw new Error("Send should be handled by outbound, not actions handler.");
1857
- if (action === "react") {
1858
- const account = resolveNextcloudTalkAccount({
1859
- cfg,
1860
- accountId
1861
- });
1862
- if (!isAccountConfigured(account)) throw new Error(`Nextcloud Talk account "${account.accountId}" is disabled or not configured.`);
1863
- const target = readStringParam(params, "to", {
1864
- required: true,
1865
- label: "to (room token)"
1866
- });
1867
- const messageIdRaw = resolveReactionMessageId({
1868
- args: params,
1869
- toolContext
1870
- });
1871
- if (messageIdRaw == null) throw new Error("messageId required");
1872
- const messageId = String(messageIdRaw);
1873
- const emoji = readStringParam(params, "emoji", { required: true });
1874
- if (params.remove === true) throw new Error("Nextcloud Talk reaction removal is not supported yet; only adding reactions is implemented.");
1875
- await sendReactionNextcloudTalk(target, messageId, emoji, {
1876
- accountId: accountId ?? void 0,
1877
- cfg
1878
- });
1879
- return jsonResult({
1880
- ok: true,
1881
- added: emoji
1882
- });
1883
- }
1884
- throw new Error(`Action ${action} not supported for ${providerId}.`);
1885
- }
1886
- };
1887
- // extensions/nextcloud-talk/src/message-adapter.ts
1888
- function sendNextcloudTalkMessage(ctx, text = ctx.text) {
1889
- return sendMessageNextcloudTalk(ctx.to, text, {
1890
- accountId: ctx.accountId ?? void 0,
1891
- replyTo: ctx.replyToId ?? void 0,
1892
- cfg: ctx.cfg,
1893
- onPlatformSendDispatch: ctx.onPlatformSendDispatch,
1894
- assertDirectAdapterHandoff: ctx.assertDirectAdapterHandoff
1895
- });
1896
- }
1897
- const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
1898
- id: "nextcloud-talk",
1899
- durableFinal: { capabilities: {
1900
- text: true,
1901
- media: true,
1902
- replyTo: true
1903
- } },
1904
- send: {
1905
- text: sendNextcloudTalkMessage,
1906
- media: (ctx) => sendNextcloudTalkMessage(ctx, ctx.mediaUrl ? `${ctx.text}\n\nAttachment: ${ctx.mediaUrl}` : ctx.text)
2280
+ path: "/healthz",
2281
+ contentType: "text/plain"
2282
+ }
2283
+ } : void 0,
2284
+ reuseExistingSameOwner: true,
2285
+ throwOnFailure: true
2286
+ });
2287
+ let stopPromise;
2288
+ return () => {
2289
+ if (stopPromise) return stopPromise;
2290
+ registration.target.stopping = true;
2291
+ const release = () => {
2292
+ removeTarget();
2293
+ unregister();
2294
+ };
2295
+ const pending = registration.target.pendingResponses;
2296
+ if (pending.size === 0) {
2297
+ release();
2298
+ stopPromise = Promise.resolve();
2299
+ } else stopPromise = Promise.all(pending).then(release);
2300
+ return stopPromise;
2301
+ };
2302
+ } catch (error) {
2303
+ removeTarget();
2304
+ throw error;
1907
2305
  }
1908
- });
1909
- // extensions/nextcloud-talk/src/session-route.ts
1910
- function resolveNextcloudTalkOutboundSessionRoute(params) {
1911
- const roomId = stripNextcloudTalkTargetPrefix(params.target);
1912
- if (!roomId) return null;
1913
- return buildChannelOutboundSessionRoute({
1914
- cfg: params.cfg,
1915
- agentId: params.agentId,
1916
- channel: "nextcloud-talk",
1917
- accountId: params.accountId,
1918
- recipientSessionExact: false,
1919
- peer: {
1920
- kind: "group",
1921
- id: roomId
1922
- },
1923
- chatType: "group",
1924
- from: `nextcloud-talk:room:${roomId}`,
1925
- to: `nextcloud-talk:${roomId}`
1926
- });
1927
2306
  }
1928
2307
  // extensions/nextcloud-talk/src/setup-core.ts
1929
2308
  const t$1 = createSetupTranslator$1();
@@ -2019,6 +2398,16 @@ const nextcloudTalkSetupAdapter = {
2019
2398
  }, setupInput.useEnv ? ["botSecret", "botSecretFile"] : void 0);
2020
2399
  }
2021
2400
  };
2401
+ function secretField(flags, description) {
2402
+ return {
2403
+ kind: "string",
2404
+ sensitive: true,
2405
+ cli: {
2406
+ flags,
2407
+ description
2408
+ }
2409
+ };
2410
+ }
2022
2411
  const nextcloudTalkSetupContract = defineChannelSetupContract({
2023
2412
  fields: {
2024
2413
  baseUrl: {
@@ -2035,46 +2424,11 @@ const nextcloudTalkSetupContract = defineChannelSetupContract({
2035
2424
  description: "Legacy Nextcloud base URL alias"
2036
2425
  }
2037
2426
  },
2038
- secret: {
2039
- kind: "string",
2040
- sensitive: true,
2041
- cli: {
2042
- flags: "--secret <secret>",
2043
- description: "Nextcloud Talk bot secret"
2044
- }
2045
- },
2046
- token: {
2047
- kind: "string",
2048
- sensitive: true,
2049
- cli: {
2050
- flags: "--token <secret>",
2051
- description: "Legacy Nextcloud bot secret alias"
2052
- }
2053
- },
2054
- password: {
2055
- kind: "string",
2056
- sensitive: true,
2057
- cli: {
2058
- flags: "--password <secret>",
2059
- description: "Legacy Nextcloud bot secret alias"
2060
- }
2061
- },
2062
- secretFile: {
2063
- kind: "string",
2064
- sensitive: true,
2065
- cli: {
2066
- flags: "--secret-file <path>",
2067
- description: "Nextcloud Talk bot secret file"
2068
- }
2069
- },
2070
- tokenFile: {
2071
- kind: "string",
2072
- sensitive: true,
2073
- cli: {
2074
- flags: "--token-file <path>",
2075
- description: "Legacy Nextcloud bot secret file alias"
2076
- }
2077
- },
2427
+ secret: secretField("--secret <secret>", "Nextcloud Talk bot secret"),
2428
+ token: secretField("--token <secret>", "Legacy Nextcloud bot secret alias"),
2429
+ password: secretField("--password <secret>", "Legacy Nextcloud bot secret alias"),
2430
+ secretFile: secretField("--secret-file <path>", "Nextcloud Talk bot secret file"),
2431
+ tokenFile: secretField("--token-file <path>", "Legacy Nextcloud bot secret file alias"),
2078
2432
  useEnv: {
2079
2433
  kind: "boolean",
2080
2434
  cli: {
@@ -2152,7 +2506,7 @@ const nextcloudTalkSetupWizard = {
2152
2506
  envPrompt: t("wizard.nextcloudTalk.botSecretEnvPrompt"),
2153
2507
  keepPrompt: t("wizard.nextcloudTalk.botSecretKeep"),
2154
2508
  inputPrompt: t("wizard.nextcloudTalk.botSecretInput"),
2155
- allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$1,
2509
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$2,
2156
2510
  resolveAccount: ({ cfg, accountId }) => resolveNextcloudTalkAccount({
2157
2511
  cfg,
2158
2512
  accountId
@@ -2160,7 +2514,7 @@ const nextcloudTalkSetupWizard = {
2160
2514
  accountConfigured: (account) => Boolean(account.secret && account.baseUrl),
2161
2515
  hasConfiguredValue: (account) => Boolean(hasConfiguredSecretInput(account.config.botSecret) || account.config.botSecretFile),
2162
2516
  resolvedValue: (account) => account.secret || void 0,
2163
- envValue: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$1 ? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET) : void 0,
2517
+ envValue: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$2 ? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET) : void 0,
2164
2518
  patchAccount: ({ cfg, accountId, patch, clearFields }) => setNextcloudTalkAccountConfig(cfg, accountId, patch, clearFields),
2165
2519
  useEnv: {
2166
2520
  clearFields: ["botSecret", "botSecretFile"],
@@ -2214,7 +2568,235 @@ const nextcloudTalkSetupWizard = {
2214
2568
  dmPolicy: nextcloudTalkDmPolicy,
2215
2569
  disable: (cfg) => setSetupChannelEnabled(cfg, channel, false)
2216
2570
  };
2571
+ // extensions/nextcloud-talk/src/webhook-spool.ts
2572
+ const NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS = 500;
2573
+ function describeIgnoredWebhookEvent(rawEvent) {
2574
+ const envelope = parseRawObject(rawEvent);
2575
+ const type = typeof envelope.type === "string" ? envelope.type : "unknown";
2576
+ const object = isRecord$1(envelope.object) ? envelope.object : null;
2577
+ return `type=${type} objectType=${typeof object?.type === "string" ? object.type : "unknown"}`;
2578
+ }
2579
+ const NextcloudTalkWebhookPayloadSchema = z.object({
2580
+ type: z.enum([
2581
+ "Create",
2582
+ "Update",
2583
+ "Delete",
2584
+ "Activity"
2585
+ ]),
2586
+ actor: z.object({
2587
+ type: z.literal("Person"),
2588
+ id: z.string().min(1),
2589
+ name: z.string()
2590
+ }),
2591
+ object: z.object({
2592
+ type: z.literal("Note"),
2593
+ id: z.string().min(1),
2594
+ name: z.string(),
2595
+ content: z.string(),
2596
+ mediaType: z.string()
2597
+ }),
2598
+ target: z.object({
2599
+ type: z.literal("Collection"),
2600
+ id: z.string().min(1),
2601
+ name: z.string()
2602
+ })
2603
+ });
2604
+ function parseClaimedMessage(payload, claimedId) {
2605
+ const rawEnvelope = parseRawObject(payload.rawEvent);
2606
+ const result = NextcloudTalkWebhookPayloadSchema.safeParse(rawEnvelope);
2607
+ const fileShare = parseNextcloudTalkFileSharedActivity(rawEnvelope);
2608
+ if (!result.success) throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk ingress row ${claimedId} has invalid message identity.`);
2609
+ const webhook = result.data;
2610
+ if (!(webhook.type === "Create" || webhook.type === "Activity" && fileShare !== null) || webhook.object.id !== claimedId) throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk ingress row ${claimedId} has invalid message identity.`);
2611
+ const roomId = requiredString(webhook.target.id, "target.id");
2612
+ const quote = parseNextcloudTalkQuotedReply(rawEnvelope);
2613
+ return {
2614
+ messageId: webhook.object.id,
2615
+ roomToken: roomId,
2616
+ roomName: webhook.target.name,
2617
+ senderId: webhook.actor.id,
2618
+ senderName: webhook.actor.name,
2619
+ text: fileShare ? fileShare.text : webhook.object.content || webhook.object.name,
2620
+ ...fileShare ? { mentionText: fileShare.mentionText } : {},
2621
+ mediaType: webhook.object.mediaType || "text/plain",
2622
+ timestamp: payload.receivedAt,
2623
+ isGroupChat: true,
2624
+ ...fileShare?.attachment ? { attachment: fileShare.attachment } : {},
2625
+ ...fileShare?.attachmentIssue ? { attachmentIssue: fileShare.attachmentIssue } : {},
2626
+ ...quote ? { quote } : {}
2627
+ };
2628
+ }
2629
+ function resolveNonRetryableFailure(error) {
2630
+ if (error instanceof NextcloudTalkWebhookPayloadError) return {
2631
+ reason: "invalid-event",
2632
+ message: error.message
2633
+ };
2634
+ const message = formatErrorMessage(error);
2635
+ if (message.includes("Nextcloud Talk: bot send was rejected") || message.includes("Nextcloud Talk: forbidden")) return {
2636
+ reason: "nextcloud-talk-auth",
2637
+ message
2638
+ };
2639
+ return null;
2640
+ }
2641
+ function createNextcloudTalkWebhookSpool(options) {
2642
+ let queue = options.queue;
2643
+ const getQueue = () => {
2644
+ queue ??= getNextcloudTalkRuntime().state.openChannelIngressQueue({ accountId: options.accountId });
2645
+ return queue;
2646
+ };
2647
+ const legacyReplayStore = options.legacyReplayStore === null ? null : options.legacyReplayStore ?? getNextcloudTalkRuntime().state.openKeyedStore({
2648
+ namespace: resolvePersistentDedupePluginStateNamespace({
2649
+ namespace: options.accountId,
2650
+ namespacePrefix: "replay-dedupe"
2651
+ }),
2652
+ maxEntries: 1e4,
2653
+ defaultTtlMs: 864e5
2654
+ });
2655
+ const legacyMigration = legacyReplayStore ? migrateNextcloudTalkLegacyReplayState({
2656
+ queue: getQueue(),
2657
+ store: legacyReplayStore
2658
+ }) : Promise.resolve();
2659
+ const monitor = createChannelIngressMonitor({
2660
+ queue: getQueue,
2661
+ inspect: inspectNextcloudTalkWebhookEnvelope,
2662
+ inspectAsync: async (rawEvent) => {
2663
+ await startAfterMigration;
2664
+ return inspectNextcloudTalkWebhookEnvelope(rawEvent);
2665
+ },
2666
+ payload: {
2667
+ version: 1,
2668
+ serialize: (rawEvent, { receivedAt }) => ({
2669
+ receivedAt,
2670
+ rawEvent
2671
+ }),
2672
+ deserialize: (body) => body.rawEvent,
2673
+ encode: ({ body }) => ({
2674
+ version: 1,
2675
+ ...body
2676
+ }),
2677
+ decode: (payload) => ({
2678
+ version: payload.version,
2679
+ body: {
2680
+ receivedAt: payload.receivedAt,
2681
+ rawEvent: payload.rawEvent
2682
+ }
2683
+ }),
2684
+ createClaimError: (kind, claim) => new NextcloudTalkWebhookPayloadError(kind === "invalid-version" ? `Nextcloud Talk ingress row ${claim.id} has an unsupported version.` : `Nextcloud Talk ingress row ${claim.id} has invalid message identity.`)
2685
+ },
2686
+ deliver: async (_rawEvent, lifecycle, claim) => {
2687
+ const message = parseClaimedMessage(claim.payload, claim.id);
2688
+ await options.deliver(message, lifecycle);
2689
+ },
2690
+ pollIntervalMs: options.pollIntervalMs ?? NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS,
2691
+ retention: {
2692
+ completedMaxEntries: 1e4,
2693
+ failedMaxEntries: 1e4
2694
+ },
2695
+ drain: {
2696
+ startLimit: 32,
2697
+ resolveNonRetryableFailure,
2698
+ adoptionStallTimeoutMs: options.adoptionStallTimeoutMs,
2699
+ onLog: (message) => options.runtime.log?.(`nextcloud-talk ${message}`)
2700
+ },
2701
+ ...options.abortSignal ? { abortSignal: options.abortSignal } : {},
2702
+ createStoppedError: () => /* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"),
2703
+ onError: (error) => options.runtime.error?.(`nextcloud-talk ingress drain failed: ${formatErrorMessage(error)}`)
2704
+ });
2705
+ let stopping = false;
2706
+ const startAfterMigration = legacyMigration.then(() => {
2707
+ if (!stopping) monitor.start();
2708
+ });
2709
+ return {
2710
+ ready: async () => await startAfterMigration,
2711
+ receive: (rawEvent) => {
2712
+ if (stopping) return Promise.reject(/* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"));
2713
+ return monitor.admit(rawEvent).then((result) => {
2714
+ if (result.kind === "ignored") {
2715
+ options.runtime.log?.(`nextcloud-talk: ignored non-message webhook event (${describeIgnoredWebhookEvent(rawEvent)})`);
2716
+ return "ignored";
2717
+ }
2718
+ return "accepted";
2719
+ });
2720
+ },
2721
+ stop: async () => {
2722
+ stopping = true;
2723
+ const pauseTask = monitor.pause();
2724
+ await startAfterMigration.catch(() => void 0);
2725
+ await monitor.stop();
2726
+ await pauseTask;
2727
+ },
2728
+ waitForIdle: async () => {
2729
+ await startAfterMigration.catch(() => void 0);
2730
+ await monitor.waitForIdle();
2731
+ }
2732
+ };
2733
+ }
2217
2734
  // extensions/nextcloud-talk/src/channel.ts
2735
+ const nextcloudTalkApprovalAuth = createChannelApprovalAuth({
2736
+ channelLabel: "Nextcloud Talk",
2737
+ resolveInputs: ({ cfg, accountId }) => {
2738
+ return { allowFrom: resolveNextcloudTalkAccount({
2739
+ cfg,
2740
+ accountId
2741
+ }).config.allowFrom };
2742
+ },
2743
+ normalizeApprover: (value) => normalizeNextcloudTalkAllowEntry(String(value)) || void 0
2744
+ }).approvalAuth;
2745
+ function normalizeOrigin(value) {
2746
+ return URL.parse(value)?.origin.toLowerCase() ?? null;
2747
+ }
2748
+ function sendNextcloudTalkMessage(ctx, text = ctx.text) {
2749
+ return sendMessageNextcloudTalk(ctx.to, text, {
2750
+ accountId: ctx.accountId ?? void 0,
2751
+ replyTo: ctx.replyToId ?? void 0,
2752
+ silent: ctx.silent,
2753
+ cfg: ctx.cfg,
2754
+ onPlatformSendDispatch: ctx.onPlatformSendDispatch,
2755
+ assertDirectAdapterHandoff: ctx.assertDirectAdapterHandoff
2756
+ });
2757
+ }
2758
+ function isWebAttachmentUrl(value) {
2759
+ try {
2760
+ const url = new URL(value);
2761
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !Array.from(value).some((character) => {
2762
+ const code = character.charCodeAt(0);
2763
+ return code <= 32 || code === 127;
2764
+ });
2765
+ } catch {
2766
+ return false;
2767
+ }
2768
+ }
2769
+ const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
2770
+ id: "nextcloud-talk",
2771
+ durableFinal: { capabilities: {
2772
+ text: true,
2773
+ media: true,
2774
+ replyTo: true,
2775
+ silent: true
2776
+ } },
2777
+ send: {
2778
+ text: sendNextcloudTalkMessage,
2779
+ media: async (ctx) => {
2780
+ if (!ctx.mediaUrl) return sendNextcloudTalkMessage(ctx);
2781
+ const account = resolveNextcloudTalkAccount({
2782
+ cfg: ctx.cfg,
2783
+ accountId: ctx.accountId
2784
+ });
2785
+ if (ctx.audioAsVoice !== true && account.enabled && !account.config.apiUser?.trim() && account.config.apiPassword === void 0 && !account.config.apiPasswordFile?.trim() && isWebAttachmentUrl(ctx.mediaUrl)) {
2786
+ ctx.signal?.throwIfAborted();
2787
+ return sendNextcloudTalkMessage({
2788
+ ...ctx,
2789
+ assertDirectAdapterHandoff: () => {
2790
+ ctx.signal?.throwIfAborted();
2791
+ ctx.assertDirectAdapterHandoff?.();
2792
+ }
2793
+ }, `${ctx.text}\n\nAttachment: ${ctx.mediaUrl}`);
2794
+ }
2795
+ const { sendMediaNextcloudTalk } = await import("./outbound-media-CpFe1Xgz.mjs");
2796
+ return sendMediaNextcloudTalk(ctx);
2797
+ }
2798
+ }
2799
+ });
2218
2800
  const meta = {
2219
2801
  id: "nextcloud-talk",
2220
2802
  label: "Nextcloud Talk",
@@ -2260,13 +2842,46 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
2260
2842
  reactions: true,
2261
2843
  threads: false,
2262
2844
  media: true,
2845
+ tts: { voice: {
2846
+ synthesisTarget: "audio-file",
2847
+ audioFileFormats: [
2848
+ "mp3",
2849
+ "wav",
2850
+ "audio/mpeg",
2851
+ "audio/wav"
2852
+ ],
2853
+ captionedFinalText: true
2854
+ } },
2263
2855
  nativeCommands: false,
2264
2856
  blockStreaming: true
2265
2857
  },
2266
2858
  reload: { configPrefixes: ["channels.nextcloud-talk"] },
2267
- configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
2859
+ configSchema: NextcloudTalkChannelConfigSchema,
2268
2860
  config: {
2269
- ...nextcloudTalkConfigAdapter,
2861
+ ...createScopedChannelConfigAdapter({
2862
+ sectionKey: "nextcloud-talk",
2863
+ listAccountIds: listNextcloudTalkAccountIds,
2864
+ resolveAccount: adaptScopedAccountAccessor(resolveNextcloudTalkAccount),
2865
+ inspectAccount: (cfg, accountId) => {
2866
+ const account = inspectNextcloudTalkAccount({
2867
+ cfg,
2868
+ accountId
2869
+ });
2870
+ return {
2871
+ ...account,
2872
+ baseUrl: account.baseUrl ? "[set]" : "[missing]"
2873
+ };
2874
+ },
2875
+ defaultAccountId: resolveDefaultNextcloudTalkAccountId,
2876
+ clearBaseFields: [
2877
+ "botSecret",
2878
+ "botSecretFile",
2879
+ "baseUrl",
2880
+ "name"
2881
+ ],
2882
+ resolveAllowFrom: (account) => account.config.allowFrom,
2883
+ formatAllowFrom: normalizeNextcloudTalkAllowlist
2884
+ }),
2270
2885
  isConfigured: isNextcloudTalkAccountConfigured,
2271
2886
  describeAccount: (account) => describeWebhookAccountSnapshot({
2272
2887
  account,
@@ -2293,7 +2908,24 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
2293
2908
  ],
2294
2909
  normalizeTarget: normalizeNextcloudTalkMessagingTarget,
2295
2910
  inferTargetChatType: ({ to }) => normalizeNextcloudTalkMessagingTarget(to) ? "group" : void 0,
2296
- resolveOutboundSessionRoute: resolveNextcloudTalkOutboundSessionRoute,
2911
+ resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
2912
+ const roomId = stripNextcloudTalkTargetPrefix(target);
2913
+ if (!roomId) return null;
2914
+ return buildChannelOutboundSessionRoute({
2915
+ cfg,
2916
+ agentId,
2917
+ channel: "nextcloud-talk",
2918
+ accountId,
2919
+ recipientSessionExact: false,
2920
+ peer: {
2921
+ kind: "group",
2922
+ id: roomId
2923
+ },
2924
+ chatType: "group",
2925
+ from: `nextcloud-talk:room:${roomId}`,
2926
+ to: `nextcloud-talk:${roomId}`
2927
+ });
2928
+ },
2297
2929
  targetResolver: {
2298
2930
  looksLikeId: looksLikeNextcloudTalkTargetId,
2299
2931
  hint: "<roomToken>"
@@ -2305,14 +2937,14 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
2305
2937
  },
2306
2938
  setupContract: nextcloudTalkSetupContract,
2307
2939
  status: createComputedAccountStatusAdapter({
2308
- defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID$2),
2940
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
2309
2941
  buildChannelSummary: ({ snapshot }) => buildWebhookChannelStatusSummary(snapshot, { secretSource: snapshot.secretSource ?? "none" }),
2310
2942
  collectStatusIssues: (accounts) => accounts.flatMap((account) => {
2311
2943
  const probe = account.probe;
2312
2944
  if (!probe || probe.ok !== false || probe.code !== "missing_response_feature" || !probe.message) return [];
2313
2945
  return [{
2314
2946
  channel: "nextcloud-talk",
2315
- accountId: account.accountId ?? DEFAULT_ACCOUNT_ID$2,
2947
+ accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
2316
2948
  kind: "config",
2317
2949
  message: probe.message,
2318
2950
  fix: "Add --feature response to the Talk bot."
@@ -2336,16 +2968,145 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
2336
2968
  }
2337
2969
  })
2338
2970
  }),
2339
- gateway: nextcloudTalkGatewayAdapter,
2971
+ gateway: {
2972
+ startAccount: async (ctx) => {
2973
+ const account = ctx.account;
2974
+ if (!account.secret || !account.baseUrl) throw new Error(`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`);
2975
+ ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk Gateway webhook route`);
2976
+ const statusSink = createAccountStatusSink(ctx);
2977
+ await runPassiveAccountLifecycle({
2978
+ abortSignal: ctx.abortSignal,
2979
+ start: async () => {
2980
+ const core = getNextcloudTalkRuntime();
2981
+ const cfg = ctx.cfg;
2982
+ const monitoredAccount = resolveNextcloudTalkAccount({
2983
+ cfg,
2984
+ accountId: account.accountId
2985
+ });
2986
+ const runtime = resolveLoggerBackedRuntime(ctx.runtime, core.logging.getChildLogger());
2987
+ if (!monitoredAccount.secret) throw new Error(`Nextcloud Talk bot secret not configured for account "${monitoredAccount.accountId}"`);
2988
+ const path = monitoredAccount.config.webhookPath ?? "/nextcloud-talk-webhook";
2989
+ const gatewayPort = (0, gateway_config_runtime_exports.resolveGatewayPort)({ gateway: cfg.gateway });
2990
+ const legacyListener = resolveNextcloudTalkLegacyWebhook(monitoredAccount.config);
2991
+ const routeConflict = describeNextcloudTalkWebhookRouteConflict(path, gatewayPort);
2992
+ if (routeConflict && !legacyListener) throw new Error(`[nextcloud-talk:${monitoredAccount.accountId}] ${routeConflict}`);
2993
+ const logger = core.logging.getChildLogger({
2994
+ channel: "nextcloud-talk",
2995
+ accountId: monitoredAccount.accountId
2996
+ });
2997
+ const expectedBackendOrigin = normalizeOrigin(monitoredAccount.baseUrl);
2998
+ const spool = createNextcloudTalkWebhookSpool({
2999
+ accountId: monitoredAccount.accountId,
3000
+ runtime,
3001
+ abortSignal: ctx.abortSignal,
3002
+ deliver: async (message, lifecycle) => {
3003
+ core.channel.activity.record({
3004
+ channel: "nextcloud-talk",
3005
+ accountId: monitoredAccount.accountId,
3006
+ direction: "inbound",
3007
+ at: message.timestamp
3008
+ });
3009
+ await handleNextcloudTalkInbound({
3010
+ message,
3011
+ account: monitoredAccount,
3012
+ config: cfg,
3013
+ runtime,
3014
+ statusSink,
3015
+ turnAdoptionLifecycle: lifecycle
3016
+ });
3017
+ }
3018
+ });
3019
+ let unregister;
3020
+ let stopPromise;
3021
+ const stop = () => {
3022
+ stopPromise ??= (async () => {
3023
+ await unregister?.();
3024
+ unregister = void 0;
3025
+ await spool.stop();
3026
+ })();
3027
+ return stopPromise;
3028
+ };
3029
+ if (!ctx.abortSignal.aborted) ctx.abortSignal.addEventListener("abort", () => void stop(), { once: true });
3030
+ if (ctx.abortSignal.aborted) {
3031
+ await stop();
3032
+ return { stop };
3033
+ }
3034
+ try {
3035
+ await spool.ready();
3036
+ if (ctx.abortSignal.aborted) {
3037
+ await stop();
3038
+ return { stop };
3039
+ }
3040
+ unregister = registerNextcloudTalkWebhook({
3041
+ accountId: monitoredAccount.accountId,
3042
+ legacyListener,
3043
+ path,
3044
+ secret: monitoredAccount.secret,
3045
+ isBackendAllowed: (backend) => !expectedBackendOrigin || normalizeOrigin(backend) === expectedBackendOrigin,
3046
+ onWebhook: spool.receive,
3047
+ onError: (error) => {
3048
+ logger.error(`[nextcloud-talk:${monitoredAccount.accountId}] webhook error: ${error.message}`);
3049
+ },
3050
+ trustedProxies: cfg.gateway?.trustedProxies,
3051
+ allowRealIpFallback: cfg.gateway?.allowRealIpFallback
3052
+ });
3053
+ } catch (error) {
3054
+ await stop();
3055
+ throw error;
3056
+ }
3057
+ statusSink(channelReadyPatch());
3058
+ if (routeConflict && legacyListener) {
3059
+ logger.warn(`[nextcloud-talk:${monitoredAccount.accountId}] ${routeConflict} Legacy webhook listener ${legacyListener.host}:${legacyListener.port} remains available; verify the new route before removing the legacyWebhook pin.`);
3060
+ return { stop };
3061
+ }
3062
+ logger.info(`[nextcloud-talk:${monitoredAccount.accountId}] Gateway webhook route ready at port ${gatewayPort}${path}; point the Nextcloud bot callback or reverse-proxy upstream here.`);
3063
+ if (legacyListener) logger.info(`[nextcloud-talk:${monitoredAccount.accountId}] legacy webhook listener ${legacyListener.host}:${legacyListener.port} forwards to the Gateway route. After verifying the callback or proxy upstream uses the Gateway port, remove the legacyWebhook pin; use legacyWebhook: false to override an inherited endpoint.`);
3064
+ return { stop };
3065
+ },
3066
+ stop: async (monitor) => {
3067
+ await monitor.stop();
3068
+ }
3069
+ });
3070
+ },
3071
+ logoutAccount: async ({ accountId, cfg }) => {
3072
+ const { nextConfig, changed, cleared } = clearAccountFieldsFromConfigSection({
3073
+ cfg,
3074
+ sectionKey: "nextcloud-talk",
3075
+ accountId,
3076
+ fields: ["botSecret"]
3077
+ });
3078
+ const loggedOut = resolveNextcloudTalkAccount({
3079
+ cfg: nextConfig,
3080
+ accountId
3081
+ }).secretSource === "none";
3082
+ if (changed) await getNextcloudTalkRuntime().config.replaceConfigFile({
3083
+ nextConfig,
3084
+ afterWrite: { mode: "auto" }
3085
+ });
3086
+ return {
3087
+ cleared,
3088
+ envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
3089
+ loggedOut
3090
+ };
3091
+ }
3092
+ },
2340
3093
  message: nextcloudTalkMessageAdapter,
2341
3094
  actions: nextcloudTalkMessageActions
2342
3095
  },
2343
3096
  pairing: { text: {
2344
- ...nextcloudTalkPairingTextAdapter,
3097
+ idLabel: "nextcloudUserId",
3098
+ message: "OpenClaw: your access has been approved.",
3099
+ normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => normalizeLowercaseStringOrEmpty(entry)),
2345
3100
  notify: createLoggedPairingApprovalNotifier(({ id }) => `[nextcloud-talk] User ${id} approved for pairing`)
2346
3101
  } },
2347
3102
  security: {
2348
- ...nextcloudTalkSecurityAdapter,
3103
+ resolveDmPolicy: createScopedDmSecurityResolver({
3104
+ channelKey: "nextcloud-talk",
3105
+ resolvePolicy: (account) => account.config.dmPolicy,
3106
+ resolveAllowFrom: (account) => account.config.allowFrom,
3107
+ policyPathSuffix: "dmPolicy",
3108
+ normalizeEntry: normalizeNextcloudTalkAllowEntry
3109
+ }),
2349
3110
  collectWarnings: collectNextcloudTalkOpenGroupFindings
2350
3111
  },
2351
3112
  outbound: {
@@ -2367,4 +3128,4 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
2367
3128
  }
2368
3129
  }
2369
3130
  });
2370
- export { nextcloudTalkPlugin as t };
3131
+ export { resolveNextcloudTalkAccount as a, releaseNextcloudTalkGuardedResponse as i, stripNextcloudTalkTargetPrefix as n, resolveNextcloudTalkApiCredentialsResult as o, parseWebDavFileId as r, nextcloudTalkPlugin as t };