@kodelyth/nextcloud-talk 2026.5.39 → 2026.5.42

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.
Files changed (78) hide show
  1. package/api.ts +1 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +4 -0
  4. package/dist/api.js +2 -0
  5. package/dist/channel-ej3z6XJ5.js +2094 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/contract-api.js +2 -0
  8. package/dist/doctor-contract-Dia7keG4.js +7 -0
  9. package/dist/doctor-contract-api.js +2 -0
  10. package/dist/index.js +22 -0
  11. package/dist/runtime-api-DCIDXlUd.js +14 -0
  12. package/dist/runtime-api.js +2 -0
  13. package/dist/secret-contract-DQ2wQ4m1.js +86 -0
  14. package/dist/secret-contract-api.js +2 -0
  15. package/dist/setup-entry.js +15 -0
  16. package/doctor-contract-api.ts +1 -0
  17. package/index.ts +20 -0
  18. package/klaw.plugin.json +2 -799
  19. package/package.json +4 -4
  20. package/runtime-api.ts +29 -0
  21. package/secret-contract-api.ts +5 -0
  22. package/setup-entry.ts +13 -0
  23. package/src/accounts.test.ts +31 -0
  24. package/src/accounts.ts +149 -0
  25. package/src/api-credentials.ts +31 -0
  26. package/src/approval-auth.test.ts +17 -0
  27. package/src/approval-auth.ts +27 -0
  28. package/src/bot-preflight.test.ts +135 -0
  29. package/src/bot-preflight.ts +183 -0
  30. package/src/channel-api.ts +5 -0
  31. package/src/channel.adapters.ts +52 -0
  32. package/src/channel.core.test.ts +75 -0
  33. package/src/channel.lifecycle.test.ts +91 -0
  34. package/src/channel.status.test.ts +28 -0
  35. package/src/channel.ts +225 -0
  36. package/src/config-schema.ts +79 -0
  37. package/src/core.test.ts +325 -0
  38. package/src/doctor-contract.ts +9 -0
  39. package/src/doctor.test.ts +87 -0
  40. package/src/doctor.ts +40 -0
  41. package/src/gateway.ts +109 -0
  42. package/src/inbound.authz.test.ts +146 -0
  43. package/src/inbound.behavior.test.ts +309 -0
  44. package/src/inbound.ts +392 -0
  45. package/src/message-actions.test.ts +270 -0
  46. package/src/message-actions.ts +82 -0
  47. package/src/message-adapter.ts +28 -0
  48. package/src/monitor-runtime.ts +138 -0
  49. package/src/monitor.replay.test.ts +276 -0
  50. package/src/monitor.test-fixtures.ts +30 -0
  51. package/src/monitor.test-harness.ts +59 -0
  52. package/src/monitor.ts +385 -0
  53. package/src/normalize.ts +44 -0
  54. package/src/policy.ts +111 -0
  55. package/src/replay-guard.ts +128 -0
  56. package/src/room-info.test.ts +160 -0
  57. package/src/room-info.ts +130 -0
  58. package/src/runtime.ts +9 -0
  59. package/src/secret-contract.ts +103 -0
  60. package/src/secret-input.ts +4 -0
  61. package/src/send.cfg-threading.test.ts +359 -0
  62. package/src/send.runtime.ts +8 -0
  63. package/src/send.ts +269 -0
  64. package/src/session-route.ts +40 -0
  65. package/src/setup-core.ts +250 -0
  66. package/src/setup-surface.ts +195 -0
  67. package/src/setup.test.ts +445 -0
  68. package/src/signature.ts +82 -0
  69. package/src/types.ts +195 -0
  70. package/tsconfig.json +16 -0
  71. package/api.js +0 -7
  72. package/channel-plugin-api.js +0 -7
  73. package/contract-api.js +0 -7
  74. package/doctor-contract-api.js +0 -7
  75. package/index.js +0 -7
  76. package/runtime-api.js +0 -7
  77. package/secret-contract-api.js +0 -7
  78. package/setup-entry.js +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodelyth/nextcloud-talk",
3
- "version": "2026.5.39",
3
+ "version": "2026.5.42",
4
4
  "description": "Klaw Nextcloud Talk channel plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  "type": "module",
10
10
  "devDependencies": {
11
11
  "@kodelyth/plugin-sdk": "1.0.1",
12
- "@kodelyth/klaw": "2026.5.41"
12
+ "@kodelyth/klaw": "2026.5.42"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "@kodelyth/klaw": ">=2026.5.19"
@@ -21,9 +21,9 @@
21
21
  },
22
22
  "klaw": {
23
23
  "extensions": [
24
- "./index.js"
24
+ "./index.ts"
25
25
  ],
26
- "setupEntry": "./setup-entry.js",
26
+ "setupEntry": "./setup-entry.ts",
27
27
  "channel": {
28
28
  "id": "nextcloud-talk",
29
29
  "label": "Nextcloud Talk",
package/runtime-api.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Private runtime barrel for the bundled Nextcloud Talk extension.
2
+ // Keep this barrel thin and aligned with the local extension surface.
3
+
4
+ export type { AllowlistMatch } from "klaw/plugin-sdk/allow-from";
5
+ export type { ChannelGroupContext } from "klaw/plugin-sdk/channel-contract";
6
+ export { logInboundDrop } from "klaw/plugin-sdk/channel-logging";
7
+ export { createChannelPairingController } from "klaw/plugin-sdk/channel-pairing";
8
+ export type {
9
+ BlockStreamingCoalesceConfig,
10
+ DmConfig,
11
+ DmPolicy,
12
+ GroupPolicy,
13
+ GroupToolPolicyConfig,
14
+ KlawConfig,
15
+ } from "klaw/plugin-sdk/config-contracts";
16
+ export {
17
+ GROUP_POLICY_BLOCKED_LABEL,
18
+ resolveAllowlistProviderRuntimeGroupPolicy,
19
+ resolveDefaultGroupPolicy,
20
+ warnMissingProviderGroupPolicyFallbackOnce,
21
+ } from "klaw/plugin-sdk/runtime-group-policy";
22
+ export { createChannelMessageReplyPipeline } from "klaw/plugin-sdk/channel-message";
23
+ export type { OutboundReplyPayload } from "klaw/plugin-sdk/reply-payload";
24
+ export { deliverFormattedTextWithAttachments } from "klaw/plugin-sdk/reply-payload";
25
+ export type { PluginRuntime } from "klaw/plugin-sdk/runtime-store";
26
+ export type { RuntimeEnv } from "klaw/plugin-sdk/runtime";
27
+ export type { SecretInput } from "klaw/plugin-sdk/secret-input";
28
+ export { fetchWithSsrFGuard } from "klaw/plugin-sdk/ssrf-runtime";
29
+ export { setNextcloudTalkRuntime } from "./src/runtime.js";
@@ -0,0 +1,5 @@
1
+ export {
2
+ channelSecrets,
3
+ collectRuntimeConfigAssignments,
4
+ secretTargetRegistryEntries,
5
+ } from "./src/secret-contract.js";
package/setup-entry.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineBundledChannelSetupEntry } from "klaw/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./api.js",
7
+ exportName: "nextcloudTalkPlugin",
8
+ },
9
+ secrets: {
10
+ specifier: "./secret-contract-api.js",
11
+ exportName: "channelSecrets",
12
+ },
13
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ listNextcloudTalkAccountIds,
4
+ resolveDefaultNextcloudTalkAccountId,
5
+ resolveNextcloudTalkAccount,
6
+ } from "./accounts.js";
7
+ import type { CoreConfig } from "./types.js";
8
+
9
+ describe("Nextcloud Talk account resolution", () => {
10
+ it("preserves top-level default account when named accounts are configured", () => {
11
+ const cfg = {
12
+ channels: {
13
+ "nextcloud-talk": {
14
+ baseUrl: "https://cloud.example.com",
15
+ botSecret: "shared-secret",
16
+ accounts: {
17
+ work: { enabled: false },
18
+ },
19
+ },
20
+ },
21
+ } satisfies CoreConfig;
22
+
23
+ expect(listNextcloudTalkAccountIds(cfg)).toEqual(["default", "work"]);
24
+ expect(resolveDefaultNextcloudTalkAccountId(cfg)).toBe("default");
25
+ expect(resolveNextcloudTalkAccount({ cfg })).toMatchObject({
26
+ accountId: "default",
27
+ baseUrl: "https://cloud.example.com",
28
+ secret: "shared-secret",
29
+ });
30
+ });
31
+ });
@@ -0,0 +1,149 @@
1
+ import {
2
+ createAccountListHelpers,
3
+ DEFAULT_ACCOUNT_ID,
4
+ hasConfiguredAccountValue,
5
+ normalizeAccountId,
6
+ resolveAccountWithDefaultFallback,
7
+ resolveMergedAccountConfig,
8
+ } from "klaw/plugin-sdk/account-core";
9
+ import { tryReadSecretFileSync } from "klaw/plugin-sdk/secret-file-runtime";
10
+ import {
11
+ normalizeLowercaseStringOrEmpty,
12
+ normalizeOptionalString,
13
+ } from "klaw/plugin-sdk/string-coerce-runtime";
14
+ import { normalizeResolvedSecretInputString } from "./secret-input.js";
15
+ import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
16
+
17
+ function isTruthyEnvValue(value?: string): boolean {
18
+ const normalized = normalizeLowercaseStringOrEmpty(value);
19
+ return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
20
+ }
21
+
22
+ const debugAccounts = (...args: unknown[]) => {
23
+ if (isTruthyEnvValue(process.env.KLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) {
24
+ console.warn("[nextcloud-talk:accounts]", ...args);
25
+ }
26
+ };
27
+
28
+ export type ResolvedNextcloudTalkAccount = {
29
+ accountId: string;
30
+ enabled: boolean;
31
+ name?: string;
32
+ baseUrl: string;
33
+ secret: string;
34
+ secretSource: "env" | "secretFile" | "config" | "none";
35
+ config: NextcloudTalkAccountConfig;
36
+ };
37
+
38
+ const {
39
+ listAccountIds: listNextcloudTalkAccountIdsInternal,
40
+ resolveDefaultAccountId: resolveDefaultNextcloudTalkAccountId,
41
+ } = createAccountListHelpers("nextcloud-talk", {
42
+ normalizeAccountId,
43
+ hasImplicitDefaultAccount: (cfg) => {
44
+ const channel = cfg.channels?.["nextcloud-talk"];
45
+ return Boolean(
46
+ channel?.baseUrl?.trim() &&
47
+ (hasConfiguredAccountValue(channel.botSecret) ||
48
+ channel.botSecretFile?.trim() ||
49
+ process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
50
+ );
51
+ },
52
+ });
53
+ export { resolveDefaultNextcloudTalkAccountId };
54
+
55
+ export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
56
+ const ids = listNextcloudTalkAccountIdsInternal(cfg);
57
+ debugAccounts("listNextcloudTalkAccountIds", ids);
58
+ return ids;
59
+ }
60
+
61
+ function mergeNextcloudTalkAccountConfig(
62
+ cfg: CoreConfig,
63
+ accountId: string,
64
+ ): NextcloudTalkAccountConfig {
65
+ return resolveMergedAccountConfig<NextcloudTalkAccountConfig>({
66
+ channelConfig: cfg.channels?.["nextcloud-talk"] as NextcloudTalkAccountConfig | undefined,
67
+ accounts: cfg.channels?.["nextcloud-talk"]?.accounts as
68
+ | Record<string, Partial<NextcloudTalkAccountConfig>>
69
+ | undefined,
70
+ accountId,
71
+ omitKeys: ["defaultAccount"],
72
+ normalizeAccountId,
73
+ });
74
+ }
75
+
76
+ function resolveNextcloudTalkSecret(
77
+ cfg: CoreConfig,
78
+ opts: { accountId?: string },
79
+ ): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } {
80
+ const resolvedAccountId = opts.accountId ?? resolveDefaultNextcloudTalkAccountId(cfg);
81
+ const merged = mergeNextcloudTalkAccountConfig(cfg, resolvedAccountId);
82
+
83
+ const envSecret = normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET);
84
+ if (envSecret && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
85
+ return { secret: envSecret, source: "env" };
86
+ }
87
+
88
+ if (merged.botSecretFile) {
89
+ const fileSecret = tryReadSecretFileSync(
90
+ merged.botSecretFile,
91
+ "Nextcloud Talk bot secret file",
92
+ { rejectSymlink: true },
93
+ );
94
+ if (fileSecret) {
95
+ return { secret: fileSecret, source: "secretFile" };
96
+ }
97
+ }
98
+
99
+ const inlineSecret = normalizeResolvedSecretInputString({
100
+ value: merged.botSecret,
101
+ path: `channels.nextcloud-talk.accounts.${resolvedAccountId}.botSecret`,
102
+ });
103
+ if (inlineSecret) {
104
+ return { secret: inlineSecret, source: "config" };
105
+ }
106
+
107
+ return { secret: "", source: "none" };
108
+ }
109
+
110
+ export function resolveNextcloudTalkAccount(params: {
111
+ cfg: CoreConfig;
112
+ accountId?: string | null;
113
+ }): ResolvedNextcloudTalkAccount {
114
+ const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
115
+ const resolvedAccountId = params.accountId ?? resolveDefaultNextcloudTalkAccountId(params.cfg);
116
+
117
+ const resolve = (accountId: string) => {
118
+ const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId);
119
+ const accountEnabled = merged.enabled !== false;
120
+ const enabled = baseEnabled && accountEnabled;
121
+ const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId });
122
+ const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? "";
123
+
124
+ debugAccounts("resolve", {
125
+ accountId,
126
+ enabled,
127
+ secretSource: secretResolution.source,
128
+ baseUrl: baseUrl ? "[set]" : "[missing]",
129
+ });
130
+
131
+ return {
132
+ accountId,
133
+ enabled,
134
+ name: normalizeOptionalString(merged.name),
135
+ baseUrl,
136
+ secret: secretResolution.secret,
137
+ secretSource: secretResolution.source,
138
+ config: merged,
139
+ } satisfies ResolvedNextcloudTalkAccount;
140
+ };
141
+
142
+ return resolveAccountWithDefaultFallback({
143
+ accountId: resolvedAccountId,
144
+ normalizeAccountId,
145
+ resolvePrimary: resolve,
146
+ hasCredential: (account) => account.secretSource !== "none",
147
+ resolveDefaultAccountId: () => resolveDefaultNextcloudTalkAccountId(params.cfg),
148
+ });
149
+ }
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { normalizeResolvedSecretInputString } from "./secret-input.js";
3
+
4
+ export function resolveNextcloudTalkApiCredentials(params: {
5
+ apiUser?: string;
6
+ apiPassword?: unknown;
7
+ apiPasswordFile?: string;
8
+ }): { apiUser: string; apiPassword: string } | undefined {
9
+ const apiUser = params.apiUser?.trim();
10
+ if (!apiUser) {
11
+ return undefined;
12
+ }
13
+
14
+ const inlinePassword = normalizeResolvedSecretInputString({
15
+ value: params.apiPassword,
16
+ path: "channels.nextcloud-talk.apiPassword",
17
+ });
18
+ if (inlinePassword) {
19
+ return { apiUser, apiPassword: inlinePassword };
20
+ }
21
+
22
+ if (!params.apiPasswordFile) {
23
+ return undefined;
24
+ }
25
+ try {
26
+ const filePassword = readFileSync(params.apiPasswordFile, "utf-8").trim();
27
+ return filePassword ? { apiUser, apiPassword: filePassword } : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { nextcloudTalkApprovalAuth } from "./approval-auth.js";
3
+
4
+ describe("nextcloudTalkApprovalAuth", () => {
5
+ it("matches Nextcloud Talk actor ids case-insensitively", () => {
6
+ const cfg = { channels: { "nextcloud-talk": { allowFrom: ["Owner"] } } };
7
+
8
+ expect(
9
+ nextcloudTalkApprovalAuth.authorizeActorAction({
10
+ cfg,
11
+ senderId: "owner",
12
+ action: "approve",
13
+ approvalKind: "exec",
14
+ }),
15
+ ).toEqual({ authorized: true });
16
+ });
17
+ });
@@ -0,0 +1,27 @@
1
+ import {
2
+ createResolvedApproverActionAuthAdapter,
3
+ resolveApprovalApprovers,
4
+ } from "klaw/plugin-sdk/approval-auth-runtime";
5
+ import { normalizeOptionalLowercaseString } from "klaw/plugin-sdk/string-coerce-runtime";
6
+ import { resolveNextcloudTalkAccount } from "./accounts.js";
7
+ import type { CoreConfig } from "./types.js";
8
+
9
+ function normalizeNextcloudTalkApproverId(value: string | number): string | undefined {
10
+ return normalizeOptionalLowercaseString(
11
+ String(value)
12
+ .trim()
13
+ .replace(/^(nextcloud-talk|nc-talk|nc):/i, ""),
14
+ );
15
+ }
16
+
17
+ export const nextcloudTalkApprovalAuth = createResolvedApproverActionAuthAdapter({
18
+ channelLabel: "Nextcloud Talk",
19
+ resolveApprovers: ({ cfg, accountId }) => {
20
+ const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
21
+ return resolveApprovalApprovers({
22
+ allowFrom: account.config.allowFrom,
23
+ normalizeApprover: normalizeNextcloudTalkApproverId,
24
+ });
25
+ },
26
+ normalizeSenderId: (value) => normalizeNextcloudTalkApproverId(value),
27
+ });
@@ -0,0 +1,135 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
3
+
4
+ const hoisted = vi.hoisted(() => ({
5
+ fetchWithSsrFGuard: vi.fn(),
6
+ ssrfPolicyFromPrivateNetworkOptIn: vi.fn(() => undefined),
7
+ }));
8
+
9
+ vi.mock("../runtime-api.js", () => ({
10
+ fetchWithSsrFGuard: hoisted.fetchWithSsrFGuard,
11
+ }));
12
+
13
+ vi.mock("./send.runtime.js", () => ({
14
+ ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn,
15
+ }));
16
+
17
+ const { probeNextcloudTalkBotResponseFeature } = await import("./bot-preflight.js");
18
+
19
+ function account(
20
+ overrides: Partial<ResolvedNextcloudTalkAccount> = {},
21
+ ): ResolvedNextcloudTalkAccount {
22
+ return {
23
+ accountId: "default",
24
+ enabled: true,
25
+ baseUrl: "https://cloud.example.com",
26
+ secret: "secret",
27
+ secretSource: "config",
28
+ config: {
29
+ baseUrl: "https://cloud.example.com",
30
+ botSecret: "secret",
31
+ apiUser: "admin",
32
+ apiPassword: "app-password",
33
+ webhookPublicUrl: "https://bot.example.com/nextcloud-talk-webhook",
34
+ },
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function mockBotAdmin(features: number): void {
40
+ hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({
41
+ response: new Response(
42
+ JSON.stringify({
43
+ ocs: {
44
+ data: [
45
+ {
46
+ id: 7,
47
+ name: "Klaw",
48
+ url: "https://bot.example.com/nextcloud-talk-webhook",
49
+ features,
50
+ },
51
+ ],
52
+ },
53
+ }),
54
+ { status: 200, headers: { "content-type": "application/json" } },
55
+ ),
56
+ release: async () => {},
57
+ finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin",
58
+ });
59
+ }
60
+
61
+ describe("probeNextcloudTalkBotResponseFeature", () => {
62
+ beforeEach(() => {
63
+ hoisted.fetchWithSsrFGuard.mockClear();
64
+ });
65
+
66
+ afterEach(() => {
67
+ hoisted.fetchWithSsrFGuard.mockReset();
68
+ });
69
+
70
+ it("passes when the matching bot has the response feature bit", async () => {
71
+ mockBotAdmin(1 | 2 | 8);
72
+
73
+ await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
74
+ ok: true,
75
+ code: "ok",
76
+ botId: "7",
77
+ botName: "Klaw",
78
+ features: 11,
79
+ message: 'Nextcloud Talk bot "Klaw" has the response feature.',
80
+ });
81
+ });
82
+
83
+ it("reports missing response feature for the matching webhook bot", async () => {
84
+ mockBotAdmin(1 | 8);
85
+
86
+ await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
87
+ ok: false,
88
+ code: "missing_response_feature",
89
+ botId: "7",
90
+ botName: "Klaw",
91
+ features: 9,
92
+ message:
93
+ 'Nextcloud Talk bot "Klaw" (7) is missing the response feature (features=9); outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.',
94
+ });
95
+ });
96
+
97
+ it("reports malformed bot admin JSON with a stable channel error", async () => {
98
+ hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({
99
+ response: new Response("{ nope", {
100
+ status: 200,
101
+ headers: { "content-type": "application/json" },
102
+ }),
103
+ release: async () => {},
104
+ finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin",
105
+ });
106
+
107
+ await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
108
+ ok: false,
109
+ code: "request_failed",
110
+ message:
111
+ "Nextcloud Talk bot response feature probe failed: Nextcloud Talk bot response feature probe failed: malformed JSON response",
112
+ });
113
+ });
114
+
115
+ it("skips when API credentials are absent", async () => {
116
+ await expect(
117
+ probeNextcloudTalkBotResponseFeature({
118
+ account: account({
119
+ config: {
120
+ baseUrl: "https://cloud.example.com",
121
+ botSecret: "secret",
122
+ webhookPublicUrl: "https://bot.example.com/nextcloud-talk-webhook",
123
+ },
124
+ }),
125
+ }),
126
+ ).resolves.toEqual({
127
+ ok: true,
128
+ skipped: true,
129
+ code: "missing_api_credentials",
130
+ message:
131
+ "Nextcloud Talk bot response feature probe skipped: apiUser/apiPassword are not configured.",
132
+ });
133
+ expect(hoisted.fetchWithSsrFGuard).not.toHaveBeenCalled();
134
+ });
135
+ });
@@ -0,0 +1,183 @@
1
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
2
+ import { readProviderJsonResponse } from "klaw/plugin-sdk/provider-http";
3
+ import { fetchWithSsrFGuard } from "../runtime-api.js";
4
+ import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
5
+ import { resolveNextcloudTalkApiCredentials } from "./api-credentials.js";
6
+ import { ssrfPolicyFromPrivateNetworkOptIn } from "./send.runtime.js";
7
+
8
+ const BOT_FEATURE_RESPONSE = 2;
9
+
10
+ type NextcloudTalkBotAdminEntry = {
11
+ id?: number | string;
12
+ name?: string;
13
+ url?: string;
14
+ features?: number | string;
15
+ };
16
+
17
+ export type NextcloudTalkBotResponseFeatureProbe = {
18
+ ok: boolean;
19
+ skipped?: boolean;
20
+ code:
21
+ | "ok"
22
+ | "missing_api_credentials"
23
+ | "missing_webhook_url"
24
+ | "missing_base_url"
25
+ | "bot_not_found"
26
+ | "missing_response_feature"
27
+ | "api_error"
28
+ | "request_failed";
29
+ message: string;
30
+ botId?: string;
31
+ botName?: string;
32
+ features?: number;
33
+ status?: number;
34
+ };
35
+
36
+ function normalizeUrlForMatch(value: string | undefined): string {
37
+ if (!value?.trim()) {
38
+ return "";
39
+ }
40
+ try {
41
+ const url = new URL(value.trim());
42
+ url.hash = "";
43
+ return url.toString().replace(/\/$/, "");
44
+ } catch {
45
+ return value.trim().replace(/\/$/, "");
46
+ }
47
+ }
48
+
49
+ function coerceFeatureMask(value: unknown): number | undefined {
50
+ if (typeof value === "number" && Number.isFinite(value)) {
51
+ return value;
52
+ }
53
+ if (typeof value === "string" && value.trim()) {
54
+ const parsed = Number.parseInt(value, 10);
55
+ return Number.isFinite(parsed) ? parsed : undefined;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function formatMissingResponseFeatureMessage(bot: NextcloudTalkBotAdminEntry, features?: number) {
61
+ const id = bot.id == null ? "unknown" : String(bot.id);
62
+ const name = bot.name?.trim() || "matching bot";
63
+ const featureText = typeof features === "number" ? ` (features=${features})` : "";
64
+ return `Nextcloud Talk bot "${name}" (${id}) is missing the response feature${featureText}; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction ${id} 1 or reinstall the bot with --feature response.`;
65
+ }
66
+
67
+ export async function probeNextcloudTalkBotResponseFeature(params: {
68
+ account: ResolvedNextcloudTalkAccount;
69
+ timeoutMs?: number;
70
+ }): Promise<NextcloudTalkBotResponseFeatureProbe> {
71
+ const { account, timeoutMs } = params;
72
+ const baseUrl = account.baseUrl?.trim();
73
+ if (!baseUrl) {
74
+ return {
75
+ ok: true,
76
+ skipped: true,
77
+ code: "missing_base_url",
78
+ message: "Nextcloud Talk bot response feature probe skipped: baseUrl is not configured.",
79
+ };
80
+ }
81
+
82
+ const webhookUrl = normalizeUrlForMatch(account.config.webhookPublicUrl);
83
+ if (!webhookUrl) {
84
+ return {
85
+ ok: true,
86
+ skipped: true,
87
+ code: "missing_webhook_url",
88
+ message:
89
+ "Nextcloud Talk bot response feature probe skipped: webhookPublicUrl is not configured.",
90
+ };
91
+ }
92
+
93
+ const credentials = resolveNextcloudTalkApiCredentials({
94
+ apiUser: account.config.apiUser,
95
+ apiPassword: account.config.apiPassword,
96
+ apiPasswordFile: account.config.apiPasswordFile,
97
+ });
98
+ if (!credentials) {
99
+ return {
100
+ ok: true,
101
+ skipped: true,
102
+ code: "missing_api_credentials",
103
+ message:
104
+ "Nextcloud Talk bot response feature probe skipped: apiUser/apiPassword are not configured.",
105
+ };
106
+ }
107
+
108
+ const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/admin`;
109
+ const auth = Buffer.from(`${credentials.apiUser}:${credentials.apiPassword}`, "utf-8").toString(
110
+ "base64",
111
+ );
112
+
113
+ try {
114
+ const { response, release } = await fetchWithSsrFGuard({
115
+ url,
116
+ init: {
117
+ method: "GET",
118
+ headers: {
119
+ Authorization: `Basic ${auth}`,
120
+ "OCS-APIRequest": "true",
121
+ Accept: "application/json",
122
+ },
123
+ },
124
+ auditContext: "nextcloud-talk.bot-response-preflight",
125
+ policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
126
+ timeoutMs,
127
+ });
128
+ try {
129
+ if (!response.ok) {
130
+ const body = await response.text().catch(() => "");
131
+ return {
132
+ ok: false,
133
+ code: "api_error",
134
+ status: response.status,
135
+ message: `Nextcloud Talk bot response feature probe failed (${response.status})${body ? `: ${body}` : ""}`,
136
+ };
137
+ }
138
+
139
+ const payload = await readProviderJsonResponse<{
140
+ ocs?: { data?: NextcloudTalkBotAdminEntry[] };
141
+ }>(response, "Nextcloud Talk bot response feature probe failed");
142
+ const bots = Array.isArray(payload.ocs?.data) ? payload.ocs.data : [];
143
+ const bot = bots.find((entry) => normalizeUrlForMatch(entry.url) === webhookUrl);
144
+ if (!bot) {
145
+ return {
146
+ ok: false,
147
+ code: "bot_not_found",
148
+ message: `Nextcloud Talk bot response feature probe could not find a bot with webhook URL ${webhookUrl}.`,
149
+ };
150
+ }
151
+
152
+ const features = coerceFeatureMask(bot.features);
153
+ if (features == null || (features & BOT_FEATURE_RESPONSE) !== BOT_FEATURE_RESPONSE) {
154
+ return {
155
+ ok: false,
156
+ code: "missing_response_feature",
157
+ botId: bot.id == null ? undefined : String(bot.id),
158
+ botName: bot.name,
159
+ features,
160
+ message: formatMissingResponseFeatureMessage(bot, features),
161
+ };
162
+ }
163
+
164
+ return {
165
+ ok: true,
166
+ code: "ok",
167
+ botId: bot.id == null ? undefined : String(bot.id),
168
+ botName: bot.name,
169
+ features,
170
+ message: `Nextcloud Talk bot "${bot.name ?? bot.id ?? "matching bot"}" has the response feature.`,
171
+ };
172
+ } finally {
173
+ await release();
174
+ }
175
+ } catch (error) {
176
+ const detail = error instanceof Error ? error.message : formatErrorMessage(error);
177
+ return {
178
+ ok: false,
179
+ code: "request_failed",
180
+ message: `Nextcloud Talk bot response feature probe failed: ${detail}`,
181
+ };
182
+ }
183
+ }
@@ -0,0 +1,5 @@
1
+ export type { ChannelPlugin } from "klaw/plugin-sdk/channel-plugin-common";
2
+ export type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
3
+ export { clearAccountEntryFields } from "klaw/plugin-sdk/channel-plugin-common";
4
+ export { DEFAULT_ACCOUNT_ID } from "klaw/plugin-sdk/account-id";
5
+ export { buildChannelConfigSchema } from "klaw/plugin-sdk/channel-config-schema";