@openclaw/nextcloud-talk 2026.10.10-beta.5 → 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.
- package/dist/.setup/api-BvrRSilb.mjs +2 -0
- package/dist/.setup/{channel-B8-CvvK1.mjs → channel-BA1-OoqO.mjs} +1724 -994
- package/dist/.setup/{doctor-contract-DwH950tH.mjs → doctor-contract-CkWQa5uZ.mjs} +6 -12
- package/dist/.setup/outbound-media-CpFe1Xgz.mjs +303 -0
- package/dist/.setup/reply-BEkv387l.mjs +64 -0
- package/dist/.setup/{runtime-api-DVkVOUSo.mjs → runtime-api-CGy_KpdV.mjs} +2 -4
- package/dist/.setup/{secret-contract-api-DoyAH9bS.mjs → secret-contract-api-QMihLdr1.mjs} +1 -1
- package/dist/.setup/secret-contract-yjCLewtg.mjs +19 -0
- package/dist/.source-module-urls/3332e832383d54fca83a3cfe8961ecdecf361c1eec0972c99f00a876d9481d98.json +1 -1
- package/dist/api.js +1 -1
- package/dist/channel-plugin-api.js +1 -1
- package/dist/configured-state.js +3 -7
- package/dist/doctor-contract-api.js +1 -1
- package/dist/index.js +1 -2
- package/dist/runtime-api.js +1 -1
- package/dist/secret-contract-api.js +1 -1
- package/dist/setup-entry.js +3 -4
- package/node_modules/saxes/README.md +323 -0
- package/node_modules/saxes/node_modules/xmlchars/LICENSE +18 -0
- package/node_modules/saxes/node_modules/xmlchars/README.md +33 -0
- package/node_modules/saxes/node_modules/xmlchars/package.json +51 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed4.d.ts +31 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed4.js +44 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed4.js.map +1 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed5.d.ts +51 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed5.js +105 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.0/ed5.js.map +1 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.1/ed2.d.ts +73 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.1/ed2.js +145 -0
- package/node_modules/saxes/node_modules/xmlchars/xml/1.1/ed2.js.map +1 -0
- package/node_modules/saxes/node_modules/xmlchars/xmlchars.d.ts +170 -0
- package/node_modules/saxes/node_modules/xmlchars/xmlchars.js +191 -0
- package/node_modules/saxes/node_modules/xmlchars/xmlchars.js.map +1 -0
- package/node_modules/saxes/node_modules/xmlchars/xmlns/1.0/ed3.d.ts +28 -0
- package/node_modules/saxes/node_modules/xmlchars/xmlns/1.0/ed3.js +65 -0
- package/node_modules/saxes/node_modules/xmlchars/xmlns/1.0/ed3.js.map +1 -0
- package/node_modules/saxes/package.json +71 -0
- package/node_modules/saxes/saxes.d.ts +635 -0
- package/node_modules/saxes/saxes.js +2053 -0
- package/node_modules/saxes/saxes.js.map +1 -0
- package/node_modules/zod/package.json +1 -1
- package/node_modules/zod/src/v4/classic/tests/array.test.ts +27 -0
- package/node_modules/zod/src/v4/classic/tests/map.test.ts +7 -0
- package/node_modules/zod/src/v4/classic/tests/set.test.ts +6 -0
- package/node_modules/zod/src/v4/core/compile.ts +9 -0
- package/node_modules/zod/src/v4/core/schemas.ts +35 -1
- package/node_modules/zod/src/v4/core/util.ts +13 -0
- package/node_modules/zod/src/v4/core/versions.ts +1 -1
- package/node_modules/zod/v4/core/compile.cjs +8 -0
- package/node_modules/zod/v4/core/compile.js +8 -0
- package/node_modules/zod/v4/core/schemas.cjs +30 -0
- package/node_modules/zod/v4/core/schemas.d.cts +3 -1
- package/node_modules/zod/v4/core/schemas.d.ts +3 -1
- package/node_modules/zod/v4/core/schemas.js +30 -0
- package/node_modules/zod/v4/core/util.cjs +15 -0
- package/node_modules/zod/v4/core/util.d.cts +2 -0
- package/node_modules/zod/v4/core/util.d.ts +2 -0
- package/node_modules/zod/v4/core/util.js +14 -0
- package/node_modules/zod/v4/core/versions.cjs +1 -1
- package/node_modules/zod/v4/core/versions.js +1 -1
- package/openclaw.plugin.json +23 -1
- package/package.json +8 -6
- package/dist/.setup/api-BmNQoi-o.mjs +0 -2
- package/dist/.setup/secret-contract-Bvm0PmP3.mjs +0 -15
|
@@ -1,58 +1,187 @@
|
|
|
1
|
-
import { a as __exportAll, n as legacyConfigRules, o as __reExport, r as normalizeCompatibilityConfig } from "./doctor-contract-
|
|
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
|
|
4
|
-
import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries } from "./secret-contract-
|
|
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
|
|
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 {
|
|
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";
|
|
55
|
-
|
|
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);
|
|
184
|
+
// extensions/nextcloud-talk/src/api-credentials.ts
|
|
56
185
|
function resolveNextcloudTalkApiCredentialsResult(params) {
|
|
57
186
|
const apiUser = params.apiUser?.trim();
|
|
58
187
|
if (!apiUser) return { status: "missing" };
|
|
@@ -84,8 +213,7 @@ function resolveNextcloudTalkApiCredentials(params) {
|
|
|
84
213
|
const result = resolveNextcloudTalkApiCredentialsResult(params);
|
|
85
214
|
return result.status === "available" ? result.value : void 0;
|
|
86
215
|
}
|
|
87
|
-
|
|
88
|
-
//#region extensions/nextcloud-talk/src/accounts.ts
|
|
216
|
+
// extensions/nextcloud-talk/src/accounts.ts
|
|
89
217
|
function resolveNextcloudTalkSecret(accountId, merged) {
|
|
90
218
|
const configuredSecret = resolveSecretInputString({
|
|
91
219
|
value: merged.botSecret,
|
|
@@ -98,7 +226,7 @@ function resolveNextcloudTalkSecret(accountId, merged) {
|
|
|
98
226
|
status: "configured_unavailable"
|
|
99
227
|
};
|
|
100
228
|
const envSecret = normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET);
|
|
101
|
-
if (envSecret && accountId === DEFAULT_ACCOUNT_ID) return {
|
|
229
|
+
if (envSecret && accountId === DEFAULT_ACCOUNT_ID$1) return {
|
|
102
230
|
secret: envSecret,
|
|
103
231
|
source: "env",
|
|
104
232
|
status: "available"
|
|
@@ -178,23 +306,11 @@ function inspectNextcloudTalkAccount(params) {
|
|
|
178
306
|
function isNextcloudTalkAccountConfigured(account) {
|
|
179
307
|
return Boolean(account.tokenStatus !== "missing" && account.baseUrl?.trim());
|
|
180
308
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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();
|
|
185
313
|
}
|
|
186
|
-
const nextcloudTalkApprovalAuth = createChannelApprovalAuth({
|
|
187
|
-
channelLabel: "Nextcloud Talk",
|
|
188
|
-
resolveInputs: ({ cfg, accountId }) => {
|
|
189
|
-
return { allowFrom: resolveNextcloudTalkAccount({
|
|
190
|
-
cfg,
|
|
191
|
-
accountId
|
|
192
|
-
}).config.allowFrom };
|
|
193
|
-
},
|
|
194
|
-
normalizeApprover: normalizeNextcloudTalkApproverId
|
|
195
|
-
}).approvalAuth;
|
|
196
|
-
//#endregion
|
|
197
|
-
//#region extensions/nextcloud-talk/src/guarded-response.ts
|
|
198
314
|
async function readNextcloudTalkErrorBody(response, ...credentials) {
|
|
199
315
|
try {
|
|
200
316
|
let body = await readProviderTextResponse(response, "Nextcloud Talk error", {
|
|
@@ -207,46 +323,20 @@ async function readNextcloudTalkErrorBody(response, ...credentials) {
|
|
|
207
323
|
return "";
|
|
208
324
|
}
|
|
209
325
|
}
|
|
210
|
-
|
|
211
|
-
//#region extensions/nextcloud-talk/src/signature.ts
|
|
212
|
-
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
|
|
213
|
-
const RANDOM_HEADER = "x-nextcloud-talk-random";
|
|
214
|
-
const BACKEND_HEADER = "x-nextcloud-talk-backend";
|
|
215
|
-
/**
|
|
216
|
-
* Verify the HMAC-SHA256 signature of an incoming webhook request.
|
|
217
|
-
* Signature is calculated as: HMAC-SHA256(random + body, secret)
|
|
218
|
-
*/
|
|
219
|
-
function verifyNextcloudTalkSignature(params) {
|
|
220
|
-
const { signature, random, body, secret } = params;
|
|
221
|
-
if (!signature || !random || !secret) return false;
|
|
222
|
-
const expected = createHmac("sha256", secret).update(random + body).digest("hex");
|
|
223
|
-
return safeEqualSecret(signature, expected);
|
|
224
|
-
}
|
|
225
|
-
function extractNextcloudTalkHeaders(headers) {
|
|
226
|
-
const getHeader = (name) => {
|
|
227
|
-
const value = headers[name];
|
|
228
|
-
return Array.isArray(value) ? value[0] : value;
|
|
229
|
-
};
|
|
230
|
-
const signature = getHeader(SIGNATURE_HEADER);
|
|
231
|
-
const random = getHeader(RANDOM_HEADER);
|
|
232
|
-
const backend = getHeader(BACKEND_HEADER);
|
|
233
|
-
if (!signature || !random || !backend) return null;
|
|
234
|
-
return {
|
|
235
|
-
signature,
|
|
236
|
-
random,
|
|
237
|
-
backend
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
function generateNextcloudTalkSignature(params) {
|
|
241
|
-
const { body, secret } = params;
|
|
242
|
-
const random = randomBytes(32).toString("hex");
|
|
326
|
+
function buildNextcloudTalkOcsOptions(auth, allowPrivateNetwork) {
|
|
243
327
|
return {
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
246
337
|
};
|
|
247
338
|
}
|
|
248
|
-
|
|
249
|
-
//#region extensions/nextcloud-talk/src/bot-preflight.ts
|
|
339
|
+
// extensions/nextcloud-talk/src/bot-preflight.ts
|
|
250
340
|
const BOT_FEATURE_RESPONSE = 2;
|
|
251
341
|
function normalizeUrlForMatch(value) {
|
|
252
342
|
const trimmed = value?.trim() ?? "";
|
|
@@ -290,16 +380,8 @@ async function probeNextcloudTalkBotResponseFeature(params) {
|
|
|
290
380
|
try {
|
|
291
381
|
const { response, release } = await fetchWithSsrFGuard({
|
|
292
382
|
url,
|
|
293
|
-
|
|
294
|
-
method: "GET",
|
|
295
|
-
headers: {
|
|
296
|
-
Authorization: `Basic ${auth}`,
|
|
297
|
-
"OCS-APIRequest": "true",
|
|
298
|
-
Accept: "application/json"
|
|
299
|
-
}
|
|
300
|
-
},
|
|
383
|
+
...buildNextcloudTalkOcsOptions(auth, account.config.network?.dangerouslyAllowPrivateNetwork),
|
|
301
384
|
auditContext: "nextcloud-talk.bot-response-preflight",
|
|
302
|
-
policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config.network?.dangerouslyAllowPrivateNetwork),
|
|
303
385
|
timeoutMs
|
|
304
386
|
});
|
|
305
387
|
try {
|
|
@@ -347,49 +429,7 @@ async function probeNextcloudTalkBotResponseFeature(params) {
|
|
|
347
429
|
};
|
|
348
430
|
}
|
|
349
431
|
}
|
|
350
|
-
|
|
351
|
-
//#region extensions/nextcloud-talk/src/channel.adapters.ts
|
|
352
|
-
const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter({
|
|
353
|
-
sectionKey: "nextcloud-talk",
|
|
354
|
-
listAccountIds: listNextcloudTalkAccountIds,
|
|
355
|
-
resolveAccount: adaptScopedAccountAccessor(resolveNextcloudTalkAccount),
|
|
356
|
-
inspectAccount: (cfg, accountId) => {
|
|
357
|
-
const account = inspectNextcloudTalkAccount({
|
|
358
|
-
cfg,
|
|
359
|
-
accountId
|
|
360
|
-
});
|
|
361
|
-
return {
|
|
362
|
-
...account,
|
|
363
|
-
baseUrl: account.baseUrl ? "[set]" : "[missing]"
|
|
364
|
-
};
|
|
365
|
-
},
|
|
366
|
-
defaultAccountId: resolveDefaultNextcloudTalkAccountId,
|
|
367
|
-
clearBaseFields: [
|
|
368
|
-
"botSecret",
|
|
369
|
-
"botSecretFile",
|
|
370
|
-
"baseUrl",
|
|
371
|
-
"name"
|
|
372
|
-
],
|
|
373
|
-
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
374
|
-
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({
|
|
375
|
-
allowFrom,
|
|
376
|
-
stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i
|
|
377
|
-
})
|
|
378
|
-
});
|
|
379
|
-
const nextcloudTalkSecurityAdapter = { resolveDmPolicy: createScopedDmSecurityResolver({
|
|
380
|
-
channelKey: "nextcloud-talk",
|
|
381
|
-
resolvePolicy: (account) => account.config.dmPolicy,
|
|
382
|
-
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
383
|
-
policyPathSuffix: "dmPolicy",
|
|
384
|
-
normalizeEntry: (raw) => normalizeLowercaseStringOrEmpty(raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
|
|
385
|
-
}) };
|
|
386
|
-
const nextcloudTalkPairingTextAdapter = {
|
|
387
|
-
idLabel: "nextcloudUserId",
|
|
388
|
-
message: "OpenClaw: your access has been approved.",
|
|
389
|
-
normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => normalizeLowercaseStringOrEmpty(entry))
|
|
390
|
-
};
|
|
391
|
-
//#endregion
|
|
392
|
-
//#region extensions/nextcloud-talk/src/config-schema.ts
|
|
432
|
+
// extensions/nextcloud-talk/src/config-schema.ts
|
|
393
433
|
const NextcloudTalkRoomSchema = buildGroupEntrySchema({ allowFrom: z.array(z.string()).optional() }).omit({ toolsBySender: true });
|
|
394
434
|
const NextcloudTalkNetworkSchema = z.object({
|
|
395
435
|
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
|
@@ -414,6 +454,7 @@ const NextcloudTalkAccountSchemaBase = z.object({
|
|
|
414
454
|
webhookPath: z.string().optional(),
|
|
415
455
|
webhookPublicUrl: z.string().optional(),
|
|
416
456
|
allowFrom: z.array(z.string()).optional(),
|
|
457
|
+
mediaAllowFrom: z.array(z.string()).optional(),
|
|
417
458
|
groupAllowFrom: z.array(z.string()).optional(),
|
|
418
459
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
419
460
|
rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
|
|
@@ -433,161 +474,37 @@ const NextcloudTalkConfigSchema = buildMultiAccountChannelSchema(NextcloudTalkAc
|
|
|
433
474
|
});
|
|
434
475
|
}
|
|
435
476
|
});
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
["/startupz", "startup"]
|
|
445
|
-
]);
|
|
446
|
-
function classifyGatewayProbePath(pathname) {
|
|
447
|
-
for (const [root, status] of GATEWAY_PROBE_ROUTES) {
|
|
448
|
-
if (pathname === root) return status;
|
|
449
|
-
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."
|
|
450
485
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
/** Trims string input and returns undefined for non-strings or empty strings. */
|
|
462
|
-
function normalizeOptionalString$1(value) {
|
|
463
|
-
return normalizeNullableString(value) ?? void 0;
|
|
464
|
-
}
|
|
465
|
-
/** Lowercases a normalized optional string. */
|
|
466
|
-
function normalizeOptionalLowercaseString$1(value) {
|
|
467
|
-
return normalizeOptionalString$1(value)?.toLowerCase();
|
|
468
|
-
}
|
|
469
|
-
/** Lowercases a normalized string or returns an empty string when absent. */
|
|
470
|
-
function normalizeLowercaseStringOrEmpty$1(value) {
|
|
471
|
-
return normalizeOptionalLowercaseString$1(value) ?? "";
|
|
472
|
-
}
|
|
473
|
-
//#endregion
|
|
474
|
-
//#region src/plugins/http-path-prefix.ts
|
|
475
|
-
function prefixMatchPath(pathname, prefix) {
|
|
476
|
-
return pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`);
|
|
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;
|
|
477
495
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
return collapsed.replace(/\/+$/, "");
|
|
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}.`;
|
|
485
502
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
let decoded = pathname;
|
|
492
|
-
let malformedEncoding = false;
|
|
493
|
-
let decodePasses = 0;
|
|
494
|
-
for (let pass = 0; pass < MAX_PATH_DECODE_PASSES; pass++) {
|
|
495
|
-
let nextDecoded;
|
|
496
|
-
try {
|
|
497
|
-
nextDecoded = decodeURIComponent(decoded);
|
|
498
|
-
} catch {
|
|
499
|
-
malformedEncoding = true;
|
|
500
|
-
break;
|
|
501
|
-
}
|
|
502
|
-
if (nextDecoded === decoded) break;
|
|
503
|
-
decodePasses += 1;
|
|
504
|
-
decoded = nextDecoded;
|
|
505
|
-
candidates.add(normalizePathForSecurity(decoded));
|
|
506
|
-
}
|
|
507
|
-
let decodePassLimitReached = false;
|
|
508
|
-
if (!malformedEncoding) try {
|
|
509
|
-
decodePassLimitReached = decodeURIComponent(decoded) !== decoded;
|
|
510
|
-
} catch {
|
|
511
|
-
malformedEncoding = true;
|
|
512
|
-
}
|
|
513
|
-
return {
|
|
514
|
-
candidates: [...candidates],
|
|
515
|
-
decodePasses,
|
|
516
|
-
decodePassLimitReached,
|
|
517
|
-
malformedEncoding
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
function canonicalizePathForSecurity(pathname) {
|
|
521
|
-
const { candidates, decodePasses, decodePassLimitReached, malformedEncoding } = buildCanonicalPathCandidates(pathname);
|
|
522
|
-
return {
|
|
523
|
-
canonicalPath: candidates[candidates.length - 1] ?? "/",
|
|
524
|
-
candidates,
|
|
525
|
-
decodePasses,
|
|
526
|
-
decodePassLimitReached,
|
|
527
|
-
malformedEncoding,
|
|
528
|
-
rawNormalizedPath: normalizePathSeparators(normalizeLowercaseStringOrEmpty$1(pathname)) || "/"
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
const PROTECTED_PLUGIN_ROUTE_PREFIXES = ["/api/channels"];
|
|
532
|
-
//#endregion
|
|
533
|
-
//#region src/gateway/server/plugins-http/path-context.ts
|
|
534
|
-
function normalizeProtectedPrefix(prefix) {
|
|
535
|
-
const collapsed = normalizeLowercaseStringOrEmpty$1(prefix).replace(/\/{2,}/g, "/");
|
|
536
|
-
if (collapsed.length <= 1) return collapsed || "/";
|
|
537
|
-
return collapsed.replace(/\/+$/, "");
|
|
538
|
-
}
|
|
539
|
-
const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES = PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix);
|
|
540
|
-
/** Returns true when any decoded path candidate targets a protected route. */
|
|
541
|
-
function isProtectedPluginRoutePathFromContext(context) {
|
|
542
|
-
if (context.candidates.some((candidate) => NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatchPath(candidate, prefix)))) return true;
|
|
543
|
-
if (context.decodePassLimitReached) return true;
|
|
544
|
-
if (!context.malformedEncoding) return false;
|
|
545
|
-
return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatchPath(context.rawNormalizedPath, prefix));
|
|
546
|
-
}
|
|
547
|
-
/** Builds all security-relevant decoded path candidates for a request path. */
|
|
548
|
-
function resolvePluginRoutePathContext(pathname) {
|
|
549
|
-
const canonical = canonicalizePathForSecurity(pathname);
|
|
550
|
-
return {
|
|
551
|
-
pathname,
|
|
552
|
-
canonicalPath: canonical.canonicalPath,
|
|
553
|
-
candidates: canonical.candidates,
|
|
554
|
-
malformedEncoding: canonical.malformedEncoding,
|
|
555
|
-
decodePassLimitReached: canonical.decodePassLimitReached,
|
|
556
|
-
rawNormalizedPath: canonical.rawNormalizedPath
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
//#endregion
|
|
560
|
-
//#region \0openclaw:bundled-sdk:openclaw/plugin-sdk/gateway-config-runtime
|
|
561
|
-
var gateway_config_runtime_exports = /* @__PURE__ */ __exportAll({
|
|
562
|
-
classifyGatewayProbePath: () => classifyGatewayProbePath,
|
|
563
|
-
isProtectedPluginRoutePathFromContext: () => isProtectedPluginRoutePathFromContext,
|
|
564
|
-
resolvePluginRoutePathContext: () => resolvePluginRoutePathContext
|
|
565
|
-
});
|
|
566
|
-
import * as import_openclaw_plugin_sdk_gateway_config_runtime from "openclaw/plugin-sdk/gateway-config-runtime";
|
|
567
|
-
__reExport(gateway_config_runtime_exports, import_openclaw_plugin_sdk_gateway_config_runtime);
|
|
568
|
-
//#endregion
|
|
569
|
-
//#region extensions/nextcloud-talk/src/webhook-route.ts
|
|
570
|
-
const DEFAULT_NEXTCLOUD_TALK_WEBHOOK_PATH = "/nextcloud-talk-webhook";
|
|
571
|
-
function resolveNextcloudTalkLegacyWebhook(config) {
|
|
572
|
-
const listener = config.legacyWebhook;
|
|
573
|
-
return listener ? {
|
|
574
|
-
port: listener.port,
|
|
575
|
-
host: listener.host ?? "0.0.0.0"
|
|
576
|
-
} : void 0;
|
|
577
|
-
}
|
|
578
|
-
function describeNextcloudTalkWebhookRouteConflict(path, gatewayPort) {
|
|
579
|
-
const pathname = URL.parse(path, "http://localhost")?.pathname ?? path;
|
|
580
|
-
const probe = classifyGatewayProbePath(pathname);
|
|
581
|
-
const reason = probe !== "outside" && probe !== "namespace" ? "is reserved for Gateway checks" : isProtectedPluginRoutePathFromContext(resolvePluginRoutePathContext(pathname)) ? "requires Gateway authentication" : void 0;
|
|
582
|
-
if (!reason) return;
|
|
583
|
-
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}.`;
|
|
584
|
-
}
|
|
585
|
-
//#endregion
|
|
586
|
-
//#region extensions/nextcloud-talk/src/doctor.ts
|
|
587
|
-
function sanitizeLegacyReplaySegment(value) {
|
|
588
|
-
const trimmed = value.trim();
|
|
589
|
-
if (!trimmed) return "default";
|
|
590
|
-
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, "_");
|
|
591
508
|
}
|
|
592
509
|
function runNextcloudTalkDoctorSequence(params) {
|
|
593
510
|
const infoNotes = [];
|
|
@@ -646,10 +563,10 @@ const nextcloudTalkDoctor = {
|
|
|
646
563
|
};
|
|
647
564
|
}
|
|
648
565
|
};
|
|
649
|
-
|
|
650
|
-
//#region extensions/nextcloud-talk/src/policy.ts
|
|
566
|
+
// extensions/nextcloud-talk/src/policy.ts
|
|
651
567
|
function normalizeNextcloudTalkAllowEntry(raw) {
|
|
652
|
-
|
|
568
|
+
const normalized = raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, "").trim().toLowerCase();
|
|
569
|
+
return normalized && normalized !== "*" && !normalized.includes("/") ? `users/${normalized}` : normalized;
|
|
653
570
|
}
|
|
654
571
|
function normalizeNextcloudTalkAllowlist(values) {
|
|
655
572
|
return (values ?? []).map((value) => normalizeNextcloudTalkAllowEntry(String(value))).filter(Boolean);
|
|
@@ -668,20 +585,15 @@ function resolveNextcloudTalkAllowlistMatch(params) {
|
|
|
668
585
|
function resolveNextcloudTalkRoomMatch(params) {
|
|
669
586
|
const rooms = params.rooms ?? {};
|
|
670
587
|
const allowlistConfigured = Object.keys(rooms).length > 0;
|
|
671
|
-
const
|
|
588
|
+
const roomConfig = resolveChannelEntryMatchWithFallback({
|
|
672
589
|
entries: rooms,
|
|
673
590
|
keys: buildChannelKeyCandidates(params.roomToken),
|
|
674
591
|
wildcardKey: "*",
|
|
675
592
|
normalizeKey: normalizeChannelSlug
|
|
676
|
-
});
|
|
677
|
-
const roomConfig = match.entry;
|
|
678
|
-
const allowed = !allowlistConfigured || Boolean(roomConfig);
|
|
593
|
+
}).entry;
|
|
679
594
|
return {
|
|
680
595
|
roomConfig,
|
|
681
|
-
|
|
682
|
-
roomKey: match.matchKey ?? match.key,
|
|
683
|
-
matchSource: match.matchSource,
|
|
684
|
-
allowed,
|
|
596
|
+
allowed: !allowlistConfigured || Boolean(roomConfig),
|
|
685
597
|
allowlistConfigured
|
|
686
598
|
};
|
|
687
599
|
}
|
|
@@ -726,8 +638,334 @@ function resolveNextcloudTalkGroupRequireMention(params) {
|
|
|
726
638
|
path: exactPath
|
|
727
639
|
});
|
|
728
640
|
}
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
}
|
|
968
|
+
// extensions/nextcloud-talk/src/room-info.ts
|
|
731
969
|
const ROOM_CACHE_TTL_MS = 3e5;
|
|
732
970
|
const ROOM_CACHE_ERROR_TTL_MS = 3e4;
|
|
733
971
|
const ROOM_CACHE_MAX_ENTRIES = 1e3;
|
|
@@ -748,10 +986,6 @@ function resolveRoomKindFromType(type) {
|
|
|
748
986
|
}
|
|
749
987
|
async function resolveNextcloudTalkRoomKind(params) {
|
|
750
988
|
const { account, roomToken, runtime } = params;
|
|
751
|
-
const key = `${account.accountId}:${roomToken}`;
|
|
752
|
-
const cached = roomCache.get(key);
|
|
753
|
-
if (cached && Date.now() < cached.expiresAt) return cached.kind;
|
|
754
|
-
roomCache.delete(key);
|
|
755
989
|
const apiCredentials = resolveNextcloudTalkApiCredentials({
|
|
756
990
|
apiUser: account.config.apiUser,
|
|
757
991
|
apiPassword: account.config.apiPassword,
|
|
@@ -759,6 +993,17 @@ async function resolveNextcloudTalkRoomKind(params) {
|
|
|
759
993
|
});
|
|
760
994
|
const baseUrl = account.baseUrl?.trim();
|
|
761
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);
|
|
762
1007
|
const fallback = (error) => {
|
|
763
1008
|
runtime?.error?.(`nextcloud-talk: room lookup error: ${String(error)}`);
|
|
764
1009
|
return cacheRoomInfo(key, void 0, ROOM_CACHE_ERROR_TTL_MS);
|
|
@@ -770,16 +1015,8 @@ async function resolveNextcloudTalkRoomKind(params) {
|
|
|
770
1015
|
try {
|
|
771
1016
|
guarded = await fetchWithSsrFGuard({
|
|
772
1017
|
url: `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`,
|
|
773
|
-
|
|
774
|
-
method: "GET",
|
|
775
|
-
headers: {
|
|
776
|
-
Authorization: `Basic ${auth}`,
|
|
777
|
-
"OCS-APIRequest": "true",
|
|
778
|
-
Accept: "application/json"
|
|
779
|
-
}
|
|
780
|
-
},
|
|
1018
|
+
...buildNextcloudTalkOcsOptions(auth, account.config.network?.dangerouslyAllowPrivateNetwork),
|
|
781
1019
|
auditContext: "nextcloud-talk.room-info",
|
|
782
|
-
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config.network?.dangerouslyAllowPrivateNetwork),
|
|
783
1020
|
timeoutMs: params.timeoutMs ?? NEXTCLOUD_TALK_ROOM_INFO_TIMEOUT_MS
|
|
784
1021
|
});
|
|
785
1022
|
} catch (error) {
|
|
@@ -805,8 +1042,7 @@ async function resolveNextcloudTalkRoomKind(params) {
|
|
|
805
1042
|
await release();
|
|
806
1043
|
}
|
|
807
1044
|
}
|
|
808
|
-
|
|
809
|
-
//#region extensions/nextcloud-talk/src/normalize.ts
|
|
1045
|
+
// extensions/nextcloud-talk/src/normalize.ts
|
|
810
1046
|
function stripNextcloudTalkTargetPrefix(raw) {
|
|
811
1047
|
return raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, "").trim().replace(/^room:/i, "").trim() || void 0;
|
|
812
1048
|
}
|
|
@@ -816,12 +1052,47 @@ function normalizeNextcloudTalkMessagingTarget(raw) {
|
|
|
816
1052
|
}
|
|
817
1053
|
function looksLikeNextcloudTalkTargetId(raw) {
|
|
818
1054
|
const trimmed = raw.trim();
|
|
819
|
-
if (!trimmed) return false;
|
|
820
1055
|
if (/^(nextcloud-talk|nc-talk|nc|room):/i.test(trimmed)) return true;
|
|
821
1056
|
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
|
822
1057
|
}
|
|
823
|
-
|
|
824
|
-
|
|
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
|
+
}
|
|
1095
|
+
// extensions/nextcloud-talk/src/send.ts
|
|
825
1096
|
const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS = 200;
|
|
826
1097
|
const NEXTCLOUD_TALK_SEND_TIMEOUT_MS = 3e4;
|
|
827
1098
|
/** Collapses and caps an already-redacted error body for display. */
|
|
@@ -837,7 +1108,7 @@ function resolveNextcloudTalkSendContext(opts) {
|
|
|
837
1108
|
accountId: opts.accountId
|
|
838
1109
|
});
|
|
839
1110
|
const baseUrl = account.baseUrl;
|
|
840
|
-
const secret =
|
|
1111
|
+
const secret = account.secret;
|
|
841
1112
|
if (!baseUrl) throw new Error(`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`);
|
|
842
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).`);
|
|
843
1114
|
return {
|
|
@@ -852,8 +1123,36 @@ function normalizeRoomToken(to) {
|
|
|
852
1123
|
if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends");
|
|
853
1124
|
return normalized;
|
|
854
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
|
+
}
|
|
855
1153
|
async function sendMessageNextcloudTalk(to, text, opts) {
|
|
856
|
-
const
|
|
1154
|
+
const context = resolveNextcloudTalkSendContext(opts);
|
|
1155
|
+
const { cfg, account } = context;
|
|
857
1156
|
const roomToken = normalizeRoomToken(to);
|
|
858
1157
|
if (!text?.trim()) throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
|
859
1158
|
const tableMode = resolveMarkdownTableMode({
|
|
@@ -862,32 +1161,18 @@ async function sendMessageNextcloudTalk(to, text, opts) {
|
|
|
862
1161
|
accountId: account.accountId
|
|
863
1162
|
});
|
|
864
1163
|
const message = convertMarkdownTables(text.trim(), tableMode);
|
|
865
|
-
const body = JSON.stringify({
|
|
866
|
-
message,
|
|
867
|
-
replyTo: opts.replyTo || void 0
|
|
868
|
-
});
|
|
869
|
-
const { random, signature } = generateNextcloudTalkSignature({
|
|
870
|
-
body: message,
|
|
871
|
-
secret
|
|
872
|
-
});
|
|
873
|
-
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`;
|
|
874
1164
|
await opts.onPlatformSendDispatch?.();
|
|
875
|
-
const { response, release } = await
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
"OCS-APIRequest": "true",
|
|
883
|
-
"X-Nextcloud-Talk-Bot-Random": random,
|
|
884
|
-
"X-Nextcloud-Talk-Bot-Signature": signature
|
|
885
|
-
},
|
|
886
|
-
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 } : {}
|
|
887
1172
|
},
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1173
|
+
signedValue: message,
|
|
1174
|
+
beforeRequest: opts.assertDirectAdapterHandoff,
|
|
1175
|
+
auditContext: "nextcloud-talk-send"
|
|
891
1176
|
});
|
|
892
1177
|
try {
|
|
893
1178
|
if (!response.ok) {
|
|
@@ -933,58 +1218,178 @@ async function sendMessageNextcloudTalk(to, text, opts) {
|
|
|
933
1218
|
}
|
|
934
1219
|
}
|
|
935
1220
|
async function sendReactionNextcloudTalk(roomToken, messageId, reaction, opts) {
|
|
936
|
-
const {
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
});
|
|
943
|
-
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`;
|
|
944
|
-
const { response, release } = await fetchWithSsrFGuard({
|
|
945
|
-
url,
|
|
946
|
-
init: {
|
|
947
|
-
method: "POST",
|
|
948
|
-
headers: {
|
|
949
|
-
"Content-Type": "application/json",
|
|
950
|
-
"OCS-APIRequest": "true",
|
|
951
|
-
"X-Nextcloud-Talk-Bot-Random": random,
|
|
952
|
-
"X-Nextcloud-Talk-Bot-Signature": signature
|
|
953
|
-
},
|
|
954
|
-
body
|
|
955
|
-
},
|
|
956
|
-
auditContext: "nextcloud-talk-reaction",
|
|
957
|
-
policy: ssrfPolicyFromPrivateNetworkOptIn$1(account.config.network?.dangerouslyAllowPrivateNetwork),
|
|
958
|
-
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"
|
|
959
1227
|
});
|
|
960
1228
|
try {
|
|
961
1229
|
if (!response.ok) {
|
|
962
1230
|
const errorBody = collapseErrorSnippet(await readNextcloudTalkErrorBody(response, signature));
|
|
963
1231
|
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
|
|
964
1232
|
}
|
|
965
|
-
return { ok: true };
|
|
966
1233
|
} finally {
|
|
967
1234
|
await release();
|
|
968
1235
|
}
|
|
969
1236
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
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
|
+
};
|
|
975
1275
|
}
|
|
976
|
-
async function
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
+
}
|
|
1373
|
+
// extensions/nextcloud-talk/src/inbound.ts
|
|
1374
|
+
const CHANNEL_ID = "nextcloud-talk";
|
|
1375
|
+
function hasAllowEntries(entries) {
|
|
1376
|
+
return normalizeNextcloudTalkAllowlist(entries).length > 0;
|
|
1377
|
+
}
|
|
1378
|
+
async function handleNextcloudTalkInbound(params) {
|
|
1379
|
+
const { message, account, config, runtime, statusSink } = params;
|
|
1380
|
+
const ingressAbortSignal = params.turnAdoptionLifecycle?.abortSignal;
|
|
1381
|
+
ingressAbortSignal?.throwIfAborted();
|
|
1382
|
+
const core = getNextcloudTalkRuntime();
|
|
1383
|
+
const pairing = createChannelPairingController({
|
|
1384
|
+
core,
|
|
1385
|
+
channel: CHANNEL_ID,
|
|
1386
|
+
accountId: account.accountId
|
|
1387
|
+
});
|
|
1388
|
+
const rawBody = message.text?.trim() ?? "";
|
|
1389
|
+
const hasInboundMedia = Boolean(message.attachment || message.attachmentIssue);
|
|
1390
|
+
if (!rawBody && !hasInboundMedia) {
|
|
1391
|
+
logInboundDrop({
|
|
1392
|
+
log: (messageLocal) => runtime.log?.(messageLocal),
|
|
988
1393
|
channel: CHANNEL_ID,
|
|
989
1394
|
reason: `empty message body (mediaType=${message.mediaType})`,
|
|
990
1395
|
target: message.senderId
|
|
@@ -997,10 +1402,7 @@ async function handleNextcloudTalkInbound(params) {
|
|
|
997
1402
|
runtime
|
|
998
1403
|
});
|
|
999
1404
|
const isGroup = roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
|
|
1000
|
-
const senderId = message
|
|
1001
|
-
const senderName = message.senderName;
|
|
1002
|
-
const roomToken = message.roomToken;
|
|
1003
|
-
const roomName = message.roomName;
|
|
1405
|
+
const { senderId, senderName, roomToken, roomName } = message;
|
|
1004
1406
|
statusSink?.({ lastInboundAt: message.timestamp });
|
|
1005
1407
|
const roomMatch = resolveNextcloudTalkRoomMatch({
|
|
1006
1408
|
rooms: account.config.rooms,
|
|
@@ -1094,6 +1496,9 @@ async function handleNextcloudTalkInbound(params) {
|
|
|
1094
1496
|
}
|
|
1095
1497
|
});
|
|
1096
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
|
+
};
|
|
1097
1502
|
warnMissingProviderGroupPolicyFallbackOnce({
|
|
1098
1503
|
providerMissingFallbackApplied,
|
|
1099
1504
|
providerKey: "nextcloud-talk",
|
|
@@ -1101,8 +1506,18 @@ async function handleNextcloudTalkInbound(params) {
|
|
|
1101
1506
|
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
|
|
1102
1507
|
log: (messageValue) => runtime.log?.(messageValue)
|
|
1103
1508
|
});
|
|
1104
|
-
const commandAuthorized = access.commandAccess.authorized;
|
|
1105
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
|
+
};
|
|
1106
1521
|
if (isGroup) {
|
|
1107
1522
|
if (access.routeAccess.reason === "room_not_allowlisted") {
|
|
1108
1523
|
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
|
|
@@ -1121,21 +1536,25 @@ async function handleNextcloudTalkInbound(params) {
|
|
|
1121
1536
|
return;
|
|
1122
1537
|
}
|
|
1123
1538
|
} else if (access.senderAccess.decision !== "allow") {
|
|
1124
|
-
if (access.senderAccess.decision === "pairing")
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
+
}
|
|
1139
1558
|
runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${accessReason})`);
|
|
1140
1559
|
return;
|
|
1141
1560
|
}
|
|
@@ -1149,7 +1568,7 @@ async function handleNextcloudTalkInbound(params) {
|
|
|
1149
1568
|
return;
|
|
1150
1569
|
}
|
|
1151
1570
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config);
|
|
1152
|
-
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;
|
|
1153
1572
|
const route = resolveAgentRoute({
|
|
1154
1573
|
cfg: config,
|
|
1155
1574
|
channel: CHANNEL_ID,
|
|
@@ -1159,114 +1578,375 @@ async function handleNextcloudTalkInbound(params) {
|
|
|
1159
1578
|
id: isGroup ? roomToken : senderId
|
|
1160
1579
|
}
|
|
1161
1580
|
});
|
|
1162
|
-
|
|
1581
|
+
const contextBinding = {
|
|
1163
1582
|
agentId: route.agentId,
|
|
1164
1583
|
sessionKey: route.sessionKey,
|
|
1165
1584
|
messageId: message.messageId,
|
|
1166
1585
|
inboundEventKind: "user_request"
|
|
1586
|
+
};
|
|
1587
|
+
const buildEnvelope = await createChannelInboundEnvelopeBuilderAsync({
|
|
1588
|
+
cfg: config,
|
|
1589
|
+
route
|
|
1167
1590
|
});
|
|
1591
|
+
ingressAbortSignal?.throwIfAborted();
|
|
1592
|
+
access = await resolveAccess(isGroup ? wasMentioned : void 0, contextBinding);
|
|
1168
1593
|
if (access.ingress.admission !== "dispatch") {
|
|
1169
|
-
|
|
1594
|
+
logAuthorizationChanged();
|
|
1170
1595
|
return;
|
|
1171
1596
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
|
1183
|
-
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
|
1184
|
-
const ctxPayload = core.channel.inbound.buildContext({
|
|
1185
|
-
channelIngress: access,
|
|
1186
|
-
channel: CHANNEL_ID,
|
|
1187
|
-
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,
|
|
1188
1607
|
messageId: message.messageId,
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
agentId: route.agentId,
|
|
1202
|
-
dmScope: route.dmScope,
|
|
1203
|
-
accountId: route.accountId,
|
|
1204
|
-
routeSessionKey: route.sessionKey
|
|
1205
|
-
},
|
|
1206
|
-
reply: {
|
|
1207
|
-
to: `nextcloud-talk:${roomToken}`,
|
|
1208
|
-
originatingTo: `nextcloud-talk:${roomToken}`
|
|
1209
|
-
},
|
|
1210
|
-
message: {
|
|
1211
|
-
body,
|
|
1212
|
-
bodyForAgent: rawBody,
|
|
1213
|
-
rawBody,
|
|
1214
|
-
commandBody
|
|
1215
|
-
},
|
|
1216
|
-
access: {
|
|
1217
|
-
commands: { authorized: commandAuthorized },
|
|
1218
|
-
mentions: {
|
|
1219
|
-
canDetectMention: isGroup,
|
|
1220
|
-
wasMentioned: isGroup && wasMentioned
|
|
1221
|
-
}
|
|
1222
|
-
},
|
|
1223
|
-
extra: {
|
|
1224
|
-
GroupSubject: isGroup ? roomName || roomToken : void 0,
|
|
1225
|
-
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");
|
|
1226
1620
|
}
|
|
1621
|
+
};
|
|
1622
|
+
const throwIfIngressAborted = () => ingressAbortSignal?.throwIfAborted();
|
|
1623
|
+
const mediaSenderAllowed = hasInboundMedia && isNextcloudTalkMediaSenderAllowed({
|
|
1624
|
+
mediaAllowFrom: account.config.mediaAllowFrom,
|
|
1625
|
+
senderId
|
|
1227
1626
|
});
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
+
}
|
|
1252
1726
|
}
|
|
1253
|
-
}
|
|
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
|
|
1803
|
+
},
|
|
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
|
|
1254
1814
|
},
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
|
1257
1844
|
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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());
|
|
1269
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
|
+
};
|
|
1270
1950
|
var NextcloudTalkWebhookPayloadError = class extends Error {
|
|
1271
1951
|
constructor(message, options) {
|
|
1272
1952
|
super(message, options);
|
|
@@ -1287,9 +1967,91 @@ function requiredString(value, field) {
|
|
|
1287
1967
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
1288
1968
|
throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk webhook is missing ${field}.`);
|
|
1289
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
|
+
}
|
|
1290
2050
|
function inspectNextcloudTalkWebhookEnvelope(rawEvent) {
|
|
1291
2051
|
const envelope = parseRawObject(rawEvent);
|
|
1292
|
-
|
|
2052
|
+
const isCreateMessage = envelope.type === "Create";
|
|
2053
|
+
const isFileShare = parseNextcloudTalkFileSharedActivity(envelope) !== null;
|
|
2054
|
+
if (!isCreateMessage && !isFileShare) return null;
|
|
1293
2055
|
const object = isRecord$1(envelope.object) ? envelope.object : null;
|
|
1294
2056
|
if (object?.type !== void 0 && object.type !== "Note") return null;
|
|
1295
2057
|
if (!object) throw new NextcloudTalkWebhookPayloadError("Nextcloud Talk webhook is missing object.");
|
|
@@ -1311,7 +2073,6 @@ function parseLegacyReplayKey(key) {
|
|
|
1311
2073
|
/** Convert the shipped replay guard's live window into durable completion tombstones. */
|
|
1312
2074
|
async function migrateNextcloudTalkLegacyReplayState(params) {
|
|
1313
2075
|
const entries = await params.store.entries();
|
|
1314
|
-
let migrated = 0;
|
|
1315
2076
|
for (const entry of entries) {
|
|
1316
2077
|
const identity = parseLegacyReplayKey(entry.value.key);
|
|
1317
2078
|
if (!identity || !Number.isFinite(entry.value.seenAt)) continue;
|
|
@@ -1327,13 +2088,10 @@ async function migrateNextcloudTalkLegacyReplayState(params) {
|
|
|
1327
2088
|
if (result.kind === "accepted" || result.kind === "pending" && result.record.payload.rawEvent === "") {
|
|
1328
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}.`);
|
|
1329
2090
|
}
|
|
1330
|
-
migrated += 1;
|
|
1331
2091
|
}
|
|
1332
2092
|
await params.store.clear();
|
|
1333
|
-
return migrated;
|
|
1334
2093
|
}
|
|
1335
|
-
|
|
1336
|
-
//#region extensions/nextcloud-talk/src/monitor.ts
|
|
2094
|
+
// extensions/nextcloud-talk/src/monitor.ts
|
|
1337
2095
|
const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 65536;
|
|
1338
2096
|
const NEXTCLOUD_TALK_WEBHOOK_ACCEPTED_HEADER = "x-openclaw-delivery-accepted";
|
|
1339
2097
|
const NEXTCLOUD_TALK_WEBHOOK_ACCEPTED_VALUE = "durable";
|
|
@@ -1343,12 +2101,7 @@ const WEBHOOK_IN_FLIGHT_KEY = "nextcloud-talk-webhook";
|
|
|
1343
2101
|
const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
|
|
1344
2102
|
function writeWebhookError(res, status, error) {
|
|
1345
2103
|
if (res.headersSent) return;
|
|
1346
|
-
res
|
|
1347
|
-
res.end(JSON.stringify({ error }));
|
|
1348
|
-
}
|
|
1349
|
-
async function rejectWebhookRequest(req, res, status, error) {
|
|
1350
|
-
if (res.headersSent) return;
|
|
1351
|
-
await sendHttpRequestRejection(req, res, status, JSON.stringify({ error }), "application/json");
|
|
2104
|
+
writeJsonWithoutCharset(res, status, { error });
|
|
1352
2105
|
}
|
|
1353
2106
|
function createWebhookGuards() {
|
|
1354
2107
|
return {
|
|
@@ -1460,12 +2213,8 @@ async function handleWebhookRequest(req, res, targetsByPath, path) {
|
|
|
1460
2213
|
}
|
|
1461
2214
|
webhookAuthRateLimiter.reset(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
|
|
1462
2215
|
} catch (err) {
|
|
1463
|
-
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
1464
|
-
await
|
|
1465
|
-
return;
|
|
1466
|
-
}
|
|
1467
|
-
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
1468
|
-
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");
|
|
1469
2218
|
return;
|
|
1470
2219
|
}
|
|
1471
2220
|
const error = err instanceof Error ? err : new Error(formatErrorMessage(err));
|
|
@@ -1527,433 +2276,35 @@ function registerNextcloudTalkWebhook(target) {
|
|
|
1527
2276
|
handler: (req, res) => handleWebhookRequest(req, res, targets, path),
|
|
1528
2277
|
legacyListener: target.legacyListener ? {
|
|
1529
2278
|
...target.legacyListener,
|
|
1530
|
-
health: {
|
|
1531
|
-
path: "/healthz",
|
|
1532
|
-
contentType: "text/plain"
|
|
1533
|
-
}
|
|
1534
|
-
} : void 0,
|
|
1535
|
-
reuseExistingSameOwner: true,
|
|
1536
|
-
throwOnFailure: true
|
|
1537
|
-
});
|
|
1538
|
-
let stopPromise;
|
|
1539
|
-
return () => {
|
|
1540
|
-
if (stopPromise) return stopPromise;
|
|
1541
|
-
registration.target.stopping = true;
|
|
1542
|
-
const release = () => {
|
|
1543
|
-
removeTarget();
|
|
1544
|
-
unregister();
|
|
1545
|
-
};
|
|
1546
|
-
const pending = registration.target.pendingResponses;
|
|
1547
|
-
if (pending.size === 0) {
|
|
1548
|
-
release();
|
|
1549
|
-
stopPromise = Promise.resolve();
|
|
1550
|
-
} else stopPromise = Promise.all(pending).then(release);
|
|
1551
|
-
return stopPromise;
|
|
1552
|
-
};
|
|
1553
|
-
} catch (error) {
|
|
1554
|
-
removeTarget();
|
|
1555
|
-
throw error;
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
//#endregion
|
|
1559
|
-
//#region extensions/nextcloud-talk/src/webhook-spool.ts
|
|
1560
|
-
const NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS = 500;
|
|
1561
|
-
function describeIgnoredWebhookEvent(rawEvent) {
|
|
1562
|
-
const envelope = parseRawObject(rawEvent);
|
|
1563
|
-
const type = typeof envelope.type === "string" ? envelope.type : "unknown";
|
|
1564
|
-
const object = isRecord$1(envelope.object) ? envelope.object : null;
|
|
1565
|
-
return `type=${type} objectType=${typeof object?.type === "string" ? object.type : "unknown"}`;
|
|
1566
|
-
}
|
|
1567
|
-
const NextcloudTalkWebhookPayloadSchema = z.object({
|
|
1568
|
-
type: z.enum([
|
|
1569
|
-
"Create",
|
|
1570
|
-
"Update",
|
|
1571
|
-
"Delete"
|
|
1572
|
-
]),
|
|
1573
|
-
actor: z.object({
|
|
1574
|
-
type: z.literal("Person"),
|
|
1575
|
-
id: z.string().min(1),
|
|
1576
|
-
name: z.string()
|
|
1577
|
-
}),
|
|
1578
|
-
object: z.object({
|
|
1579
|
-
type: z.literal("Note"),
|
|
1580
|
-
id: z.string().min(1),
|
|
1581
|
-
name: z.string(),
|
|
1582
|
-
content: z.string(),
|
|
1583
|
-
mediaType: z.string()
|
|
1584
|
-
}),
|
|
1585
|
-
target: z.object({
|
|
1586
|
-
type: z.literal("Collection"),
|
|
1587
|
-
id: z.string().min(1),
|
|
1588
|
-
name: z.string()
|
|
1589
|
-
})
|
|
1590
|
-
});
|
|
1591
|
-
function parseClaimedMessage(payload, claimedId) {
|
|
1592
|
-
const result = NextcloudTalkWebhookPayloadSchema.safeParse(parseRawObject(payload.rawEvent));
|
|
1593
|
-
if (!result.success || result.data.type !== "Create" || result.data.object.id !== claimedId) throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk ingress row ${claimedId} has invalid message identity.`);
|
|
1594
|
-
const webhook = result.data;
|
|
1595
|
-
const roomId = requiredString(webhook.target.id, "target.id");
|
|
1596
|
-
return {
|
|
1597
|
-
messageId: webhook.object.id,
|
|
1598
|
-
roomToken: roomId,
|
|
1599
|
-
roomName: webhook.target.name,
|
|
1600
|
-
senderId: webhook.actor.id,
|
|
1601
|
-
senderName: webhook.actor.name,
|
|
1602
|
-
text: webhook.object.content || webhook.object.name,
|
|
1603
|
-
mediaType: webhook.object.mediaType || "text/plain",
|
|
1604
|
-
timestamp: payload.receivedAt,
|
|
1605
|
-
isGroupChat: true
|
|
1606
|
-
};
|
|
1607
|
-
}
|
|
1608
|
-
function resolveNonRetryableFailure(error) {
|
|
1609
|
-
if (error instanceof NextcloudTalkWebhookPayloadError) return {
|
|
1610
|
-
reason: "invalid-event",
|
|
1611
|
-
message: error.message
|
|
1612
|
-
};
|
|
1613
|
-
const message = formatErrorMessage(error);
|
|
1614
|
-
if (message.includes("Nextcloud Talk: bot send was rejected") || message.includes("Nextcloud Talk: forbidden")) return {
|
|
1615
|
-
reason: "nextcloud-talk-auth",
|
|
1616
|
-
message
|
|
1617
|
-
};
|
|
1618
|
-
return null;
|
|
1619
|
-
}
|
|
1620
|
-
function createNextcloudTalkWebhookSpool(options) {
|
|
1621
|
-
let queue = options.queue;
|
|
1622
|
-
const getQueue = () => {
|
|
1623
|
-
queue ??= getNextcloudTalkRuntime().state.openChannelIngressQueue({ accountId: options.accountId });
|
|
1624
|
-
return queue;
|
|
1625
|
-
};
|
|
1626
|
-
const legacyReplayStore = options.legacyReplayStore === null ? null : options.legacyReplayStore ?? getNextcloudTalkRuntime().state.openKeyedStore({
|
|
1627
|
-
namespace: resolvePersistentDedupePluginStateNamespace({
|
|
1628
|
-
namespace: options.accountId,
|
|
1629
|
-
namespacePrefix: "replay-dedupe"
|
|
1630
|
-
}),
|
|
1631
|
-
maxEntries: 1e4,
|
|
1632
|
-
defaultTtlMs: 864e5
|
|
1633
|
-
});
|
|
1634
|
-
const legacyMigration = legacyReplayStore ? migrateNextcloudTalkLegacyReplayState({
|
|
1635
|
-
queue: getQueue(),
|
|
1636
|
-
store: legacyReplayStore
|
|
1637
|
-
}) : Promise.resolve();
|
|
1638
|
-
const monitor = createChannelIngressMonitor({
|
|
1639
|
-
queue: getQueue,
|
|
1640
|
-
inspect: (rawEvent) => inspectNextcloudTalkWebhookEnvelope(rawEvent),
|
|
1641
|
-
payload: {
|
|
1642
|
-
version: 1,
|
|
1643
|
-
serialize: (rawEvent, { receivedAt }) => ({
|
|
1644
|
-
receivedAt,
|
|
1645
|
-
rawEvent
|
|
1646
|
-
}),
|
|
1647
|
-
deserialize: (body) => body.rawEvent,
|
|
1648
|
-
encode: ({ body }) => ({
|
|
1649
|
-
version: 1,
|
|
1650
|
-
...body
|
|
1651
|
-
}),
|
|
1652
|
-
decode: (payload) => ({
|
|
1653
|
-
version: payload.version,
|
|
1654
|
-
body: {
|
|
1655
|
-
receivedAt: payload.receivedAt,
|
|
1656
|
-
rawEvent: payload.rawEvent
|
|
1657
|
-
}
|
|
1658
|
-
}),
|
|
1659
|
-
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.`)
|
|
1660
|
-
},
|
|
1661
|
-
deliver: async (_rawEvent, lifecycle, claim) => {
|
|
1662
|
-
const message = parseClaimedMessage(claim.payload, claim.id);
|
|
1663
|
-
await options.deliver(message, lifecycle);
|
|
1664
|
-
},
|
|
1665
|
-
pollIntervalMs: options.pollIntervalMs ?? NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS,
|
|
1666
|
-
retention: {
|
|
1667
|
-
completedMaxEntries: 1e4,
|
|
1668
|
-
failedMaxEntries: 1e4
|
|
1669
|
-
},
|
|
1670
|
-
drain: {
|
|
1671
|
-
startLimit: 32,
|
|
1672
|
-
resolveNonRetryableFailure,
|
|
1673
|
-
...options.adoptionStallTimeoutMs === void 0 ? {} : { adoptionStallTimeoutMs: options.adoptionStallTimeoutMs },
|
|
1674
|
-
onLog: (message) => options.runtime.log?.(`nextcloud-talk ${message}`)
|
|
1675
|
-
},
|
|
1676
|
-
...options.abortSignal ? { abortSignal: options.abortSignal } : {},
|
|
1677
|
-
createStoppedError: () => /* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"),
|
|
1678
|
-
onError: (error) => options.runtime.error?.(`nextcloud-talk ingress drain failed: ${formatErrorMessage(error)}`)
|
|
1679
|
-
});
|
|
1680
|
-
let stopping = false;
|
|
1681
|
-
const inFlightReceives = /* @__PURE__ */ new Set();
|
|
1682
|
-
const startAfterMigration = legacyMigration.then(() => {
|
|
1683
|
-
if (!stopping) monitor.start();
|
|
1684
|
-
});
|
|
1685
|
-
return {
|
|
1686
|
-
ready: async () => await startAfterMigration,
|
|
1687
|
-
receive: (rawEvent) => {
|
|
1688
|
-
if (stopping) return Promise.reject(/* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"));
|
|
1689
|
-
const receiveTask = (async () => {
|
|
1690
|
-
await startAfterMigration;
|
|
1691
|
-
if ((await monitor.admit(rawEvent)).kind === "ignored") {
|
|
1692
|
-
options.runtime.log?.(`nextcloud-talk: ignored non-message webhook event (${describeIgnoredWebhookEvent(rawEvent)})`);
|
|
1693
|
-
return "ignored";
|
|
1694
|
-
}
|
|
1695
|
-
return "accepted";
|
|
1696
|
-
})();
|
|
1697
|
-
inFlightReceives.add(receiveTask);
|
|
1698
|
-
receiveTask.then(() => inFlightReceives.delete(receiveTask), () => inFlightReceives.delete(receiveTask));
|
|
1699
|
-
return receiveTask;
|
|
1700
|
-
},
|
|
1701
|
-
stop: async () => {
|
|
1702
|
-
stopping = true;
|
|
1703
|
-
const pendingReceives = [...inFlightReceives];
|
|
1704
|
-
const pauseTask = monitor.pause();
|
|
1705
|
-
await startAfterMigration.catch(() => void 0);
|
|
1706
|
-
await Promise.allSettled(pendingReceives);
|
|
1707
|
-
await monitor.stop();
|
|
1708
|
-
await pauseTask;
|
|
1709
|
-
},
|
|
1710
|
-
waitForIdle: async () => {
|
|
1711
|
-
await startAfterMigration.catch(() => void 0);
|
|
1712
|
-
await monitor.waitForIdle();
|
|
1713
|
-
}
|
|
1714
|
-
};
|
|
1715
|
-
}
|
|
1716
|
-
//#endregion
|
|
1717
|
-
//#region extensions/nextcloud-talk/src/monitor-runtime.ts
|
|
1718
|
-
function normalizeOrigin(value) {
|
|
1719
|
-
return URL.parse(value)?.origin.toLowerCase() ?? null;
|
|
1720
|
-
}
|
|
1721
|
-
async function monitorNextcloudTalkProvider(opts) {
|
|
1722
|
-
const core = getNextcloudTalkRuntime();
|
|
1723
|
-
const cfg = opts.config;
|
|
1724
|
-
const account = resolveNextcloudTalkAccount({
|
|
1725
|
-
cfg,
|
|
1726
|
-
accountId: opts.accountId
|
|
1727
|
-
});
|
|
1728
|
-
const runtime = resolveLoggerBackedRuntime(opts.runtime, core.logging.getChildLogger());
|
|
1729
|
-
if (!account.secret) throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
|
|
1730
|
-
const path = account.config.webhookPath ?? "/nextcloud-talk-webhook";
|
|
1731
|
-
const gatewayPort = (0, gateway_config_runtime_exports.resolveGatewayPort)({ gateway: cfg.gateway });
|
|
1732
|
-
const legacyListener = resolveNextcloudTalkLegacyWebhook(account.config);
|
|
1733
|
-
const routeConflict = describeNextcloudTalkWebhookRouteConflict(path, gatewayPort);
|
|
1734
|
-
if (routeConflict && !legacyListener) throw new Error(`[nextcloud-talk:${account.accountId}] ${routeConflict}`);
|
|
1735
|
-
const logger = core.logging.getChildLogger({
|
|
1736
|
-
channel: "nextcloud-talk",
|
|
1737
|
-
accountId: account.accountId
|
|
1738
|
-
});
|
|
1739
|
-
const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
|
|
1740
|
-
const spool = createNextcloudTalkWebhookSpool({
|
|
1741
|
-
accountId: account.accountId,
|
|
1742
|
-
runtime,
|
|
1743
|
-
abortSignal: opts.abortSignal,
|
|
1744
|
-
deliver: async (message, lifecycle) => {
|
|
1745
|
-
core.channel.activity.record({
|
|
1746
|
-
channel: "nextcloud-talk",
|
|
1747
|
-
accountId: account.accountId,
|
|
1748
|
-
direction: "inbound",
|
|
1749
|
-
at: message.timestamp
|
|
1750
|
-
});
|
|
1751
|
-
await handleNextcloudTalkInbound({
|
|
1752
|
-
message,
|
|
1753
|
-
account,
|
|
1754
|
-
config: cfg,
|
|
1755
|
-
runtime,
|
|
1756
|
-
statusSink: opts.statusSink,
|
|
1757
|
-
turnAdoptionLifecycle: lifecycle
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
1760
|
-
});
|
|
1761
|
-
let unregister;
|
|
1762
|
-
let stopPromise;
|
|
1763
|
-
const stop = () => {
|
|
1764
|
-
stopPromise ??= (async () => {
|
|
1765
|
-
await unregister?.();
|
|
1766
|
-
unregister = void 0;
|
|
1767
|
-
await spool.stop();
|
|
1768
|
-
})();
|
|
1769
|
-
return stopPromise;
|
|
1770
|
-
};
|
|
1771
|
-
if (opts.abortSignal && !opts.abortSignal.aborted) opts.abortSignal.addEventListener("abort", () => void stop(), { once: true });
|
|
1772
|
-
if (opts.abortSignal?.aborted) {
|
|
1773
|
-
await stop();
|
|
1774
|
-
return { stop };
|
|
1775
|
-
}
|
|
1776
|
-
try {
|
|
1777
|
-
await spool.ready();
|
|
1778
|
-
if (opts.abortSignal?.aborted) {
|
|
1779
|
-
await stop();
|
|
1780
|
-
return { stop };
|
|
1781
|
-
}
|
|
1782
|
-
unregister = registerNextcloudTalkWebhook({
|
|
1783
|
-
accountId: account.accountId,
|
|
1784
|
-
legacyListener,
|
|
1785
|
-
path,
|
|
1786
|
-
secret: account.secret,
|
|
1787
|
-
isBackendAllowed: (backend) => {
|
|
1788
|
-
if (!expectedBackendOrigin) return true;
|
|
1789
|
-
return normalizeOrigin(backend) === expectedBackendOrigin;
|
|
1790
|
-
},
|
|
1791
|
-
onWebhook: spool.receive,
|
|
1792
|
-
onError: (error) => {
|
|
1793
|
-
logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
|
|
1794
|
-
},
|
|
1795
|
-
trustedProxies: cfg.gateway?.trustedProxies,
|
|
1796
|
-
allowRealIpFallback: cfg.gateway?.allowRealIpFallback
|
|
1797
|
-
});
|
|
1798
|
-
} catch (error) {
|
|
1799
|
-
await stop();
|
|
1800
|
-
throw error;
|
|
1801
|
-
}
|
|
1802
|
-
opts.statusSink?.(channelReadyPatch());
|
|
1803
|
-
if (routeConflict && legacyListener) {
|
|
1804
|
-
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.`);
|
|
1805
|
-
return { stop };
|
|
1806
|
-
}
|
|
1807
|
-
logger.info(`[nextcloud-talk:${account.accountId}] Gateway webhook route ready at port ${gatewayPort}${path}; point the Nextcloud bot callback or reverse-proxy upstream here.`);
|
|
1808
|
-
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.`);
|
|
1809
|
-
return { stop };
|
|
1810
|
-
}
|
|
1811
|
-
//#endregion
|
|
1812
|
-
//#region extensions/nextcloud-talk/src/gateway.ts
|
|
1813
|
-
const nextcloudTalkGatewayAdapter = {
|
|
1814
|
-
startAccount: async (ctx) => {
|
|
1815
|
-
const account = ctx.account;
|
|
1816
|
-
if (!account.secret || !account.baseUrl) throw new Error(`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`);
|
|
1817
|
-
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk Gateway webhook route`);
|
|
1818
|
-
const statusSink = createAccountStatusSink({
|
|
1819
|
-
accountId: ctx.accountId,
|
|
1820
|
-
setStatus: ctx.setStatus
|
|
1821
|
-
});
|
|
1822
|
-
await runPassiveAccountLifecycle({
|
|
1823
|
-
abortSignal: ctx.abortSignal,
|
|
1824
|
-
start: async () => await monitorNextcloudTalkProvider({
|
|
1825
|
-
accountId: account.accountId,
|
|
1826
|
-
config: ctx.cfg,
|
|
1827
|
-
runtime: ctx.runtime,
|
|
1828
|
-
abortSignal: ctx.abortSignal,
|
|
1829
|
-
statusSink
|
|
1830
|
-
}),
|
|
1831
|
-
stop: async (monitor) => {
|
|
1832
|
-
await monitor.stop();
|
|
1833
|
-
}
|
|
1834
|
-
});
|
|
1835
|
-
},
|
|
1836
|
-
logoutAccount: async ({ accountId, cfg }) => {
|
|
1837
|
-
const { nextConfig, changed, cleared } = clearAccountFieldsFromConfigSection({
|
|
1838
|
-
cfg,
|
|
1839
|
-
sectionKey: "nextcloud-talk",
|
|
1840
|
-
accountId,
|
|
1841
|
-
fields: ["botSecret"]
|
|
1842
|
-
});
|
|
1843
|
-
const loggedOut = resolveNextcloudTalkAccount({
|
|
1844
|
-
cfg: nextConfig,
|
|
1845
|
-
accountId
|
|
1846
|
-
}).secretSource === "none";
|
|
1847
|
-
if (changed) await getNextcloudTalkRuntime().config.replaceConfigFile({
|
|
1848
|
-
nextConfig,
|
|
1849
|
-
afterWrite: { mode: "auto" }
|
|
1850
|
-
});
|
|
1851
|
-
return {
|
|
1852
|
-
cleared,
|
|
1853
|
-
envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
|
|
1854
|
-
loggedOut
|
|
1855
|
-
};
|
|
1856
|
-
}
|
|
1857
|
-
};
|
|
1858
|
-
//#endregion
|
|
1859
|
-
//#region extensions/nextcloud-talk/src/message-actions.ts
|
|
1860
|
-
const providerId = "nextcloud-talk";
|
|
1861
|
-
function isAccountConfigured(account) {
|
|
1862
|
-
return Boolean(account.enabled && account.secret?.trim() && account.baseUrl?.trim());
|
|
1863
|
-
}
|
|
1864
|
-
function hasConfiguredAccount(cfg, accountId) {
|
|
1865
|
-
if (accountId) return isAccountConfigured(resolveNextcloudTalkAccount({
|
|
1866
|
-
cfg,
|
|
1867
|
-
accountId
|
|
1868
|
-
}));
|
|
1869
|
-
return listNextcloudTalkAccountIds(cfg).map((id) => resolveNextcloudTalkAccount({
|
|
1870
|
-
cfg,
|
|
1871
|
-
accountId: id
|
|
1872
|
-
})).some(isAccountConfigured);
|
|
1873
|
-
}
|
|
1874
|
-
const nextcloudTalkMessageActions = {
|
|
1875
|
-
describeMessageTool: ({ cfg, accountId }) => {
|
|
1876
|
-
if (!hasConfiguredAccount(cfg, accountId)) return null;
|
|
1877
|
-
return { actions: ["send", "react"] };
|
|
1878
|
-
},
|
|
1879
|
-
supportsAction: ({ action }) => action === "react",
|
|
1880
|
-
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
|
1881
|
-
if (action === "send") throw new Error("Send should be handled by outbound, not actions handler.");
|
|
1882
|
-
if (action === "react") {
|
|
1883
|
-
const account = resolveNextcloudTalkAccount({
|
|
1884
|
-
cfg,
|
|
1885
|
-
accountId
|
|
1886
|
-
});
|
|
1887
|
-
if (!isAccountConfigured(account)) throw new Error(`Nextcloud Talk account "${account.accountId}" is disabled or not configured.`);
|
|
1888
|
-
const target = readStringParam(params, "to", {
|
|
1889
|
-
required: true,
|
|
1890
|
-
label: "to (room token)"
|
|
1891
|
-
});
|
|
1892
|
-
const messageIdRaw = resolveReactionMessageId({
|
|
1893
|
-
args: params,
|
|
1894
|
-
toolContext
|
|
1895
|
-
});
|
|
1896
|
-
if (messageIdRaw == null) throw new Error("messageId required");
|
|
1897
|
-
const messageId = String(messageIdRaw);
|
|
1898
|
-
const emoji = readStringParam(params, "emoji", { required: true });
|
|
1899
|
-
if (params.remove === true) throw new Error("Nextcloud Talk reaction removal is not supported yet; only adding reactions is implemented.");
|
|
1900
|
-
await sendReactionNextcloudTalk(target, messageId, emoji, {
|
|
1901
|
-
accountId: accountId ?? void 0,
|
|
1902
|
-
cfg
|
|
1903
|
-
});
|
|
1904
|
-
return jsonResult({
|
|
1905
|
-
ok: true,
|
|
1906
|
-
added: emoji
|
|
1907
|
-
});
|
|
1908
|
-
}
|
|
1909
|
-
throw new Error(`Action ${action} not supported for ${providerId}.`);
|
|
1910
|
-
}
|
|
1911
|
-
};
|
|
1912
|
-
//#endregion
|
|
1913
|
-
//#region extensions/nextcloud-talk/src/message-adapter.ts
|
|
1914
|
-
function sendNextcloudTalkMessage(ctx, text = ctx.text) {
|
|
1915
|
-
return sendMessageNextcloudTalk(ctx.to, text, {
|
|
1916
|
-
accountId: ctx.accountId ?? void 0,
|
|
1917
|
-
replyTo: ctx.replyToId ?? void 0,
|
|
1918
|
-
cfg: ctx.cfg,
|
|
1919
|
-
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
|
|
1920
|
-
assertDirectAdapterHandoff: ctx.assertDirectAdapterHandoff
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
|
|
1924
|
-
id: "nextcloud-talk",
|
|
1925
|
-
durableFinal: { capabilities: {
|
|
1926
|
-
text: true,
|
|
1927
|
-
media: true,
|
|
1928
|
-
replyTo: true
|
|
1929
|
-
} },
|
|
1930
|
-
send: {
|
|
1931
|
-
text: sendNextcloudTalkMessage,
|
|
1932
|
-
media: (ctx) => sendNextcloudTalkMessage(ctx, ctx.mediaUrl ? `${ctx.text}\n\nAttachment: ${ctx.mediaUrl}` : ctx.text)
|
|
2279
|
+
health: {
|
|
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;
|
|
1933
2305
|
}
|
|
1934
|
-
});
|
|
1935
|
-
//#endregion
|
|
1936
|
-
//#region extensions/nextcloud-talk/src/session-route.ts
|
|
1937
|
-
function resolveNextcloudTalkOutboundSessionRoute(params) {
|
|
1938
|
-
const roomId = stripNextcloudTalkTargetPrefix(params.target);
|
|
1939
|
-
if (!roomId) return null;
|
|
1940
|
-
return buildChannelOutboundSessionRoute({
|
|
1941
|
-
cfg: params.cfg,
|
|
1942
|
-
agentId: params.agentId,
|
|
1943
|
-
channel: "nextcloud-talk",
|
|
1944
|
-
accountId: params.accountId,
|
|
1945
|
-
recipientSessionExact: false,
|
|
1946
|
-
peer: {
|
|
1947
|
-
kind: "group",
|
|
1948
|
-
id: roomId
|
|
1949
|
-
},
|
|
1950
|
-
chatType: "group",
|
|
1951
|
-
from: `nextcloud-talk:room:${roomId}`,
|
|
1952
|
-
to: `nextcloud-talk:${roomId}`
|
|
1953
|
-
});
|
|
1954
2306
|
}
|
|
1955
|
-
|
|
1956
|
-
//#region extensions/nextcloud-talk/src/setup-core.ts
|
|
2307
|
+
// extensions/nextcloud-talk/src/setup-core.ts
|
|
1957
2308
|
const t$1 = createSetupTranslator$1();
|
|
1958
2309
|
const channel$1 = "nextcloud-talk";
|
|
1959
2310
|
function normalizeNextcloudTalkBaseUrl(value) {
|
|
@@ -2047,6 +2398,16 @@ const nextcloudTalkSetupAdapter = {
|
|
|
2047
2398
|
}, setupInput.useEnv ? ["botSecret", "botSecretFile"] : void 0);
|
|
2048
2399
|
}
|
|
2049
2400
|
};
|
|
2401
|
+
function secretField(flags, description) {
|
|
2402
|
+
return {
|
|
2403
|
+
kind: "string",
|
|
2404
|
+
sensitive: true,
|
|
2405
|
+
cli: {
|
|
2406
|
+
flags,
|
|
2407
|
+
description
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2050
2411
|
const nextcloudTalkSetupContract = defineChannelSetupContract({
|
|
2051
2412
|
fields: {
|
|
2052
2413
|
baseUrl: {
|
|
@@ -2063,46 +2424,11 @@ const nextcloudTalkSetupContract = defineChannelSetupContract({
|
|
|
2063
2424
|
description: "Legacy Nextcloud base URL alias"
|
|
2064
2425
|
}
|
|
2065
2426
|
},
|
|
2066
|
-
secret:
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
description: "Nextcloud Talk bot secret"
|
|
2072
|
-
}
|
|
2073
|
-
},
|
|
2074
|
-
token: {
|
|
2075
|
-
kind: "string",
|
|
2076
|
-
sensitive: true,
|
|
2077
|
-
cli: {
|
|
2078
|
-
flags: "--token <secret>",
|
|
2079
|
-
description: "Legacy Nextcloud bot secret alias"
|
|
2080
|
-
}
|
|
2081
|
-
},
|
|
2082
|
-
password: {
|
|
2083
|
-
kind: "string",
|
|
2084
|
-
sensitive: true,
|
|
2085
|
-
cli: {
|
|
2086
|
-
flags: "--password <secret>",
|
|
2087
|
-
description: "Legacy Nextcloud bot secret alias"
|
|
2088
|
-
}
|
|
2089
|
-
},
|
|
2090
|
-
secretFile: {
|
|
2091
|
-
kind: "string",
|
|
2092
|
-
sensitive: true,
|
|
2093
|
-
cli: {
|
|
2094
|
-
flags: "--secret-file <path>",
|
|
2095
|
-
description: "Nextcloud Talk bot secret file"
|
|
2096
|
-
}
|
|
2097
|
-
},
|
|
2098
|
-
tokenFile: {
|
|
2099
|
-
kind: "string",
|
|
2100
|
-
sensitive: true,
|
|
2101
|
-
cli: {
|
|
2102
|
-
flags: "--token-file <path>",
|
|
2103
|
-
description: "Legacy Nextcloud bot secret file alias"
|
|
2104
|
-
}
|
|
2105
|
-
},
|
|
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"),
|
|
2106
2432
|
useEnv: {
|
|
2107
2433
|
kind: "boolean",
|
|
2108
2434
|
cli: {
|
|
@@ -2114,8 +2440,7 @@ const nextcloudTalkSetupContract = defineChannelSetupContract({
|
|
|
2114
2440
|
},
|
|
2115
2441
|
legacyAdapter: nextcloudTalkSetupAdapter
|
|
2116
2442
|
});
|
|
2117
|
-
|
|
2118
|
-
//#region extensions/nextcloud-talk/src/setup-surface.ts
|
|
2443
|
+
// extensions/nextcloud-talk/src/setup-surface.ts
|
|
2119
2444
|
const t = createSetupTranslator();
|
|
2120
2445
|
const channel = "nextcloud-talk";
|
|
2121
2446
|
const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
|
|
@@ -2181,7 +2506,7 @@ const nextcloudTalkSetupWizard = {
|
|
|
2181
2506
|
envPrompt: t("wizard.nextcloudTalk.botSecretEnvPrompt"),
|
|
2182
2507
|
keepPrompt: t("wizard.nextcloudTalk.botSecretKeep"),
|
|
2183
2508
|
inputPrompt: t("wizard.nextcloudTalk.botSecretInput"),
|
|
2184
|
-
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$
|
|
2509
|
+
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$2,
|
|
2185
2510
|
resolveAccount: ({ cfg, accountId }) => resolveNextcloudTalkAccount({
|
|
2186
2511
|
cfg,
|
|
2187
2512
|
accountId
|
|
@@ -2189,7 +2514,7 @@ const nextcloudTalkSetupWizard = {
|
|
|
2189
2514
|
accountConfigured: (account) => Boolean(account.secret && account.baseUrl),
|
|
2190
2515
|
hasConfiguredValue: (account) => Boolean(hasConfiguredSecretInput(account.config.botSecret) || account.config.botSecretFile),
|
|
2191
2516
|
resolvedValue: (account) => account.secret || void 0,
|
|
2192
|
-
envValue: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$
|
|
2517
|
+
envValue: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$2 ? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET) : void 0,
|
|
2193
2518
|
patchAccount: ({ cfg, accountId, patch, clearFields }) => setNextcloudTalkAccountConfig(cfg, accountId, patch, clearFields),
|
|
2194
2519
|
useEnv: {
|
|
2195
2520
|
clearFields: ["botSecret", "botSecretFile"],
|
|
@@ -2243,8 +2568,235 @@ const nextcloudTalkSetupWizard = {
|
|
|
2243
2568
|
dmPolicy: nextcloudTalkDmPolicy,
|
|
2244
2569
|
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false)
|
|
2245
2570
|
};
|
|
2246
|
-
|
|
2247
|
-
|
|
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
|
+
}
|
|
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
|
+
});
|
|
2248
2800
|
const meta = {
|
|
2249
2801
|
id: "nextcloud-talk",
|
|
2250
2802
|
label: "Nextcloud Talk",
|
|
@@ -2290,13 +2842,46 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
|
|
|
2290
2842
|
reactions: true,
|
|
2291
2843
|
threads: false,
|
|
2292
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
|
+
} },
|
|
2293
2855
|
nativeCommands: false,
|
|
2294
2856
|
blockStreaming: true
|
|
2295
2857
|
},
|
|
2296
2858
|
reload: { configPrefixes: ["channels.nextcloud-talk"] },
|
|
2297
|
-
configSchema:
|
|
2859
|
+
configSchema: NextcloudTalkChannelConfigSchema,
|
|
2298
2860
|
config: {
|
|
2299
|
-
...
|
|
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
|
+
}),
|
|
2300
2885
|
isConfigured: isNextcloudTalkAccountConfigured,
|
|
2301
2886
|
describeAccount: (account) => describeWebhookAccountSnapshot({
|
|
2302
2887
|
account,
|
|
@@ -2323,7 +2908,24 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
|
|
|
2323
2908
|
],
|
|
2324
2909
|
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
|
2325
2910
|
inferTargetChatType: ({ to }) => normalizeNextcloudTalkMessagingTarget(to) ? "group" : void 0,
|
|
2326
|
-
resolveOutboundSessionRoute:
|
|
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
|
+
},
|
|
2327
2929
|
targetResolver: {
|
|
2328
2930
|
looksLikeId: looksLikeNextcloudTalkTargetId,
|
|
2329
2931
|
hint: "<roomToken>"
|
|
@@ -2335,14 +2937,14 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
|
|
|
2335
2937
|
},
|
|
2336
2938
|
setupContract: nextcloudTalkSetupContract,
|
|
2337
2939
|
status: createComputedAccountStatusAdapter({
|
|
2338
|
-
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID
|
|
2940
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
2339
2941
|
buildChannelSummary: ({ snapshot }) => buildWebhookChannelStatusSummary(snapshot, { secretSource: snapshot.secretSource ?? "none" }),
|
|
2340
2942
|
collectStatusIssues: (accounts) => accounts.flatMap((account) => {
|
|
2341
2943
|
const probe = account.probe;
|
|
2342
2944
|
if (!probe || probe.ok !== false || probe.code !== "missing_response_feature" || !probe.message) return [];
|
|
2343
2945
|
return [{
|
|
2344
2946
|
channel: "nextcloud-talk",
|
|
2345
|
-
accountId: account.accountId ?? DEFAULT_ACCOUNT_ID
|
|
2947
|
+
accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
2346
2948
|
kind: "config",
|
|
2347
2949
|
message: probe.message,
|
|
2348
2950
|
fix: "Add --feature response to the Talk bot."
|
|
@@ -2366,16 +2968,145 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
|
|
|
2366
2968
|
}
|
|
2367
2969
|
})
|
|
2368
2970
|
}),
|
|
2369
|
-
gateway:
|
|
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
|
+
},
|
|
2370
3093
|
message: nextcloudTalkMessageAdapter,
|
|
2371
3094
|
actions: nextcloudTalkMessageActions
|
|
2372
3095
|
},
|
|
2373
3096
|
pairing: { text: {
|
|
2374
|
-
|
|
3097
|
+
idLabel: "nextcloudUserId",
|
|
3098
|
+
message: "OpenClaw: your access has been approved.",
|
|
3099
|
+
normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => normalizeLowercaseStringOrEmpty(entry)),
|
|
2375
3100
|
notify: createLoggedPairingApprovalNotifier(({ id }) => `[nextcloud-talk] User ${id} approved for pairing`)
|
|
2376
3101
|
} },
|
|
2377
3102
|
security: {
|
|
2378
|
-
|
|
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
|
+
}),
|
|
2379
3110
|
collectWarnings: collectNextcloudTalkOpenGroupFindings
|
|
2380
3111
|
},
|
|
2381
3112
|
outbound: {
|
|
@@ -2397,5 +3128,4 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
|
|
|
2397
3128
|
}
|
|
2398
3129
|
}
|
|
2399
3130
|
});
|
|
2400
|
-
|
|
2401
|
-
export { nextcloudTalkPlugin as t };
|
|
3131
|
+
export { resolveNextcloudTalkAccount as a, releaseNextcloudTalkGuardedResponse as i, stripNextcloudTalkTargetPrefix as n, resolveNextcloudTalkApiCredentialsResult as o, parseWebDavFileId as r, nextcloudTalkPlugin as t };
|