@openclaw/nextcloud-talk 2026.2.24 → 2026.3.1
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/package.json +1 -1
- package/src/accounts.ts +22 -3
- package/src/channel.startup.test.ts +115 -0
- package/src/channel.ts +4 -1
- package/src/config-schema.ts +1 -0
- package/src/inbound.authz.test.ts +84 -0
- package/src/inbound.ts +60 -61
- package/src/monitor.auth-order.test.ts +28 -0
- package/src/monitor.backend.test.ts +46 -0
- package/src/monitor.replay.test.ts +67 -0
- package/src/monitor.test-harness.ts +59 -0
- package/src/monitor.ts +180 -39
- package/src/replay-guard.test.ts +70 -0
- package/src/replay-guard.ts +65 -0
- package/src/types.ts +5 -0
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
normalizeAccountId,
|
|
5
|
+
normalizeOptionalAccountId,
|
|
6
|
+
} from "openclaw/plugin-sdk/account-id";
|
|
3
7
|
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
|
|
4
8
|
|
|
5
9
|
function isTruthyEnvValue(value?: string): boolean {
|
|
@@ -48,6 +52,15 @@ export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
|
|
55
|
+
const preferred = normalizeOptionalAccountId(cfg.channels?.["nextcloud-talk"]?.defaultAccount);
|
|
56
|
+
if (
|
|
57
|
+
preferred &&
|
|
58
|
+
listNextcloudTalkAccountIds(cfg).some(
|
|
59
|
+
(accountId) => normalizeAccountId(accountId) === preferred,
|
|
60
|
+
)
|
|
61
|
+
) {
|
|
62
|
+
return preferred;
|
|
63
|
+
}
|
|
51
64
|
const ids = listNextcloudTalkAccountIds(cfg);
|
|
52
65
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
53
66
|
return DEFAULT_ACCOUNT_ID;
|
|
@@ -76,8 +89,14 @@ function mergeNextcloudTalkAccountConfig(
|
|
|
76
89
|
cfg: CoreConfig,
|
|
77
90
|
accountId: string,
|
|
78
91
|
): NextcloudTalkAccountConfig {
|
|
79
|
-
const {
|
|
80
|
-
|
|
92
|
+
const {
|
|
93
|
+
accounts: _ignored,
|
|
94
|
+
defaultAccount: _ignoredDefaultAccount,
|
|
95
|
+
...base
|
|
96
|
+
} = (cfg.channels?.["nextcloud-talk"] ?? {}) as NextcloudTalkAccountConfig & {
|
|
97
|
+
accounts?: unknown;
|
|
98
|
+
defaultAccount?: unknown;
|
|
99
|
+
};
|
|
81
100
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
|
82
101
|
return { ...base, ...account };
|
|
83
102
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAccountSnapshot,
|
|
3
|
+
ChannelGatewayContext,
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
|
8
|
+
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
|
9
|
+
|
|
10
|
+
const hoisted = vi.hoisted(() => ({
|
|
11
|
+
monitorNextcloudTalkProvider: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("./monitor.js", async () => {
|
|
15
|
+
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider,
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
import { nextcloudTalkPlugin } from "./channel.js";
|
|
23
|
+
|
|
24
|
+
function createStartAccountCtx(params: {
|
|
25
|
+
account: ResolvedNextcloudTalkAccount;
|
|
26
|
+
abortSignal: AbortSignal;
|
|
27
|
+
}): ChannelGatewayContext<ResolvedNextcloudTalkAccount> {
|
|
28
|
+
const snapshot: ChannelAccountSnapshot = {
|
|
29
|
+
accountId: params.account.accountId,
|
|
30
|
+
configured: true,
|
|
31
|
+
enabled: true,
|
|
32
|
+
running: false,
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
accountId: params.account.accountId,
|
|
36
|
+
account: params.account,
|
|
37
|
+
cfg: {} as OpenClawConfig,
|
|
38
|
+
runtime: createRuntimeEnv(),
|
|
39
|
+
abortSignal: params.abortSignal,
|
|
40
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
41
|
+
getStatus: () => snapshot,
|
|
42
|
+
setStatus: (next) => {
|
|
43
|
+
Object.assign(snapshot, next);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildAccount(): ResolvedNextcloudTalkAccount {
|
|
49
|
+
return {
|
|
50
|
+
accountId: "default",
|
|
51
|
+
enabled: true,
|
|
52
|
+
baseUrl: "https://nextcloud.example.com",
|
|
53
|
+
secret: "secret",
|
|
54
|
+
secretSource: "config",
|
|
55
|
+
config: {
|
|
56
|
+
baseUrl: "https://nextcloud.example.com",
|
|
57
|
+
botSecret: "secret",
|
|
58
|
+
webhookPath: "/nextcloud-talk-webhook",
|
|
59
|
+
webhookPort: 8788,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("nextcloudTalkPlugin gateway.startAccount", () => {
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
|
70
|
+
const stop = vi.fn();
|
|
71
|
+
hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop });
|
|
72
|
+
const abort = new AbortController();
|
|
73
|
+
|
|
74
|
+
const task = nextcloudTalkPlugin.gateway!.startAccount!(
|
|
75
|
+
createStartAccountCtx({
|
|
76
|
+
account: buildAccount(),
|
|
77
|
+
abortSignal: abort.signal,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
82
|
+
|
|
83
|
+
let settled = false;
|
|
84
|
+
void task.then(() => {
|
|
85
|
+
settled = true;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
89
|
+
expect(settled).toBe(false);
|
|
90
|
+
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
|
|
91
|
+
expect(stop).not.toHaveBeenCalled();
|
|
92
|
+
|
|
93
|
+
abort.abort();
|
|
94
|
+
await task;
|
|
95
|
+
|
|
96
|
+
expect(stop).toHaveBeenCalledOnce();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("stops immediately when startAccount receives an already-aborted signal", async () => {
|
|
100
|
+
const stop = vi.fn();
|
|
101
|
+
hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop });
|
|
102
|
+
const abort = new AbortController();
|
|
103
|
+
abort.abort();
|
|
104
|
+
|
|
105
|
+
await nextcloudTalkPlugin.gateway!.startAccount!(
|
|
106
|
+
createStartAccountCtx({
|
|
107
|
+
account: buildAccount(),
|
|
108
|
+
abortSignal: abort.signal,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
|
|
113
|
+
expect(stop).toHaveBeenCalledOnce();
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type OpenClawConfig,
|
|
13
13
|
type ChannelSetupInput,
|
|
14
14
|
} from "openclaw/plugin-sdk";
|
|
15
|
+
import { waitForAbortSignal } from "../../../src/infra/abort-signal.js";
|
|
15
16
|
import {
|
|
16
17
|
listNextcloudTalkAccountIds,
|
|
17
18
|
resolveDefaultNextcloudTalkAccountId,
|
|
@@ -332,7 +333,9 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|
|
332
333
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
333
334
|
});
|
|
334
335
|
|
|
335
|
-
|
|
336
|
+
// Keep webhook channels pending for the account lifecycle.
|
|
337
|
+
await waitForAbortSignal(ctx.abortSignal);
|
|
338
|
+
stop();
|
|
336
339
|
},
|
|
337
340
|
logoutAccount: async ({ accountId, cfg }) => {
|
|
338
341
|
const nextCfg = { ...cfg } as OpenClawConfig;
|
package/src/config-schema.ts
CHANGED
|
@@ -60,6 +60,7 @@ export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRe
|
|
|
60
60
|
|
|
61
61
|
export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
|
|
62
62
|
accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
|
|
63
|
+
defaultAccount: z.string().optional(),
|
|
63
64
|
}).superRefine((value, ctx) => {
|
|
64
65
|
requireOpenAllowFrom({
|
|
65
66
|
policy: value.dmPolicy,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
|
4
|
+
import { handleNextcloudTalkInbound } from "./inbound.js";
|
|
5
|
+
import { setNextcloudTalkRuntime } from "./runtime.js";
|
|
6
|
+
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
|
|
7
|
+
|
|
8
|
+
describe("nextcloud-talk inbound authz", () => {
|
|
9
|
+
it("does not treat DM pairing-store entries as group allowlist entries", async () => {
|
|
10
|
+
const readAllowFromStore = vi.fn(async () => ["attacker"]);
|
|
11
|
+
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
|
|
12
|
+
|
|
13
|
+
setNextcloudTalkRuntime({
|
|
14
|
+
channel: {
|
|
15
|
+
pairing: {
|
|
16
|
+
readAllowFromStore,
|
|
17
|
+
},
|
|
18
|
+
commands: {
|
|
19
|
+
shouldHandleTextCommands: () => false,
|
|
20
|
+
},
|
|
21
|
+
text: {
|
|
22
|
+
hasControlCommand: () => false,
|
|
23
|
+
},
|
|
24
|
+
mentions: {
|
|
25
|
+
buildMentionRegexes,
|
|
26
|
+
matchesMentionPatterns: () => false,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
} as unknown as PluginRuntime);
|
|
30
|
+
|
|
31
|
+
const message: NextcloudTalkInboundMessage = {
|
|
32
|
+
messageId: "m-1",
|
|
33
|
+
roomToken: "room-1",
|
|
34
|
+
roomName: "Room 1",
|
|
35
|
+
senderId: "attacker",
|
|
36
|
+
senderName: "Attacker",
|
|
37
|
+
text: "hello",
|
|
38
|
+
mediaType: "text/plain",
|
|
39
|
+
timestamp: Date.now(),
|
|
40
|
+
isGroupChat: true,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const account: ResolvedNextcloudTalkAccount = {
|
|
44
|
+
accountId: "default",
|
|
45
|
+
enabled: true,
|
|
46
|
+
baseUrl: "",
|
|
47
|
+
secret: "",
|
|
48
|
+
secretSource: "none",
|
|
49
|
+
config: {
|
|
50
|
+
dmPolicy: "pairing",
|
|
51
|
+
allowFrom: [],
|
|
52
|
+
groupPolicy: "allowlist",
|
|
53
|
+
groupAllowFrom: [],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const config: CoreConfig = {
|
|
58
|
+
channels: {
|
|
59
|
+
"nextcloud-talk": {
|
|
60
|
+
dmPolicy: "pairing",
|
|
61
|
+
allowFrom: [],
|
|
62
|
+
groupPolicy: "allowlist",
|
|
63
|
+
groupAllowFrom: [],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
await handleNextcloudTalkInbound({
|
|
69
|
+
message,
|
|
70
|
+
account,
|
|
71
|
+
config,
|
|
72
|
+
runtime: {
|
|
73
|
+
log: vi.fn(),
|
|
74
|
+
error: vi.fn(),
|
|
75
|
+
} as unknown as RuntimeEnv,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(readAllowFromStore).toHaveBeenCalledWith({
|
|
79
|
+
channel: "nextcloud-talk",
|
|
80
|
+
accountId: "default",
|
|
81
|
+
});
|
|
82
|
+
expect(buildMentionRegexes).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/inbound.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
GROUP_POLICY_BLOCKED_LABEL,
|
|
3
|
+
createScopedPairingAccess,
|
|
3
4
|
createNormalizedOutboundDeliverer,
|
|
4
5
|
createReplyPrefixOptions,
|
|
5
6
|
formatTextWithAttachmentLinks,
|
|
6
7
|
logInboundDrop,
|
|
7
|
-
|
|
8
|
+
readStoreAllowFromForDmPolicy,
|
|
9
|
+
resolveDmGroupAccessWithCommandGate,
|
|
8
10
|
resolveOutboundMediaUrls,
|
|
9
11
|
resolveAllowlistProviderRuntimeGroupPolicy,
|
|
10
12
|
resolveDefaultGroupPolicy,
|
|
@@ -57,6 +59,11 @@ export async function handleNextcloudTalkInbound(params: {
|
|
|
57
59
|
}): Promise<void> {
|
|
58
60
|
const { message, account, config, runtime, statusSink } = params;
|
|
59
61
|
const core = getNextcloudTalkRuntime();
|
|
62
|
+
const pairing = createScopedPairingAccess({
|
|
63
|
+
core,
|
|
64
|
+
channel: CHANNEL_ID,
|
|
65
|
+
accountId: account.accountId,
|
|
66
|
+
});
|
|
60
67
|
|
|
61
68
|
const rawBody = message.text?.trim() ?? "";
|
|
62
69
|
if (!rawBody) {
|
|
@@ -96,10 +103,12 @@ export async function handleNextcloudTalkInbound(params: {
|
|
|
96
103
|
|
|
97
104
|
const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
|
|
98
105
|
const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
|
|
99
|
-
const storeAllowFrom =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
|
107
|
+
provider: CHANNEL_ID,
|
|
108
|
+
accountId: account.accountId,
|
|
109
|
+
dmPolicy,
|
|
110
|
+
readStore: pairing.readStoreForDmPolicy,
|
|
111
|
+
});
|
|
103
112
|
const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
|
|
104
113
|
|
|
105
114
|
const roomMatch = resolveNextcloudTalkRoomMatch({
|
|
@@ -118,11 +127,6 @@ export async function handleNextcloudTalkInbound(params: {
|
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom);
|
|
121
|
-
const baseGroupAllowFrom =
|
|
122
|
-
configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
|
|
123
|
-
|
|
124
|
-
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
|
|
125
|
-
const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean);
|
|
126
130
|
|
|
127
131
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
128
132
|
cfg: config as OpenClawConfig,
|
|
@@ -130,25 +134,33 @@ export async function handleNextcloudTalkInbound(params: {
|
|
|
130
134
|
});
|
|
131
135
|
const useAccessGroups =
|
|
132
136
|
(config.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false;
|
|
133
|
-
const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
|
|
134
|
-
allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
|
135
|
-
senderId,
|
|
136
|
-
}).allowed;
|
|
137
137
|
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
138
|
+
const access = resolveDmGroupAccessWithCommandGate({
|
|
139
|
+
isGroup,
|
|
140
|
+
dmPolicy,
|
|
141
|
+
groupPolicy,
|
|
142
|
+
allowFrom: configAllowFrom,
|
|
143
|
+
groupAllowFrom: configGroupAllowFrom,
|
|
144
|
+
storeAllowFrom: storeAllowList,
|
|
145
|
+
isSenderAllowed: (allowFrom) =>
|
|
146
|
+
resolveNextcloudTalkAllowlistMatch({
|
|
147
|
+
allowFrom,
|
|
148
|
+
senderId,
|
|
149
|
+
}).allowed,
|
|
150
|
+
command: {
|
|
151
|
+
useAccessGroups,
|
|
152
|
+
allowTextCommands,
|
|
153
|
+
hasControlCommand,
|
|
154
|
+
},
|
|
148
155
|
});
|
|
149
|
-
const commandAuthorized =
|
|
156
|
+
const commandAuthorized = access.commandAuthorized;
|
|
157
|
+
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
|
|
150
158
|
|
|
151
159
|
if (isGroup) {
|
|
160
|
+
if (access.decision !== "allow") {
|
|
161
|
+
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
152
164
|
const groupAllow = resolveNextcloudTalkGroupAllow({
|
|
153
165
|
groupPolicy,
|
|
154
166
|
outerAllowFrom: effectiveGroupAllowFrom,
|
|
@@ -160,48 +172,35 @@ export async function handleNextcloudTalkInbound(params: {
|
|
|
160
172
|
return;
|
|
161
173
|
}
|
|
162
174
|
} else {
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
core.channel.pairing.buildPairingReply({
|
|
184
|
-
channel: CHANNEL_ID,
|
|
185
|
-
idLine: `Your Nextcloud user id: ${senderId}`,
|
|
186
|
-
code,
|
|
187
|
-
}),
|
|
188
|
-
{ accountId: account.accountId },
|
|
189
|
-
);
|
|
190
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
191
|
-
} catch (err) {
|
|
192
|
-
runtime.error?.(
|
|
193
|
-
`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
175
|
+
if (access.decision !== "allow") {
|
|
176
|
+
if (access.decision === "pairing") {
|
|
177
|
+
const { code, created } = await pairing.upsertPairingRequest({
|
|
178
|
+
id: senderId,
|
|
179
|
+
meta: { name: senderName || undefined },
|
|
180
|
+
});
|
|
181
|
+
if (created) {
|
|
182
|
+
try {
|
|
183
|
+
await sendMessageNextcloudTalk(
|
|
184
|
+
roomToken,
|
|
185
|
+
core.channel.pairing.buildPairingReply({
|
|
186
|
+
channel: CHANNEL_ID,
|
|
187
|
+
idLine: `Your Nextcloud user id: ${senderId}`,
|
|
188
|
+
code,
|
|
189
|
+
}),
|
|
190
|
+
{ accountId: account.accountId },
|
|
191
|
+
);
|
|
192
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
|
|
196
195
|
}
|
|
197
196
|
}
|
|
198
|
-
runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`);
|
|
199
|
-
return;
|
|
200
197
|
}
|
|
198
|
+
runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`);
|
|
199
|
+
return;
|
|
201
200
|
}
|
|
202
201
|
}
|
|
203
202
|
|
|
204
|
-
if (
|
|
203
|
+
if (access.shouldBlockControlCommand) {
|
|
205
204
|
logInboundDrop({
|
|
206
205
|
log: (message) => runtime.log?.(message),
|
|
207
206
|
channel: CHANNEL_ID,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { startWebhookServer } from "./monitor.test-harness.js";
|
|
3
|
+
|
|
4
|
+
describe("createNextcloudTalkWebhookServer auth order", () => {
|
|
5
|
+
it("rejects missing signature headers before reading request body", async () => {
|
|
6
|
+
const readBody = vi.fn(async () => {
|
|
7
|
+
throw new Error("should not be called for missing signature headers");
|
|
8
|
+
});
|
|
9
|
+
const harness = await startWebhookServer({
|
|
10
|
+
path: "/nextcloud-auth-order",
|
|
11
|
+
maxBodyBytes: 128,
|
|
12
|
+
readBody,
|
|
13
|
+
onMessage: vi.fn(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const response = await fetch(harness.webhookUrl, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
"content-type": "application/json",
|
|
20
|
+
},
|
|
21
|
+
body: "{}",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(response.status).toBe(400);
|
|
25
|
+
expect(await response.json()).toEqual({ error: "Missing signature headers" });
|
|
26
|
+
expect(readBody).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { startWebhookServer } from "./monitor.test-harness.js";
|
|
3
|
+
import { generateNextcloudTalkSignature } from "./signature.js";
|
|
4
|
+
|
|
5
|
+
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
|
|
6
|
+
it("rejects requests from unexpected backend origins", async () => {
|
|
7
|
+
const onMessage = vi.fn(async () => {});
|
|
8
|
+
const harness = await startWebhookServer({
|
|
9
|
+
path: "/nextcloud-backend-check",
|
|
10
|
+
isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
|
|
11
|
+
onMessage,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const payload = {
|
|
15
|
+
type: "Create",
|
|
16
|
+
actor: { type: "Person", id: "alice", name: "Alice" },
|
|
17
|
+
object: {
|
|
18
|
+
type: "Note",
|
|
19
|
+
id: "msg-1",
|
|
20
|
+
name: "hello",
|
|
21
|
+
content: "hello",
|
|
22
|
+
mediaType: "text/plain",
|
|
23
|
+
},
|
|
24
|
+
target: { type: "Collection", id: "room-1", name: "Room 1" },
|
|
25
|
+
};
|
|
26
|
+
const body = JSON.stringify(payload);
|
|
27
|
+
const { random, signature } = generateNextcloudTalkSignature({
|
|
28
|
+
body,
|
|
29
|
+
secret: "nextcloud-secret",
|
|
30
|
+
});
|
|
31
|
+
const response = await fetch(harness.webhookUrl, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
"content-type": "application/json",
|
|
35
|
+
"x-nextcloud-talk-random": random,
|
|
36
|
+
"x-nextcloud-talk-signature": signature,
|
|
37
|
+
"x-nextcloud-talk-backend": "https://nextcloud.unexpected",
|
|
38
|
+
},
|
|
39
|
+
body,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(response.status).toBe(401);
|
|
43
|
+
expect(await response.json()).toEqual({ error: "Invalid backend" });
|
|
44
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { startWebhookServer } from "./monitor.test-harness.js";
|
|
3
|
+
import { generateNextcloudTalkSignature } from "./signature.js";
|
|
4
|
+
import type { NextcloudTalkInboundMessage } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function createSignedRequest(body: string): { random: string; signature: string } {
|
|
7
|
+
return generateNextcloudTalkSignature({
|
|
8
|
+
body,
|
|
9
|
+
secret: "nextcloud-secret",
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("createNextcloudTalkWebhookServer replay handling", () => {
|
|
14
|
+
it("acknowledges replayed requests and skips onMessage side effects", async () => {
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
const onMessage = vi.fn(async () => {});
|
|
17
|
+
const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => {
|
|
18
|
+
if (seen.has(message.messageId)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
seen.add(message.messageId);
|
|
22
|
+
return true;
|
|
23
|
+
});
|
|
24
|
+
const harness = await startWebhookServer({
|
|
25
|
+
path: "/nextcloud-replay",
|
|
26
|
+
shouldProcessMessage,
|
|
27
|
+
onMessage,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const payload = {
|
|
31
|
+
type: "Create",
|
|
32
|
+
actor: { type: "Person", id: "alice", name: "Alice" },
|
|
33
|
+
object: {
|
|
34
|
+
type: "Note",
|
|
35
|
+
id: "msg-1",
|
|
36
|
+
name: "hello",
|
|
37
|
+
content: "hello",
|
|
38
|
+
mediaType: "text/plain",
|
|
39
|
+
},
|
|
40
|
+
target: { type: "Collection", id: "room-1", name: "Room 1" },
|
|
41
|
+
};
|
|
42
|
+
const body = JSON.stringify(payload);
|
|
43
|
+
const { random, signature } = createSignedRequest(body);
|
|
44
|
+
const headers = {
|
|
45
|
+
"content-type": "application/json",
|
|
46
|
+
"x-nextcloud-talk-random": random,
|
|
47
|
+
"x-nextcloud-talk-signature": signature,
|
|
48
|
+
"x-nextcloud-talk-backend": "https://nextcloud.example",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const first = await fetch(harness.webhookUrl, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers,
|
|
54
|
+
body,
|
|
55
|
+
});
|
|
56
|
+
const second = await fetch(harness.webhookUrl, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers,
|
|
59
|
+
body,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(first.status).toBe(200);
|
|
63
|
+
expect(second.status).toBe(200);
|
|
64
|
+
expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
|
|
65
|
+
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type AddressInfo } from "node:net";
|
|
2
|
+
import { afterEach } from "vitest";
|
|
3
|
+
import { createNextcloudTalkWebhookServer } from "./monitor.js";
|
|
4
|
+
import type { NextcloudTalkWebhookServerOptions } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type WebhookHarness = {
|
|
7
|
+
webhookUrl: string;
|
|
8
|
+
stop: () => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const cleanupFns: Array<() => Promise<void>> = [];
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
while (cleanupFns.length > 0) {
|
|
15
|
+
const cleanup = cleanupFns.pop();
|
|
16
|
+
if (cleanup) {
|
|
17
|
+
await cleanup();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type StartWebhookServerParams = Omit<
|
|
23
|
+
NextcloudTalkWebhookServerOptions,
|
|
24
|
+
"port" | "host" | "path" | "secret"
|
|
25
|
+
> & {
|
|
26
|
+
path: string;
|
|
27
|
+
secret?: string;
|
|
28
|
+
host?: string;
|
|
29
|
+
port?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function startWebhookServer(
|
|
33
|
+
params: StartWebhookServerParams,
|
|
34
|
+
): Promise<WebhookHarness> {
|
|
35
|
+
const host = params.host ?? "127.0.0.1";
|
|
36
|
+
const port = params.port ?? 0;
|
|
37
|
+
const secret = params.secret ?? "nextcloud-secret";
|
|
38
|
+
const { server, start } = createNextcloudTalkWebhookServer({
|
|
39
|
+
...params,
|
|
40
|
+
port,
|
|
41
|
+
host,
|
|
42
|
+
secret,
|
|
43
|
+
});
|
|
44
|
+
await start();
|
|
45
|
+
const address = server.address() as AddressInfo | null;
|
|
46
|
+
if (!address) {
|
|
47
|
+
throw new Error("missing server address");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const harness: WebhookHarness = {
|
|
51
|
+
webhookUrl: `http://${host}:${address.port}${params.path}`,
|
|
52
|
+
stop: () =>
|
|
53
|
+
new Promise<void>((resolve) => {
|
|
54
|
+
server.close(() => resolve());
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
cleanupFns.push(harness.stop);
|
|
58
|
+
return harness;
|
|
59
|
+
}
|
package/src/monitor.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import {
|
|
3
4
|
createLoggerBackedRuntime,
|
|
4
5
|
type RuntimeEnv,
|
|
@@ -8,11 +9,13 @@ import {
|
|
|
8
9
|
} from "openclaw/plugin-sdk";
|
|
9
10
|
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
|
10
11
|
import { handleNextcloudTalkInbound } from "./inbound.js";
|
|
12
|
+
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
11
13
|
import { getNextcloudTalkRuntime } from "./runtime.js";
|
|
12
14
|
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
|
|
13
15
|
import type {
|
|
14
16
|
CoreConfig,
|
|
15
17
|
NextcloudTalkInboundMessage,
|
|
18
|
+
NextcloudTalkWebhookHeaders,
|
|
16
19
|
NextcloudTalkWebhookPayload,
|
|
17
20
|
NextcloudTalkWebhookServerOptions,
|
|
18
21
|
} from "./types.js";
|
|
@@ -23,6 +26,14 @@ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
|
|
|
23
26
|
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
24
27
|
const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
|
25
28
|
const HEALTH_PATH = "/healthz";
|
|
29
|
+
const WEBHOOK_ERRORS = {
|
|
30
|
+
missingSignatureHeaders: "Missing signature headers",
|
|
31
|
+
invalidBackend: "Invalid backend",
|
|
32
|
+
invalidSignature: "Invalid signature",
|
|
33
|
+
invalidPayloadFormat: "Invalid payload format",
|
|
34
|
+
payloadTooLarge: "Payload too large",
|
|
35
|
+
internalServerError: "Internal server error",
|
|
36
|
+
} as const;
|
|
26
37
|
|
|
27
38
|
function formatError(err: unknown): string {
|
|
28
39
|
if (err instanceof Error) {
|
|
@@ -31,6 +42,14 @@ function formatError(err: unknown): string {
|
|
|
31
42
|
return typeof err === "string" ? err : JSON.stringify(err);
|
|
32
43
|
}
|
|
33
44
|
|
|
45
|
+
function normalizeOrigin(value: string): string | null {
|
|
46
|
+
try {
|
|
47
|
+
return new URL(value).origin.toLowerCase();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
|
|
35
54
|
try {
|
|
36
55
|
const data = JSON.parse(body);
|
|
@@ -51,6 +70,83 @@ function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
|
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
72
|
|
|
73
|
+
function writeJsonResponse(
|
|
74
|
+
res: ServerResponse,
|
|
75
|
+
status: number,
|
|
76
|
+
body?: Record<string, unknown>,
|
|
77
|
+
): void {
|
|
78
|
+
if (body) {
|
|
79
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
80
|
+
res.end(JSON.stringify(body));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.writeHead(status);
|
|
84
|
+
res.end();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeWebhookError(res: ServerResponse, status: number, error: string): void {
|
|
88
|
+
if (res.headersSent) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
writeJsonResponse(res, status, { error });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateWebhookHeaders(params: {
|
|
95
|
+
req: IncomingMessage;
|
|
96
|
+
res: ServerResponse;
|
|
97
|
+
isBackendAllowed?: (backend: string) => boolean;
|
|
98
|
+
}): NextcloudTalkWebhookHeaders | null {
|
|
99
|
+
const headers = extractNextcloudTalkHeaders(
|
|
100
|
+
params.req.headers as Record<string, string | string[] | undefined>,
|
|
101
|
+
);
|
|
102
|
+
if (!headers) {
|
|
103
|
+
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) {
|
|
107
|
+
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return headers;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function verifyWebhookSignature(params: {
|
|
114
|
+
headers: NextcloudTalkWebhookHeaders;
|
|
115
|
+
body: string;
|
|
116
|
+
secret: string;
|
|
117
|
+
res: ServerResponse;
|
|
118
|
+
}): boolean {
|
|
119
|
+
const isValid = verifyNextcloudTalkSignature({
|
|
120
|
+
signature: params.headers.signature,
|
|
121
|
+
random: params.headers.random,
|
|
122
|
+
body: params.body,
|
|
123
|
+
secret: params.secret,
|
|
124
|
+
});
|
|
125
|
+
if (!isValid) {
|
|
126
|
+
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function decodeWebhookCreateMessage(params: {
|
|
133
|
+
body: string;
|
|
134
|
+
res: ServerResponse;
|
|
135
|
+
}):
|
|
136
|
+
| { kind: "message"; message: NextcloudTalkInboundMessage }
|
|
137
|
+
| { kind: "ignore" }
|
|
138
|
+
| { kind: "invalid" } {
|
|
139
|
+
const payload = parseWebhookPayload(params.body);
|
|
140
|
+
if (!payload) {
|
|
141
|
+
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
|
|
142
|
+
return { kind: "invalid" };
|
|
143
|
+
}
|
|
144
|
+
if (payload.type !== "Create") {
|
|
145
|
+
return { kind: "ignore" };
|
|
146
|
+
}
|
|
147
|
+
return { kind: "message", message: payloadToInboundMessage(payload) };
|
|
148
|
+
}
|
|
149
|
+
|
|
54
150
|
function payloadToInboundMessage(
|
|
55
151
|
payload: NextcloudTalkWebhookPayload,
|
|
56
152
|
): NextcloudTalkInboundMessage {
|
|
@@ -92,6 +188,9 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
92
188
|
opts.maxBodyBytes > 0
|
|
93
189
|
? Math.floor(opts.maxBodyBytes)
|
|
94
190
|
: DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
|
191
|
+
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
|
|
192
|
+
const isBackendAllowed = opts.isBackendAllowed;
|
|
193
|
+
const shouldProcessMessage = opts.shouldProcessMessage;
|
|
95
194
|
|
|
96
195
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
97
196
|
if (req.url === HEALTH_PATH) {
|
|
@@ -107,47 +206,49 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
107
206
|
}
|
|
108
207
|
|
|
109
208
|
try {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
209
|
+
const headers = validateWebhookHeaders({
|
|
210
|
+
req,
|
|
211
|
+
res,
|
|
212
|
+
isBackendAllowed,
|
|
213
|
+
});
|
|
115
214
|
if (!headers) {
|
|
116
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
117
|
-
res.end(JSON.stringify({ error: "Missing signature headers" }));
|
|
118
215
|
return;
|
|
119
216
|
}
|
|
120
217
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
218
|
+
const body = await readBody(req, maxBodyBytes);
|
|
219
|
+
|
|
220
|
+
const hasValidSignature = verifyWebhookSignature({
|
|
221
|
+
headers,
|
|
124
222
|
body,
|
|
125
223
|
secret,
|
|
224
|
+
res,
|
|
126
225
|
});
|
|
127
|
-
|
|
128
|
-
if (!isValid) {
|
|
129
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
130
|
-
res.end(JSON.stringify({ error: "Invalid signature" }));
|
|
226
|
+
if (!hasValidSignature) {
|
|
131
227
|
return;
|
|
132
228
|
}
|
|
133
229
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
res
|
|
137
|
-
|
|
230
|
+
const decoded = decodeWebhookCreateMessage({
|
|
231
|
+
body,
|
|
232
|
+
res,
|
|
233
|
+
});
|
|
234
|
+
if (decoded.kind === "invalid") {
|
|
138
235
|
return;
|
|
139
236
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
res.writeHead(200);
|
|
143
|
-
res.end();
|
|
237
|
+
if (decoded.kind === "ignore") {
|
|
238
|
+
writeJsonResponse(res, 200);
|
|
144
239
|
return;
|
|
145
240
|
}
|
|
146
241
|
|
|
147
|
-
const message =
|
|
242
|
+
const message = decoded.message;
|
|
243
|
+
if (shouldProcessMessage) {
|
|
244
|
+
const shouldProcess = await shouldProcessMessage(message);
|
|
245
|
+
if (!shouldProcess) {
|
|
246
|
+
writeJsonResponse(res, 200);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
148
250
|
|
|
149
|
-
res
|
|
150
|
-
res.end();
|
|
251
|
+
writeJsonResponse(res, 200);
|
|
151
252
|
|
|
152
253
|
try {
|
|
153
254
|
await onMessage(message);
|
|
@@ -156,25 +257,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
156
257
|
}
|
|
157
258
|
} catch (err) {
|
|
158
259
|
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
159
|
-
|
|
160
|
-
res.writeHead(413, { "Content-Type": "application/json" });
|
|
161
|
-
res.end(JSON.stringify({ error: "Payload too large" }));
|
|
162
|
-
}
|
|
260
|
+
writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
|
|
163
261
|
return;
|
|
164
262
|
}
|
|
165
263
|
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
166
|
-
|
|
167
|
-
res.writeHead(408, { "Content-Type": "application/json" });
|
|
168
|
-
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
|
169
|
-
}
|
|
264
|
+
writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
170
265
|
return;
|
|
171
266
|
}
|
|
172
267
|
const error = err instanceof Error ? err : new Error(formatError(err));
|
|
173
268
|
onError?.(error);
|
|
174
|
-
|
|
175
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
176
|
-
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
177
|
-
}
|
|
269
|
+
writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
|
|
178
270
|
}
|
|
179
271
|
});
|
|
180
272
|
|
|
@@ -184,12 +276,25 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
184
276
|
});
|
|
185
277
|
};
|
|
186
278
|
|
|
279
|
+
let stopped = false;
|
|
187
280
|
const stop = () => {
|
|
188
|
-
|
|
281
|
+
if (stopped) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
stopped = true;
|
|
285
|
+
try {
|
|
286
|
+
server.close();
|
|
287
|
+
} catch {
|
|
288
|
+
// ignore close races while shutting down
|
|
289
|
+
}
|
|
189
290
|
};
|
|
190
291
|
|
|
191
292
|
if (abortSignal) {
|
|
192
|
-
abortSignal.
|
|
293
|
+
if (abortSignal.aborted) {
|
|
294
|
+
stop();
|
|
295
|
+
} else {
|
|
296
|
+
abortSignal.addEventListener("abort", stop, { once: true });
|
|
297
|
+
}
|
|
193
298
|
}
|
|
194
299
|
|
|
195
300
|
return { server, start, stop };
|
|
@@ -232,12 +337,41 @@ export async function monitorNextcloudTalkProvider(
|
|
|
232
337
|
channel: "nextcloud-talk",
|
|
233
338
|
accountId: account.accountId,
|
|
234
339
|
});
|
|
340
|
+
const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
|
|
341
|
+
const replayGuard = createNextcloudTalkReplayGuard({
|
|
342
|
+
stateDir: core.state.resolveStateDir(process.env, os.homedir),
|
|
343
|
+
onDiskError: (error) => {
|
|
344
|
+
logger.warn(
|
|
345
|
+
`[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
|
|
346
|
+
);
|
|
347
|
+
},
|
|
348
|
+
});
|
|
235
349
|
|
|
236
350
|
const { start, stop } = createNextcloudTalkWebhookServer({
|
|
237
351
|
port,
|
|
238
352
|
host,
|
|
239
353
|
path,
|
|
240
354
|
secret: account.secret,
|
|
355
|
+
isBackendAllowed: (backend) => {
|
|
356
|
+
if (!expectedBackendOrigin) {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
const backendOrigin = normalizeOrigin(backend);
|
|
360
|
+
return backendOrigin === expectedBackendOrigin;
|
|
361
|
+
},
|
|
362
|
+
shouldProcessMessage: async (message) => {
|
|
363
|
+
const shouldProcess = await replayGuard.shouldProcessMessage({
|
|
364
|
+
accountId: account.accountId,
|
|
365
|
+
roomToken: message.roomToken,
|
|
366
|
+
messageId: message.messageId,
|
|
367
|
+
});
|
|
368
|
+
if (!shouldProcess) {
|
|
369
|
+
logger.warn(
|
|
370
|
+
`[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return shouldProcess;
|
|
374
|
+
},
|
|
241
375
|
onMessage: async (message) => {
|
|
242
376
|
core.channel.activity.record({
|
|
243
377
|
channel: "nextcloud-talk",
|
|
@@ -263,7 +397,14 @@ export async function monitorNextcloudTalkProvider(
|
|
|
263
397
|
abortSignal: opts.abortSignal,
|
|
264
398
|
});
|
|
265
399
|
|
|
400
|
+
if (opts.abortSignal?.aborted) {
|
|
401
|
+
return { stop };
|
|
402
|
+
}
|
|
266
403
|
await start();
|
|
404
|
+
if (opts.abortSignal?.aborted) {
|
|
405
|
+
stop();
|
|
406
|
+
return { stop };
|
|
407
|
+
}
|
|
267
408
|
|
|
268
409
|
const publicUrl =
|
|
269
410
|
account.config.webhookPublicUrl ??
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
while (tempDirs.length > 0) {
|
|
11
|
+
const dir = tempDirs.pop();
|
|
12
|
+
if (dir) {
|
|
13
|
+
await rm(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function makeTempDir(): Promise<string> {
|
|
19
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
|
20
|
+
tempDirs.push(dir);
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("createNextcloudTalkReplayGuard", () => {
|
|
25
|
+
it("persists replay decisions across guard instances", async () => {
|
|
26
|
+
const stateDir = await makeTempDir();
|
|
27
|
+
|
|
28
|
+
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
|
|
29
|
+
const firstAttempt = await firstGuard.shouldProcessMessage({
|
|
30
|
+
accountId: "account-a",
|
|
31
|
+
roomToken: "room-1",
|
|
32
|
+
messageId: "msg-1",
|
|
33
|
+
});
|
|
34
|
+
const replayAttempt = await firstGuard.shouldProcessMessage({
|
|
35
|
+
accountId: "account-a",
|
|
36
|
+
roomToken: "room-1",
|
|
37
|
+
messageId: "msg-1",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
|
|
41
|
+
const restartReplayAttempt = await secondGuard.shouldProcessMessage({
|
|
42
|
+
accountId: "account-a",
|
|
43
|
+
roomToken: "room-1",
|
|
44
|
+
messageId: "msg-1",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(firstAttempt).toBe(true);
|
|
48
|
+
expect(replayAttempt).toBe(false);
|
|
49
|
+
expect(restartReplayAttempt).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("scopes replay state by account namespace", async () => {
|
|
53
|
+
const stateDir = await makeTempDir();
|
|
54
|
+
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
|
55
|
+
|
|
56
|
+
const accountAFirst = await guard.shouldProcessMessage({
|
|
57
|
+
accountId: "account-a",
|
|
58
|
+
roomToken: "room-1",
|
|
59
|
+
messageId: "msg-9",
|
|
60
|
+
});
|
|
61
|
+
const accountBFirst = await guard.shouldProcessMessage({
|
|
62
|
+
accountId: "account-b",
|
|
63
|
+
roomToken: "room-1",
|
|
64
|
+
messageId: "msg-9",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(accountAFirst).toBe(true);
|
|
68
|
+
expect(accountBFirst).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createPersistentDedupe } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const DEFAULT_MEMORY_MAX_SIZE = 1_000;
|
|
6
|
+
const DEFAULT_FILE_MAX_ENTRIES = 10_000;
|
|
7
|
+
|
|
8
|
+
function sanitizeSegment(value: string): string {
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return "default";
|
|
12
|
+
}
|
|
13
|
+
return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildReplayKey(params: { roomToken: string; messageId: string }): string | null {
|
|
17
|
+
const roomToken = params.roomToken.trim();
|
|
18
|
+
const messageId = params.messageId.trim();
|
|
19
|
+
if (!roomToken || !messageId) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return `${roomToken}:${messageId}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type NextcloudTalkReplayGuardOptions = {
|
|
26
|
+
stateDir: string;
|
|
27
|
+
ttlMs?: number;
|
|
28
|
+
memoryMaxSize?: number;
|
|
29
|
+
fileMaxEntries?: number;
|
|
30
|
+
onDiskError?: (error: unknown) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type NextcloudTalkReplayGuard = {
|
|
34
|
+
shouldProcessMessage: (params: {
|
|
35
|
+
accountId: string;
|
|
36
|
+
roomToken: string;
|
|
37
|
+
messageId: string;
|
|
38
|
+
}) => Promise<boolean>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function createNextcloudTalkReplayGuard(
|
|
42
|
+
options: NextcloudTalkReplayGuardOptions,
|
|
43
|
+
): NextcloudTalkReplayGuard {
|
|
44
|
+
const stateDir = options.stateDir.trim();
|
|
45
|
+
const persistentDedupe = createPersistentDedupe({
|
|
46
|
+
ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
|
|
47
|
+
memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
|
|
48
|
+
fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
|
|
49
|
+
resolveFilePath: (namespace) =>
|
|
50
|
+
path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
|
|
55
|
+
const replayKey = buildReplayKey({ roomToken, messageId });
|
|
56
|
+
if (!replayKey) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return await persistentDedupe.checkAndRecord(replayKey, {
|
|
60
|
+
namespace: accountId,
|
|
61
|
+
onDiskError: options.onDiskError,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -79,6 +79,8 @@ export type NextcloudTalkAccountConfig = {
|
|
|
79
79
|
export type NextcloudTalkConfig = {
|
|
80
80
|
/** Optional per-account Nextcloud Talk configuration (multi-account). */
|
|
81
81
|
accounts?: Record<string, NextcloudTalkAccountConfig>;
|
|
82
|
+
/** Optional default account id when multiple accounts are configured. */
|
|
83
|
+
defaultAccount?: string;
|
|
82
84
|
} & NextcloudTalkAccountConfig;
|
|
83
85
|
|
|
84
86
|
export type CoreConfig = {
|
|
@@ -169,6 +171,9 @@ export type NextcloudTalkWebhookServerOptions = {
|
|
|
169
171
|
path: string;
|
|
170
172
|
secret: string;
|
|
171
173
|
maxBodyBytes?: number;
|
|
174
|
+
readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise<string>;
|
|
175
|
+
isBackendAllowed?: (backend: string) => boolean;
|
|
176
|
+
shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise<boolean>;
|
|
172
177
|
onMessage: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
|
|
173
178
|
onError?: (error: Error) => void;
|
|
174
179
|
abortSignal?: AbortSignal;
|