@openclaw/nextcloud-talk 2026.1.29

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/src/format.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Format utilities for Nextcloud Talk messages.
3
+ *
4
+ * Nextcloud Talk supports markdown natively, so most formatting passes through.
5
+ * This module handles any edge cases or transformations needed.
6
+ */
7
+
8
+ /**
9
+ * Convert markdown to Nextcloud Talk compatible format.
10
+ * Nextcloud Talk supports standard markdown, so minimal transformation needed.
11
+ */
12
+ export function markdownToNextcloudTalk(text: string): string {
13
+ return text.trim();
14
+ }
15
+
16
+ /**
17
+ * Escape special characters in text to prevent markdown interpretation.
18
+ */
19
+ export function escapeNextcloudTalkMarkdown(text: string): string {
20
+ return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1");
21
+ }
22
+
23
+ /**
24
+ * Format a mention for a Nextcloud user.
25
+ * Nextcloud Talk uses @user format for mentions.
26
+ */
27
+ export function formatNextcloudTalkMention(userId: string): string {
28
+ return `@${userId.replace(/^@/, "")}`;
29
+ }
30
+
31
+ /**
32
+ * Format a code block for Nextcloud Talk.
33
+ */
34
+ export function formatNextcloudTalkCodeBlock(code: string, language?: string): string {
35
+ const lang = language ?? "";
36
+ return `\`\`\`${lang}\n${code}\n\`\`\``;
37
+ }
38
+
39
+ /**
40
+ * Format inline code for Nextcloud Talk.
41
+ */
42
+ export function formatNextcloudTalkInlineCode(code: string): string {
43
+ if (code.includes("`")) {
44
+ return `\`\` ${code} \`\``;
45
+ }
46
+ return `\`${code}\``;
47
+ }
48
+
49
+ /**
50
+ * Strip Nextcloud Talk specific formatting from text.
51
+ * Useful for extracting plain text content.
52
+ */
53
+ export function stripNextcloudTalkFormatting(text: string): string {
54
+ return (
55
+ text
56
+ .replace(/```[\s\S]*?```/g, "")
57
+ .replace(/`[^`]+`/g, "")
58
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
59
+ .replace(/\*([^*]+)\*/g, "$1")
60
+ .replace(/_([^_]+)_/g, "$1")
61
+ .replace(/~~([^~]+)~~/g, "$1")
62
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
63
+ .replace(/\s+/g, " ")
64
+ .trim()
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Truncate text to a maximum length, preserving word boundaries.
70
+ */
71
+ export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string {
72
+ if (text.length <= maxLength) return text;
73
+ const truncated = text.slice(0, maxLength - suffix.length);
74
+ const lastSpace = truncated.lastIndexOf(" ");
75
+ if (lastSpace > maxLength * 0.7) {
76
+ return truncated.slice(0, lastSpace) + suffix;
77
+ }
78
+ return truncated + suffix;
79
+ }
package/src/inbound.ts ADDED
@@ -0,0 +1,336 @@
1
+ import {
2
+ logInboundDrop,
3
+ resolveControlCommandGate,
4
+ type OpenClawConfig,
5
+ type RuntimeEnv,
6
+ } from "openclaw/plugin-sdk";
7
+
8
+ import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
9
+ import {
10
+ normalizeNextcloudTalkAllowlist,
11
+ resolveNextcloudTalkAllowlistMatch,
12
+ resolveNextcloudTalkGroupAllow,
13
+ resolveNextcloudTalkMentionGate,
14
+ resolveNextcloudTalkRequireMention,
15
+ resolveNextcloudTalkRoomMatch,
16
+ } from "./policy.js";
17
+ import { resolveNextcloudTalkRoomKind } from "./room-info.js";
18
+ import { sendMessageNextcloudTalk } from "./send.js";
19
+ import { getNextcloudTalkRuntime } from "./runtime.js";
20
+ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
21
+
22
+ const CHANNEL_ID = "nextcloud-talk" as const;
23
+
24
+ async function deliverNextcloudTalkReply(params: {
25
+ payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
26
+ roomToken: string;
27
+ accountId: string;
28
+ statusSink?: (patch: { lastOutboundAt?: number }) => void;
29
+ }): Promise<void> {
30
+ const { payload, roomToken, accountId, statusSink } = params;
31
+ const text = payload.text ?? "";
32
+ const mediaList = payload.mediaUrls?.length
33
+ ? payload.mediaUrls
34
+ : payload.mediaUrl
35
+ ? [payload.mediaUrl]
36
+ : [];
37
+
38
+ if (!text.trim() && mediaList.length === 0) return;
39
+
40
+ const mediaBlock = mediaList.length
41
+ ? mediaList.map((url) => `Attachment: ${url}`).join("\n")
42
+ : "";
43
+ const combined = text.trim()
44
+ ? mediaBlock
45
+ ? `${text.trim()}\n\n${mediaBlock}`
46
+ : text.trim()
47
+ : mediaBlock;
48
+
49
+ await sendMessageNextcloudTalk(roomToken, combined, {
50
+ accountId,
51
+ replyTo: payload.replyToId,
52
+ });
53
+ statusSink?.({ lastOutboundAt: Date.now() });
54
+ }
55
+
56
+ export async function handleNextcloudTalkInbound(params: {
57
+ message: NextcloudTalkInboundMessage;
58
+ account: ResolvedNextcloudTalkAccount;
59
+ config: CoreConfig;
60
+ runtime: RuntimeEnv;
61
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
62
+ }): Promise<void> {
63
+ const { message, account, config, runtime, statusSink } = params;
64
+ const core = getNextcloudTalkRuntime();
65
+
66
+ const rawBody = message.text?.trim() ?? "";
67
+ if (!rawBody) return;
68
+
69
+ const roomKind = await resolveNextcloudTalkRoomKind({
70
+ account,
71
+ roomToken: message.roomToken,
72
+ runtime,
73
+ });
74
+ const isGroup =
75
+ roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
76
+ const senderId = message.senderId;
77
+ const senderName = message.senderName;
78
+ const roomToken = message.roomToken;
79
+ const roomName = message.roomName;
80
+
81
+ statusSink?.({ lastInboundAt: message.timestamp });
82
+
83
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
84
+ const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
85
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
86
+
87
+ const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
88
+ const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
89
+ const storeAllowFrom = await core.channel.pairing
90
+ .readAllowFromStore(CHANNEL_ID)
91
+ .catch(() => []);
92
+ const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
93
+
94
+ const roomMatch = resolveNextcloudTalkRoomMatch({
95
+ rooms: account.config.rooms,
96
+ roomToken,
97
+ roomName,
98
+ });
99
+ const roomConfig = roomMatch.roomConfig;
100
+ if (isGroup && !roomMatch.allowed) {
101
+ runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
102
+ return;
103
+ }
104
+ if (roomConfig?.enabled === false) {
105
+ runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`);
106
+ return;
107
+ }
108
+
109
+ const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom);
110
+ const baseGroupAllowFrom =
111
+ configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
112
+
113
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
114
+ const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean);
115
+
116
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
117
+ cfg: config as OpenClawConfig,
118
+ surface: CHANNEL_ID,
119
+ });
120
+ const useAccessGroups = config.commands?.useAccessGroups !== false;
121
+ const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
122
+ allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
123
+ senderId,
124
+ senderName,
125
+ }).allowed;
126
+ const hasControlCommand = core.channel.text.hasControlCommand(
127
+ rawBody,
128
+ config as OpenClawConfig,
129
+ );
130
+ const commandGate = resolveControlCommandGate({
131
+ useAccessGroups,
132
+ authorizers: [
133
+ {
134
+ configured:
135
+ (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
136
+ allowed: senderAllowedForCommands,
137
+ },
138
+ ],
139
+ allowTextCommands,
140
+ hasControlCommand,
141
+ });
142
+ const commandAuthorized = commandGate.commandAuthorized;
143
+
144
+ if (isGroup) {
145
+ const groupAllow = resolveNextcloudTalkGroupAllow({
146
+ groupPolicy,
147
+ outerAllowFrom: effectiveGroupAllowFrom,
148
+ innerAllowFrom: roomAllowFrom,
149
+ senderId,
150
+ senderName,
151
+ });
152
+ if (!groupAllow.allowed) {
153
+ runtime.log?.(
154
+ `nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`,
155
+ );
156
+ return;
157
+ }
158
+ } else {
159
+ if (dmPolicy === "disabled") {
160
+ runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`);
161
+ return;
162
+ }
163
+ if (dmPolicy !== "open") {
164
+ const dmAllowed = resolveNextcloudTalkAllowlistMatch({
165
+ allowFrom: effectiveAllowFrom,
166
+ senderId,
167
+ senderName,
168
+ }).allowed;
169
+ if (!dmAllowed) {
170
+ if (dmPolicy === "pairing") {
171
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
172
+ channel: CHANNEL_ID,
173
+ id: senderId,
174
+ meta: { name: senderName || undefined },
175
+ });
176
+ if (created) {
177
+ try {
178
+ await sendMessageNextcloudTalk(
179
+ roomToken,
180
+ core.channel.pairing.buildPairingReply({
181
+ channel: CHANNEL_ID,
182
+ idLine: `Your Nextcloud user id: ${senderId}`,
183
+ code,
184
+ }),
185
+ { accountId: account.accountId },
186
+ );
187
+ statusSink?.({ lastOutboundAt: Date.now() });
188
+ } catch (err) {
189
+ runtime.error?.(
190
+ `nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`,
191
+ );
192
+ }
193
+ }
194
+ }
195
+ runtime.log?.(
196
+ `nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`,
197
+ );
198
+ return;
199
+ }
200
+ }
201
+ }
202
+
203
+ if (isGroup && commandGate.shouldBlock) {
204
+ logInboundDrop({
205
+ log: (message) => runtime.log?.(message),
206
+ channel: CHANNEL_ID,
207
+ reason: "control command (unauthorized)",
208
+ target: senderId,
209
+ });
210
+ return;
211
+ }
212
+
213
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(
214
+ config as OpenClawConfig,
215
+ );
216
+ const wasMentioned = mentionRegexes.length
217
+ ? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes)
218
+ : false;
219
+ const shouldRequireMention = isGroup
220
+ ? resolveNextcloudTalkRequireMention({
221
+ roomConfig,
222
+ wildcardConfig: roomMatch.wildcardConfig,
223
+ })
224
+ : false;
225
+ const mentionGate = resolveNextcloudTalkMentionGate({
226
+ isGroup,
227
+ requireMention: shouldRequireMention,
228
+ wasMentioned,
229
+ allowTextCommands,
230
+ hasControlCommand,
231
+ commandAuthorized,
232
+ });
233
+ if (isGroup && mentionGate.shouldSkip) {
234
+ runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
235
+ return;
236
+ }
237
+
238
+ const route = core.channel.routing.resolveAgentRoute({
239
+ cfg: config as OpenClawConfig,
240
+ channel: CHANNEL_ID,
241
+ accountId: account.accountId,
242
+ peer: {
243
+ kind: isGroup ? "group" : "dm",
244
+ id: isGroup ? roomToken : senderId,
245
+ },
246
+ });
247
+
248
+ const fromLabel = isGroup
249
+ ? `room:${roomName || roomToken}`
250
+ : senderName || `user:${senderId}`;
251
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
252
+ agentId: route.agentId,
253
+ });
254
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(
255
+ config as OpenClawConfig,
256
+ );
257
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
258
+ storePath,
259
+ sessionKey: route.sessionKey,
260
+ });
261
+ const body = core.channel.reply.formatAgentEnvelope({
262
+ channel: "Nextcloud Talk",
263
+ from: fromLabel,
264
+ timestamp: message.timestamp,
265
+ previousTimestamp,
266
+ envelope: envelopeOptions,
267
+ body: rawBody,
268
+ });
269
+
270
+ const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
271
+
272
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
273
+ Body: body,
274
+ RawBody: rawBody,
275
+ CommandBody: rawBody,
276
+ From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
277
+ To: `nextcloud-talk:${roomToken}`,
278
+ SessionKey: route.sessionKey,
279
+ AccountId: route.accountId,
280
+ ChatType: isGroup ? "group" : "direct",
281
+ ConversationLabel: fromLabel,
282
+ SenderName: senderName || undefined,
283
+ SenderId: senderId,
284
+ GroupSubject: isGroup ? roomName || roomToken : undefined,
285
+ GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
286
+ Provider: CHANNEL_ID,
287
+ Surface: CHANNEL_ID,
288
+ WasMentioned: isGroup ? wasMentioned : undefined,
289
+ MessageSid: message.messageId,
290
+ Timestamp: message.timestamp,
291
+ OriginatingChannel: CHANNEL_ID,
292
+ OriginatingTo: `nextcloud-talk:${roomToken}`,
293
+ CommandAuthorized: commandAuthorized,
294
+ });
295
+
296
+ await core.channel.session.recordInboundSession({
297
+ storePath,
298
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
299
+ ctx: ctxPayload,
300
+ onRecordError: (err) => {
301
+ runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
302
+ },
303
+ });
304
+
305
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
306
+ ctx: ctxPayload,
307
+ cfg: config as OpenClawConfig,
308
+ dispatcherOptions: {
309
+ deliver: async (payload) => {
310
+ await deliverNextcloudTalkReply({
311
+ payload: payload as {
312
+ text?: string;
313
+ mediaUrls?: string[];
314
+ mediaUrl?: string;
315
+ replyToId?: string;
316
+ },
317
+ roomToken,
318
+ accountId: account.accountId,
319
+ statusSink,
320
+ });
321
+ },
322
+ onError: (err, info) => {
323
+ runtime.error?.(
324
+ `nextcloud-talk ${info.kind} reply failed: ${String(err)}`,
325
+ );
326
+ },
327
+ },
328
+ replyOptions: {
329
+ skillFilter: roomConfig?.skills,
330
+ disableBlockStreaming:
331
+ typeof account.config.blockStreaming === "boolean"
332
+ ? !account.config.blockStreaming
333
+ : undefined,
334
+ },
335
+ });
336
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+
3
+ import type { RuntimeEnv } from "openclaw/plugin-sdk";
4
+
5
+ import { resolveNextcloudTalkAccount } from "./accounts.js";
6
+ import { handleNextcloudTalkInbound } from "./inbound.js";
7
+ import { getNextcloudTalkRuntime } from "./runtime.js";
8
+ import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
9
+ import type {
10
+ CoreConfig,
11
+ NextcloudTalkInboundMessage,
12
+ NextcloudTalkWebhookPayload,
13
+ NextcloudTalkWebhookServerOptions,
14
+ } from "./types.js";
15
+
16
+ const DEFAULT_WEBHOOK_PORT = 8788;
17
+ const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
18
+ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
19
+ const HEALTH_PATH = "/healthz";
20
+
21
+ function formatError(err: unknown): string {
22
+ if (err instanceof Error) return err.message;
23
+ return typeof err === "string" ? err : JSON.stringify(err);
24
+ }
25
+
26
+ function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
27
+ try {
28
+ const data = JSON.parse(body);
29
+ if (
30
+ !data.type ||
31
+ !data.actor?.type ||
32
+ !data.actor?.id ||
33
+ !data.object?.type ||
34
+ !data.object?.id ||
35
+ !data.target?.type ||
36
+ !data.target?.id
37
+ ) {
38
+ return null;
39
+ }
40
+ return data as NextcloudTalkWebhookPayload;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function payloadToInboundMessage(
47
+ payload: NextcloudTalkWebhookPayload,
48
+ ): NextcloudTalkInboundMessage {
49
+ // Payload doesn't indicate DM vs room; mark as group and let inbound handler refine.
50
+ const isGroupChat = true;
51
+
52
+ return {
53
+ messageId: String(payload.object.id),
54
+ roomToken: payload.target.id,
55
+ roomName: payload.target.name,
56
+ senderId: payload.actor.id,
57
+ senderName: payload.actor.name,
58
+ text: payload.object.content || payload.object.name || "",
59
+ mediaType: payload.object.mediaType || "text/plain",
60
+ timestamp: Date.now(),
61
+ isGroupChat,
62
+ };
63
+ }
64
+
65
+ function readBody(req: IncomingMessage): Promise<string> {
66
+ return new Promise((resolve, reject) => {
67
+ const chunks: Buffer[] = [];
68
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
69
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
70
+ req.on("error", reject);
71
+ });
72
+ }
73
+
74
+ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): {
75
+ server: Server;
76
+ start: () => Promise<void>;
77
+ stop: () => void;
78
+ } {
79
+ const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
80
+
81
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
82
+ if (req.url === HEALTH_PATH) {
83
+ res.writeHead(200, { "Content-Type": "text/plain" });
84
+ res.end("ok");
85
+ return;
86
+ }
87
+
88
+ if (req.url !== path || req.method !== "POST") {
89
+ res.writeHead(404);
90
+ res.end();
91
+ return;
92
+ }
93
+
94
+ try {
95
+ const body = await readBody(req);
96
+
97
+ const headers = extractNextcloudTalkHeaders(
98
+ req.headers as Record<string, string | string[] | undefined>,
99
+ );
100
+ if (!headers) {
101
+ res.writeHead(400, { "Content-Type": "application/json" });
102
+ res.end(JSON.stringify({ error: "Missing signature headers" }));
103
+ return;
104
+ }
105
+
106
+ const isValid = verifyNextcloudTalkSignature({
107
+ signature: headers.signature,
108
+ random: headers.random,
109
+ body,
110
+ secret,
111
+ });
112
+
113
+ if (!isValid) {
114
+ res.writeHead(401, { "Content-Type": "application/json" });
115
+ res.end(JSON.stringify({ error: "Invalid signature" }));
116
+ return;
117
+ }
118
+
119
+ const payload = parseWebhookPayload(body);
120
+ if (!payload) {
121
+ res.writeHead(400, { "Content-Type": "application/json" });
122
+ res.end(JSON.stringify({ error: "Invalid payload format" }));
123
+ return;
124
+ }
125
+
126
+ if (payload.type !== "Create") {
127
+ res.writeHead(200);
128
+ res.end();
129
+ return;
130
+ }
131
+
132
+ const message = payloadToInboundMessage(payload);
133
+
134
+ res.writeHead(200);
135
+ res.end();
136
+
137
+ try {
138
+ await onMessage(message);
139
+ } catch (err) {
140
+ onError?.(err instanceof Error ? err : new Error(formatError(err)));
141
+ }
142
+ } catch (err) {
143
+ const error = err instanceof Error ? err : new Error(formatError(err));
144
+ onError?.(error);
145
+ if (!res.headersSent) {
146
+ res.writeHead(500, { "Content-Type": "application/json" });
147
+ res.end(JSON.stringify({ error: "Internal server error" }));
148
+ }
149
+ }
150
+ });
151
+
152
+ const start = (): Promise<void> => {
153
+ return new Promise((resolve) => {
154
+ server.listen(port, host, () => resolve());
155
+ });
156
+ };
157
+
158
+ const stop = () => {
159
+ server.close();
160
+ };
161
+
162
+ if (abortSignal) {
163
+ abortSignal.addEventListener("abort", stop, { once: true });
164
+ }
165
+
166
+ return { server, start, stop };
167
+ }
168
+
169
+ export type NextcloudTalkMonitorOptions = {
170
+ accountId?: string;
171
+ config?: CoreConfig;
172
+ runtime?: RuntimeEnv;
173
+ abortSignal?: AbortSignal;
174
+ onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
175
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
176
+ };
177
+
178
+ export async function monitorNextcloudTalkProvider(
179
+ opts: NextcloudTalkMonitorOptions,
180
+ ): Promise<{ stop: () => void }> {
181
+ const core = getNextcloudTalkRuntime();
182
+ const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
183
+ const account = resolveNextcloudTalkAccount({
184
+ cfg,
185
+ accountId: opts.accountId,
186
+ });
187
+ const runtime: RuntimeEnv = opts.runtime ?? {
188
+ log: (message: string) => core.logging.getChildLogger().info(message),
189
+ error: (message: string) => core.logging.getChildLogger().error(message),
190
+ exit: () => {
191
+ throw new Error("Runtime exit not available");
192
+ },
193
+ };
194
+
195
+ if (!account.secret) {
196
+ throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
197
+ }
198
+
199
+ const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
200
+ const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
201
+ const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
202
+
203
+ const logger = core.logging.getChildLogger({
204
+ channel: "nextcloud-talk",
205
+ accountId: account.accountId,
206
+ });
207
+
208
+ const { start, stop } = createNextcloudTalkWebhookServer({
209
+ port,
210
+ host,
211
+ path,
212
+ secret: account.secret,
213
+ onMessage: async (message) => {
214
+ core.channel.activity.record({
215
+ channel: "nextcloud-talk",
216
+ accountId: account.accountId,
217
+ direction: "inbound",
218
+ at: message.timestamp,
219
+ });
220
+ if (opts.onMessage) {
221
+ await opts.onMessage(message);
222
+ return;
223
+ }
224
+ await handleNextcloudTalkInbound({
225
+ message,
226
+ account,
227
+ config: cfg,
228
+ runtime,
229
+ statusSink: opts.statusSink,
230
+ });
231
+ },
232
+ onError: (error) => {
233
+ logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
234
+ },
235
+ abortSignal: opts.abortSignal,
236
+ });
237
+
238
+ await start();
239
+
240
+ const publicUrl =
241
+ account.config.webhookPublicUrl ??
242
+ `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
243
+ logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
244
+
245
+ return { stop };
246
+ }
@@ -0,0 +1,31 @@
1
+ export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
2
+ const trimmed = raw.trim();
3
+ if (!trimmed) return undefined;
4
+
5
+ let normalized = trimmed;
6
+
7
+ if (normalized.startsWith("nextcloud-talk:")) {
8
+ normalized = normalized.slice("nextcloud-talk:".length).trim();
9
+ } else if (normalized.startsWith("nc-talk:")) {
10
+ normalized = normalized.slice("nc-talk:".length).trim();
11
+ } else if (normalized.startsWith("nc:")) {
12
+ normalized = normalized.slice("nc:".length).trim();
13
+ }
14
+
15
+ if (normalized.startsWith("room:")) {
16
+ normalized = normalized.slice("room:".length).trim();
17
+ }
18
+
19
+ if (!normalized) return undefined;
20
+
21
+ return `nextcloud-talk:${normalized}`.toLowerCase();
22
+ }
23
+
24
+ export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
25
+ const trimmed = raw.trim();
26
+ if (!trimmed) return false;
27
+
28
+ if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) return true;
29
+
30
+ return /^[a-z0-9]{8,}$/i.test(trimmed);
31
+ }