@openclaw/nextcloud-talk 2026.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +30 -0
- package/src/accounts.ts +154 -0
- package/src/channel.ts +404 -0
- package/src/config-schema.ts +78 -0
- package/src/format.ts +79 -0
- package/src/inbound.ts +336 -0
- package/src/monitor.ts +246 -0
- package/src/normalize.ts +31 -0
- package/src/onboarding.ts +341 -0
- package/src/policy.ts +175 -0
- package/src/room-info.ts +111 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +206 -0
- package/src/signature.ts +67 -0
- package/src/types.ts +179 -0
package/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
import { nextcloudTalkPlugin } from "./src/channel.js";
|
|
5
|
+
import { setNextcloudTalkRuntime } from "./src/runtime.js";
|
|
6
|
+
|
|
7
|
+
const plugin = {
|
|
8
|
+
id: "nextcloud-talk",
|
|
9
|
+
name: "Nextcloud Talk",
|
|
10
|
+
description: "Nextcloud Talk channel plugin",
|
|
11
|
+
configSchema: emptyPluginConfigSchema(),
|
|
12
|
+
register(api: OpenClawPluginApi) {
|
|
13
|
+
setNextcloudTalkRuntime(api.runtime);
|
|
14
|
+
api.registerChannel({ plugin: nextcloudTalkPlugin });
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openclaw/nextcloud-talk",
|
|
3
|
+
"version": "2026.1.29",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenClaw Nextcloud Talk channel plugin",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"./index.ts"
|
|
9
|
+
],
|
|
10
|
+
"channel": {
|
|
11
|
+
"id": "nextcloud-talk",
|
|
12
|
+
"label": "Nextcloud Talk",
|
|
13
|
+
"selectionLabel": "Nextcloud Talk (self-hosted)",
|
|
14
|
+
"docsPath": "/channels/nextcloud-talk",
|
|
15
|
+
"docsLabel": "nextcloud-talk",
|
|
16
|
+
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
|
17
|
+
"aliases": [
|
|
18
|
+
"nc-talk",
|
|
19
|
+
"nc"
|
|
20
|
+
],
|
|
21
|
+
"order": 65,
|
|
22
|
+
"quickstartAllowFrom": true
|
|
23
|
+
},
|
|
24
|
+
"install": {
|
|
25
|
+
"npmSpec": "@openclaw/nextcloud-talk",
|
|
26
|
+
"localPath": "extensions/nextcloud-talk",
|
|
27
|
+
"defaultChoice": "npm"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
|
|
8
|
+
|
|
9
|
+
function isTruthyEnvValue(value?: string): boolean {
|
|
10
|
+
if (!value) return false;
|
|
11
|
+
return TRUTHY_ENV.has(value.trim().toLowerCase());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const debugAccounts = (...args: unknown[]) => {
|
|
15
|
+
if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) {
|
|
16
|
+
console.warn("[nextcloud-talk:accounts]", ...args);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ResolvedNextcloudTalkAccount = {
|
|
21
|
+
accountId: string;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
name?: string;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
secret: string;
|
|
26
|
+
secretSource: "env" | "secretFile" | "config" | "none";
|
|
27
|
+
config: NextcloudTalkAccountConfig;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
|
31
|
+
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
|
|
32
|
+
if (!accounts || typeof accounts !== "object") return [];
|
|
33
|
+
const ids = new Set<string>();
|
|
34
|
+
for (const key of Object.keys(accounts)) {
|
|
35
|
+
if (!key) continue;
|
|
36
|
+
ids.add(normalizeAccountId(key));
|
|
37
|
+
}
|
|
38
|
+
return [...ids];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
|
|
42
|
+
const ids = listConfiguredAccountIds(cfg);
|
|
43
|
+
debugAccounts("listNextcloudTalkAccountIds", ids);
|
|
44
|
+
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
|
45
|
+
return ids.sort((a, b) => a.localeCompare(b));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
|
|
49
|
+
const ids = listNextcloudTalkAccountIds(cfg);
|
|
50
|
+
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
|
51
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveAccountConfig(
|
|
55
|
+
cfg: CoreConfig,
|
|
56
|
+
accountId: string,
|
|
57
|
+
): NextcloudTalkAccountConfig | undefined {
|
|
58
|
+
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
|
|
59
|
+
if (!accounts || typeof accounts !== "object") return undefined;
|
|
60
|
+
const direct = accounts[accountId] as NextcloudTalkAccountConfig | undefined;
|
|
61
|
+
if (direct) return direct;
|
|
62
|
+
const normalized = normalizeAccountId(accountId);
|
|
63
|
+
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
|
|
64
|
+
return matchKey ? (accounts[matchKey] as NextcloudTalkAccountConfig | undefined) : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mergeNextcloudTalkAccountConfig(
|
|
68
|
+
cfg: CoreConfig,
|
|
69
|
+
accountId: string,
|
|
70
|
+
): NextcloudTalkAccountConfig {
|
|
71
|
+
const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ??
|
|
72
|
+
{}) as NextcloudTalkAccountConfig & { accounts?: unknown };
|
|
73
|
+
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
|
74
|
+
return { ...base, ...account };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveNextcloudTalkSecret(
|
|
78
|
+
cfg: CoreConfig,
|
|
79
|
+
opts: { accountId?: string },
|
|
80
|
+
): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } {
|
|
81
|
+
const merged = mergeNextcloudTalkAccountConfig(cfg, opts.accountId ?? DEFAULT_ACCOUNT_ID);
|
|
82
|
+
|
|
83
|
+
const envSecret = process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim();
|
|
84
|
+
if (envSecret && (!opts.accountId || opts.accountId === DEFAULT_ACCOUNT_ID)) {
|
|
85
|
+
return { secret: envSecret, source: "env" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (merged.botSecretFile) {
|
|
89
|
+
try {
|
|
90
|
+
const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim();
|
|
91
|
+
if (fileSecret) return { secret: fileSecret, source: "secretFile" };
|
|
92
|
+
} catch {
|
|
93
|
+
// File not found or unreadable, fall through.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (merged.botSecret?.trim()) {
|
|
98
|
+
return { secret: merged.botSecret.trim(), source: "config" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { secret: "", source: "none" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveNextcloudTalkAccount(params: {
|
|
105
|
+
cfg: CoreConfig;
|
|
106
|
+
accountId?: string | null;
|
|
107
|
+
}): ResolvedNextcloudTalkAccount {
|
|
108
|
+
const hasExplicitAccountId = Boolean(params.accountId?.trim());
|
|
109
|
+
const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
|
|
110
|
+
|
|
111
|
+
const resolve = (accountId: string) => {
|
|
112
|
+
const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId);
|
|
113
|
+
const accountEnabled = merged.enabled !== false;
|
|
114
|
+
const enabled = baseEnabled && accountEnabled;
|
|
115
|
+
const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId });
|
|
116
|
+
const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? "";
|
|
117
|
+
|
|
118
|
+
debugAccounts("resolve", {
|
|
119
|
+
accountId,
|
|
120
|
+
enabled,
|
|
121
|
+
secretSource: secretResolution.source,
|
|
122
|
+
baseUrl: baseUrl ? "[set]" : "[missing]",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
accountId,
|
|
127
|
+
enabled,
|
|
128
|
+
name: merged.name?.trim() || undefined,
|
|
129
|
+
baseUrl,
|
|
130
|
+
secret: secretResolution.secret,
|
|
131
|
+
secretSource: secretResolution.source,
|
|
132
|
+
config: merged,
|
|
133
|
+
} satisfies ResolvedNextcloudTalkAccount;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const normalized = normalizeAccountId(params.accountId);
|
|
137
|
+
const primary = resolve(normalized);
|
|
138
|
+
if (hasExplicitAccountId) return primary;
|
|
139
|
+
if (primary.secretSource !== "none") return primary;
|
|
140
|
+
|
|
141
|
+
const fallbackId = resolveDefaultNextcloudTalkAccountId(params.cfg);
|
|
142
|
+
if (fallbackId === primary.accountId) return primary;
|
|
143
|
+
const fallback = resolve(fallbackId);
|
|
144
|
+
if (fallback.secretSource === "none") return primary;
|
|
145
|
+
return fallback;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function listEnabledNextcloudTalkAccounts(
|
|
149
|
+
cfg: CoreConfig,
|
|
150
|
+
): ResolvedNextcloudTalkAccount[] {
|
|
151
|
+
return listNextcloudTalkAccountIds(cfg)
|
|
152
|
+
.map((accountId) => resolveNextcloudTalkAccount({ cfg, accountId }))
|
|
153
|
+
.filter((account) => account.enabled);
|
|
154
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyAccountNameToChannelSection,
|
|
3
|
+
buildChannelConfigSchema,
|
|
4
|
+
DEFAULT_ACCOUNT_ID,
|
|
5
|
+
deleteAccountFromConfigSection,
|
|
6
|
+
formatPairingApproveHint,
|
|
7
|
+
normalizeAccountId,
|
|
8
|
+
setAccountEnabledInConfigSection,
|
|
9
|
+
type ChannelPlugin,
|
|
10
|
+
type OpenClawConfig,
|
|
11
|
+
type ChannelSetupInput,
|
|
12
|
+
} from "openclaw/plugin-sdk";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
listNextcloudTalkAccountIds,
|
|
16
|
+
resolveDefaultNextcloudTalkAccountId,
|
|
17
|
+
resolveNextcloudTalkAccount,
|
|
18
|
+
type ResolvedNextcloudTalkAccount,
|
|
19
|
+
} from "./accounts.js";
|
|
20
|
+
import { NextcloudTalkConfigSchema } from "./config-schema.js";
|
|
21
|
+
import { monitorNextcloudTalkProvider } from "./monitor.js";
|
|
22
|
+
import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget } from "./normalize.js";
|
|
23
|
+
import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
|
|
24
|
+
import { getNextcloudTalkRuntime } from "./runtime.js";
|
|
25
|
+
import { sendMessageNextcloudTalk } from "./send.js";
|
|
26
|
+
import type { CoreConfig } from "./types.js";
|
|
27
|
+
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
|
|
28
|
+
|
|
29
|
+
const meta = {
|
|
30
|
+
id: "nextcloud-talk",
|
|
31
|
+
label: "Nextcloud Talk",
|
|
32
|
+
selectionLabel: "Nextcloud Talk (self-hosted)",
|
|
33
|
+
docsPath: "/channels/nextcloud-talk",
|
|
34
|
+
docsLabel: "nextcloud-talk",
|
|
35
|
+
blurb: "Self-hosted chat via Nextcloud Talk webhook bots.",
|
|
36
|
+
aliases: ["nc-talk", "nc"],
|
|
37
|
+
order: 65,
|
|
38
|
+
quickstartAllowFrom: true,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type NextcloudSetupInput = ChannelSetupInput & {
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
secret?: string;
|
|
44
|
+
secretFile?: string;
|
|
45
|
+
useEnv?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> = {
|
|
49
|
+
id: "nextcloud-talk",
|
|
50
|
+
meta,
|
|
51
|
+
onboarding: nextcloudTalkOnboardingAdapter,
|
|
52
|
+
pairing: {
|
|
53
|
+
idLabel: "nextcloudUserId",
|
|
54
|
+
normalizeAllowEntry: (entry) =>
|
|
55
|
+
entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
|
|
56
|
+
notifyApproval: async ({ id }) => {
|
|
57
|
+
console.log(`[nextcloud-talk] User ${id} approved for pairing`);
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
capabilities: {
|
|
61
|
+
chatTypes: ["direct", "group"],
|
|
62
|
+
reactions: true,
|
|
63
|
+
threads: false,
|
|
64
|
+
media: true,
|
|
65
|
+
nativeCommands: false,
|
|
66
|
+
blockStreaming: true,
|
|
67
|
+
},
|
|
68
|
+
reload: { configPrefixes: ["channels.nextcloud-talk"] },
|
|
69
|
+
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
|
|
70
|
+
config: {
|
|
71
|
+
listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig),
|
|
72
|
+
resolveAccount: (cfg, accountId) =>
|
|
73
|
+
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
74
|
+
defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig),
|
|
75
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
76
|
+
setAccountEnabledInConfigSection({
|
|
77
|
+
cfg,
|
|
78
|
+
sectionKey: "nextcloud-talk",
|
|
79
|
+
accountId,
|
|
80
|
+
enabled,
|
|
81
|
+
allowTopLevel: true,
|
|
82
|
+
}),
|
|
83
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
84
|
+
deleteAccountFromConfigSection({
|
|
85
|
+
cfg,
|
|
86
|
+
sectionKey: "nextcloud-talk",
|
|
87
|
+
accountId,
|
|
88
|
+
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
|
|
89
|
+
}),
|
|
90
|
+
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
|
91
|
+
describeAccount: (account) => ({
|
|
92
|
+
accountId: account.accountId,
|
|
93
|
+
name: account.name,
|
|
94
|
+
enabled: account.enabled,
|
|
95
|
+
configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
|
96
|
+
secretSource: account.secretSource,
|
|
97
|
+
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
|
98
|
+
}),
|
|
99
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
100
|
+
(resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
|
101
|
+
(entry) => String(entry).toLowerCase(),
|
|
102
|
+
),
|
|
103
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
104
|
+
allowFrom
|
|
105
|
+
.map((entry) => String(entry).trim())
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
|
|
108
|
+
.map((entry) => entry.toLowerCase()),
|
|
109
|
+
},
|
|
110
|
+
security: {
|
|
111
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
112
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
113
|
+
const useAccountPath = Boolean(
|
|
114
|
+
cfg.channels?.["nextcloud-talk"]?.accounts?.[resolvedAccountId],
|
|
115
|
+
);
|
|
116
|
+
const basePath = useAccountPath
|
|
117
|
+
? `channels.nextcloud-talk.accounts.${resolvedAccountId}.`
|
|
118
|
+
: "channels.nextcloud-talk.";
|
|
119
|
+
return {
|
|
120
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
121
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
122
|
+
policyPath: `${basePath}dmPolicy`,
|
|
123
|
+
allowFromPath: basePath,
|
|
124
|
+
approveHint: formatPairingApproveHint("nextcloud-talk"),
|
|
125
|
+
normalizeEntry: (raw) =>
|
|
126
|
+
raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
collectWarnings: ({ account, cfg }) => {
|
|
130
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
131
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
132
|
+
if (groupPolicy !== "open") return [];
|
|
133
|
+
const roomAllowlistConfigured =
|
|
134
|
+
account.config.rooms && Object.keys(account.config.rooms).length > 0;
|
|
135
|
+
if (roomAllowlistConfigured) {
|
|
136
|
+
return [
|
|
137
|
+
`- 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.`,
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
return [
|
|
141
|
+
`- 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.`,
|
|
142
|
+
];
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
groups: {
|
|
146
|
+
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
|
147
|
+
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
|
148
|
+
const rooms = account.config.rooms;
|
|
149
|
+
if (!rooms || !groupId) return true;
|
|
150
|
+
|
|
151
|
+
const roomConfig = rooms[groupId];
|
|
152
|
+
if (roomConfig?.requireMention !== undefined) {
|
|
153
|
+
return roomConfig.requireMention;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const wildcardConfig = rooms["*"];
|
|
157
|
+
if (wildcardConfig?.requireMention !== undefined) {
|
|
158
|
+
return wildcardConfig.requireMention;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
163
|
+
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
|
|
164
|
+
},
|
|
165
|
+
messaging: {
|
|
166
|
+
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
|
167
|
+
targetResolver: {
|
|
168
|
+
looksLikeId: looksLikeNextcloudTalkTargetId,
|
|
169
|
+
hint: "<roomToken>",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
setup: {
|
|
173
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
174
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
175
|
+
applyAccountNameToChannelSection({
|
|
176
|
+
cfg: cfg as OpenClawConfig,
|
|
177
|
+
channelKey: "nextcloud-talk",
|
|
178
|
+
accountId,
|
|
179
|
+
name,
|
|
180
|
+
}),
|
|
181
|
+
validateInput: ({ accountId, input }) => {
|
|
182
|
+
const setupInput = input as NextcloudSetupInput;
|
|
183
|
+
if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
184
|
+
return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.";
|
|
185
|
+
}
|
|
186
|
+
if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) {
|
|
187
|
+
return "Nextcloud Talk requires bot secret or --secret-file (or --use-env).";
|
|
188
|
+
}
|
|
189
|
+
if (!setupInput.baseUrl) {
|
|
190
|
+
return "Nextcloud Talk requires --base-url.";
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
},
|
|
194
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
195
|
+
const setupInput = input as NextcloudSetupInput;
|
|
196
|
+
const namedConfig = applyAccountNameToChannelSection({
|
|
197
|
+
cfg: cfg as OpenClawConfig,
|
|
198
|
+
channelKey: "nextcloud-talk",
|
|
199
|
+
accountId,
|
|
200
|
+
name: setupInput.name,
|
|
201
|
+
});
|
|
202
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
203
|
+
return {
|
|
204
|
+
...namedConfig,
|
|
205
|
+
channels: {
|
|
206
|
+
...namedConfig.channels,
|
|
207
|
+
"nextcloud-talk": {
|
|
208
|
+
...namedConfig.channels?.["nextcloud-talk"],
|
|
209
|
+
enabled: true,
|
|
210
|
+
baseUrl: setupInput.baseUrl,
|
|
211
|
+
...(setupInput.useEnv
|
|
212
|
+
? {}
|
|
213
|
+
: setupInput.secretFile
|
|
214
|
+
? { botSecretFile: setupInput.secretFile }
|
|
215
|
+
: setupInput.secret
|
|
216
|
+
? { botSecret: setupInput.secret }
|
|
217
|
+
: {}),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
} as OpenClawConfig;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
...namedConfig,
|
|
224
|
+
channels: {
|
|
225
|
+
...namedConfig.channels,
|
|
226
|
+
"nextcloud-talk": {
|
|
227
|
+
...namedConfig.channels?.["nextcloud-talk"],
|
|
228
|
+
enabled: true,
|
|
229
|
+
accounts: {
|
|
230
|
+
...namedConfig.channels?.["nextcloud-talk"]?.accounts,
|
|
231
|
+
[accountId]: {
|
|
232
|
+
...namedConfig.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
|
233
|
+
enabled: true,
|
|
234
|
+
baseUrl: setupInput.baseUrl,
|
|
235
|
+
...(setupInput.secretFile
|
|
236
|
+
? { botSecretFile: setupInput.secretFile }
|
|
237
|
+
: setupInput.secret
|
|
238
|
+
? { botSecret: setupInput.secret }
|
|
239
|
+
: {}),
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
} as OpenClawConfig;
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
outbound: {
|
|
248
|
+
deliveryMode: "direct",
|
|
249
|
+
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
250
|
+
chunkerMode: "markdown",
|
|
251
|
+
textChunkLimit: 4000,
|
|
252
|
+
sendText: async ({ to, text, accountId, replyToId }) => {
|
|
253
|
+
const result = await sendMessageNextcloudTalk(to, text, {
|
|
254
|
+
accountId: accountId ?? undefined,
|
|
255
|
+
replyTo: replyToId ?? undefined,
|
|
256
|
+
});
|
|
257
|
+
return { channel: "nextcloud-talk", ...result };
|
|
258
|
+
},
|
|
259
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
|
260
|
+
const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
|
261
|
+
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
|
|
262
|
+
accountId: accountId ?? undefined,
|
|
263
|
+
replyTo: replyToId ?? undefined,
|
|
264
|
+
});
|
|
265
|
+
return { channel: "nextcloud-talk", ...result };
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
status: {
|
|
269
|
+
defaultRuntime: {
|
|
270
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
271
|
+
running: false,
|
|
272
|
+
lastStartAt: null,
|
|
273
|
+
lastStopAt: null,
|
|
274
|
+
lastError: null,
|
|
275
|
+
},
|
|
276
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
277
|
+
configured: snapshot.configured ?? false,
|
|
278
|
+
secretSource: snapshot.secretSource ?? "none",
|
|
279
|
+
running: snapshot.running ?? false,
|
|
280
|
+
mode: "webhook",
|
|
281
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
282
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
283
|
+
lastError: snapshot.lastError ?? null,
|
|
284
|
+
}),
|
|
285
|
+
buildAccountSnapshot: ({ account, runtime }) => {
|
|
286
|
+
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
|
|
287
|
+
return {
|
|
288
|
+
accountId: account.accountId,
|
|
289
|
+
name: account.name,
|
|
290
|
+
enabled: account.enabled,
|
|
291
|
+
configured,
|
|
292
|
+
secretSource: account.secretSource,
|
|
293
|
+
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
|
294
|
+
running: runtime?.running ?? false,
|
|
295
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
296
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
297
|
+
lastError: runtime?.lastError ?? null,
|
|
298
|
+
mode: "webhook",
|
|
299
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
300
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
301
|
+
};
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
gateway: {
|
|
305
|
+
startAccount: async (ctx) => {
|
|
306
|
+
const account = ctx.account;
|
|
307
|
+
if (!account.secret || !account.baseUrl) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
|
|
314
|
+
|
|
315
|
+
const { stop } = await monitorNextcloudTalkProvider({
|
|
316
|
+
accountId: account.accountId,
|
|
317
|
+
config: ctx.cfg as CoreConfig,
|
|
318
|
+
runtime: ctx.runtime,
|
|
319
|
+
abortSignal: ctx.abortSignal,
|
|
320
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return { stop };
|
|
324
|
+
},
|
|
325
|
+
logoutAccount: async ({ accountId, cfg }) => {
|
|
326
|
+
const nextCfg = { ...cfg } as OpenClawConfig;
|
|
327
|
+
const nextSection = cfg.channels?.["nextcloud-talk"]
|
|
328
|
+
? { ...cfg.channels["nextcloud-talk"] }
|
|
329
|
+
: undefined;
|
|
330
|
+
let cleared = false;
|
|
331
|
+
let changed = false;
|
|
332
|
+
|
|
333
|
+
if (nextSection) {
|
|
334
|
+
if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) {
|
|
335
|
+
delete nextSection.botSecret;
|
|
336
|
+
cleared = true;
|
|
337
|
+
changed = true;
|
|
338
|
+
}
|
|
339
|
+
const accounts =
|
|
340
|
+
nextSection.accounts && typeof nextSection.accounts === "object"
|
|
341
|
+
? { ...nextSection.accounts }
|
|
342
|
+
: undefined;
|
|
343
|
+
if (accounts && accountId in accounts) {
|
|
344
|
+
const entry = accounts[accountId];
|
|
345
|
+
if (entry && typeof entry === "object") {
|
|
346
|
+
const nextEntry = { ...entry } as Record<string, unknown>;
|
|
347
|
+
if ("botSecret" in nextEntry) {
|
|
348
|
+
const secret = nextEntry.botSecret;
|
|
349
|
+
if (typeof secret === "string" ? secret.trim() : secret) {
|
|
350
|
+
cleared = true;
|
|
351
|
+
}
|
|
352
|
+
delete nextEntry.botSecret;
|
|
353
|
+
changed = true;
|
|
354
|
+
}
|
|
355
|
+
if (Object.keys(nextEntry).length === 0) {
|
|
356
|
+
delete accounts[accountId];
|
|
357
|
+
changed = true;
|
|
358
|
+
} else {
|
|
359
|
+
accounts[accountId] = nextEntry as typeof entry;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (accounts) {
|
|
364
|
+
if (Object.keys(accounts).length === 0) {
|
|
365
|
+
delete nextSection.accounts;
|
|
366
|
+
changed = true;
|
|
367
|
+
} else {
|
|
368
|
+
nextSection.accounts = accounts;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (changed) {
|
|
374
|
+
if (nextSection && Object.keys(nextSection).length > 0) {
|
|
375
|
+
nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection };
|
|
376
|
+
} else {
|
|
377
|
+
const nextChannels = { ...nextCfg.channels } as Record<string, unknown>;
|
|
378
|
+
delete nextChannels["nextcloud-talk"];
|
|
379
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
380
|
+
nextCfg.channels = nextChannels as OpenClawConfig["channels"];
|
|
381
|
+
} else {
|
|
382
|
+
delete nextCfg.channels;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const resolved = resolveNextcloudTalkAccount({
|
|
388
|
+
cfg: (changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig)),
|
|
389
|
+
accountId,
|
|
390
|
+
});
|
|
391
|
+
const loggedOut = resolved.secretSource === "none";
|
|
392
|
+
|
|
393
|
+
if (changed) {
|
|
394
|
+
await getNextcloudTalkRuntime().config.writeConfigFile(nextCfg);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
cleared,
|
|
399
|
+
envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
|
|
400
|
+
loggedOut,
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BlockStreamingCoalesceSchema,
|
|
3
|
+
DmConfigSchema,
|
|
4
|
+
DmPolicySchema,
|
|
5
|
+
GroupPolicySchema,
|
|
6
|
+
MarkdownConfigSchema,
|
|
7
|
+
ToolPolicySchema,
|
|
8
|
+
requireOpenAllowFrom,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
export const NextcloudTalkRoomSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
requireMention: z.boolean().optional(),
|
|
15
|
+
tools: ToolPolicySchema,
|
|
16
|
+
skills: z.array(z.string()).optional(),
|
|
17
|
+
enabled: z.boolean().optional(),
|
|
18
|
+
allowFrom: z.array(z.string()).optional(),
|
|
19
|
+
systemPrompt: z.string().optional(),
|
|
20
|
+
})
|
|
21
|
+
.strict();
|
|
22
|
+
|
|
23
|
+
export const NextcloudTalkAccountSchemaBase = z
|
|
24
|
+
.object({
|
|
25
|
+
name: z.string().optional(),
|
|
26
|
+
enabled: z.boolean().optional(),
|
|
27
|
+
markdown: MarkdownConfigSchema,
|
|
28
|
+
baseUrl: z.string().optional(),
|
|
29
|
+
botSecret: z.string().optional(),
|
|
30
|
+
botSecretFile: z.string().optional(),
|
|
31
|
+
apiUser: z.string().optional(),
|
|
32
|
+
apiPassword: z.string().optional(),
|
|
33
|
+
apiPasswordFile: z.string().optional(),
|
|
34
|
+
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
35
|
+
webhookPort: z.number().int().positive().optional(),
|
|
36
|
+
webhookHost: z.string().optional(),
|
|
37
|
+
webhookPath: z.string().optional(),
|
|
38
|
+
webhookPublicUrl: z.string().optional(),
|
|
39
|
+
allowFrom: z.array(z.string()).optional(),
|
|
40
|
+
groupAllowFrom: z.array(z.string()).optional(),
|
|
41
|
+
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
42
|
+
rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
|
|
43
|
+
historyLimit: z.number().int().min(0).optional(),
|
|
44
|
+
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
45
|
+
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
46
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
47
|
+
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
48
|
+
blockStreaming: z.boolean().optional(),
|
|
49
|
+
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
50
|
+
mediaMaxMb: z.number().positive().optional(),
|
|
51
|
+
})
|
|
52
|
+
.strict();
|
|
53
|
+
|
|
54
|
+
export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine(
|
|
55
|
+
(value, ctx) => {
|
|
56
|
+
requireOpenAllowFrom({
|
|
57
|
+
policy: value.dmPolicy,
|
|
58
|
+
allowFrom: value.allowFrom,
|
|
59
|
+
ctx,
|
|
60
|
+
path: ["allowFrom"],
|
|
61
|
+
message:
|
|
62
|
+
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
|
|
68
|
+
accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
|
|
69
|
+
}).superRefine((value, ctx) => {
|
|
70
|
+
requireOpenAllowFrom({
|
|
71
|
+
policy: value.dmPolicy,
|
|
72
|
+
allowFrom: value.allowFrom,
|
|
73
|
+
ctx,
|
|
74
|
+
path: ["allowFrom"],
|
|
75
|
+
message:
|
|
76
|
+
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
|
|
77
|
+
});
|
|
78
|
+
});
|