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