Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
17
openclaw/extensions/nextcloud-talk/index.ts
Normal file
17
openclaw/extensions/nextcloud-talk/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { nextcloudTalkPlugin } from "./src/channel.js";
|
||||
import { setNextcloudTalkRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "nextcloud-talk",
|
||||
name: "Nextcloud Talk",
|
||||
description: "Nextcloud Talk channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setNextcloudTalkRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: nextcloudTalkPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
openclaw/extensions/nextcloud-talk/openclaw.plugin.json
Normal file
9
openclaw/extensions/nextcloud-talk/openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "nextcloud-talk",
|
||||
"channels": ["nextcloud-talk"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
30
openclaw/extensions/nextcloud-talk/package.json
Normal file
30
openclaw/extensions/nextcloud-talk/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@openclaw/nextcloud-talk",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "nextcloud-talk",
|
||||
"label": "Nextcloud Talk",
|
||||
"selectionLabel": "Nextcloud Talk (self-hosted)",
|
||||
"docsPath": "/channels/nextcloud-talk",
|
||||
"docsLabel": "nextcloud-talk",
|
||||
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
||||
"aliases": [
|
||||
"nc-talk",
|
||||
"nc"
|
||||
],
|
||||
"order": 65,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/nextcloud-talk",
|
||||
"localPath": "extensions/nextcloud-talk",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
170
openclaw/extensions/nextcloud-talk/src/accounts.ts
Normal file
170
openclaw/extensions/nextcloud-talk/src/accounts.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
|
||||
|
||||
function isTruthyEnvValue(value?: string): boolean {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
|
||||
const debugAccounts = (...args: unknown[]) => {
|
||||
if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) {
|
||||
console.warn("[nextcloud-talk:accounts]", ...args);
|
||||
}
|
||||
};
|
||||
|
||||
export type ResolvedNextcloudTalkAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
secret: string;
|
||||
secretSource: "env" | "secretFile" | "config" | "none";
|
||||
config: NextcloudTalkAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
debugAccounts("listNextcloudTalkAccountIds", ids);
|
||||
if (ids.length === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
|
||||
const ids = listNextcloudTalkAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): NextcloudTalkAccountConfig | undefined {
|
||||
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const direct = accounts[accountId] as NextcloudTalkAccountConfig | undefined;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
|
||||
return matchKey ? (accounts[matchKey] as NextcloudTalkAccountConfig | undefined) : undefined;
|
||||
}
|
||||
|
||||
function mergeNextcloudTalkAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): NextcloudTalkAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ??
|
||||
{}) as NextcloudTalkAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
function resolveNextcloudTalkSecret(
|
||||
cfg: CoreConfig,
|
||||
opts: { accountId?: string },
|
||||
): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } {
|
||||
const merged = mergeNextcloudTalkAccountConfig(cfg, opts.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
|
||||
const envSecret = process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim();
|
||||
if (envSecret && (!opts.accountId || opts.accountId === DEFAULT_ACCOUNT_ID)) {
|
||||
return { secret: envSecret, source: "env" };
|
||||
}
|
||||
|
||||
if (merged.botSecretFile) {
|
||||
try {
|
||||
const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim();
|
||||
if (fileSecret) {
|
||||
return { secret: fileSecret, source: "secretFile" };
|
||||
}
|
||||
} catch {
|
||||
// File not found or unreadable, fall through.
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.botSecret?.trim()) {
|
||||
return { secret: merged.botSecret.trim(), source: "config" };
|
||||
}
|
||||
|
||||
return { secret: "", source: "none" };
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedNextcloudTalkAccount {
|
||||
const hasExplicitAccountId = Boolean(params.accountId?.trim());
|
||||
const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
|
||||
|
||||
const resolve = (accountId: string) => {
|
||||
const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId });
|
||||
const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? "";
|
||||
|
||||
debugAccounts("resolve", {
|
||||
accountId,
|
||||
enabled,
|
||||
secretSource: secretResolution.source,
|
||||
baseUrl: baseUrl ? "[set]" : "[missing]",
|
||||
});
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
baseUrl,
|
||||
secret: secretResolution.secret,
|
||||
secretSource: secretResolution.source,
|
||||
config: merged,
|
||||
} satisfies ResolvedNextcloudTalkAccount;
|
||||
};
|
||||
|
||||
const normalized = normalizeAccountId(params.accountId);
|
||||
const primary = resolve(normalized);
|
||||
if (hasExplicitAccountId) {
|
||||
return primary;
|
||||
}
|
||||
if (primary.secretSource !== "none") {
|
||||
return primary;
|
||||
}
|
||||
|
||||
const fallbackId = resolveDefaultNextcloudTalkAccountId(params.cfg);
|
||||
if (fallbackId === primary.accountId) {
|
||||
return primary;
|
||||
}
|
||||
const fallback = resolve(fallbackId);
|
||||
if (fallback.secretSource === "none") {
|
||||
return primary;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function listEnabledNextcloudTalkAccounts(cfg: CoreConfig): ResolvedNextcloudTalkAccount[] {
|
||||
return listNextcloudTalkAccountIds(cfg)
|
||||
.map((accountId) => resolveNextcloudTalkAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
115
openclaw/extensions/nextcloud-talk/src/channel.startup.test.ts
Normal file
115
openclaw/extensions/nextcloud-talk/src/channel.startup.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorNextcloudTalkProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider,
|
||||
};
|
||||
});
|
||||
|
||||
import { nextcloudTalkPlugin } from "./channel.js";
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
account: ResolvedNextcloudTalkAccount;
|
||||
abortSignal: AbortSignal;
|
||||
}): ChannelGatewayContext<ResolvedNextcloudTalkAccount> {
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: params.account.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
return {
|
||||
accountId: params.account.accountId,
|
||||
account: params.account,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: createRuntimeEnv(),
|
||||
abortSignal: params.abortSignal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: (next) => {
|
||||
Object.assign(snapshot, next);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccount(): ResolvedNextcloudTalkAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
baseUrl: "https://nextcloud.example.com",
|
||||
secret: "secret",
|
||||
secretSource: "config",
|
||||
config: {
|
||||
baseUrl: "https://nextcloud.example.com",
|
||||
botSecret: "secret",
|
||||
webhookPath: "/nextcloud-talk-webhook",
|
||||
webhookPort: 8788,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("nextcloudTalkPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop });
|
||||
const abort = new AbortController();
|
||||
|
||||
const task = nextcloudTalkPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
account: buildAccount(),
|
||||
abortSignal: abort.signal,
|
||||
}),
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
let settled = false;
|
||||
void task.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(settled).toBe(false);
|
||||
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops immediately when startAccount receives an already-aborted signal", async () => {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop });
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
|
||||
await nextcloudTalkPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
account: buildAccount(),
|
||||
abortSignal: abort.signal,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
419
openclaw/extensions/nextcloud-talk/src/channel.ts
Normal file
419
openclaw/extensions/nextcloud-talk/src/channel.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
normalizeAccountId,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
type ChannelSetupInput,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { waitForAbortSignal } from "../../../src/infra/abort-signal.js";
|
||||
import {
|
||||
listNextcloudTalkAccountIds,
|
||||
resolveDefaultNextcloudTalkAccountId,
|
||||
resolveNextcloudTalkAccount,
|
||||
type ResolvedNextcloudTalkAccount,
|
||||
} from "./accounts.js";
|
||||
import { NextcloudTalkConfigSchema } from "./config-schema.js";
|
||||
import { monitorNextcloudTalkProvider } from "./monitor.js";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
normalizeNextcloudTalkMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
|
||||
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { sendMessageNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const meta = {
|
||||
id: "nextcloud-talk",
|
||||
label: "Nextcloud Talk",
|
||||
selectionLabel: "Nextcloud Talk (self-hosted)",
|
||||
docsPath: "/channels/nextcloud-talk",
|
||||
docsLabel: "nextcloud-talk",
|
||||
blurb: "Self-hosted chat via Nextcloud Talk webhook bots.",
|
||||
aliases: ["nc-talk", "nc"],
|
||||
order: 65,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
type NextcloudSetupInput = ChannelSetupInput & {
|
||||
baseUrl?: string;
|
||||
secret?: string;
|
||||
secretFile?: string;
|
||||
useEnv?: boolean;
|
||||
};
|
||||
|
||||
export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> = {
|
||||
id: "nextcloud-talk",
|
||||
meta,
|
||||
onboarding: nextcloudTalkOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "nextcloudUserId",
|
||||
normalizeAllowEntry: (entry) =>
|
||||
entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
|
||||
notifyApproval: async ({ id }) => {
|
||||
console.log(`[nextcloud-talk] User ${id} approved for pairing`);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: true,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.nextcloud-talk"] },
|
||||
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "nextcloud-talk",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "nextcloud-talk",
|
||||
accountId,
|
||||
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
||||
secretSource: account.secretSource,
|
||||
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(
|
||||
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []
|
||||
).map((entry) => String(entry).toLowerCase()),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
cfg.channels?.["nextcloud-talk"]?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.nextcloud-talk.accounts.${resolvedAccountId}.`
|
||||
: "channels.nextcloud-talk.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("nextcloud-talk"),
|
||||
normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent:
|
||||
(cfg.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
const roomAllowlistConfigured =
|
||||
account.config.rooms && Object.keys(account.config.rooms).length > 0;
|
||||
if (roomAllowlistConfigured) {
|
||||
return [
|
||||
`- Nextcloud Talk rooms: groupPolicy="open" allows any member in allowed rooms to trigger (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`- Nextcloud Talk rooms: groupPolicy="open" with no channels.nextcloud-talk.rooms allowlist; any room can add + ping (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom or configure channels.nextcloud-talk.rooms.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const rooms = account.config.rooms;
|
||||
if (!rooms || !groupId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roomConfig = rooms[groupId];
|
||||
if (roomConfig?.requireMention !== undefined) {
|
||||
return roomConfig.requireMention;
|
||||
}
|
||||
|
||||
const wildcardConfig = rooms["*"];
|
||||
if (wildcardConfig?.requireMention !== undefined) {
|
||||
return wildcardConfig.requireMention;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeNextcloudTalkTargetId,
|
||||
hint: "<roomToken>",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg,
|
||||
channelKey: "nextcloud-talk",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
const setupInput = input as NextcloudSetupInput;
|
||||
if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.";
|
||||
}
|
||||
if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) {
|
||||
return "Nextcloud Talk requires bot secret or --secret-file (or --use-env).";
|
||||
}
|
||||
if (!setupInput.baseUrl) {
|
||||
return "Nextcloud Talk requires --base-url.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const setupInput = input as NextcloudSetupInput;
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg,
|
||||
channelKey: "nextcloud-talk",
|
||||
accountId,
|
||||
name: setupInput.name,
|
||||
});
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
"nextcloud-talk": {
|
||||
...namedConfig.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
baseUrl: setupInput.baseUrl,
|
||||
...(setupInput.useEnv
|
||||
? {}
|
||||
: setupInput.secretFile
|
||||
? { botSecretFile: setupInput.secretFile }
|
||||
: setupInput.secret
|
||||
? { botSecret: setupInput.secret }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
"nextcloud-talk": {
|
||||
...namedConfig.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...namedConfig.channels?.["nextcloud-talk"]?.accounts,
|
||||
[accountId]: {
|
||||
...namedConfig.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
baseUrl: setupInput.baseUrl,
|
||||
...(setupInput.secretFile
|
||||
? { botSecretFile: setupInput.secretFile }
|
||||
: setupInput.secret
|
||||
? { botSecret: setupInput.secret }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageNextcloudTalk(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "nextcloud-talk", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
||||
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "nextcloud-talk", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
secretSource: snapshot.secretSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
mode: "webhook",
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime }) => {
|
||||
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
secretSource: account.secretSource,
|
||||
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
mode: "webhook",
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
if (!account.secret || !account.baseUrl) {
|
||||
throw new Error(
|
||||
`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
|
||||
|
||||
const { stop } = await monitorNextcloudTalkProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
|
||||
// Keep webhook channels pending for the account lifecycle.
|
||||
await waitForAbortSignal(ctx.abortSignal);
|
||||
stop();
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const nextSection = cfg.channels?.["nextcloud-talk"]
|
||||
? { ...cfg.channels["nextcloud-talk"] }
|
||||
: undefined;
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (nextSection) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) {
|
||||
delete nextSection.botSecret;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
const accounts =
|
||||
nextSection.accounts && typeof nextSection.accounts === "object"
|
||||
? { ...nextSection.accounts }
|
||||
: undefined;
|
||||
if (accounts && accountId in accounts) {
|
||||
const entry = accounts[accountId];
|
||||
if (entry && typeof entry === "object") {
|
||||
const nextEntry = { ...entry } as Record<string, unknown>;
|
||||
if ("botSecret" in nextEntry) {
|
||||
const secret = nextEntry.botSecret;
|
||||
if (typeof secret === "string" ? secret.trim() : secret) {
|
||||
cleared = true;
|
||||
}
|
||||
delete nextEntry.botSecret;
|
||||
changed = true;
|
||||
}
|
||||
if (Object.keys(nextEntry).length === 0) {
|
||||
delete accounts[accountId];
|
||||
changed = true;
|
||||
} else {
|
||||
accounts[accountId] = nextEntry as typeof entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (accounts) {
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete nextSection.accounts;
|
||||
changed = true;
|
||||
} else {
|
||||
nextSection.accounts = accounts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (nextSection && Object.keys(nextSection).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels } as Record<string, unknown>;
|
||||
delete nextChannels["nextcloud-talk"];
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels as OpenClawConfig["channels"];
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveNextcloudTalkAccount({
|
||||
cfg: changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig),
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.secretSource === "none";
|
||||
|
||||
if (changed) {
|
||||
await getNextcloudTalkRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
return {
|
||||
cleared,
|
||||
envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
|
||||
loggedOut,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
72
openclaw/extensions/nextcloud-talk/src/config-schema.ts
Normal file
72
openclaw/extensions/nextcloud-talk/src/config-schema.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ReplyRuntimeConfigSchemaShape,
|
||||
ToolPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export const NextcloudTalkRoomSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const NextcloudTalkAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
baseUrl: z.string().optional(),
|
||||
botSecret: z.string().optional(),
|
||||
botSecretFile: z.string().optional(),
|
||||
apiUser: z.string().optional(),
|
||||
apiPassword: z.string().optional(),
|
||||
apiPasswordFile: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
webhookPort: z.number().int().positive().optional(),
|
||||
webhookHost: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
webhookPublicUrl: z.string().optional(),
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
groupAllowFrom: z.array(z.string()).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
|
||||
...ReplyRuntimeConfigSchemaShape,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine(
|
||||
(value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
79
openclaw/extensions/nextcloud-talk/src/format.ts
Normal file
79
openclaw/extensions/nextcloud-talk/src/format.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Format utilities for Nextcloud Talk messages.
|
||||
*
|
||||
* Nextcloud Talk supports markdown natively, so most formatting passes through.
|
||||
* This module handles any edge cases or transformations needed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert markdown to Nextcloud Talk compatible format.
|
||||
* Nextcloud Talk supports standard markdown, so minimal transformation needed.
|
||||
*/
|
||||
export function markdownToNextcloudTalk(text: string): string {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in text to prevent markdown interpretation.
|
||||
*/
|
||||
export function escapeNextcloudTalkMarkdown(text: string): string {
|
||||
return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a mention for a Nextcloud user.
|
||||
* Nextcloud Talk uses @user format for mentions.
|
||||
*/
|
||||
export function formatNextcloudTalkMention(userId: string): string {
|
||||
return `@${userId.replace(/^@/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a code block for Nextcloud Talk.
|
||||
*/
|
||||
export function formatNextcloudTalkCodeBlock(code: string, language?: string): string {
|
||||
const lang = language ?? "";
|
||||
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format inline code for Nextcloud Talk.
|
||||
*/
|
||||
export function formatNextcloudTalkInlineCode(code: string): string {
|
||||
if (code.includes("`")) {
|
||||
return `\`\` ${code} \`\``;
|
||||
}
|
||||
return `\`${code}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Nextcloud Talk specific formatting from text.
|
||||
* Useful for extracting plain text content.
|
||||
*/
|
||||
export function stripNextcloudTalkFormatting(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/`[^`]+`/g, "")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/\*([^*]+)\*/g, "$1")
|
||||
.replace(/_([^_]+)_/g, "$1")
|
||||
.replace(/~~([^~]+)~~/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a maximum length, preserving word boundaries.
|
||||
*/
|
||||
export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
const truncated = text.slice(0, maxLength - suffix.length);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
if (lastSpace > maxLength * 0.7) {
|
||||
return truncated.slice(0, lastSpace) + suffix;
|
||||
}
|
||||
return truncated + suffix;
|
||||
}
|
||||
84
openclaw/extensions/nextcloud-talk/src/inbound.authz.test.ts
Normal file
84
openclaw/extensions/nextcloud-talk/src/inbound.authz.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import { handleNextcloudTalkInbound } from "./inbound.js";
|
||||
import { setNextcloudTalkRuntime } from "./runtime.js";
|
||||
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
|
||||
|
||||
describe("nextcloud-talk inbound authz", () => {
|
||||
it("does not treat DM pairing-store entries as group allowlist entries", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => ["attacker"]);
|
||||
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
|
||||
|
||||
setNextcloudTalkRuntime({
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: () => false,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns: () => false,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const message: NextcloudTalkInboundMessage = {
|
||||
messageId: "m-1",
|
||||
roomToken: "room-1",
|
||||
roomName: "Room 1",
|
||||
senderId: "attacker",
|
||||
senderName: "Attacker",
|
||||
text: "hello",
|
||||
mediaType: "text/plain",
|
||||
timestamp: Date.now(),
|
||||
isGroupChat: true,
|
||||
};
|
||||
|
||||
const account: ResolvedNextcloudTalkAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
baseUrl: "",
|
||||
secret: "",
|
||||
secretSource: "none",
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
};
|
||||
|
||||
const config: CoreConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleNextcloudTalkInbound({
|
||||
message,
|
||||
account,
|
||||
config,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith({
|
||||
channel: "nextcloud-talk",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(buildMentionRegexes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
337
openclaw/extensions/nextcloud-talk/src/inbound.ts
Normal file
337
openclaw/extensions/nextcloud-talk/src/inbound.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
createScopedPairingAccess,
|
||||
createNormalizedOutboundDeliverer,
|
||||
createReplyPrefixOptions,
|
||||
formatTextWithAttachmentLinks,
|
||||
logInboundDrop,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithCommandGate,
|
||||
resolveOutboundMediaUrls,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type OutboundReplyPayload,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import {
|
||||
normalizeNextcloudTalkAllowlist,
|
||||
resolveNextcloudTalkAllowlistMatch,
|
||||
resolveNextcloudTalkGroupAllow,
|
||||
resolveNextcloudTalkMentionGate,
|
||||
resolveNextcloudTalkRequireMention,
|
||||
resolveNextcloudTalkRoomMatch,
|
||||
} from "./policy.js";
|
||||
import { resolveNextcloudTalkRoomKind } from "./room-info.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { sendMessageNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./types.js";
|
||||
|
||||
const CHANNEL_ID = "nextcloud-talk" as const;
|
||||
|
||||
async function deliverNextcloudTalkReply(params: {
|
||||
payload: OutboundReplyPayload;
|
||||
roomToken: string;
|
||||
accountId: string;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { payload, roomToken, accountId, statusSink } = params;
|
||||
const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload));
|
||||
if (!combined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessageNextcloudTalk(roomToken, combined, {
|
||||
accountId,
|
||||
replyTo: payload.replyToId,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
}
|
||||
|
||||
export async function handleNextcloudTalkInbound(params: {
|
||||
message: NextcloudTalkInboundMessage;
|
||||
account: ResolvedNextcloudTalkAccount;
|
||||
config: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { message, account, config, runtime, statusSink } = params;
|
||||
const core = getNextcloudTalkRuntime();
|
||||
const pairing = createScopedPairingAccess({
|
||||
core,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const rawBody = message.text?.trim() ?? "";
|
||||
if (!rawBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roomKind = await resolveNextcloudTalkRoomKind({
|
||||
account,
|
||||
roomToken: message.roomToken,
|
||||
runtime,
|
||||
});
|
||||
const isGroup = roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
|
||||
const senderId = message.senderId;
|
||||
const senderName = message.senderName;
|
||||
const roomToken = message.roomToken;
|
||||
const roomName = message.roomName;
|
||||
|
||||
statusSink?.({ lastInboundAt: message.timestamp });
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config as OpenClawConfig);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent:
|
||||
((config.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] ??
|
||||
undefined) !== undefined,
|
||||
groupPolicy: account.config.groupPolicy as GroupPolicy | undefined,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "nextcloud-talk",
|
||||
accountId: account.accountId,
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
|
||||
log: (message) => runtime.log?.(message),
|
||||
});
|
||||
|
||||
const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
|
||||
const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
});
|
||||
const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
|
||||
|
||||
const roomMatch = resolveNextcloudTalkRoomMatch({
|
||||
rooms: account.config.rooms,
|
||||
roomToken,
|
||||
roomName,
|
||||
});
|
||||
const roomConfig = roomMatch.roomConfig;
|
||||
if (isGroup && !roomMatch.allowed) {
|
||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
|
||||
return;
|
||||
}
|
||||
if (roomConfig?.enabled === false) {
|
||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom);
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config as OpenClawConfig,
|
||||
surface: CHANNEL_ID,
|
||||
});
|
||||
const useAccessGroups =
|
||||
(config.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false;
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const access = resolveDmGroupAccessWithCommandGate({
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: configGroupAllowFrom,
|
||||
storeAllowFrom: storeAllowList,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId,
|
||||
}).allowed,
|
||||
command: {
|
||||
useAccessGroups,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
},
|
||||
});
|
||||
const commandAuthorized = access.commandAuthorized;
|
||||
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
|
||||
|
||||
if (isGroup) {
|
||||
if (access.decision !== "allow") {
|
||||
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`);
|
||||
return;
|
||||
}
|
||||
const groupAllow = resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy,
|
||||
outerAllowFrom: effectiveGroupAllowFrom,
|
||||
innerAllowFrom: roomAllowFrom,
|
||||
senderId,
|
||||
});
|
||||
if (!groupAllow.allowed) {
|
||||
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (access.decision !== "allow") {
|
||||
if (access.decision === "pairing") {
|
||||
const { code, created } = await pairing.upsertPairingRequest({
|
||||
id: senderId,
|
||||
meta: { name: senderName || undefined },
|
||||
});
|
||||
if (created) {
|
||||
try {
|
||||
await sendMessageNextcloudTalk(
|
||||
roomToken,
|
||||
core.channel.pairing.buildPairingReply({
|
||||
channel: CHANNEL_ID,
|
||||
idLine: `Your Nextcloud user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ accountId: account.accountId },
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (access.shouldBlockControlCommand) {
|
||||
logInboundDrop({
|
||||
log: (message) => runtime.log?.(message),
|
||||
channel: CHANNEL_ID,
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
|
||||
const wasMentioned = mentionRegexes.length
|
||||
? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes)
|
||||
: false;
|
||||
const shouldRequireMention = isGroup
|
||||
? resolveNextcloudTalkRequireMention({
|
||||
roomConfig,
|
||||
wildcardConfig: roomMatch.wildcardConfig,
|
||||
})
|
||||
: false;
|
||||
const mentionGate = resolveNextcloudTalkMentionGate({
|
||||
isGroup,
|
||||
requireMention: shouldRequireMention,
|
||||
wasMentioned,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
commandAuthorized,
|
||||
});
|
||||
if (isGroup && mentionGate.shouldSkip) {
|
||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? roomToken : senderId,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(
|
||||
(config.session as Record<string, unknown> | undefined)?.store as string | undefined,
|
||||
{
|
||||
agentId: route.agentId,
|
||||
},
|
||||
);
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Nextcloud Talk",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: rawBody,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
|
||||
To: `nextcloud-talk:${roomToken}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
GroupSubject: isGroup ? roomName || roomToken : undefined,
|
||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
MessageSid: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: `nextcloud-talk:${roomToken}`,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
});
|
||||
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config as OpenClawConfig,
|
||||
agentId: route.agentId,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
|
||||
await deliverNextcloudTalkReply({
|
||||
payload,
|
||||
roomToken,
|
||||
accountId: account.accountId,
|
||||
statusSink,
|
||||
});
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config as OpenClawConfig,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: deliverReply,
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter: roomConfig?.skills,
|
||||
onModelSelected,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
|
||||
describe("createNextcloudTalkWebhookServer auth order", () => {
|
||||
it("rejects missing signature headers before reading request body", async () => {
|
||||
const readBody = vi.fn(async () => {
|
||||
throw new Error("should not be called for missing signature headers");
|
||||
});
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-order",
|
||||
maxBodyBytes: 128,
|
||||
readBody,
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
|
||||
const response = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: "Missing signature headers" });
|
||||
expect(readBody).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
import { generateNextcloudTalkSignature } from "./signature.js";
|
||||
|
||||
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
|
||||
it("rejects requests from unexpected backend origins", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-backend-check",
|
||||
isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
|
||||
onMessage,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "Create",
|
||||
actor: { type: "Person", id: "alice", name: "Alice" },
|
||||
object: {
|
||||
type: "Note",
|
||||
id: "msg-1",
|
||||
name: "hello",
|
||||
content: "hello",
|
||||
mediaType: "text/plain",
|
||||
},
|
||||
target: { type: "Collection", id: "room-1", name: "Room 1" },
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const { random, signature } = generateNextcloudTalkSignature({
|
||||
body,
|
||||
secret: "nextcloud-secret",
|
||||
});
|
||||
const response = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-nextcloud-talk-random": random,
|
||||
"x-nextcloud-talk-signature": signature,
|
||||
"x-nextcloud-talk-backend": "https://nextcloud.unexpected",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: "Invalid backend" });
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
||||
import { readNextcloudTalkWebhookBody } from "./monitor.js";
|
||||
|
||||
describe("readNextcloudTalkWebhookBody", () => {
|
||||
it("reads valid body within max bytes", async () => {
|
||||
const req = createMockIncomingRequest(['{"type":"Create"}']);
|
||||
const body = await readNextcloudTalkWebhookBody(req, 1024);
|
||||
expect(body).toBe('{"type":"Create"}');
|
||||
});
|
||||
|
||||
it("rejects when payload exceeds max bytes", async () => {
|
||||
const req = createMockIncomingRequest(["x".repeat(300)]);
|
||||
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
import { generateNextcloudTalkSignature } from "./signature.js";
|
||||
import type { NextcloudTalkInboundMessage } from "./types.js";
|
||||
|
||||
function createSignedRequest(body: string): { random: string; signature: string } {
|
||||
return generateNextcloudTalkSignature({
|
||||
body,
|
||||
secret: "nextcloud-secret",
|
||||
});
|
||||
}
|
||||
|
||||
describe("createNextcloudTalkWebhookServer replay handling", () => {
|
||||
it("acknowledges replayed requests and skips onMessage side effects", async () => {
|
||||
const seen = new Set<string>();
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => {
|
||||
if (seen.has(message.messageId)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(message.messageId);
|
||||
return true;
|
||||
});
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-replay",
|
||||
shouldProcessMessage,
|
||||
onMessage,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "Create",
|
||||
actor: { type: "Person", id: "alice", name: "Alice" },
|
||||
object: {
|
||||
type: "Note",
|
||||
id: "msg-1",
|
||||
name: "hello",
|
||||
content: "hello",
|
||||
mediaType: "text/plain",
|
||||
},
|
||||
target: { type: "Collection", id: "room-1", name: "Room 1" },
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const { random, signature } = createSignedRequest(body);
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"x-nextcloud-talk-random": random,
|
||||
"x-nextcloud-talk-signature": signature,
|
||||
"x-nextcloud-talk-backend": "https://nextcloud.example",
|
||||
};
|
||||
|
||||
const first = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const second = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type AddressInfo } from "node:net";
|
||||
import { afterEach } from "vitest";
|
||||
import { createNextcloudTalkWebhookServer } from "./monitor.js";
|
||||
import type { NextcloudTalkWebhookServerOptions } from "./types.js";
|
||||
|
||||
export type WebhookHarness = {
|
||||
webhookUrl: string;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
const cleanupFns: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupFns.length > 0) {
|
||||
const cleanup = cleanupFns.pop();
|
||||
if (cleanup) {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type StartWebhookServerParams = Omit<
|
||||
NextcloudTalkWebhookServerOptions,
|
||||
"port" | "host" | "path" | "secret"
|
||||
> & {
|
||||
path: string;
|
||||
secret?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
export async function startWebhookServer(
|
||||
params: StartWebhookServerParams,
|
||||
): Promise<WebhookHarness> {
|
||||
const host = params.host ?? "127.0.0.1";
|
||||
const port = params.port ?? 0;
|
||||
const secret = params.secret ?? "nextcloud-secret";
|
||||
const { server, start } = createNextcloudTalkWebhookServer({
|
||||
...params,
|
||||
port,
|
||||
host,
|
||||
secret,
|
||||
});
|
||||
await start();
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address) {
|
||||
throw new Error("missing server address");
|
||||
}
|
||||
|
||||
const harness: WebhookHarness = {
|
||||
webhookUrl: `http://${host}:${address.port}${params.path}`,
|
||||
stop: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
cleanupFns.push(harness.stop);
|
||||
return harness;
|
||||
}
|
||||
415
openclaw/extensions/nextcloud-talk/src/monitor.ts
Normal file
415
openclaw/extensions/nextcloud-talk/src/monitor.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import os from "node:os";
|
||||
import {
|
||||
createLoggerBackedRuntime,
|
||||
type RuntimeEnv,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import { handleNextcloudTalkInbound } from "./inbound.js";
|
||||
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
|
||||
import type {
|
||||
CoreConfig,
|
||||
NextcloudTalkInboundMessage,
|
||||
NextcloudTalkWebhookHeaders,
|
||||
NextcloudTalkWebhookPayload,
|
||||
NextcloudTalkWebhookServerOptions,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_WEBHOOK_PORT = 8788;
|
||||
const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
|
||||
const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
|
||||
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
||||
const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
||||
const HEALTH_PATH = "/healthz";
|
||||
const WEBHOOK_ERRORS = {
|
||||
missingSignatureHeaders: "Missing signature headers",
|
||||
invalidBackend: "Invalid backend",
|
||||
invalidSignature: "Invalid signature",
|
||||
invalidPayloadFormat: "Invalid payload format",
|
||||
payloadTooLarge: "Payload too large",
|
||||
internalServerError: "Internal server error",
|
||||
} as const;
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return typeof err === "string" ? err : JSON.stringify(err);
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string): string | null {
|
||||
try {
|
||||
return new URL(value).origin.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (
|
||||
!data.type ||
|
||||
!data.actor?.type ||
|
||||
!data.actor?.id ||
|
||||
!data.object?.type ||
|
||||
!data.object?.id ||
|
||||
!data.target?.type ||
|
||||
!data.target?.id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return data as NextcloudTalkWebhookPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonResponse(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
body?: Record<string, unknown>,
|
||||
): void {
|
||||
if (body) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
return;
|
||||
}
|
||||
res.writeHead(status);
|
||||
res.end();
|
||||
}
|
||||
|
||||
function writeWebhookError(res: ServerResponse, status: number, error: string): void {
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
writeJsonResponse(res, status, { error });
|
||||
}
|
||||
|
||||
function validateWebhookHeaders(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
isBackendAllowed?: (backend: string) => boolean;
|
||||
}): NextcloudTalkWebhookHeaders | null {
|
||||
const headers = extractNextcloudTalkHeaders(
|
||||
params.req.headers as Record<string, string | string[] | undefined>,
|
||||
);
|
||||
if (!headers) {
|
||||
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders);
|
||||
return null;
|
||||
}
|
||||
if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) {
|
||||
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend);
|
||||
return null;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function verifyWebhookSignature(params: {
|
||||
headers: NextcloudTalkWebhookHeaders;
|
||||
body: string;
|
||||
secret: string;
|
||||
res: ServerResponse;
|
||||
}): boolean {
|
||||
const isValid = verifyNextcloudTalkSignature({
|
||||
signature: params.headers.signature,
|
||||
random: params.headers.random,
|
||||
body: params.body,
|
||||
secret: params.secret,
|
||||
});
|
||||
if (!isValid) {
|
||||
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function decodeWebhookCreateMessage(params: {
|
||||
body: string;
|
||||
res: ServerResponse;
|
||||
}):
|
||||
| { kind: "message"; message: NextcloudTalkInboundMessage }
|
||||
| { kind: "ignore" }
|
||||
| { kind: "invalid" } {
|
||||
const payload = parseWebhookPayload(params.body);
|
||||
if (!payload) {
|
||||
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
if (payload.type !== "Create") {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
return { kind: "message", message: payloadToInboundMessage(payload) };
|
||||
}
|
||||
|
||||
function payloadToInboundMessage(
|
||||
payload: NextcloudTalkWebhookPayload,
|
||||
): NextcloudTalkInboundMessage {
|
||||
// Payload doesn't indicate DM vs room; mark as group and let inbound handler refine.
|
||||
const isGroupChat = true;
|
||||
|
||||
return {
|
||||
messageId: String(payload.object.id),
|
||||
roomToken: payload.target.id,
|
||||
roomName: payload.target.name,
|
||||
senderId: payload.actor.id,
|
||||
senderName: payload.actor.name ?? "",
|
||||
text: payload.object.content || payload.object.name || "",
|
||||
mediaType: payload.object.mediaType || "text/plain",
|
||||
timestamp: Date.now(),
|
||||
isGroupChat,
|
||||
};
|
||||
}
|
||||
|
||||
export function readNextcloudTalkWebhookBody(
|
||||
req: IncomingMessage,
|
||||
maxBodyBytes: number,
|
||||
): Promise<string> {
|
||||
return readRequestBodyWithLimit(req, {
|
||||
maxBytes: maxBodyBytes,
|
||||
timeoutMs: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): {
|
||||
server: Server;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
} {
|
||||
const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
|
||||
const maxBodyBytes =
|
||||
typeof opts.maxBodyBytes === "number" &&
|
||||
Number.isFinite(opts.maxBodyBytes) &&
|
||||
opts.maxBodyBytes > 0
|
||||
? Math.floor(opts.maxBodyBytes)
|
||||
: DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
||||
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
|
||||
const isBackendAllowed = opts.isBackendAllowed;
|
||||
const shouldProcessMessage = opts.shouldProcessMessage;
|
||||
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.url === HEALTH_PATH) {
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== path || req.method !== "POST") {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = validateWebhookHeaders({
|
||||
req,
|
||||
res,
|
||||
isBackendAllowed,
|
||||
});
|
||||
if (!headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readBody(req, maxBodyBytes);
|
||||
|
||||
const hasValidSignature = verifyWebhookSignature({
|
||||
headers,
|
||||
body,
|
||||
secret,
|
||||
res,
|
||||
});
|
||||
if (!hasValidSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = decodeWebhookCreateMessage({
|
||||
body,
|
||||
res,
|
||||
});
|
||||
if (decoded.kind === "invalid") {
|
||||
return;
|
||||
}
|
||||
if (decoded.kind === "ignore") {
|
||||
writeJsonResponse(res, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = decoded.message;
|
||||
if (shouldProcessMessage) {
|
||||
const shouldProcess = await shouldProcessMessage(message);
|
||||
if (!shouldProcess) {
|
||||
writeJsonResponse(res, 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
writeJsonResponse(res, 200);
|
||||
|
||||
try {
|
||||
await onMessage(message);
|
||||
} catch (err) {
|
||||
onError?.(err instanceof Error ? err : new Error(formatError(err)));
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
||||
writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
|
||||
return;
|
||||
}
|
||||
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
||||
writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(formatError(err));
|
||||
onError?.(error);
|
||||
writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
|
||||
}
|
||||
});
|
||||
|
||||
const start = (): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, host, () => resolve());
|
||||
});
|
||||
};
|
||||
|
||||
let stopped = false;
|
||||
const stop = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close races while shutting down
|
||||
}
|
||||
};
|
||||
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
stop();
|
||||
} else {
|
||||
abortSignal.addEventListener("abort", stop, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { server, start, stop };
|
||||
}
|
||||
|
||||
export type NextcloudTalkMonitorOptions = {
|
||||
accountId?: string;
|
||||
config?: CoreConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export async function monitorNextcloudTalkProvider(
|
||||
opts: NextcloudTalkMonitorOptions,
|
||||
): Promise<{ stop: () => void }> {
|
||||
const core = getNextcloudTalkRuntime();
|
||||
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const runtime: RuntimeEnv =
|
||||
opts.runtime ??
|
||||
createLoggerBackedRuntime({
|
||||
logger: core.logging.getChildLogger(),
|
||||
exitError: () => new Error("Runtime exit not available"),
|
||||
});
|
||||
|
||||
if (!account.secret) {
|
||||
throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
|
||||
}
|
||||
|
||||
const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
|
||||
const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
|
||||
const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
|
||||
|
||||
const logger = core.logging.getChildLogger({
|
||||
channel: "nextcloud-talk",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
|
||||
const replayGuard = createNextcloudTalkReplayGuard({
|
||||
stateDir: core.state.resolveStateDir(process.env, os.homedir),
|
||||
onDiskError: (error) => {
|
||||
logger.warn(
|
||||
`[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { start, stop } = createNextcloudTalkWebhookServer({
|
||||
port,
|
||||
host,
|
||||
path,
|
||||
secret: account.secret,
|
||||
isBackendAllowed: (backend) => {
|
||||
if (!expectedBackendOrigin) {
|
||||
return true;
|
||||
}
|
||||
const backendOrigin = normalizeOrigin(backend);
|
||||
return backendOrigin === expectedBackendOrigin;
|
||||
},
|
||||
shouldProcessMessage: async (message) => {
|
||||
const shouldProcess = await replayGuard.shouldProcessMessage({
|
||||
accountId: account.accountId,
|
||||
roomToken: message.roomToken,
|
||||
messageId: message.messageId,
|
||||
});
|
||||
if (!shouldProcess) {
|
||||
logger.warn(
|
||||
`[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
|
||||
);
|
||||
}
|
||||
return shouldProcess;
|
||||
},
|
||||
onMessage: async (message) => {
|
||||
core.channel.activity.record({
|
||||
channel: "nextcloud-talk",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
at: message.timestamp,
|
||||
});
|
||||
if (opts.onMessage) {
|
||||
await opts.onMessage(message);
|
||||
return;
|
||||
}
|
||||
await handleNextcloudTalkInbound({
|
||||
message,
|
||||
account,
|
||||
config: cfg,
|
||||
runtime,
|
||||
statusSink: opts.statusSink,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
|
||||
},
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return { stop };
|
||||
}
|
||||
await start();
|
||||
if (opts.abortSignal?.aborted) {
|
||||
stop();
|
||||
return { stop };
|
||||
}
|
||||
|
||||
const publicUrl =
|
||||
account.config.webhookPublicUrl ??
|
||||
`http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
|
||||
logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
|
||||
|
||||
return { stop };
|
||||
}
|
||||
39
openclaw/extensions/nextcloud-talk/src/normalize.ts
Normal file
39
openclaw/extensions/nextcloud-talk/src/normalize.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let normalized = trimmed;
|
||||
|
||||
if (normalized.startsWith("nextcloud-talk:")) {
|
||||
normalized = normalized.slice("nextcloud-talk:".length).trim();
|
||||
} else if (normalized.startsWith("nc-talk:")) {
|
||||
normalized = normalized.slice("nc-talk:".length).trim();
|
||||
} else if (normalized.startsWith("nc:")) {
|
||||
normalized = normalized.slice("nc:".length).trim();
|
||||
}
|
||||
|
||||
if (normalized.startsWith("room:")) {
|
||||
normalized = normalized.slice("room:".length).trim();
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `nextcloud-talk:${normalized}`.toLowerCase();
|
||||
}
|
||||
|
||||
export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
||||
}
|
||||
349
openclaw/extensions/nextcloud-talk/src/onboarding.ts
Normal file
349
openclaw/extensions/nextcloud-talk/src/onboarding.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
promptAccountId,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type OpenClawConfig,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
listNextcloudTalkAccountIds,
|
||||
resolveDefaultNextcloudTalkAccountId,
|
||||
resolveNextcloudTalkAccount,
|
||||
} from "./accounts.js";
|
||||
import type { CoreConfig, DmPolicy } from "./types.js";
|
||||
|
||||
const channel = "nextcloud-talk" as const;
|
||||
|
||||
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
||||
const existingConfig = cfg.channels?.["nextcloud-talk"];
|
||||
const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x));
|
||||
const allowFrom: string[] =
|
||||
dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom;
|
||||
|
||||
const newNextcloudTalkConfig = {
|
||||
...existingConfig,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk": newNextcloudTalkConfig,
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) SSH into your Nextcloud server",
|
||||
'2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction',
|
||||
"3) Copy the shared secret you used in the command",
|
||||
"4) Enable the bot in your Nextcloud Talk room settings",
|
||||
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
|
||||
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
||||
].join("\n"),
|
||||
"Nextcloud Talk bot setup",
|
||||
);
|
||||
}
|
||||
|
||||
async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Check the Nextcloud admin panel for user IDs",
|
||||
"2) Or look at the webhook payload logs when someone messages",
|
||||
"3) User IDs are typically lowercase usernames in Nextcloud",
|
||||
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
||||
].join("\n"),
|
||||
"Nextcloud Talk user id",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptNextcloudTalkAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveNextcloudTalkAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
await noteNextcloudTalkUserIdHelp(prompter);
|
||||
|
||||
const parseInput = (value: string) =>
|
||||
value
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
let resolvedIds: string[] = [];
|
||||
while (resolvedIds.length === 0) {
|
||||
const entry = await prompter.text({
|
||||
message: "Nextcloud Talk allowFrom (user id)",
|
||||
placeholder: "username",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
resolvedIds = parseInput(String(entry));
|
||||
if (resolvedIds.length === 0) {
|
||||
await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
|
||||
...resolvedIds,
|
||||
];
|
||||
const unique = mergeAllowFromEntries(undefined, merged);
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk": {
|
||||
...cfg.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk": {
|
||||
...cfg.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.["nextcloud-talk"]?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
||||
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function promptNextcloudTalkAllowFromForAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultNextcloudTalkAccountId(params.cfg);
|
||||
return promptNextcloudTalkAllowFrom({
|
||||
cfg: params.cfg,
|
||||
prompter: params.prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Nextcloud Talk",
|
||||
channel,
|
||||
policyKey: "channels.nextcloud-talk.dmPolicy",
|
||||
allowFromKey: "channels.nextcloud-talk.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
|
||||
promptAllowFrom: promptNextcloudTalkAllowFromForAccount as (params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string | undefined;
|
||||
}) => Promise<OpenClawConfig>,
|
||||
};
|
||||
|
||||
export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => {
|
||||
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
return Boolean(account.secret && account.baseUrl);
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`],
|
||||
selectionHint: configured ? "configured" : "self-hosted chat",
|
||||
quickstartScore: configured ? 1 : 5,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim();
|
||||
const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
|
||||
let accountId = nextcloudTalkOverride
|
||||
? normalizeAccountId(nextcloudTalkOverride)
|
||||
: defaultAccountId;
|
||||
|
||||
if (shouldPromptAccountIds && !nextcloudTalkOverride) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: cfg as CoreConfig,
|
||||
prompter,
|
||||
label: "Nextcloud Talk",
|
||||
currentId: accountId,
|
||||
listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[],
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as CoreConfig;
|
||||
const resolvedAccount = resolveNextcloudTalkAccount({
|
||||
cfg: next,
|
||||
accountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim());
|
||||
const hasConfigSecret = Boolean(
|
||||
resolvedAccount.config.botSecret || resolvedAccount.config.botSecretFile,
|
||||
);
|
||||
|
||||
let baseUrl = resolvedAccount.baseUrl;
|
||||
if (!baseUrl) {
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
|
||||
validate: (value) => {
|
||||
const v = String(value ?? "").trim();
|
||||
if (!v) {
|
||||
return "Required";
|
||||
}
|
||||
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
||||
return "URL must start with http:// or https://";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
let secret: string | null = null;
|
||||
if (!accountConfigured) {
|
||||
await noteNextcloudTalkSecretHelp(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv && !resolvedAccount.config.botSecret) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
"nextcloud-talk": {
|
||||
...next.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
secret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Nextcloud Talk bot secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigSecret) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Nextcloud Talk secret already configured. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
secret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Nextcloud Talk bot secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
secret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Nextcloud Talk bot secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (secret || baseUrl !== resolvedAccount.baseUrl) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
"nextcloud-talk": {
|
||||
...next.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
...(secret ? { botSecret: secret } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
"nextcloud-talk": {
|
||||
...next.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.["nextcloud-talk"]?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
||||
enabled:
|
||||
next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
|
||||
baseUrl,
|
||||
...(secret ? { botSecret: secret } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptNextcloudTalkAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
33
openclaw/extensions/nextcloud-talk/src/policy.test.ts
Normal file
33
openclaw/extensions/nextcloud-talk/src/policy.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveNextcloudTalkAllowlistMatch } from "./policy.js";
|
||||
|
||||
describe("nextcloud-talk policy", () => {
|
||||
describe("resolveNextcloudTalkAllowlistMatch", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "user-id",
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows sender id match with normalization", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["nc:User-Id"],
|
||||
senderId: "user-id",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("blocks when sender id does not match", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["allowed"],
|
||||
senderId: "other",
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
openclaw/extensions/nextcloud-talk/src/policy.ts
Normal file
180
openclaw/extensions/nextcloud-talk/src/policy.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type {
|
||||
AllowlistMatch,
|
||||
ChannelGroupContext,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
resolveMentionGatingWithBypass,
|
||||
resolveNestedAllowlistDecision,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { NextcloudTalkRoomConfig } from "./types.js";
|
||||
|
||||
function normalizeAllowEntry(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^(nextcloud-talk|nc-talk|nc):/i, "");
|
||||
}
|
||||
|
||||
export function normalizeNextcloudTalkAllowlist(
|
||||
values: Array<string | number> | undefined,
|
||||
): string[] {
|
||||
return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number> | undefined;
|
||||
senderId: string;
|
||||
}): AllowlistMatch<"wildcard" | "id"> {
|
||||
const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
|
||||
if (allowFrom.length === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (allowFrom.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
const senderId = normalizeAllowEntry(params.senderId);
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export type NextcloudTalkRoomMatch = {
|
||||
roomConfig?: NextcloudTalkRoomConfig;
|
||||
wildcardConfig?: NextcloudTalkRoomConfig;
|
||||
roomKey?: string;
|
||||
matchSource?: "direct" | "parent" | "wildcard";
|
||||
allowed: boolean;
|
||||
allowlistConfigured: boolean;
|
||||
};
|
||||
|
||||
export function resolveNextcloudTalkRoomMatch(params: {
|
||||
rooms?: Record<string, NextcloudTalkRoomConfig>;
|
||||
roomToken: string;
|
||||
roomName?: string | null;
|
||||
}): NextcloudTalkRoomMatch {
|
||||
const rooms = params.rooms ?? {};
|
||||
const allowlistConfigured = Object.keys(rooms).length > 0;
|
||||
const roomName = params.roomName?.trim() || undefined;
|
||||
const roomCandidates = buildChannelKeyCandidates(
|
||||
params.roomToken,
|
||||
roomName,
|
||||
roomName ? normalizeChannelSlug(roomName) : undefined,
|
||||
);
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries: rooms,
|
||||
keys: roomCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
const roomConfig = match.entry;
|
||||
const allowed = resolveNestedAllowlistDecision({
|
||||
outerConfigured: allowlistConfigured,
|
||||
outerMatched: Boolean(roomConfig),
|
||||
innerConfigured: false,
|
||||
innerMatched: false,
|
||||
});
|
||||
|
||||
return {
|
||||
roomConfig,
|
||||
wildcardConfig: match.wildcardEntry,
|
||||
roomKey: match.matchKey ?? match.key,
|
||||
matchSource: match.matchSource,
|
||||
allowed,
|
||||
allowlistConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg as {
|
||||
channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } };
|
||||
};
|
||||
const roomToken = params.groupId?.trim();
|
||||
if (!roomToken) {
|
||||
return undefined;
|
||||
}
|
||||
const roomName = params.groupChannel?.trim() || undefined;
|
||||
const match = resolveNextcloudTalkRoomMatch({
|
||||
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
|
||||
roomToken,
|
||||
roomName,
|
||||
});
|
||||
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkRequireMention(params: {
|
||||
roomConfig?: NextcloudTalkRoomConfig;
|
||||
wildcardConfig?: NextcloudTalkRoomConfig;
|
||||
}): boolean {
|
||||
if (typeof params.roomConfig?.requireMention === "boolean") {
|
||||
return params.roomConfig.requireMention;
|
||||
}
|
||||
if (typeof params.wildcardConfig?.requireMention === "boolean") {
|
||||
return params.wildcardConfig.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkGroupAllow(params: {
|
||||
groupPolicy: GroupPolicy;
|
||||
outerAllowFrom: Array<string | number> | undefined;
|
||||
innerAllowFrom: Array<string | number> | undefined;
|
||||
senderId: string;
|
||||
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
|
||||
if (params.groupPolicy === "disabled") {
|
||||
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
|
||||
}
|
||||
if (params.groupPolicy === "open") {
|
||||
return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } };
|
||||
}
|
||||
|
||||
const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom);
|
||||
const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom);
|
||||
if (outerAllow.length === 0 && innerAllow.length === 0) {
|
||||
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
|
||||
}
|
||||
|
||||
const outerMatch = resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: params.outerAllowFrom,
|
||||
senderId: params.senderId,
|
||||
});
|
||||
const innerMatch = resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: params.innerAllowFrom,
|
||||
senderId: params.senderId,
|
||||
});
|
||||
const allowed = resolveNestedAllowlistDecision({
|
||||
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
|
||||
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
|
||||
innerConfigured: innerAllow.length > 0,
|
||||
innerMatched: innerMatch.allowed,
|
||||
});
|
||||
|
||||
return { allowed, outerMatch, innerMatch };
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkMentionGate(params: {
|
||||
isGroup: boolean;
|
||||
requireMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
commandAuthorized: boolean;
|
||||
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
|
||||
const result = resolveMentionGatingWithBypass({
|
||||
isGroup: params.isGroup,
|
||||
requireMention: params.requireMention,
|
||||
canDetectMention: true,
|
||||
wasMentioned: params.wasMentioned,
|
||||
allowTextCommands: params.allowTextCommands,
|
||||
hasControlCommand: params.hasControlCommand,
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
|
||||
}
|
||||
70
openclaw/extensions/nextcloud-talk/src/replay-guard.test.ts
Normal file
70
openclaw/extensions/nextcloud-talk/src/replay-guard.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("createNextcloudTalkReplayGuard", () => {
|
||||
it("persists replay decisions across guard instances", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
|
||||
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
const firstAttempt = await firstGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
const replayAttempt = await firstGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
const restartReplayAttempt = await secondGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(firstAttempt).toBe(true);
|
||||
expect(replayAttempt).toBe(false);
|
||||
expect(restartReplayAttempt).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes replay state by account namespace", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
|
||||
const accountAFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
const accountBFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-b",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
|
||||
expect(accountAFirst).toBe(true);
|
||||
expect(accountBFirst).toBe(true);
|
||||
});
|
||||
});
|
||||
65
openclaw/extensions/nextcloud-talk/src/replay-guard.ts
Normal file
65
openclaw/extensions/nextcloud-talk/src/replay-guard.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import path from "node:path";
|
||||
import { createPersistentDedupe } from "openclaw/plugin-sdk";
|
||||
|
||||
const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_MEMORY_MAX_SIZE = 1_000;
|
||||
const DEFAULT_FILE_MAX_ENTRIES = 10_000;
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "default";
|
||||
}
|
||||
return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
}
|
||||
|
||||
function buildReplayKey(params: { roomToken: string; messageId: string }): string | null {
|
||||
const roomToken = params.roomToken.trim();
|
||||
const messageId = params.messageId.trim();
|
||||
if (!roomToken || !messageId) {
|
||||
return null;
|
||||
}
|
||||
return `${roomToken}:${messageId}`;
|
||||
}
|
||||
|
||||
export type NextcloudTalkReplayGuardOptions = {
|
||||
stateDir: string;
|
||||
ttlMs?: number;
|
||||
memoryMaxSize?: number;
|
||||
fileMaxEntries?: number;
|
||||
onDiskError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export type NextcloudTalkReplayGuard = {
|
||||
shouldProcessMessage: (params: {
|
||||
accountId: string;
|
||||
roomToken: string;
|
||||
messageId: string;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function createNextcloudTalkReplayGuard(
|
||||
options: NextcloudTalkReplayGuardOptions,
|
||||
): NextcloudTalkReplayGuard {
|
||||
const stateDir = options.stateDir.trim();
|
||||
const persistentDedupe = createPersistentDedupe({
|
||||
ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
|
||||
memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
|
||||
fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
|
||||
resolveFilePath: (namespace) =>
|
||||
path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`),
|
||||
});
|
||||
|
||||
return {
|
||||
shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
|
||||
const replayKey = buildReplayKey({ roomToken, messageId });
|
||||
if (!replayKey) {
|
||||
return true;
|
||||
}
|
||||
return await persistentDedupe.checkAndRecord(replayKey, {
|
||||
namespace: accountId,
|
||||
onDiskError: options.onDiskError,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
125
openclaw/extensions/nextcloud-talk/src/room-info.ts
Normal file
125
openclaw/extensions/nextcloud-talk/src/room-info.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
|
||||
const ROOM_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const ROOM_CACHE_ERROR_TTL_MS = 30 * 1000;
|
||||
|
||||
const roomCache = new Map<
|
||||
string,
|
||||
{ kind?: "direct" | "group"; fetchedAt: number; error?: string }
|
||||
>();
|
||||
|
||||
function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) {
|
||||
return `${params.accountId}:${params.roomToken}`;
|
||||
}
|
||||
|
||||
function readApiPassword(params: {
|
||||
apiPassword?: string;
|
||||
apiPasswordFile?: string;
|
||||
}): string | undefined {
|
||||
if (params.apiPassword?.trim()) {
|
||||
return params.apiPassword.trim();
|
||||
}
|
||||
if (!params.apiPasswordFile) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const value = readFileSync(params.apiPasswordFile, "utf-8").trim();
|
||||
return value || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function coerceRoomType(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveRoomKindFromType(type: number | undefined): "direct" | "group" | undefined {
|
||||
if (!type) {
|
||||
return undefined;
|
||||
}
|
||||
if (type === 1 || type === 5 || type === 6) {
|
||||
return "direct";
|
||||
}
|
||||
return "group";
|
||||
}
|
||||
|
||||
export async function resolveNextcloudTalkRoomKind(params: {
|
||||
account: ResolvedNextcloudTalkAccount;
|
||||
roomToken: string;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<"direct" | "group" | undefined> {
|
||||
const { account, roomToken, runtime } = params;
|
||||
const key = resolveRoomCacheKey({ accountId: account.accountId, roomToken });
|
||||
const cached = roomCache.get(key);
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.fetchedAt;
|
||||
if (cached.kind && age < ROOM_CACHE_TTL_MS) {
|
||||
return cached.kind;
|
||||
}
|
||||
if (cached.error && age < ROOM_CACHE_ERROR_TTL_MS) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const apiUser = account.config.apiUser?.trim();
|
||||
const apiPassword = readApiPassword({
|
||||
apiPassword: account.config.apiPassword,
|
||||
apiPasswordFile: account.config.apiPasswordFile,
|
||||
});
|
||||
if (!apiUser || !apiPassword) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`;
|
||||
const auth = Buffer.from(`${apiUser}:${apiPassword}`, "utf-8").toString("base64");
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
"OCS-APIRequest": "true",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
roomCache.set(key, {
|
||||
fetchedAt: Date.now(),
|
||||
error: `status:${response.status}`,
|
||||
});
|
||||
runtime?.log?.(`nextcloud-talk: room lookup failed (${response.status}) token=${roomToken}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ocs?: { data?: { type?: number | string } };
|
||||
};
|
||||
const type = coerceRoomType(payload.ocs?.data?.type);
|
||||
const kind = resolveRoomKindFromType(type);
|
||||
roomCache.set(key, { fetchedAt: Date.now(), kind });
|
||||
return kind;
|
||||
} catch (err) {
|
||||
roomCache.set(key, {
|
||||
fetchedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
14
openclaw/extensions/nextcloud-talk/src/runtime.ts
Normal file
14
openclaw/extensions/nextcloud-talk/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setNextcloudTalkRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getNextcloudTalkRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Nextcloud Talk runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
215
openclaw/extensions/nextcloud-talk/src/send.ts
Normal file
215
openclaw/extensions/nextcloud-talk/src/send.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { generateNextcloudTalkSignature } from "./signature.js";
|
||||
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
||||
|
||||
type NextcloudTalkSendOpts = {
|
||||
baseUrl?: string;
|
||||
secret?: string;
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
function resolveCredentials(
|
||||
explicit: { baseUrl?: string; secret?: string },
|
||||
account: { baseUrl: string; secret: string; accountId: string },
|
||||
): { baseUrl: string; secret: string } {
|
||||
const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl;
|
||||
const secret = explicit.secret?.trim() ?? account.secret;
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`,
|
||||
);
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
return { baseUrl, secret };
|
||||
}
|
||||
|
||||
function normalizeRoomToken(to: string): string {
|
||||
const trimmed = to.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Room token is required for Nextcloud Talk sends");
|
||||
}
|
||||
|
||||
let normalized = trimmed;
|
||||
if (normalized.startsWith("nextcloud-talk:")) {
|
||||
normalized = normalized.slice("nextcloud-talk:".length).trim();
|
||||
} else if (normalized.startsWith("nc:")) {
|
||||
normalized = normalized.slice("nc:".length).trim();
|
||||
}
|
||||
|
||||
if (normalized.startsWith("room:")) {
|
||||
normalized = normalized.slice("room:".length).trim();
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("Room token is required for Nextcloud Talk sends");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function sendMessageNextcloudTalk(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: NextcloudTalkSendOpts = {},
|
||||
): Promise<NextcloudTalkSendResult> {
|
||||
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, secret } = resolveCredentials(
|
||||
{ baseUrl: opts.baseUrl, secret: opts.secret },
|
||||
account,
|
||||
);
|
||||
const roomToken = normalizeRoomToken(to);
|
||||
|
||||
if (!text?.trim()) {
|
||||
throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
||||
}
|
||||
|
||||
const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "nextcloud-talk",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables(
|
||||
text.trim(),
|
||||
tableMode,
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
message,
|
||||
};
|
||||
if (opts.replyTo) {
|
||||
body.replyTo = opts.replyTo;
|
||||
}
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
// Nextcloud Talk verifies signature against the extracted message text,
|
||||
// not the full JSON body. See ChecksumVerificationService.php:
|
||||
// hash_hmac('sha256', $random . $data, $secret)
|
||||
// where $data is the "message" parameter, not the raw request body.
|
||||
const { random, signature } = generateNextcloudTalkSignature({
|
||||
body: message,
|
||||
secret,
|
||||
});
|
||||
|
||||
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"OCS-APIRequest": "true",
|
||||
"X-Nextcloud-Talk-Bot-Random": random,
|
||||
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||||
},
|
||||
body: bodyStr,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
const status = response.status;
|
||||
let errorMsg = `Nextcloud Talk send failed (${status})`;
|
||||
|
||||
if (status === 400) {
|
||||
errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`;
|
||||
} else if (status === 401) {
|
||||
errorMsg = "Nextcloud Talk: authentication failed - check bot secret";
|
||||
} else if (status === 403) {
|
||||
errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room";
|
||||
} else if (status === 404) {
|
||||
errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`;
|
||||
} else if (errorBody) {
|
||||
errorMsg = `Nextcloud Talk send failed: ${errorBody}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
let messageId = "unknown";
|
||||
let timestamp: number | undefined;
|
||||
try {
|
||||
const data = (await response.json()) as {
|
||||
ocs?: {
|
||||
data?: {
|
||||
id?: number | string;
|
||||
timestamp?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (data.ocs?.data?.id != null) {
|
||||
messageId = String(data.ocs.data.id);
|
||||
}
|
||||
if (typeof data.ocs?.data?.timestamp === "number") {
|
||||
timestamp = data.ocs.data.timestamp;
|
||||
}
|
||||
} catch {
|
||||
// Response parsing failed, but message was sent.
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`);
|
||||
}
|
||||
|
||||
getNextcloudTalkRuntime().channel.activity.record({
|
||||
channel: "nextcloud-talk",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return { messageId, roomToken, timestamp };
|
||||
}
|
||||
|
||||
export async function sendReactionNextcloudTalk(
|
||||
roomToken: string,
|
||||
messageId: string,
|
||||
reaction: string,
|
||||
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
|
||||
): Promise<{ ok: true }> {
|
||||
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, secret } = resolveCredentials(
|
||||
{ baseUrl: opts.baseUrl, secret: opts.secret },
|
||||
account,
|
||||
);
|
||||
const normalizedToken = normalizeRoomToken(roomToken);
|
||||
|
||||
const body = JSON.stringify({ reaction });
|
||||
// Sign only the reaction string, not the full JSON body
|
||||
const { random, signature } = generateNextcloudTalkSignature({
|
||||
body: reaction,
|
||||
secret,
|
||||
});
|
||||
|
||||
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"OCS-APIRequest": "true",
|
||||
"X-Nextcloud-Talk-Bot-Random": random,
|
||||
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
72
openclaw/extensions/nextcloud-talk/src/signature.ts
Normal file
72
openclaw/extensions/nextcloud-talk/src/signature.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import type { NextcloudTalkWebhookHeaders } from "./types.js";
|
||||
|
||||
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
|
||||
const RANDOM_HEADER = "x-nextcloud-talk-random";
|
||||
const BACKEND_HEADER = "x-nextcloud-talk-backend";
|
||||
|
||||
/**
|
||||
* Verify the HMAC-SHA256 signature of an incoming webhook request.
|
||||
* Signature is calculated as: HMAC-SHA256(random + body, secret)
|
||||
*/
|
||||
export function verifyNextcloudTalkSignature(params: {
|
||||
signature: string;
|
||||
random: string;
|
||||
body: string;
|
||||
secret: string;
|
||||
}): boolean {
|
||||
const { signature, random, body, secret } = params;
|
||||
if (!signature || !random || !secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac("sha256", secret)
|
||||
.update(random + body)
|
||||
.digest("hex");
|
||||
|
||||
if (signature.length !== expected.length) {
|
||||
return false;
|
||||
}
|
||||
let result = 0;
|
||||
for (let i = 0; i < signature.length; i++) {
|
||||
result |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||
}
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract webhook headers from an incoming request.
|
||||
*/
|
||||
export function extractNextcloudTalkHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
): NextcloudTalkWebhookHeaders | null {
|
||||
const getHeader = (name: string): string | undefined => {
|
||||
const value = headers[name] ?? headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
const signature = getHeader(SIGNATURE_HEADER);
|
||||
const random = getHeader(RANDOM_HEADER);
|
||||
const backend = getHeader(BACKEND_HEADER);
|
||||
|
||||
if (!signature || !random || !backend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { signature, random, backend };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signature headers for an outbound request to Nextcloud Talk.
|
||||
*/
|
||||
export function generateNextcloudTalkSignature(params: { body: string; secret: string }): {
|
||||
random: string;
|
||||
signature: string;
|
||||
} {
|
||||
const { body, secret } = params;
|
||||
const random = randomBytes(32).toString("hex");
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(random + body)
|
||||
.digest("hex");
|
||||
return { random, signature };
|
||||
}
|
||||
187
openclaw/extensions/nextcloud-talk/src/types.ts
Normal file
187
openclaw/extensions/nextcloud-talk/src/types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
export type { DmPolicy, GroupPolicy };
|
||||
|
||||
export type NextcloudTalkRoomConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this room. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this room. */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for room senders (user ids). */
|
||||
allowFrom?: string[];
|
||||
/** Optional system prompt snippet for this room. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type NextcloudTalkAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this Nextcloud Talk account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Base URL of the Nextcloud instance (e.g., "https://cloud.example.com"). */
|
||||
baseUrl?: string;
|
||||
/** Bot shared secret from occ talk:bot:install output. */
|
||||
botSecret?: string;
|
||||
/** Path to file containing bot secret (for secret managers). */
|
||||
botSecretFile?: string;
|
||||
/** Optional API user for room lookups (DM detection). */
|
||||
apiUser?: string;
|
||||
/** Optional API password/app password for room lookups. */
|
||||
apiPassword?: string;
|
||||
/** Path to file containing API password/app password. */
|
||||
apiPasswordFile?: string;
|
||||
/** Direct message policy (default: pairing). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Webhook server port. Default: 8788. */
|
||||
webhookPort?: number;
|
||||
/** Webhook server host. Default: "0.0.0.0". */
|
||||
webhookHost?: string;
|
||||
/** Webhook endpoint path. Default: "/nextcloud-talk-webhook". */
|
||||
webhookPath?: string;
|
||||
/** Public URL for the webhook (used if behind reverse proxy). */
|
||||
webhookPublicUrl?: string;
|
||||
/** Optional allowlist of user IDs allowed to DM the bot. */
|
||||
allowFrom?: string[];
|
||||
/** Optional allowlist for Nextcloud Talk room senders (user ids). */
|
||||
groupAllowFrom?: string[];
|
||||
/** Group message policy (default: allowlist). */
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Per-room configuration (key is room token). */
|
||||
rooms?: Record<string, NextcloudTalkRoomConfig>;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Media upload max size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
export type NextcloudTalkConfig = {
|
||||
/** Optional per-account Nextcloud Talk configuration (multi-account). */
|
||||
accounts?: Record<string, NextcloudTalkAccountConfig>;
|
||||
} & NextcloudTalkAccountConfig;
|
||||
|
||||
export type CoreConfig = {
|
||||
channels?: {
|
||||
"nextcloud-talk"?: NextcloudTalkConfig;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nextcloud Talk webhook payload types based on Activity Streams 2.0 format.
|
||||
* Reference: https://nextcloud-talk.readthedocs.io/en/latest/bots/
|
||||
*/
|
||||
|
||||
/** Actor in the activity (the message sender). */
|
||||
export type NextcloudTalkActor = {
|
||||
type: "Person";
|
||||
/** User ID in Nextcloud. */
|
||||
id: string;
|
||||
/** Display name of the user. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** The message object in the activity. */
|
||||
export type NextcloudTalkObject = {
|
||||
type: "Note";
|
||||
/** Message ID. */
|
||||
id: string;
|
||||
/** Message text (same as content for text/plain). */
|
||||
name: string;
|
||||
/** Message content. */
|
||||
content: string;
|
||||
/** Media type of the content. */
|
||||
mediaType: string;
|
||||
};
|
||||
|
||||
/** Target conversation/room. */
|
||||
export type NextcloudTalkTarget = {
|
||||
type: "Collection";
|
||||
/** Room token. */
|
||||
id: string;
|
||||
/** Room display name. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** Incoming webhook payload from Nextcloud Talk. */
|
||||
export type NextcloudTalkWebhookPayload = {
|
||||
type: "Create" | "Update" | "Delete";
|
||||
actor: NextcloudTalkActor;
|
||||
object: NextcloudTalkObject;
|
||||
target: NextcloudTalkTarget;
|
||||
};
|
||||
|
||||
/** Result from sending a message to Nextcloud Talk. */
|
||||
export type NextcloudTalkSendResult = {
|
||||
messageId: string;
|
||||
roomToken: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
/** Parsed incoming message context. */
|
||||
export type NextcloudTalkInboundMessage = {
|
||||
messageId: string;
|
||||
roomToken: string;
|
||||
roomName: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
text: string;
|
||||
mediaType: string;
|
||||
timestamp: number;
|
||||
isGroupChat: boolean;
|
||||
};
|
||||
|
||||
/** Headers sent by Nextcloud Talk webhook. */
|
||||
export type NextcloudTalkWebhookHeaders = {
|
||||
/** HMAC-SHA256 signature of the request. */
|
||||
signature: string;
|
||||
/** Random string used in signature calculation. */
|
||||
random: string;
|
||||
/** Backend Nextcloud server URL. */
|
||||
backend: string;
|
||||
};
|
||||
|
||||
/** Options for the webhook server. */
|
||||
export type NextcloudTalkWebhookServerOptions = {
|
||||
port: number;
|
||||
host: string;
|
||||
path: string;
|
||||
secret: string;
|
||||
maxBodyBytes?: number;
|
||||
readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise<string>;
|
||||
isBackendAllowed?: (backend: string) => boolean;
|
||||
shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise<boolean>;
|
||||
onMessage: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
|
||||
onError?: (error: Error) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** Options for sending a message. */
|
||||
export type NextcloudTalkSendOptions = {
|
||||
baseUrl: string;
|
||||
secret: string;
|
||||
roomToken: string;
|
||||
message: string;
|
||||
replyTo?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user