@openclaw/nextcloud-talk 2026.7.2-beta.2 → 2026.7.2-beta.4

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/api.js CHANGED
@@ -1,2 +1,2 @@
1
- import { t as nextcloudTalkPlugin } from "./channel-BSnehBf6.js";
1
+ import { t as nextcloudTalkPlugin } from "./channel-DtnECxVM.js";
2
2
  export { nextcloudTalkPlugin };
@@ -6,7 +6,7 @@ import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
6
6
  import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
7
7
  import { createAllowlistProviderRouteAllowlistWarningCollector, resolveScopeRequireMention, resolveScopeToolsPolicy } from "openclaw/plugin-sdk/channel-policy";
8
8
  import { buildWebhookChannelStatusSummary, createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
9
- import { convertMarkdownTables, sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
9
+ import { convertMarkdownTables, renderMarkdownWithMarkers, sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
10
10
  import { createChannelApprovalAuth } from "openclaw/plugin-sdk/approval-auth-runtime";
11
11
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
12
12
  import { DEFAULT_ACCOUNT_ID, createAccountListHelpers, hasConfiguredAccountValue, normalizeAccountId, resolveAccountWithDefaultFallback, resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-core";
@@ -15,7 +15,8 @@ import { buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecr
15
15
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
16
16
  import { parseStrictNonNegativeInteger, parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
17
17
  import { readProviderJsonResponse, readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
18
- import { createAccountStatusSink, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
18
+ import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope } from "openclaw/plugin-sdk/channel-inbound";
19
+ import { bindIngressLifecycleToReplyOptions, createAccountStatusSink, createChannelIngressMonitor, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, resolveChannelStreamingBlockEnabled, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-outbound";
19
20
  import { ssrfPolicyFromPrivateNetworkOptIn, ssrfPolicyFromPrivateNetworkOptIn as ssrfPolicyFromPrivateNetworkOptIn$1 } from "openclaw/plugin-sdk/ssrf-runtime";
20
21
  import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
21
22
  import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
@@ -24,16 +25,15 @@ import { fileExists, safeEqualSecret } from "openclaw/plugin-sdk/security-runtim
24
25
  import { clearAccountEntryFields } from "openclaw/plugin-sdk/channel-plugin-common";
25
26
  import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$2 } from "openclaw/plugin-sdk/account-id";
26
27
  import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, ReplyRuntimeConfigSchemaShape, buildChannelConfigSchema, buildGroupEntrySchema, buildMultiAccountChannelSchema, requireOpenAllowFrom } from "openclaw/plugin-sdk/channel-config-schema";
27
- import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
28
+ import { formatAllowFromLowercase, resolveAllowlistMatchByCandidates } from "openclaw/plugin-sdk/allow-from";
28
29
  import { adaptScopedAccountAccessor, createScopedChannelConfigAdapter, createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
29
- import { requireChannelOpenAllowFrom, resolveLoggerBackedRuntime, runStoppablePassiveMonitor, safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
30
+ import { requireChannelOpenAllowFrom, resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
30
31
  import { z } from "zod";
31
32
  import os from "node:os";
32
33
  import path from "node:path";
33
- import { createChannelReplayGuard, migratePersistentDedupeLegacyJsonFile } from "openclaw/plugin-sdk/persistent-dedupe";
34
+ import { migratePersistentDedupeLegacyJsonFile, resolvePersistentDedupePluginStateNamespace } from "openclaw/plugin-sdk/persistent-dedupe";
34
35
  import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
35
36
  import { channelIngressRoutes, resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
36
- import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
37
37
  import { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback } from "openclaw/plugin-sdk/channel-targets";
38
38
  import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
39
39
  import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
@@ -41,9 +41,41 @@ import { createServer } from "node:http";
41
41
  import { WEBHOOK_RATE_LIMIT_DEFAULTS, createAuthRateLimiter, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk/webhook-ingress";
42
42
  import { jsonResult, readStringParam, resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
43
43
  import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, buildOutboundBaseSessionKey, normalizeAccountId as normalizeAccountId$1 } from "openclaw/plugin-sdk/routing";
44
+ import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
44
45
  import { applyAccountNameToChannelSection, baseUrlTextInput, createSetupTranslator, createStandardChannelSetupStatus, defineTokenCredential, formatDocsLink, patchScopedAccountConfig, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
45
46
  import { createSetupInputPresenceValidator, createSetupTranslator as createSetupTranslator$1, mergeAllowFromEntries, promptParsedAllowFromForAccount, resolveSetupAccountId } from "openclaw/plugin-sdk/setup-runtime";
46
47
  import { formatDocsLink as formatDocsLink$1 } from "openclaw/plugin-sdk/setup-tools";
48
+ //#region extensions/nextcloud-talk/src/api-credentials.ts
49
+ function resolveNextcloudTalkApiCredentialsResult(params) {
50
+ const apiUser = params.apiUser?.trim();
51
+ if (!apiUser) return { status: "missing" };
52
+ const inlinePassword = normalizeResolvedSecretInputString({
53
+ value: params.apiPassword,
54
+ path: "channels.nextcloud-talk.apiPassword"
55
+ });
56
+ if (inlinePassword) return {
57
+ status: "available",
58
+ value: {
59
+ apiUser,
60
+ apiPassword: inlinePassword
61
+ }
62
+ };
63
+ if (!params.apiPasswordFile?.trim()) return { status: "missing" };
64
+ const result = tryReadSecretFileSync(params.apiPasswordFile, "Nextcloud Talk API password", { rejectHardlinks: false }, { configPath: params.configPath ?? "channels.nextcloud-talk.apiPasswordFile" });
65
+ if (result.status === "available") return result.value ? {
66
+ status: "available",
67
+ value: {
68
+ apiUser,
69
+ apiPassword: result.value
70
+ }
71
+ } : { status: "missing" };
72
+ return result;
73
+ }
74
+ function resolveNextcloudTalkApiCredentials(params) {
75
+ const result = resolveNextcloudTalkApiCredentialsResult(params);
76
+ return result.status === "available" ? result.value : void 0;
77
+ }
78
+ //#endregion
47
79
  //#region extensions/nextcloud-talk/src/accounts.ts
48
80
  function isTruthyEnvValue(value) {
49
81
  const normalized = normalizeLowercaseStringOrEmpty(value);
@@ -79,13 +111,21 @@ function resolveNextcloudTalkSecret(cfg, opts) {
79
111
  const envSecret = normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET);
80
112
  if (envSecret && resolvedAccountId === DEFAULT_ACCOUNT_ID) return {
81
113
  secret: envSecret,
82
- source: "env"
114
+ source: "env",
115
+ status: "available"
83
116
  };
84
- if (merged.botSecretFile) {
85
- const fileSecret = tryReadSecretFileSync(merged.botSecretFile, "Nextcloud Talk bot secret file", { rejectSymlink: true });
86
- if (fileSecret) return {
87
- secret: fileSecret,
88
- source: "secretFile"
117
+ const botSecretFile = normalizeOptionalString(merged.botSecretFile);
118
+ if (botSecretFile) {
119
+ const result = tryReadSecretFileSync(botSecretFile, "Nextcloud Talk bot secret file", { rejectSymlink: true }, { configPath: `channels.nextcloud-talk.accounts.${resolvedAccountId}.botSecretFile` });
120
+ return result.status === "available" ? {
121
+ secret: result.value,
122
+ source: "secretFile",
123
+ status: "available"
124
+ } : {
125
+ secret: "",
126
+ source: "secretFile",
127
+ status: "configured_unavailable",
128
+ diagnostic: result.diagnostic
89
129
  };
90
130
  }
91
131
  const inlineSecret = normalizeResolvedSecretInputString({
@@ -94,11 +134,13 @@ function resolveNextcloudTalkSecret(cfg, opts) {
94
134
  });
95
135
  if (inlineSecret) return {
96
136
  secret: inlineSecret,
97
- source: "config"
137
+ source: "config",
138
+ status: "available"
98
139
  };
99
140
  return {
100
141
  secret: "",
101
- source: "none"
142
+ source: "none",
143
+ status: "missing"
102
144
  };
103
145
  }
104
146
  function resolveNextcloudTalkAccount(params) {
@@ -109,6 +151,13 @@ function resolveNextcloudTalkAccount(params) {
109
151
  const accountEnabled = merged.enabled !== false;
110
152
  const enabled = baseEnabled && accountEnabled;
111
153
  const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId });
154
+ const apiCredentialResolution = resolveNextcloudTalkApiCredentialsResult({
155
+ apiUser: merged.apiUser,
156
+ apiPassword: merged.apiPassword,
157
+ apiPasswordFile: merged.apiPasswordFile,
158
+ configPath: `channels.nextcloud-talk.accounts.${accountId}.apiPasswordFile`
159
+ });
160
+ const diagnostics = [secretResolution.diagnostic, apiCredentialResolution.status === "configured_unavailable" ? apiCredentialResolution.diagnostic : void 0].filter((diagnostic) => Boolean(diagnostic));
112
161
  const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? "";
113
162
  debugAccounts("resolve", {
114
163
  accountId,
@@ -123,6 +172,9 @@ function resolveNextcloudTalkAccount(params) {
123
172
  baseUrl,
124
173
  secret: secretResolution.secret,
125
174
  secretSource: secretResolution.source,
175
+ tokenStatus: secretResolution.status,
176
+ apiCredentialStatus: apiCredentialResolution.status,
177
+ ...diagnostics.length > 0 ? { credentialDiagnostics: diagnostics } : {},
126
178
  config: merged
127
179
  };
128
180
  };
@@ -130,7 +182,7 @@ function resolveNextcloudTalkAccount(params) {
130
182
  accountId: resolvedAccountId,
131
183
  normalizeAccountId,
132
184
  resolvePrimary: resolve,
133
- hasCredential: (account) => account.secretSource !== "none",
185
+ hasCredential: (account) => account.tokenStatus !== "missing",
134
186
  resolveDefaultAccountId: () => resolveDefaultNextcloudTalkAccountId(params.cfg)
135
187
  });
136
188
  }
@@ -150,28 +202,10 @@ const nextcloudTalkApprovalAuth = createChannelApprovalAuth({
150
202
  normalizeApprover: normalizeNextcloudTalkApproverId
151
203
  }).approvalAuth;
152
204
  //#endregion
153
- //#region extensions/nextcloud-talk/src/api-credentials.ts
154
- function resolveNextcloudTalkApiCredentials(params) {
155
- const apiUser = params.apiUser?.trim();
156
- if (!apiUser) return;
157
- const inlinePassword = normalizeResolvedSecretInputString({
158
- value: params.apiPassword,
159
- path: "channels.nextcloud-talk.apiPassword"
160
- });
161
- if (inlinePassword) return {
162
- apiUser,
163
- apiPassword: inlinePassword
164
- };
165
- if (!params.apiPasswordFile) return;
166
- try {
167
- const fileValue = tryReadSecretFileSync(params.apiPasswordFile, "Nextcloud Talk API password", { rejectHardlinks: false });
168
- return fileValue ? {
169
- apiUser,
170
- apiPassword: fileValue
171
- } : void 0;
172
- } catch {
173
- return;
174
- }
205
+ //#region extensions/nextcloud-talk/src/guarded-response.ts
206
+ async function releaseNextcloudTalkGuardedResponse(params) {
207
+ if (!params.response.bodyUsed) await params.response.body?.cancel().catch(() => void 0);
208
+ await params.release();
175
209
  }
176
210
  //#endregion
177
211
  //#region extensions/nextcloud-talk/src/signature.ts
@@ -317,7 +351,10 @@ async function probeNextcloudTalkBotResponseFeature(params) {
317
351
  message: `Nextcloud Talk bot "${bot.name ?? bot.id ?? "matching bot"}" has the response feature.`
318
352
  };
319
353
  } finally {
320
- await release();
354
+ await releaseNextcloudTalkGuardedResponse({
355
+ response,
356
+ release
357
+ });
321
358
  }
322
359
  } catch (error) {
323
360
  return {
@@ -399,44 +436,13 @@ const NextcloudTalkConfigSchema = buildMultiAccountChannelSchema(z.object({
399
436
  }
400
437
  });
401
438
  //#endregion
402
- //#region extensions/nextcloud-talk/src/replay-guard.ts
439
+ //#region extensions/nextcloud-talk/src/replay-migration-contract.ts
403
440
  const NEXTCLOUD_TALK_PLUGIN_ID = "nextcloud-talk";
404
441
  const NEXTCLOUD_TALK_REPLAY_DEDUPE_NAMESPACE_PREFIX = "replay-dedupe";
405
- const DEFAULT_REPLAY_TTL_MS = 1440 * 60 * 1e3;
406
- const DEFAULT_MEMORY_MAX_SIZE = 1e3;
407
- const DEFAULT_STATE_MAX_ENTRIES = 1e4;
408
- function buildNextcloudTalkReplayKey(params) {
409
- const roomToken = params.roomToken.trim();
410
- const messageId = params.messageId.trim();
411
- if (!roomToken || !messageId) return null;
412
- return `${roomToken}:${messageId}`;
413
- }
414
- function createNextcloudTalkReplayGuard(options) {
415
- const stateDir = options.stateDir?.trim();
416
- const baseOptions = {
417
- ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
418
- memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE
419
- };
420
- return createChannelReplayGuard({
421
- dedupe: stateDir ? {
422
- ...baseOptions,
423
- pluginId: NEXTCLOUD_TALK_PLUGIN_ID,
424
- namespacePrefix: NEXTCLOUD_TALK_REPLAY_DEDUPE_NAMESPACE_PREFIX,
425
- stateMaxEntries: options.stateMaxEntries ?? options.fileMaxEntries ?? DEFAULT_STATE_MAX_ENTRIES,
426
- env: {
427
- ...process.env,
428
- OPENCLAW_STATE_DIR: stateDir
429
- },
430
- onDiskError: options.onDiskError
431
- } : baseOptions,
432
- buildReplayKey: buildNextcloudTalkReplayKey,
433
- namespace: (event) => event.accountId
434
- });
435
- }
442
+ const NEXTCLOUD_TALK_REPLAY_DEDUPE_TTL_MS = 1440 * 60 * 1e3;
443
+ const NEXTCLOUD_TALK_REPLAY_DEDUPE_MAX_ENTRIES = 1e4;
436
444
  //#endregion
437
445
  //#region extensions/nextcloud-talk/src/doctor.ts
438
- const REPLAY_DEDUPE_TTL_MS = 1440 * 60 * 1e3;
439
- const REPLAY_DEDUPE_MAX_ENTRIES = 1e4;
440
446
  function sanitizeLegacyReplaySegment(value) {
441
447
  const trimmed = value.trim();
442
448
  if (!trimmed) return "default";
@@ -471,11 +477,11 @@ async function repairNextcloudTalkReplayDedupeState(params) {
471
477
  const result = await migratePersistentDedupeLegacyJsonFile({
472
478
  filePath: legacyPath,
473
479
  namespace: accountId,
474
- ttlMs: REPLAY_DEDUPE_TTL_MS,
480
+ ttlMs: NEXTCLOUD_TALK_REPLAY_DEDUPE_TTL_MS,
475
481
  memoryMaxSize: 0,
476
482
  pluginId: NEXTCLOUD_TALK_PLUGIN_ID,
477
483
  namespacePrefix: NEXTCLOUD_TALK_REPLAY_DEDUPE_NAMESPACE_PREFIX,
478
- stateMaxEntries: REPLAY_DEDUPE_MAX_ENTRIES,
484
+ stateMaxEntries: NEXTCLOUD_TALK_REPLAY_DEDUPE_MAX_ENTRIES,
479
485
  env
480
486
  });
481
487
  changes.push(`Migrated Nextcloud Talk replay dedupe cache for account "${accountId}" to SQLite (${result.imported} imported, ${result.skippedExpired} expired, ${result.skippedExisting} already current).`);
@@ -513,19 +519,13 @@ function normalizeNextcloudTalkAllowlist(values) {
513
519
  return (values ?? []).map((value) => normalizeNextcloudTalkAllowEntry(String(value))).filter(Boolean);
514
520
  }
515
521
  function resolveNextcloudTalkAllowlistMatch(params) {
516
- const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
517
- if (allowFrom.length === 0) return { allowed: false };
518
- if (allowFrom.includes("*")) return {
519
- allowed: true,
520
- matchKey: "*",
521
- matchSource: "wildcard"
522
- };
523
- const senderId = normalizeNextcloudTalkAllowEntry(params.senderId);
524
- return allowFrom.includes(senderId) ? {
525
- allowed: true,
526
- matchKey: senderId,
527
- matchSource: "id"
528
- } : { allowed: false };
522
+ return resolveAllowlistMatchByCandidates({
523
+ allowList: normalizeNextcloudTalkAllowlist(params.allowFrom),
524
+ candidates: [{
525
+ value: normalizeNextcloudTalkAllowEntry(params.senderId),
526
+ source: "id"
527
+ }]
528
+ });
529
529
  }
530
530
  function resolveNextcloudTalkRoomMatch(params) {
531
531
  const rooms = params.rooms ?? {};
@@ -664,7 +664,10 @@ async function resolveNextcloudTalkRoomKind(params) {
664
664
  });
665
665
  return kind;
666
666
  } finally {
667
- await release();
667
+ await releaseNextcloudTalkGuardedResponse({
668
+ response,
669
+ release
670
+ });
668
671
  }
669
672
  } catch (err) {
670
673
  cacheRoomInfo(key, {
@@ -703,6 +706,43 @@ function looksLikeNextcloudTalkTargetId(raw) {
703
706
  const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_BYTES = 8 * 1024;
704
707
  const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS = 200;
705
708
  const NEXTCLOUD_TALK_SEND_TIMEOUT_MS = 3e4;
709
+ const NEXTCLOUD_TALK_FORMAT_PROFILE = {
710
+ mechanism: "markdown",
711
+ constructs: {
712
+ bold: "native",
713
+ italic: "native",
714
+ underline: "native",
715
+ strikethrough: "native",
716
+ spoiler: "native",
717
+ codeInline: "native",
718
+ codeBlock: "native",
719
+ codeLanguage: "native",
720
+ linkLabel: "native",
721
+ heading: "native",
722
+ bulletList: "native",
723
+ orderedList: "native",
724
+ taskList: "native",
725
+ table: "native",
726
+ blockquote: "native",
727
+ image: "native",
728
+ mention: "native"
729
+ },
730
+ chunk: {
731
+ limit: 4e3,
732
+ unit: "chars",
733
+ hardCap: 32e3
734
+ }
735
+ };
736
+ function renderNextcloudTalkMarkdown(markdown) {
737
+ return renderMarkdownWithMarkers({
738
+ text: markdown,
739
+ styles: [],
740
+ links: []
741
+ }, {
742
+ styleMarkers: {},
743
+ escapeText: (text) => text
744
+ }, NEXTCLOUD_TALK_FORMAT_PROFILE);
745
+ }
706
746
  /** Collapses whitespace and caps an error-body prefix to a short, log-safe snippet. */
707
747
  function collapseErrorSnippet(text) {
708
748
  const collapsed = text.replace(/\s+/g, " ").trim();
@@ -781,7 +821,7 @@ async function sendMessageNextcloudTalk(to, text, opts) {
781
821
  channel: "nextcloud-talk",
782
822
  accountId: account.accountId
783
823
  });
784
- const message = convertMarkdownTables(text.trim(), tableMode);
824
+ const message = convertMarkdownTables(renderNextcloudTalkMarkdown(text.trim()), tableMode);
785
825
  const body = { message };
786
826
  if (opts.replyTo) body.replyTo = opts.replyTo;
787
827
  const bodyStr = JSON.stringify(body);
@@ -837,7 +877,10 @@ async function sendMessageNextcloudTalk(to, text, opts) {
837
877
  timestamp
838
878
  };
839
879
  } finally {
840
- await release();
880
+ await releaseNextcloudTalkGuardedResponse({
881
+ response,
882
+ release
883
+ });
841
884
  }
842
885
  }
843
886
  async function sendReactionNextcloudTalk(roomToken, messageId, reaction, opts) {
@@ -871,7 +914,10 @@ async function sendReactionNextcloudTalk(roomToken, messageId, reaction, opts) {
871
914
  }
872
915
  return { ok: true };
873
916
  } finally {
874
- await release();
917
+ await releaseNextcloudTalkGuardedResponse({
918
+ response,
919
+ release
920
+ });
875
921
  }
876
922
  }
877
923
  //#endregion
@@ -1077,19 +1123,17 @@ async function handleNextcloudTalkInbound(params) {
1077
1123
  runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
1078
1124
  return;
1079
1125
  }
1080
- const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
1126
+ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
1081
1127
  cfg: config,
1082
1128
  channel: CHANNEL_ID,
1083
1129
  accountId: account.accountId,
1084
1130
  peer: {
1085
1131
  kind: isGroup ? "group" : "direct",
1086
1132
  id: isGroup ? roomToken : senderId
1087
- },
1088
- runtime: core.channel,
1089
- sessionStore: config.session?.store
1133
+ }
1090
1134
  });
1091
1135
  const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
1092
- const { storePath, body } = buildEnvelope({
1136
+ const body = buildEnvelope({
1093
1137
  channel: "Nextcloud Talk",
1094
1138
  from: fromLabel,
1095
1139
  timestamp: message.timestamp,
@@ -1097,40 +1141,58 @@ async function handleNextcloudTalkInbound(params) {
1097
1141
  });
1098
1142
  const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
1099
1143
  const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
1100
- const ctxPayload = core.channel.reply.finalizeInboundContext({
1101
- Body: body,
1102
- BodyForAgent: rawBody,
1103
- RawBody: rawBody,
1104
- CommandBody: rawBody,
1105
- From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
1106
- To: `nextcloud-talk:${roomToken}`,
1107
- SessionKey: route.sessionKey,
1108
- AccountId: route.accountId,
1109
- ChatType: isGroup ? "group" : "direct",
1110
- ConversationLabel: fromLabel,
1111
- SenderName: senderName || void 0,
1112
- SenderId: senderId,
1113
- GroupSubject: isGroup ? roomName || roomToken : void 0,
1114
- GroupSystemPrompt: isGroup ? groupSystemPrompt : void 0,
1115
- Provider: CHANNEL_ID,
1116
- Surface: CHANNEL_ID,
1117
- WasMentioned: isGroup ? wasMentioned : void 0,
1118
- MessageSid: message.messageId,
1119
- Timestamp: message.timestamp,
1120
- OriginatingChannel: CHANNEL_ID,
1121
- OriginatingTo: `nextcloud-talk:${roomToken}`,
1122
- CommandAuthorized: commandAuthorized
1144
+ const ctxPayload = buildChannelInboundEventContext({
1145
+ channel: CHANNEL_ID,
1146
+ accountId: route.accountId,
1147
+ messageId: message.messageId,
1148
+ timestamp: message.timestamp,
1149
+ from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
1150
+ sender: {
1151
+ id: senderId,
1152
+ name: senderName || void 0
1153
+ },
1154
+ conversation: {
1155
+ kind: isGroup ? "group" : "direct",
1156
+ id: roomToken,
1157
+ label: fromLabel
1158
+ },
1159
+ route: {
1160
+ agentId: route.agentId,
1161
+ dmScope: route.dmScope,
1162
+ accountId: route.accountId,
1163
+ routeSessionKey: route.sessionKey
1164
+ },
1165
+ reply: {
1166
+ to: `nextcloud-talk:${roomToken}`,
1167
+ originatingTo: `nextcloud-talk:${roomToken}`
1168
+ },
1169
+ message: {
1170
+ body,
1171
+ bodyForAgent: rawBody,
1172
+ rawBody,
1173
+ commandBody: rawBody
1174
+ },
1175
+ access: {
1176
+ commands: { authorized: commandAuthorized },
1177
+ mentions: {
1178
+ canDetectMention: isGroup,
1179
+ wasMentioned: isGroup && wasMentioned
1180
+ }
1181
+ },
1182
+ extra: {
1183
+ GroupSubject: isGroup ? roomName || roomToken : void 0,
1184
+ GroupSystemPrompt: isGroup ? groupSystemPrompt : void 0
1185
+ }
1123
1186
  });
1124
- await core.channel.inbound.dispatchReply({
1187
+ await core.channel.inbound.dispatch({
1125
1188
  cfg: config,
1126
1189
  channel: CHANNEL_ID,
1127
1190
  accountId: account.accountId,
1128
- agentId: route.agentId,
1129
- routeSessionKey: route.sessionKey,
1130
- storePath,
1191
+ route: {
1192
+ agentId: route.agentId,
1193
+ sessionKey: route.sessionKey
1194
+ },
1131
1195
  ctxPayload,
1132
- recordInboundSession: core.channel.session.recordInboundSession,
1133
- dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
1134
1196
  delivery: {
1135
1197
  preparePayload: (payload) => payload.text === void 0 ? payload : {
1136
1198
  ...payload,
@@ -1151,6 +1213,7 @@ async function handleNextcloudTalkInbound(params) {
1151
1213
  },
1152
1214
  replyPipeline: {},
1153
1215
  replyOptions: {
1216
+ ...params.turnAdoptionLifecycle ? bindIngressLifecycleToReplyOptions(params.turnAdoptionLifecycle) : {},
1154
1217
  skillFilter: roomConfig?.skills,
1155
1218
  disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : void 0
1156
1219
  },
@@ -1159,6 +1222,74 @@ async function handleNextcloudTalkInbound(params) {
1159
1222
  } }
1160
1223
  });
1161
1224
  }
1225
+ var NextcloudTalkWebhookPayloadError = class extends Error {
1226
+ constructor(message, options) {
1227
+ super(message, options);
1228
+ this.name = "NextcloudTalkWebhookPayloadError";
1229
+ }
1230
+ };
1231
+ function isRecord(value) {
1232
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1233
+ }
1234
+ function parseRawObject(rawEvent) {
1235
+ let parsed;
1236
+ try {
1237
+ parsed = JSON.parse(rawEvent);
1238
+ } catch (error) {
1239
+ throw new NextcloudTalkWebhookPayloadError("Nextcloud Talk webhook contains invalid JSON.", { cause: error });
1240
+ }
1241
+ if (!isRecord(parsed)) throw new NextcloudTalkWebhookPayloadError("Nextcloud Talk webhook must be a JSON object.");
1242
+ return parsed;
1243
+ }
1244
+ function requiredString(value, field) {
1245
+ if (typeof value === "string" && value.trim()) return value.trim();
1246
+ throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk webhook is missing ${field}.`);
1247
+ }
1248
+ function inspectNextcloudTalkWebhookEnvelope(rawEvent) {
1249
+ const envelope = parseRawObject(rawEvent);
1250
+ if (envelope.type !== "Create") return null;
1251
+ const object = isRecord(envelope.object) ? envelope.object : null;
1252
+ if (object?.type !== void 0 && object.type !== "Note") return null;
1253
+ if (!object) throw new NextcloudTalkWebhookPayloadError("Nextcloud Talk webhook is missing object.");
1254
+ const target = isRecord(envelope.target) ? envelope.target : null;
1255
+ return {
1256
+ eventId: requiredString(object.id, "object.id"),
1257
+ laneKey: `room:${requiredString(target?.id, "target.id")}`
1258
+ };
1259
+ }
1260
+ function parseLegacyReplayKey(key) {
1261
+ const separator = key.lastIndexOf(":");
1262
+ const roomId = key.slice(0, separator).trim();
1263
+ const messageId = key.slice(separator + 1).trim();
1264
+ return separator > 0 && roomId && messageId ? {
1265
+ messageId,
1266
+ roomId
1267
+ } : null;
1268
+ }
1269
+ /** Convert the shipped replay guard's live window into durable completion tombstones. */
1270
+ async function migrateNextcloudTalkLegacyReplayState(params) {
1271
+ const entries = await params.store.entries();
1272
+ let migrated = 0;
1273
+ for (const entry of entries) {
1274
+ const identity = parseLegacyReplayKey(entry.value.key);
1275
+ if (!identity || !Number.isFinite(entry.value.seenAt)) continue;
1276
+ const marker = {
1277
+ version: 1,
1278
+ receivedAt: entry.value.seenAt,
1279
+ rawEvent: ""
1280
+ };
1281
+ const result = await params.queue.enqueue(identity.messageId, marker, {
1282
+ receivedAt: entry.value.seenAt,
1283
+ laneKey: `room:${identity.roomId}`
1284
+ });
1285
+ if (result.kind === "accepted" || result.kind === "pending" && result.record.payload.rawEvent === "") {
1286
+ if (!await params.queue.complete(identity.messageId, { completedAt: entry.value.seenAt })) throw new Error(`Failed to migrate Nextcloud Talk replay key ${entry.value.key}.`);
1287
+ }
1288
+ migrated += 1;
1289
+ }
1290
+ await params.store.clear();
1291
+ return migrated;
1292
+ }
1162
1293
  //#endregion
1163
1294
  //#region extensions/nextcloud-talk/src/monitor.ts
1164
1295
  const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
@@ -1166,34 +1297,6 @@ const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
1166
1297
  const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5e3;
1167
1298
  const HEALTH_PATH = "/healthz";
1168
1299
  const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
1169
- const NextcloudTalkWebhookPayloadSchema = z.object({
1170
- type: z.enum([
1171
- "Create",
1172
- "Update",
1173
- "Delete"
1174
- ]),
1175
- actor: z.object({
1176
- type: z.literal("Person"),
1177
- id: z.string().min(1),
1178
- name: z.string()
1179
- }),
1180
- object: z.object({
1181
- type: z.literal("Note"),
1182
- id: z.string().min(1),
1183
- name: z.string(),
1184
- content: z.string(),
1185
- mediaType: z.string()
1186
- }),
1187
- target: z.object({
1188
- type: z.literal("Collection"),
1189
- id: z.string().min(1),
1190
- name: z.string()
1191
- })
1192
- });
1193
- const NextcloudTalkWebhookEnvelopeSchema = z.object({
1194
- type: z.string().min(1),
1195
- object: z.object({ type: z.string().min(1).optional() }).passthrough().optional()
1196
- });
1197
1300
  const WEBHOOK_ERRORS = {
1198
1301
  missingSignatureHeaders: "Missing signature headers",
1199
1302
  invalidBackend: "Invalid backend",
@@ -1202,20 +1305,10 @@ const WEBHOOK_ERRORS = {
1202
1305
  payloadTooLarge: "Payload too large",
1203
1306
  internalServerError: "Internal server error"
1204
1307
  };
1205
- async function processNextcloudTalkReplayGuardedMessage(params) {
1206
- return (await params.replayGuard.processGuarded({
1207
- accountId: params.accountId,
1208
- roomToken: params.message.roomToken,
1209
- messageId: params.message.messageId
1210
- }, params.handleMessage, { onError: "commit" })).kind === "processed" ? "processed" : "duplicate";
1211
- }
1212
1308
  function formatError(err) {
1213
1309
  if (err instanceof Error) return err.message;
1214
1310
  return typeof err === "string" ? err : JSON.stringify(err);
1215
1311
  }
1216
- function parseWebhookPayload(body) {
1217
- return safeParseJsonWithSchema(NextcloudTalkWebhookPayloadSchema, body);
1218
- }
1219
1312
  function writeJsonResponse(res, status, body) {
1220
1313
  if (body) {
1221
1314
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -1255,37 +1348,6 @@ function verifyWebhookSignature(params) {
1255
1348
  params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
1256
1349
  return true;
1257
1350
  }
1258
- function decodeWebhookCreateMessage(params) {
1259
- const envelope = safeParseJsonWithSchema(NextcloudTalkWebhookEnvelopeSchema, params.body);
1260
- if (!envelope) {
1261
- writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
1262
- return { kind: "invalid" };
1263
- }
1264
- if (envelope.type !== "Create") return { kind: "ignore" };
1265
- if (envelope.object?.type && envelope.object.type !== "Note") return { kind: "ignore" };
1266
- const payload = parseWebhookPayload(params.body);
1267
- if (!payload) {
1268
- writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
1269
- return { kind: "invalid" };
1270
- }
1271
- return {
1272
- kind: "message",
1273
- message: payloadToInboundMessage(payload)
1274
- };
1275
- }
1276
- function payloadToInboundMessage(payload) {
1277
- return {
1278
- messageId: payload.object.id,
1279
- roomToken: payload.target.id,
1280
- roomName: payload.target.name,
1281
- senderId: payload.actor.id,
1282
- senderName: payload.actor.name ?? "",
1283
- text: payload.object.content || payload.object.name || "",
1284
- mediaType: payload.object.mediaType || "text/plain",
1285
- timestamp: Date.now(),
1286
- isGroupChat: true
1287
- };
1288
- }
1289
1351
  function readNextcloudTalkWebhookBody(req, maxBodyBytes) {
1290
1352
  return readRequestBodyWithLimit(req, {
1291
1353
  maxBytes: Math.min(maxBodyBytes, PREAUTH_WEBHOOK_MAX_BODY_BYTES),
@@ -1293,12 +1355,10 @@ function readNextcloudTalkWebhookBody(req, maxBodyBytes) {
1293
1355
  });
1294
1356
  }
1295
1357
  function createNextcloudTalkWebhookServer(opts) {
1296
- const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
1358
+ const { port, host, path, secret, onWebhook, onError, abortSignal } = opts;
1297
1359
  const maxBodyBytes = typeof opts.maxBodyBytes === "number" && Number.isFinite(opts.maxBodyBytes) && opts.maxBodyBytes > 0 ? Math.floor(opts.maxBodyBytes) : DEFAULT_WEBHOOK_MAX_BODY_BYTES;
1298
1360
  const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
1299
1361
  const isBackendAllowed = opts.isBackendAllowed;
1300
- const shouldProcessMessage = opts.shouldProcessMessage;
1301
- const processMessage = opts.processMessage;
1302
1362
  const authRateLimitMaxRequests = typeof opts.authRateLimit?.maxRequests === "number" ? opts.authRateLimit.maxRequests : WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests;
1303
1363
  const authRateLimitWindowMs = typeof opts.authRateLimit?.windowMs === "number" ? opts.authRateLimit.windowMs : WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs;
1304
1364
  const webhookAuthRateLimiter = createAuthRateLimiter({
@@ -1342,37 +1402,8 @@ function createNextcloudTalkWebhookServer(opts) {
1342
1402
  clientIp,
1343
1403
  authRateLimiter: webhookAuthRateLimiter
1344
1404
  })) return;
1345
- const decoded = decodeWebhookCreateMessage({
1346
- body,
1347
- res
1348
- });
1349
- if (decoded.kind === "invalid") return;
1350
- if (decoded.kind === "ignore") {
1351
- writeJsonResponse(res, 200);
1352
- return;
1353
- }
1354
- const message = decoded.message;
1355
- if (processMessage) {
1356
- writeJsonResponse(res, 200);
1357
- try {
1358
- await processMessage(message);
1359
- } catch (err) {
1360
- onError?.(err instanceof Error ? err : new Error(formatError(err)));
1361
- }
1362
- return;
1363
- }
1364
- if (shouldProcessMessage) {
1365
- if (!await shouldProcessMessage(message)) {
1366
- writeJsonResponse(res, 200);
1367
- return;
1368
- }
1369
- }
1405
+ await onWebhook(body);
1370
1406
  writeJsonResponse(res, 200);
1371
- try {
1372
- await onMessage(message);
1373
- } catch (err) {
1374
- onError?.(err instanceof Error ? err : new Error(formatError(err)));
1375
- }
1376
1407
  } catch (err) {
1377
1408
  if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
1378
1409
  writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
@@ -1382,27 +1413,48 @@ function createNextcloudTalkWebhookServer(opts) {
1382
1413
  writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
1383
1414
  return;
1384
1415
  }
1416
+ if (err instanceof NextcloudTalkWebhookPayloadError) {
1417
+ writeWebhookError(res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
1418
+ return;
1419
+ }
1385
1420
  const error = err instanceof Error ? err : new Error(formatError(err));
1386
1421
  onError?.(error);
1387
1422
  writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
1388
1423
  }
1389
1424
  })();
1390
1425
  });
1391
- const start = () => {
1392
- return new Promise((resolve) => {
1393
- server.listen(port, host, () => resolve());
1426
+ let stopRequested = false;
1427
+ let closePromise;
1428
+ const closeIfListening = () => {
1429
+ if (closePromise) return closePromise;
1430
+ if (!server.listening) return Promise.resolve();
1431
+ closePromise = new Promise((resolve) => {
1432
+ server.close(() => resolve());
1433
+ }).finally(() => {
1434
+ closePromise = void 0;
1394
1435
  });
1436
+ return closePromise;
1395
1437
  };
1396
- let stopped = false;
1397
- const stop = () => {
1398
- if (stopped) return;
1399
- stopped = true;
1400
- try {
1401
- server.close();
1402
- } catch {}
1438
+ const stop = async () => {
1439
+ stopRequested = true;
1440
+ await closeIfListening();
1441
+ };
1442
+ const start = () => {
1443
+ if (stopRequested) return Promise.resolve();
1444
+ return new Promise((resolve, reject) => {
1445
+ const onListenError = (error) => reject(error);
1446
+ server.once("error", onListenError);
1447
+ server.listen(port, host, () => {
1448
+ server.off("error", onListenError);
1449
+ (async () => {
1450
+ if (stopRequested) await closeIfListening();
1451
+ resolve();
1452
+ })().catch(reject);
1453
+ });
1454
+ });
1403
1455
  };
1404
1456
  if (abortSignal) if (abortSignal.aborted) stop();
1405
- else abortSignal.addEventListener("abort", stop, { once: true });
1457
+ else abortSignal.addEventListener("abort", () => void stop(), { once: true });
1406
1458
  return {
1407
1459
  server,
1408
1460
  start,
@@ -1410,6 +1462,164 @@ function createNextcloudTalkWebhookServer(opts) {
1410
1462
  };
1411
1463
  }
1412
1464
  //#endregion
1465
+ //#region extensions/nextcloud-talk/src/webhook-spool.ts
1466
+ const NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS = 500;
1467
+ const NEXTCLOUD_TALK_INGRESS_PRUNE_INTERVAL_MS = 3600 * 1e3;
1468
+ const NEXTCLOUD_TALK_INGRESS_COMPLETED_TTL_MS = 720 * 60 * 60 * 1e3;
1469
+ const NEXTCLOUD_TALK_INGRESS_COMPLETED_MAX_ENTRIES = 1e4;
1470
+ const NEXTCLOUD_TALK_INGRESS_FAILED_TTL_MS = 720 * 60 * 60 * 1e3;
1471
+ const NEXTCLOUD_TALK_INGRESS_FAILED_MAX_ENTRIES = 1e4;
1472
+ const NextcloudTalkWebhookPayloadSchema = z.object({
1473
+ type: z.enum([
1474
+ "Create",
1475
+ "Update",
1476
+ "Delete"
1477
+ ]),
1478
+ actor: z.object({
1479
+ type: z.literal("Person"),
1480
+ id: z.string().min(1),
1481
+ name: z.string()
1482
+ }),
1483
+ object: z.object({
1484
+ type: z.literal("Note"),
1485
+ id: z.string().min(1),
1486
+ name: z.string(),
1487
+ content: z.string(),
1488
+ mediaType: z.string()
1489
+ }),
1490
+ target: z.object({
1491
+ type: z.literal("Collection"),
1492
+ id: z.string().min(1),
1493
+ name: z.string()
1494
+ })
1495
+ });
1496
+ function parseClaimedMessage(payload, claimedId, claimedLaneKey) {
1497
+ if (payload.version !== 1) throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk ingress row ${claimedId} has an unsupported version.`);
1498
+ const result = NextcloudTalkWebhookPayloadSchema.safeParse(parseRawObject(payload.rawEvent));
1499
+ if (!result.success || result.data.type !== "Create" || result.data.object.id !== claimedId) throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk ingress row ${claimedId} has invalid message identity.`);
1500
+ const webhook = result.data;
1501
+ const roomId = requiredString(webhook.target.id, "target.id");
1502
+ if (claimedLaneKey !== `room:${roomId}`) throw new NextcloudTalkWebhookPayloadError(`Nextcloud Talk ingress row ${claimedId} changed room identity.`);
1503
+ return {
1504
+ messageId: webhook.object.id,
1505
+ roomToken: roomId,
1506
+ roomName: webhook.target.name,
1507
+ senderId: webhook.actor.id,
1508
+ senderName: webhook.actor.name,
1509
+ text: webhook.object.content || webhook.object.name,
1510
+ mediaType: webhook.object.mediaType || "text/plain",
1511
+ timestamp: payload.receivedAt,
1512
+ isGroupChat: true
1513
+ };
1514
+ }
1515
+ function resolveNonRetryableFailure(error) {
1516
+ if (error instanceof NextcloudTalkWebhookPayloadError) return {
1517
+ reason: "invalid-event",
1518
+ message: error.message
1519
+ };
1520
+ const message = formatErrorMessage(error);
1521
+ if (message.includes("Nextcloud Talk: bot send was rejected") || message.includes("Nextcloud Talk: forbidden")) return {
1522
+ reason: "nextcloud-talk-auth",
1523
+ message
1524
+ };
1525
+ return null;
1526
+ }
1527
+ function createNextcloudTalkWebhookSpool(options) {
1528
+ let queue = options.queue;
1529
+ const getQueue = () => {
1530
+ queue ??= getNextcloudTalkRuntime().state.openChannelIngressQueue({ accountId: options.accountId });
1531
+ return queue;
1532
+ };
1533
+ const legacyReplayStore = options.legacyReplayStore === null ? null : options.legacyReplayStore ?? getNextcloudTalkRuntime().state.openKeyedStore({
1534
+ namespace: resolvePersistentDedupePluginStateNamespace({
1535
+ namespace: options.accountId,
1536
+ namespacePrefix: "replay-dedupe"
1537
+ }),
1538
+ maxEntries: 1e4,
1539
+ defaultTtlMs: 864e5
1540
+ });
1541
+ const legacyMigration = legacyReplayStore ? migrateNextcloudTalkLegacyReplayState({
1542
+ queue: getQueue(),
1543
+ store: legacyReplayStore
1544
+ }) : Promise.resolve();
1545
+ const monitor = createChannelIngressMonitor({
1546
+ queue: getQueue,
1547
+ inspect: (rawEvent) => inspectNextcloudTalkWebhookEnvelope(rawEvent),
1548
+ payload: {
1549
+ version: 1,
1550
+ serialize: (rawEvent, { receivedAt }) => ({
1551
+ receivedAt,
1552
+ rawEvent
1553
+ }),
1554
+ deserialize: (body) => body.rawEvent,
1555
+ encode: ({ body }) => ({
1556
+ version: 1,
1557
+ ...body
1558
+ }),
1559
+ decode: (payload) => ({
1560
+ version: payload.version,
1561
+ body: {
1562
+ receivedAt: payload.receivedAt,
1563
+ rawEvent: payload.rawEvent
1564
+ }
1565
+ }),
1566
+ 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.`)
1567
+ },
1568
+ deliver: async (_rawEvent, lifecycle, claim) => {
1569
+ const message = parseClaimedMessage(claim.payload, claim.id, claim.laneKey);
1570
+ await options.deliver(message, lifecycle);
1571
+ },
1572
+ pollIntervalMs: options.pollIntervalMs ?? NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS,
1573
+ waitForDeliveryIdleBeforeRepump: true,
1574
+ retention: {
1575
+ pruneIntervalMs: NEXTCLOUD_TALK_INGRESS_PRUNE_INTERVAL_MS,
1576
+ completedTtlMs: NEXTCLOUD_TALK_INGRESS_COMPLETED_TTL_MS,
1577
+ completedMaxEntries: NEXTCLOUD_TALK_INGRESS_COMPLETED_MAX_ENTRIES,
1578
+ failedTtlMs: NEXTCLOUD_TALK_INGRESS_FAILED_TTL_MS,
1579
+ failedMaxEntries: NEXTCLOUD_TALK_INGRESS_FAILED_MAX_ENTRIES
1580
+ },
1581
+ drain: {
1582
+ resolveNonRetryableFailure,
1583
+ ...options.adoptionStallTimeoutMs === void 0 ? {} : { adoptionStallTimeoutMs: options.adoptionStallTimeoutMs },
1584
+ onLog: (message) => options.runtime.log?.(`nextcloud-talk ${message}`)
1585
+ },
1586
+ ...options.abortSignal ? { abortSignal: options.abortSignal } : {},
1587
+ createStoppedError: () => /* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"),
1588
+ onError: (error) => options.runtime.error?.(`nextcloud-talk ingress drain failed: ${formatErrorMessage(error)}`)
1589
+ });
1590
+ let stopping = false;
1591
+ const inFlightReceives = /* @__PURE__ */ new Set();
1592
+ const startAfterMigration = legacyMigration.then(() => {
1593
+ if (!stopping) monitor.start();
1594
+ });
1595
+ return {
1596
+ ready: async () => await startAfterMigration,
1597
+ receive: (rawEvent) => {
1598
+ if (stopping) return Promise.reject(/* @__PURE__ */ new Error("Nextcloud Talk ingress stopped"));
1599
+ const receiveTask = (async () => {
1600
+ await startAfterMigration;
1601
+ return (await monitor.admit(rawEvent)).kind === "ignored" ? "ignored" : "accepted";
1602
+ })();
1603
+ inFlightReceives.add(receiveTask);
1604
+ receiveTask.then(() => inFlightReceives.delete(receiveTask), () => inFlightReceives.delete(receiveTask));
1605
+ return receiveTask;
1606
+ },
1607
+ stop: async () => {
1608
+ stopping = true;
1609
+ const pendingReceives = [...inFlightReceives];
1610
+ const pauseTask = monitor.pause();
1611
+ await startAfterMigration.catch(() => void 0);
1612
+ await Promise.allSettled(pendingReceives);
1613
+ await monitor.stop();
1614
+ await pauseTask;
1615
+ },
1616
+ waitForIdle: async () => {
1617
+ await startAfterMigration.catch(() => void 0);
1618
+ await monitor.waitForIdle();
1619
+ }
1620
+ };
1621
+ }
1622
+ //#endregion
1413
1623
  //#region extensions/nextcloud-talk/src/monitor-runtime.ts
1414
1624
  const DEFAULT_WEBHOOK_PORT = 8788;
1415
1625
  const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
@@ -1438,13 +1648,29 @@ async function monitorNextcloudTalkProvider(opts) {
1438
1648
  accountId: account.accountId
1439
1649
  });
1440
1650
  const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
1441
- const replayGuard = createNextcloudTalkReplayGuard({
1442
- stateDir: core.state.resolveStateDir(process.env, os.homedir),
1443
- onDiskError: (error) => {
1444
- logger.warn(`[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`);
1651
+ const spool = (opts.createSpool ?? createNextcloudTalkWebhookSpool)({
1652
+ accountId: account.accountId,
1653
+ runtime,
1654
+ abortSignal: opts.abortSignal,
1655
+ deliver: async (message, lifecycle) => {
1656
+ core.channel.activity.record({
1657
+ channel: "nextcloud-talk",
1658
+ accountId: account.accountId,
1659
+ direction: "inbound",
1660
+ at: message.timestamp
1661
+ });
1662
+ if (opts.onMessage) await opts.onMessage(message, lifecycle);
1663
+ else await handleNextcloudTalkInbound({
1664
+ message,
1665
+ account,
1666
+ config: cfg,
1667
+ runtime,
1668
+ statusSink: opts.statusSink,
1669
+ turnAdoptionLifecycle: lifecycle
1670
+ });
1445
1671
  }
1446
1672
  });
1447
- const { start, stop } = createNextcloudTalkWebhookServer({
1673
+ const server = (opts.createServer ?? createNextcloudTalkWebhookServer)({
1448
1674
  port,
1449
1675
  host,
1450
1676
  path,
@@ -1453,39 +1679,34 @@ async function monitorNextcloudTalkProvider(opts) {
1453
1679
  if (!expectedBackendOrigin) return true;
1454
1680
  return normalizeOrigin(backend) === expectedBackendOrigin;
1455
1681
  },
1456
- processMessage: async (message) => {
1457
- if (await processNextcloudTalkReplayGuardedMessage({
1458
- replayGuard,
1459
- accountId: account.accountId,
1460
- message,
1461
- handleMessage: async () => {
1462
- core.channel.activity.record({
1463
- channel: "nextcloud-talk",
1464
- accountId: account.accountId,
1465
- direction: "inbound",
1466
- at: message.timestamp
1467
- });
1468
- if (opts.onMessage) await opts.onMessage(message);
1469
- else await handleNextcloudTalkInbound({
1470
- message,
1471
- account,
1472
- config: cfg,
1473
- runtime,
1474
- statusSink: opts.statusSink
1475
- });
1476
- }
1477
- }) === "duplicate") logger.warn(`[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`);
1478
- },
1479
- onMessage: async () => {},
1682
+ onWebhook: spool.receive,
1480
1683
  onError: (error) => {
1481
1684
  logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
1482
1685
  },
1483
1686
  abortSignal: opts.abortSignal
1484
1687
  });
1485
- if (opts.abortSignal?.aborted) return { stop };
1486
- await start();
1688
+ let stopPromise;
1689
+ const stop = () => {
1690
+ stopPromise ??= (async () => {
1691
+ await server.stop();
1692
+ await spool.stop();
1693
+ })();
1694
+ return stopPromise;
1695
+ };
1696
+ if (opts.abortSignal && !opts.abortSignal.aborted) opts.abortSignal.addEventListener("abort", () => void stop(), { once: true });
1487
1697
  if (opts.abortSignal?.aborted) {
1488
- stop();
1698
+ await stop();
1699
+ return { stop };
1700
+ }
1701
+ try {
1702
+ await spool.ready();
1703
+ await server.start();
1704
+ } catch (error) {
1705
+ await stop();
1706
+ throw error;
1707
+ }
1708
+ if (opts.abortSignal?.aborted) {
1709
+ await stop();
1489
1710
  return { stop };
1490
1711
  }
1491
1712
  const publicUrl = account.config.webhookPublicUrl ?? `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
@@ -1503,7 +1724,7 @@ const nextcloudTalkGatewayAdapter = {
1503
1724
  accountId: ctx.accountId,
1504
1725
  setStatus: ctx.setStatus
1505
1726
  });
1506
- await runStoppablePassiveMonitor({
1727
+ await runPassiveAccountLifecycle({
1507
1728
  abortSignal: ctx.abortSignal,
1508
1729
  start: async () => await monitorNextcloudTalkProvider({
1509
1730
  accountId: account.accountId,
@@ -1511,7 +1732,10 @@ const nextcloudTalkGatewayAdapter = {
1511
1732
  runtime: ctx.runtime,
1512
1733
  abortSignal: ctx.abortSignal,
1513
1734
  statusSink
1514
- })
1735
+ }),
1736
+ stop: async (monitor) => {
1737
+ await monitor.stop();
1738
+ }
1515
1739
  });
1516
1740
  },
1517
1741
  logoutAccount: async ({ accountId, cfg }) => {
@@ -1665,6 +1889,9 @@ function resolveNextcloudTalkOutboundSessionRoute(params) {
1665
1889
  //#region extensions/nextcloud-talk/src/setup-core.ts
1666
1890
  const t$1 = createSetupTranslator$1();
1667
1891
  const channel$1 = "nextcloud-talk";
1892
+ function readOptionalString(value) {
1893
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1894
+ }
1668
1895
  function addWildcardAllowFrom(allowFrom) {
1669
1896
  return mergeAllowFromEntries(allowFrom, ["*"]);
1670
1897
  }
@@ -1783,7 +2010,17 @@ const nextcloudTalkDmPolicy = {
1783
2010
  promptAllowFrom: promptNextcloudTalkAllowFromForAccount
1784
2011
  };
1785
2012
  const nextcloudTalkSetupAdapter = {
2013
+ singleAccountKeysToMove: ["rooms"],
1786
2014
  resolveAccountId: ({ accountId }) => normalizeAccountId$1(accountId),
2015
+ prepareAccountConfigInput: ({ input }) => {
2016
+ const setupInput = input;
2017
+ return {
2018
+ ...setupInput,
2019
+ baseUrl: setupInput.baseUrl ?? readOptionalString(setupInput.url),
2020
+ secret: setupInput.secret ?? readOptionalString(setupInput.token) ?? readOptionalString(setupInput.password),
2021
+ secretFile: setupInput.secretFile ?? readOptionalString(setupInput.tokenFile)
2022
+ };
2023
+ },
1787
2024
  applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
1788
2025
  cfg,
1789
2026
  channelKey: channel$1,
@@ -1813,6 +2050,72 @@ const nextcloudTalkSetupAdapter = {
1813
2050
  });
1814
2051
  }
1815
2052
  };
2053
+ const nextcloudTalkSetupContract = defineChannelSetupContract({
2054
+ fields: {
2055
+ baseUrl: {
2056
+ kind: "string",
2057
+ cli: {
2058
+ flags: "--base-url <url>",
2059
+ description: "Nextcloud base URL"
2060
+ }
2061
+ },
2062
+ url: {
2063
+ kind: "string",
2064
+ cli: {
2065
+ flags: "--url <url>",
2066
+ description: "Legacy Nextcloud base URL alias"
2067
+ }
2068
+ },
2069
+ secret: {
2070
+ kind: "string",
2071
+ sensitive: true,
2072
+ cli: {
2073
+ flags: "--secret <secret>",
2074
+ description: "Nextcloud Talk bot secret"
2075
+ }
2076
+ },
2077
+ token: {
2078
+ kind: "string",
2079
+ sensitive: true,
2080
+ cli: {
2081
+ flags: "--token <secret>",
2082
+ description: "Legacy Nextcloud bot secret alias"
2083
+ }
2084
+ },
2085
+ password: {
2086
+ kind: "string",
2087
+ sensitive: true,
2088
+ cli: {
2089
+ flags: "--password <secret>",
2090
+ description: "Legacy Nextcloud bot secret alias"
2091
+ }
2092
+ },
2093
+ secretFile: {
2094
+ kind: "string",
2095
+ sensitive: true,
2096
+ cli: {
2097
+ flags: "--secret-file <path>",
2098
+ description: "Nextcloud Talk bot secret file"
2099
+ }
2100
+ },
2101
+ tokenFile: {
2102
+ kind: "string",
2103
+ sensitive: true,
2104
+ cli: {
2105
+ flags: "--token-file <path>",
2106
+ description: "Legacy Nextcloud bot secret file alias"
2107
+ }
2108
+ },
2109
+ useEnv: {
2110
+ kind: "boolean",
2111
+ cli: {
2112
+ flags: "--use-env",
2113
+ description: "Use Nextcloud Talk environment credentials"
2114
+ }
2115
+ }
2116
+ },
2117
+ legacyAdapter: nextcloudTalkSetupAdapter
2118
+ });
1816
2119
  //#endregion
1817
2120
  //#region extensions/nextcloud-talk/src/setup-surface.ts
1818
2121
  const t = createSetupTranslator();
@@ -1992,12 +2295,14 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
1992
2295
  configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
1993
2296
  config: {
1994
2297
  ...nextcloudTalkConfigAdapter,
1995
- isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
2298
+ isConfigured: (account) => Boolean(account.tokenStatus !== "missing" && account.baseUrl?.trim()),
1996
2299
  describeAccount: (account) => describeWebhookAccountSnapshot({
1997
2300
  account,
1998
- configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
2301
+ configured: Boolean(account.tokenStatus !== "missing" && account.baseUrl?.trim()),
1999
2302
  extra: {
2000
2303
  secretSource: account.secretSource,
2304
+ tokenStatus: account.tokenStatus,
2305
+ apiCredentialStatus: account.apiCredentialStatus,
2001
2306
  baseUrl: account.baseUrl ? "[set]" : "[missing]"
2002
2307
  }
2003
2308
  })
@@ -2026,6 +2331,7 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
2026
2331
  collectRuntimeConfigAssignments
2027
2332
  },
2028
2333
  setup: nextcloudTalkSetupAdapter,
2334
+ setupContract: nextcloudTalkSetupContract,
2029
2335
  status: createComputedAccountStatusAdapter({
2030
2336
  defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID$2),
2031
2337
  buildChannelSummary: ({ snapshot }) => buildWebhookChannelStatusSummary(snapshot, { secretSource: snapshot.secretSource ?? "none" }),
@@ -2048,9 +2354,11 @@ const nextcloudTalkPlugin = createChatChannelPlugin({
2048
2354
  accountId: account.accountId,
2049
2355
  name: account.name,
2050
2356
  enabled: account.enabled,
2051
- configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
2357
+ configured: Boolean(account.tokenStatus !== "missing" && account.baseUrl?.trim()),
2052
2358
  extra: {
2053
2359
  secretSource: account.secretSource,
2360
+ tokenStatus: account.tokenStatus,
2361
+ apiCredentialStatus: account.apiCredentialStatus,
2054
2362
  baseUrl: account.baseUrl ? "[set]" : "[missing]",
2055
2363
  mode: "webhook"
2056
2364
  }
@@ -1,2 +1,2 @@
1
- import { t as nextcloudTalkPlugin } from "./channel-BSnehBf6.js";
1
+ import { t as nextcloudTalkPlugin } from "./channel-DtnECxVM.js";
2
2
  export { nextcloudTalkPlugin };
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@openclaw/nextcloud-talk",
3
- "version": "2026.7.2-beta.2",
3
+ "version": "2026.7.2-beta.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@openclaw/nextcloud-talk",
9
- "version": "2026.7.2-beta.2",
9
+ "version": "2026.7.2-beta.4",
10
10
  "dependencies": {
11
11
  "zod": "4.4.3"
12
12
  },
13
13
  "peerDependencies": {
14
- "openclaw": ">=2026.7.2-beta.2"
14
+ "openclaw": ">=2026.7.2-beta.4"
15
15
  },
16
16
  "peerDependenciesMeta": {
17
17
  "openclaw": {
@@ -9,12 +9,6 @@
9
9
  "channels": [
10
10
  "nextcloud-talk"
11
11
  ],
12
- "channelEnvVars": {
13
- "nextcloud-talk": [
14
- "NEXTCLOUD_TALK_BOT_SECRET",
15
- "NEXTCLOUD_TALK_API_PASSWORD"
16
- ]
17
- },
18
12
  "configSchema": {
19
13
  "type": "object",
20
14
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/nextcloud-talk",
3
- "version": "2026.7.2-beta.2",
3
+ "version": "2026.7.2-beta.4",
4
4
  "description": "OpenClaw Nextcloud Talk channel plugin for conversations.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "type": "module",
10
10
  "peerDependencies": {
11
- "openclaw": ">=2026.7.2-beta.2"
11
+ "openclaw": ">=2026.7.2-beta.4"
12
12
  },
13
13
  "peerDependenciesMeta": {
14
14
  "openclaw": {
@@ -20,8 +20,19 @@
20
20
  "./index.ts"
21
21
  ],
22
22
  "setupEntry": "./dist/setup-entry.js",
23
+ "setupFeatures": {
24
+ "configPromotion": true
25
+ },
23
26
  "channel": {
24
27
  "id": "nextcloud-talk",
28
+ "configuredState": {
29
+ "env": {
30
+ "anyOf": [
31
+ "NEXTCLOUD_TALK_BOT_SECRET",
32
+ "NEXTCLOUD_TALK_API_PASSWORD"
33
+ ]
34
+ }
35
+ },
25
36
  "label": "Nextcloud Talk",
26
37
  "selectionLabel": "Nextcloud Talk (self-hosted)",
27
38
  "docsPath": "/channels/nextcloud-talk",
@@ -32,7 +43,80 @@
32
43
  "nc"
33
44
  ],
34
45
  "order": 65,
35
- "quickstartAllowFrom": true
46
+ "quickstartAllowFrom": true,
47
+ "setup": {
48
+ "fields": [
49
+ {
50
+ "key": "baseUrl",
51
+ "kind": "string",
52
+ "cli": {
53
+ "flags": "--base-url <url>",
54
+ "description": "Nextcloud base URL"
55
+ }
56
+ },
57
+ {
58
+ "key": "url",
59
+ "kind": "string",
60
+ "cli": {
61
+ "flags": "--url <url>",
62
+ "description": "Legacy Nextcloud base URL alias"
63
+ }
64
+ },
65
+ {
66
+ "key": "secret",
67
+ "kind": "string",
68
+ "sensitive": true,
69
+ "cli": {
70
+ "flags": "--secret <secret>",
71
+ "description": "Nextcloud Talk bot secret"
72
+ }
73
+ },
74
+ {
75
+ "key": "token",
76
+ "kind": "string",
77
+ "sensitive": true,
78
+ "cli": {
79
+ "flags": "--token <secret>",
80
+ "description": "Legacy Nextcloud bot secret alias"
81
+ }
82
+ },
83
+ {
84
+ "key": "password",
85
+ "kind": "string",
86
+ "sensitive": true,
87
+ "cli": {
88
+ "flags": "--password <secret>",
89
+ "description": "Legacy Nextcloud bot secret alias"
90
+ }
91
+ },
92
+ {
93
+ "key": "secretFile",
94
+ "kind": "string",
95
+ "sensitive": true,
96
+ "cli": {
97
+ "flags": "--secret-file <path>",
98
+ "description": "Nextcloud Talk bot secret file"
99
+ }
100
+ },
101
+ {
102
+ "key": "tokenFile",
103
+ "kind": "string",
104
+ "sensitive": true,
105
+ "cli": {
106
+ "flags": "--token-file <path>",
107
+ "description": "Legacy Nextcloud bot secret file alias"
108
+ }
109
+ },
110
+ {
111
+ "key": "useEnv",
112
+ "kind": "boolean",
113
+ "cli": {
114
+ "flags": "--use-env",
115
+ "description": "Use Nextcloud Talk environment credentials"
116
+ }
117
+ }
118
+ ]
119
+ }
36
120
  },
37
121
  "install": {
38
122
  "npmSpec": "@openclaw/nextcloud-talk",
@@ -40,10 +124,10 @@
40
124
  "minHostVersion": ">=2026.4.10"
41
125
  },
42
126
  "compat": {
43
- "pluginApi": ">=2026.7.2-beta.2"
127
+ "pluginApi": ">=2026.7.2-beta.4"
44
128
  },
45
129
  "build": {
46
- "openclawVersion": "2026.7.2-beta.2"
130
+ "openclawVersion": "2026.7.2-beta.4"
47
131
  },
48
132
  "release": {
49
133
  "publishToClawHub": true,