Files
openclaw/extensions/nextcloud-talk/src/channel.ts
Kyle a438ff4397 fix(plugin-sdk): remove remaining bundled plugin src imports (openclaw#39638)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Kyle <3477429+kyledh@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-08 22:32:45 -05:00

417 lines
14 KiB
TypeScript

import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
formatAllowFromLowercase,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
import {
applyAccountNameToChannelSection,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
buildRuntimeAccountStatusSnapshot,
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
normalizeAccountId,
setAccountEnabledInConfigSection,
waitForAbortSignal,
type ChannelPlugin,
type OpenClawConfig,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/nextcloud-talk";
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 }) =>
mapAllowFromEntries(
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom,
).map((entry) => entry.toLowerCase()),
formatAllowFrom: ({ allowFrom }) =>
formatAllowFromLowercase({
allowFrom,
stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i,
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "nextcloud-talk",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
});
},
collectWarnings: ({ account, cfg }) => {
const roomAllowlistConfigured =
account.config.rooms && Object.keys(account.config.rooms).length > 0;
return collectAllowlistProviderGroupPolicyWarnings({
cfg,
providerConfigPresent:
(cfg.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
collectOpenGroupPolicyRouteAllowlistWarnings({
groupPolicy,
routeAllowlistConfigured: Boolean(roomAllowlistConfigured),
restrictSenders: {
surface: "Nextcloud Talk rooms",
openScope: "any member in allowed rooms",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
noRouteAllowlist: {
surface: "Nextcloud Talk rooms",
routeAllowlistPath: "channels.nextcloud-talk.rooms",
routeScope: "room",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
}),
});
},
},
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 ({ cfg, to, text, accountId, replyToId }) => {
const result = await sendMessageNextcloudTalk(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
cfg: cfg as CoreConfig,
});
return { channel: "nextcloud-talk", ...result };
},
sendMedia: async ({ cfg, 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,
cfg: cfg as CoreConfig,
});
return { channel: "nextcloud-talk", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => {
const base = buildBaseChannelStatusSummary(snapshot);
return {
configured: base.configured,
secretSource: snapshot.secretSource ?? "none",
running: base.running,
mode: "webhook",
lastStartAt: base.lastStartAt,
lastStopAt: base.lastStopAt,
lastError: base.lastError,
};
},
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
const runtimeSnapshot = buildRuntimeAccountStatusSnapshot({ runtime });
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
running: runtimeSnapshot.running,
lastStartAt: runtimeSnapshot.lastStartAt,
lastStopAt: runtimeSnapshot.lastStopAt,
lastError: runtimeSnapshot.lastError,
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 accountCleanup = clearAccountEntryFields({
accounts: nextSection.accounts,
accountId,
fields: ["botSecret"],
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
if (accountCleanup.nextAccounts) {
nextSection.accounts = accountCleanup.nextAccounts;
} else {
delete nextSection.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,
};
},
},
};