@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.15
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/README.md +3 -1
- package/index.ts +65 -0
- package/openclaw.plugin.json +3 -7
- package/package.json +32 -9
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +111 -0
- package/src/accounts.test.ts +371 -0
- package/src/accounts.ts +244 -0
- package/src/async.ts +62 -0
- package/src/bitable.ts +725 -0
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +193 -0
- package/src/bot.stripBotMention.test.ts +134 -0
- package/src/bot.test.ts +2107 -0
- package/src/bot.ts +1556 -0
- package/src/card-action.ts +79 -0
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +369 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +324 -0
- package/src/client.ts +196 -0
- package/src/config-schema.test.ts +247 -0
- package/src/config-schema.ts +306 -0
- package/src/dedup.ts +203 -0
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +156 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +187 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +445 -0
- package/src/docx.ts +1460 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +228 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +523 -0
- package/src/media.ts +484 -0
- package/src/mention.ts +133 -0
- package/src/monitor.account.ts +562 -0
- package/src/monitor.reaction.test.ts +653 -0
- package/src/monitor.startup.test.ts +190 -0
- package/src/monitor.startup.ts +64 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +155 -0
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +264 -0
- package/src/monitor.ts +95 -0
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +142 -0
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/nextclaw-sdk/account-id.ts +31 -0
- package/src/nextclaw-sdk/compat.ts +8 -0
- package/src/nextclaw-sdk/core-channel.ts +296 -0
- package/src/nextclaw-sdk/core-pairing.ts +224 -0
- package/src/nextclaw-sdk/core.ts +26 -0
- package/src/nextclaw-sdk/dedupe.ts +246 -0
- package/src/nextclaw-sdk/feishu.ts +77 -0
- package/src/nextclaw-sdk/history.ts +127 -0
- package/src/nextclaw-sdk/network-body.ts +245 -0
- package/src/nextclaw-sdk/network-fetch.ts +129 -0
- package/src/nextclaw-sdk/network-webhook.ts +182 -0
- package/src/nextclaw-sdk/network.ts +13 -0
- package/src/nextclaw-sdk/runtime-store.ts +26 -0
- package/src/nextclaw-sdk/secrets-config.ts +109 -0
- package/src/nextclaw-sdk/secrets-core.ts +170 -0
- package/src/nextclaw-sdk/secrets-prompt.ts +305 -0
- package/src/nextclaw-sdk/secrets.ts +18 -0
- package/src/nextclaw-sdk/types.ts +300 -0
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +489 -0
- package/src/outbound.test.ts +356 -0
- package/src/outbound.ts +176 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +176 -0
- package/src/policy.test.ts +154 -0
- package/src/policy.ts +123 -0
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +270 -0
- package/src/probe.ts +156 -0
- package/src/reactions.ts +153 -0
- package/src/reply-dispatcher.test.ts +513 -0
- package/src/reply-dispatcher.ts +397 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-result.ts +29 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +29 -0
- package/src/send.reply-fallback.test.ts +189 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +481 -0
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +374 -0
- package/src/targets.test.ts +70 -0
- package/src/targets.ts +107 -0
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +103 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +210 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +233 -0
- package/index.js +0 -27
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createFeishuClientMockModule,
|
|
4
|
+
createFeishuRuntimeMockModule,
|
|
5
|
+
} from "./monitor.test-mocks.js";
|
|
6
|
+
import {
|
|
7
|
+
buildWebhookConfig,
|
|
8
|
+
getFreePort,
|
|
9
|
+
withRunningWebhookMonitor,
|
|
10
|
+
} from "./monitor.webhook.test-helpers.js";
|
|
11
|
+
|
|
12
|
+
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
13
|
+
|
|
14
|
+
vi.mock("./probe.js", () => ({
|
|
15
|
+
probeFeishu: probeFeishuMock,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("./client.js", () => createFeishuClientMockModule());
|
|
19
|
+
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
|
|
20
|
+
|
|
21
|
+
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
|
22
|
+
adaptDefault: vi.fn(
|
|
23
|
+
() => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
|
|
24
|
+
res.statusCode = 200;
|
|
25
|
+
res.end("ok");
|
|
26
|
+
},
|
|
27
|
+
),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
clearFeishuWebhookRateLimitStateForTest,
|
|
32
|
+
getFeishuWebhookRateLimitStateSizeForTest,
|
|
33
|
+
isWebhookRateLimitedForTest,
|
|
34
|
+
monitorFeishuProvider,
|
|
35
|
+
stopFeishuMonitor,
|
|
36
|
+
} from "./monitor.js";
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
clearFeishuWebhookRateLimitStateForTest();
|
|
40
|
+
stopFeishuMonitor();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("Feishu webhook security hardening", () => {
|
|
44
|
+
it("rejects webhook mode without verificationToken", async () => {
|
|
45
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
46
|
+
|
|
47
|
+
const cfg = buildWebhookConfig({
|
|
48
|
+
accountId: "missing-token",
|
|
49
|
+
path: "/hook-missing-token",
|
|
50
|
+
port: await getFreePort(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(
|
|
54
|
+
/requires verificationToken/i,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects webhook mode without encryptKey", async () => {
|
|
59
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
60
|
+
|
|
61
|
+
const cfg = buildWebhookConfig({
|
|
62
|
+
accountId: "missing-encrypt-key",
|
|
63
|
+
path: "/hook-missing-encrypt",
|
|
64
|
+
port: await getFreePort(),
|
|
65
|
+
verificationToken: "verify_token",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns 415 for POST requests without json content type", async () => {
|
|
72
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
73
|
+
await withRunningWebhookMonitor(
|
|
74
|
+
{
|
|
75
|
+
accountId: "content-type",
|
|
76
|
+
path: "/hook-content-type",
|
|
77
|
+
verificationToken: "verify_token",
|
|
78
|
+
encryptKey: "encrypt_key",
|
|
79
|
+
},
|
|
80
|
+
monitorFeishuProvider,
|
|
81
|
+
async (url) => {
|
|
82
|
+
const response = await fetch(url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": "text/plain" },
|
|
85
|
+
body: "{}",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(response.status).toBe(415);
|
|
89
|
+
expect(await response.text()).toBe("Unsupported Media Type");
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rate limits webhook burst traffic with 429", async () => {
|
|
95
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
96
|
+
await withRunningWebhookMonitor(
|
|
97
|
+
{
|
|
98
|
+
accountId: "rate-limit",
|
|
99
|
+
path: "/hook-rate-limit",
|
|
100
|
+
verificationToken: "verify_token",
|
|
101
|
+
encryptKey: "encrypt_key",
|
|
102
|
+
},
|
|
103
|
+
monitorFeishuProvider,
|
|
104
|
+
async (url) => {
|
|
105
|
+
let saw429 = false;
|
|
106
|
+
for (let i = 0; i < 130; i += 1) {
|
|
107
|
+
const response = await fetch(url, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "content-type": "text/plain" },
|
|
110
|
+
body: "{}",
|
|
111
|
+
});
|
|
112
|
+
if (response.status === 429) {
|
|
113
|
+
saw429 = true;
|
|
114
|
+
expect(await response.text()).toBe("Too Many Requests");
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(saw429).toBe(true);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("caps tracked webhook rate-limit keys to prevent unbounded growth", () => {
|
|
125
|
+
const now = 1_000_000;
|
|
126
|
+
for (let i = 0; i < 4_500; i += 1) {
|
|
127
|
+
isWebhookRateLimitedForTest(`/feishu-rate-limit:key-${i}`, now);
|
|
128
|
+
}
|
|
129
|
+
expect(getFeishuWebhookRateLimitStateSizeForTest()).toBeLessThanOrEqual(4_096);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("prunes stale webhook rate-limit state after window elapses", () => {
|
|
133
|
+
const now = 2_000_000;
|
|
134
|
+
for (let i = 0; i < 100; i += 1) {
|
|
135
|
+
isWebhookRateLimitedForTest(`/feishu-rate-limit-stale:key-${i}`, now);
|
|
136
|
+
}
|
|
137
|
+
expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(100);
|
|
138
|
+
|
|
139
|
+
isWebhookRateLimitedForTest("/feishu-rate-limit-stale:fresh", now + 60_001);
|
|
140
|
+
expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import type { ClawdbotConfig } from "./nextclaw-sdk/feishu.js";
|
|
4
|
+
import { vi } from "vitest";
|
|
5
|
+
import type { monitorFeishuProvider } from "./monitor.js";
|
|
6
|
+
|
|
7
|
+
export async function getFreePort(): Promise<number> {
|
|
8
|
+
const server = createServer();
|
|
9
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
10
|
+
const address = server.address() as AddressInfo | null;
|
|
11
|
+
if (!address) {
|
|
12
|
+
throw new Error("missing server address");
|
|
13
|
+
}
|
|
14
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
15
|
+
return address.port;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function waitUntilServerReady(url: string): Promise<void> {
|
|
19
|
+
for (let i = 0; i < 50; i += 1) {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(url, { method: "GET" });
|
|
22
|
+
if (response.status >= 200 && response.status < 500) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// retry
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`server did not start: ${url}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildWebhookConfig(params: {
|
|
34
|
+
accountId: string;
|
|
35
|
+
path: string;
|
|
36
|
+
port: number;
|
|
37
|
+
verificationToken?: string;
|
|
38
|
+
encryptKey?: string;
|
|
39
|
+
}): ClawdbotConfig {
|
|
40
|
+
return {
|
|
41
|
+
channels: {
|
|
42
|
+
feishu: {
|
|
43
|
+
enabled: true,
|
|
44
|
+
accounts: {
|
|
45
|
+
[params.accountId]: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
appId: "cli_test",
|
|
48
|
+
appSecret: "secret_test", // pragma: allowlist secret
|
|
49
|
+
connectionMode: "webhook",
|
|
50
|
+
webhookHost: "127.0.0.1",
|
|
51
|
+
webhookPort: params.port,
|
|
52
|
+
webhookPath: params.path,
|
|
53
|
+
encryptKey: params.encryptKey,
|
|
54
|
+
verificationToken: params.verificationToken,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
} as ClawdbotConfig;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function withRunningWebhookMonitor(
|
|
63
|
+
params: {
|
|
64
|
+
accountId: string;
|
|
65
|
+
path: string;
|
|
66
|
+
verificationToken: string;
|
|
67
|
+
encryptKey: string;
|
|
68
|
+
},
|
|
69
|
+
monitor: typeof monitorFeishuProvider,
|
|
70
|
+
run: (url: string) => Promise<void>,
|
|
71
|
+
) {
|
|
72
|
+
const port = await getFreePort();
|
|
73
|
+
const cfg = buildWebhookConfig({
|
|
74
|
+
accountId: params.accountId,
|
|
75
|
+
path: params.path,
|
|
76
|
+
port,
|
|
77
|
+
encryptKey: params.encryptKey,
|
|
78
|
+
verificationToken: params.verificationToken,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const abortController = new AbortController();
|
|
82
|
+
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
83
|
+
const monitorPromise = monitor({
|
|
84
|
+
config: cfg,
|
|
85
|
+
runtime,
|
|
86
|
+
abortSignal: abortController.signal,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const url = `http://127.0.0.1:${port}${params.path}`;
|
|
90
|
+
await waitUntilServerReady(url);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await run(url);
|
|
94
|
+
} finally {
|
|
95
|
+
abortController.abort();
|
|
96
|
+
await monitorPromise;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const DEFAULT_ACCOUNT_ID = "default";
|
|
2
|
+
|
|
3
|
+
export function normalizeAccountId(accountId?: string | null): string {
|
|
4
|
+
const trimmed = accountId?.trim();
|
|
5
|
+
return trimmed || DEFAULT_ACCOUNT_ID;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeOptionalAccountId(accountId?: string | null): string | undefined {
|
|
9
|
+
const trimmed = accountId?.trim();
|
|
10
|
+
return trimmed ? normalizeAccountId(trimmed) : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
14
|
+
const INVALID_AGENT_CHARS_RE = /[^a-z0-9_-]+/g;
|
|
15
|
+
|
|
16
|
+
export function normalizeAgentId(value?: string | null): string {
|
|
17
|
+
const trimmed = value?.trim();
|
|
18
|
+
if (!trimmed) {
|
|
19
|
+
return "main";
|
|
20
|
+
}
|
|
21
|
+
if (VALID_AGENT_ID_RE.test(trimmed)) {
|
|
22
|
+
return trimmed.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
const normalized = trimmed
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(INVALID_AGENT_CHARS_RE, "-")
|
|
27
|
+
.replace(/^-+/, "")
|
|
28
|
+
.replace(/-+$/, "")
|
|
29
|
+
.slice(0, 64);
|
|
30
|
+
return normalized || "main";
|
|
31
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export {
|
|
2
|
+
collectAllowlistProviderRestrictSendersWarnings,
|
|
3
|
+
formatAllowFromLowercase,
|
|
4
|
+
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
|
|
5
|
+
listDirectoryUserEntriesFromAllowFromAndMapKeys,
|
|
6
|
+
mapAllowFromEntries,
|
|
7
|
+
} from "./core.js";
|
|
8
|
+
export { createPluginRuntimeStore } from "./runtime-store.js";
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { GroupPolicy, OpenClawConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function emptyPluginConfigSchema(): {
|
|
4
|
+
type: "object";
|
|
5
|
+
additionalProperties: false;
|
|
6
|
+
properties: Record<string, unknown>;
|
|
7
|
+
} {
|
|
8
|
+
return {
|
|
9
|
+
type: "object",
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const warnedMissingProviderGroupPolicy = new Set<string>();
|
|
16
|
+
|
|
17
|
+
export function resolveDefaultGroupPolicy(cfg: {
|
|
18
|
+
channels?: {
|
|
19
|
+
defaults?: {
|
|
20
|
+
groupPolicy?: GroupPolicy;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
}): GroupPolicy | undefined {
|
|
24
|
+
return cfg.channels?.defaults?.groupPolicy;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveOpenProviderRuntimeGroupPolicy(params: {
|
|
28
|
+
providerConfigPresent: boolean;
|
|
29
|
+
groupPolicy?: GroupPolicy;
|
|
30
|
+
defaultGroupPolicy?: GroupPolicy;
|
|
31
|
+
}): {
|
|
32
|
+
groupPolicy: GroupPolicy;
|
|
33
|
+
providerMissingFallbackApplied: boolean;
|
|
34
|
+
} {
|
|
35
|
+
const groupPolicy = params.providerConfigPresent
|
|
36
|
+
? (params.groupPolicy ?? params.defaultGroupPolicy ?? "open")
|
|
37
|
+
: (params.groupPolicy ?? "allowlist");
|
|
38
|
+
return {
|
|
39
|
+
groupPolicy,
|
|
40
|
+
providerMissingFallbackApplied: !params.providerConfigPresent && params.groupPolicy === undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function warnMissingProviderGroupPolicyFallbackOnce(params: {
|
|
45
|
+
providerMissingFallbackApplied: boolean;
|
|
46
|
+
providerKey: string;
|
|
47
|
+
accountId?: string;
|
|
48
|
+
blockedLabel?: string;
|
|
49
|
+
log: (message: string) => void;
|
|
50
|
+
}): boolean {
|
|
51
|
+
if (!params.providerMissingFallbackApplied) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const key = `${params.providerKey}:${params.accountId ?? "*"}`;
|
|
55
|
+
if (warnedMissingProviderGroupPolicy.has(key)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
warnedMissingProviderGroupPolicy.add(key);
|
|
59
|
+
const blockedLabel = params.blockedLabel?.trim() || "group messages";
|
|
60
|
+
params.log(
|
|
61
|
+
`${params.providerKey}: channels.${params.providerKey} is missing; defaulting groupPolicy to "allowlist" (${blockedLabel} blocked until explicitly configured).`,
|
|
62
|
+
);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function evaluateSenderGroupAccessForPolicy(params: {
|
|
67
|
+
groupPolicy: GroupPolicy;
|
|
68
|
+
providerMissingFallbackApplied?: boolean;
|
|
69
|
+
groupAllowFrom: string[];
|
|
70
|
+
senderId: string;
|
|
71
|
+
isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
|
|
72
|
+
}): {
|
|
73
|
+
allowed: boolean;
|
|
74
|
+
groupPolicy: GroupPolicy;
|
|
75
|
+
providerMissingFallbackApplied: boolean;
|
|
76
|
+
reason: "allowed" | "disabled" | "empty_allowlist" | "sender_not_allowlisted";
|
|
77
|
+
} {
|
|
78
|
+
if (params.groupPolicy === "disabled") {
|
|
79
|
+
return {
|
|
80
|
+
allowed: false,
|
|
81
|
+
groupPolicy: params.groupPolicy,
|
|
82
|
+
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
|
|
83
|
+
reason: "disabled",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (params.groupPolicy === "allowlist") {
|
|
87
|
+
if (params.groupAllowFrom.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
allowed: false,
|
|
90
|
+
groupPolicy: params.groupPolicy,
|
|
91
|
+
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
|
|
92
|
+
reason: "empty_allowlist",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (!params.isSenderAllowed(params.senderId, params.groupAllowFrom)) {
|
|
96
|
+
return {
|
|
97
|
+
allowed: false,
|
|
98
|
+
groupPolicy: params.groupPolicy,
|
|
99
|
+
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
|
|
100
|
+
reason: "sender_not_allowlisted",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
allowed: true,
|
|
106
|
+
groupPolicy: params.groupPolicy,
|
|
107
|
+
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
|
|
108
|
+
reason: "allowed",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createDefaultChannelRuntimeState<T extends Record<string, unknown>>(
|
|
113
|
+
accountId: string,
|
|
114
|
+
extra?: T,
|
|
115
|
+
): {
|
|
116
|
+
accountId: string;
|
|
117
|
+
running: false;
|
|
118
|
+
lastStartAt: null;
|
|
119
|
+
lastStopAt: null;
|
|
120
|
+
lastError: null;
|
|
121
|
+
} & T {
|
|
122
|
+
return {
|
|
123
|
+
accountId,
|
|
124
|
+
running: false,
|
|
125
|
+
lastStartAt: null,
|
|
126
|
+
lastStopAt: null,
|
|
127
|
+
lastError: null,
|
|
128
|
+
...(extra ?? ({} as T)),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildProbeChannelStatusSummary<TExtra extends Record<string, unknown>>(
|
|
133
|
+
snapshot: {
|
|
134
|
+
configured?: boolean | null;
|
|
135
|
+
running?: boolean | null;
|
|
136
|
+
lastStartAt?: number | null;
|
|
137
|
+
lastStopAt?: number | null;
|
|
138
|
+
lastError?: string | null;
|
|
139
|
+
probe?: unknown;
|
|
140
|
+
lastProbeAt?: number | null;
|
|
141
|
+
},
|
|
142
|
+
extra?: TExtra,
|
|
143
|
+
) {
|
|
144
|
+
return {
|
|
145
|
+
configured: snapshot.configured ?? false,
|
|
146
|
+
running: snapshot.running ?? false,
|
|
147
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
148
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
149
|
+
lastError: snapshot.lastError ?? null,
|
|
150
|
+
...(extra ?? ({} as TExtra)),
|
|
151
|
+
probe: snapshot.probe,
|
|
152
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function buildRuntimeAccountStatusSnapshot(params: {
|
|
157
|
+
runtime?: {
|
|
158
|
+
running?: boolean | null;
|
|
159
|
+
lastStartAt?: number | null;
|
|
160
|
+
lastStopAt?: number | null;
|
|
161
|
+
lastError?: string | null;
|
|
162
|
+
} | null;
|
|
163
|
+
probe?: unknown;
|
|
164
|
+
}) {
|
|
165
|
+
return {
|
|
166
|
+
running: params.runtime?.running ?? false,
|
|
167
|
+
lastStartAt: params.runtime?.lastStartAt ?? null,
|
|
168
|
+
lastStopAt: params.runtime?.lastStopAt ?? null,
|
|
169
|
+
lastError: params.runtime?.lastError ?? null,
|
|
170
|
+
probe: params.probe,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function mapAllowFromEntries(
|
|
175
|
+
allowFrom: Array<string | number> | null | undefined,
|
|
176
|
+
): string[] {
|
|
177
|
+
return (allowFrom ?? []).map((entry) => String(entry));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function formatAllowFromLowercase(params: {
|
|
181
|
+
allowFrom: Array<string | number>;
|
|
182
|
+
stripPrefixRe?: RegExp;
|
|
183
|
+
}): string[] {
|
|
184
|
+
return params.allowFrom
|
|
185
|
+
.map((entry) => String(entry).trim())
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.map((entry) => (params.stripPrefixRe ? entry.replace(params.stripPrefixRe, "") : entry))
|
|
188
|
+
.map((entry) => entry.toLowerCase());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function applyDirectoryQueryAndLimit(
|
|
192
|
+
ids: string[],
|
|
193
|
+
params: { query?: string | null; limit?: number | null },
|
|
194
|
+
): string[] {
|
|
195
|
+
const query = params.query?.trim().toLowerCase() || "";
|
|
196
|
+
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : undefined;
|
|
197
|
+
const filtered = ids.filter((id) => (query ? id.toLowerCase().includes(query) : true));
|
|
198
|
+
return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function dedupeIds(ids: string[]): string[] {
|
|
202
|
+
return Array.from(new Set(ids));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function collectEntryIds(params: {
|
|
206
|
+
entries?: readonly unknown[];
|
|
207
|
+
normalizeId?: (entry: string) => string | null | undefined;
|
|
208
|
+
}): string[] {
|
|
209
|
+
return (params.entries ?? [])
|
|
210
|
+
.map((entry) => String(entry).trim())
|
|
211
|
+
.filter((entry) => Boolean(entry) && entry !== "*")
|
|
212
|
+
.map((entry) => {
|
|
213
|
+
const normalized = params.normalizeId ? params.normalizeId(entry) : entry;
|
|
214
|
+
return typeof normalized === "string" ? normalized.trim() : "";
|
|
215
|
+
})
|
|
216
|
+
.filter(Boolean);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function collectMapIds(params: {
|
|
220
|
+
map?: Record<string, unknown>;
|
|
221
|
+
normalizeId?: (entry: string) => string | null | undefined;
|
|
222
|
+
}): string[] {
|
|
223
|
+
return collectEntryIds({
|
|
224
|
+
entries: Object.keys(params.map ?? {}),
|
|
225
|
+
normalizeId: params.normalizeId,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: {
|
|
230
|
+
allowFrom?: readonly unknown[];
|
|
231
|
+
map?: Record<string, unknown>;
|
|
232
|
+
query?: string | null;
|
|
233
|
+
limit?: number | null;
|
|
234
|
+
normalizeAllowFromId?: (entry: string) => string | null | undefined;
|
|
235
|
+
normalizeMapKeyId?: (entry: string) => string | null | undefined;
|
|
236
|
+
}): Array<{ kind: "user"; id: string }> {
|
|
237
|
+
const ids = dedupeIds([
|
|
238
|
+
...collectEntryIds({
|
|
239
|
+
entries: params.allowFrom,
|
|
240
|
+
normalizeId: params.normalizeAllowFromId,
|
|
241
|
+
}),
|
|
242
|
+
...collectMapIds({
|
|
243
|
+
map: params.map,
|
|
244
|
+
normalizeId: params.normalizeMapKeyId,
|
|
245
|
+
}),
|
|
246
|
+
]);
|
|
247
|
+
return applyDirectoryQueryAndLimit(ids, params).map((id) => ({ kind: "user", id }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: {
|
|
251
|
+
groups?: Record<string, unknown>;
|
|
252
|
+
allowFrom?: readonly unknown[];
|
|
253
|
+
query?: string | null;
|
|
254
|
+
limit?: number | null;
|
|
255
|
+
normalizeMapKeyId?: (entry: string) => string | null | undefined;
|
|
256
|
+
normalizeAllowFromId?: (entry: string) => string | null | undefined;
|
|
257
|
+
}): Array<{ kind: "group"; id: string }> {
|
|
258
|
+
const ids = dedupeIds([
|
|
259
|
+
...collectMapIds({
|
|
260
|
+
map: params.groups,
|
|
261
|
+
normalizeId: params.normalizeMapKeyId,
|
|
262
|
+
}),
|
|
263
|
+
...collectEntryIds({
|
|
264
|
+
entries: params.allowFrom,
|
|
265
|
+
normalizeId: params.normalizeAllowFromId,
|
|
266
|
+
}),
|
|
267
|
+
]);
|
|
268
|
+
return applyDirectoryQueryAndLimit(ids, params).map((id) => ({ kind: "group", id }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function collectAllowlistProviderRestrictSendersWarnings(params: {
|
|
272
|
+
cfg: OpenClawConfig;
|
|
273
|
+
providerConfigPresent: boolean;
|
|
274
|
+
configuredGroupPolicy?: GroupPolicy | null;
|
|
275
|
+
surface: string;
|
|
276
|
+
openScope: string;
|
|
277
|
+
groupPolicyPath: string;
|
|
278
|
+
groupAllowFromPath: string;
|
|
279
|
+
mentionGated?: boolean;
|
|
280
|
+
}): string[] {
|
|
281
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg as {
|
|
282
|
+
channels?: { defaults?: { groupPolicy?: GroupPolicy } };
|
|
283
|
+
});
|
|
284
|
+
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
|
|
285
|
+
providerConfigPresent: params.providerConfigPresent,
|
|
286
|
+
groupPolicy: params.configuredGroupPolicy ?? undefined,
|
|
287
|
+
defaultGroupPolicy,
|
|
288
|
+
});
|
|
289
|
+
if (groupPolicy !== "open") {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
const mentionSuffix = params.mentionGated === false ? "" : " (mention-gated)";
|
|
293
|
+
return [
|
|
294
|
+
`- ${params.surface}: groupPolicy="open" allows ${params.openScope} to trigger${mentionSuffix}. Set ${params.groupPolicyPath}="allowlist" + ${params.groupAllowFromPath} to restrict senders.`,
|
|
295
|
+
];
|
|
296
|
+
}
|