refactor: add plugin-owned outbound adapters
This commit is contained in:
parent
2054cb9431
commit
7cdd8a84a6
35
extensions/imessage/src/outbound-adapter.ts
Normal file
35
extensions/imessage/src/outbound-adapter.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
createScopedChannelMediaMaxBytesResolver,
|
||||||
|
createDirectTextMediaOutbound,
|
||||||
|
} from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||||
|
import {
|
||||||
|
resolveOutboundSendDep,
|
||||||
|
type OutboundSendDeps,
|
||||||
|
} from "../../../src/infra/outbound/send-deps.js";
|
||||||
|
import { sendMessageIMessage } from "./send.js";
|
||||||
|
|
||||||
|
function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
|
||||||
|
return (
|
||||||
|
resolveOutboundSendDep<typeof sendMessageIMessage>(deps, "imessage") ?? sendMessageIMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imessageOutbound = createDirectTextMediaOutbound({
|
||||||
|
channel: "imessage",
|
||||||
|
resolveSender: resolveIMessageSender,
|
||||||
|
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"),
|
||||||
|
buildTextOptions: ({ cfg, maxBytes, accountId, replyToId }) => ({
|
||||||
|
config: cfg,
|
||||||
|
maxBytes,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
replyToId: replyToId ?? undefined,
|
||||||
|
}),
|
||||||
|
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({
|
||||||
|
config: cfg,
|
||||||
|
mediaUrl,
|
||||||
|
maxBytes,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
replyToId: replyToId ?? undefined,
|
||||||
|
mediaLocalRoots,
|
||||||
|
}),
|
||||||
|
});
|
||||||
125
extensions/signal/src/outbound-adapter.ts
Normal file
125
extensions/signal/src/outbound-adapter.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
|
||||||
|
import { createScopedChannelMediaMaxBytesResolver } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||||
|
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||||
|
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
|
||||||
|
import {
|
||||||
|
resolveOutboundSendDep,
|
||||||
|
type OutboundSendDeps,
|
||||||
|
} from "../../../src/infra/outbound/send-deps.js";
|
||||||
|
import { markdownToSignalTextChunks } from "./format.js";
|
||||||
|
import { sendMessageSignal } from "./send.js";
|
||||||
|
|
||||||
|
function resolveSignalSender(deps: OutboundSendDeps | undefined) {
|
||||||
|
return resolveOutboundSendDep<typeof sendMessageSignal>(deps, "signal") ?? sendMessageSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal");
|
||||||
|
type SignalSendOpts = NonNullable<Parameters<typeof sendMessageSignal>[2]>;
|
||||||
|
|
||||||
|
function inferSignalTableMode(params: { cfg: SignalSendOpts["cfg"]; accountId?: string | null }) {
|
||||||
|
return resolveMarkdownTableMode({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channel: "signal",
|
||||||
|
accountId: params.accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signalOutbound: ChannelOutboundAdapter = {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])),
|
||||||
|
chunkerMode: "text",
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
|
||||||
|
const send = resolveSignalSender(deps);
|
||||||
|
const maxBytes = resolveSignalMaxBytes({
|
||||||
|
cfg,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
const limit = resolveTextChunkLimit(cfg, "signal", accountId ?? undefined, {
|
||||||
|
fallbackLimit: 4000,
|
||||||
|
});
|
||||||
|
const tableMode = inferSignalTableMode({ cfg, accountId });
|
||||||
|
let chunks =
|
||||||
|
limit === undefined
|
||||||
|
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode })
|
||||||
|
: markdownToSignalTextChunks(text, limit, { tableMode });
|
||||||
|
if (chunks.length === 0 && text) {
|
||||||
|
chunks = [{ text, styles: [] }];
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
abortSignal?.throwIfAborted();
|
||||||
|
const result = await send(to, chunk.text, {
|
||||||
|
cfg,
|
||||||
|
maxBytes,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
textMode: "plain",
|
||||||
|
textStyles: chunk.styles,
|
||||||
|
});
|
||||||
|
results.push({ channel: "signal" as const, ...result });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
sendFormattedMedia: async ({
|
||||||
|
cfg,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
mediaUrl,
|
||||||
|
mediaLocalRoots,
|
||||||
|
accountId,
|
||||||
|
deps,
|
||||||
|
abortSignal,
|
||||||
|
}) => {
|
||||||
|
abortSignal?.throwIfAborted();
|
||||||
|
const send = resolveSignalSender(deps);
|
||||||
|
const maxBytes = resolveSignalMaxBytes({
|
||||||
|
cfg,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
const tableMode = inferSignalTableMode({ cfg, accountId });
|
||||||
|
const formatted = markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
|
||||||
|
tableMode,
|
||||||
|
})[0] ?? {
|
||||||
|
text,
|
||||||
|
styles: [],
|
||||||
|
};
|
||||||
|
const result = await send(to, formatted.text, {
|
||||||
|
cfg,
|
||||||
|
mediaUrl,
|
||||||
|
maxBytes,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
textMode: "plain",
|
||||||
|
textStyles: formatted.styles,
|
||||||
|
mediaLocalRoots,
|
||||||
|
});
|
||||||
|
return { channel: "signal", ...result };
|
||||||
|
},
|
||||||
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
|
const send = resolveSignalSender(deps);
|
||||||
|
const maxBytes = resolveSignalMaxBytes({
|
||||||
|
cfg,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
const result = await send(to, text, {
|
||||||
|
cfg,
|
||||||
|
maxBytes,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
return { channel: "signal", ...result };
|
||||||
|
},
|
||||||
|
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
|
||||||
|
const send = resolveSignalSender(deps);
|
||||||
|
const maxBytes = resolveSignalMaxBytes({
|
||||||
|
cfg,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
const result = await send(to, text, {
|
||||||
|
cfg,
|
||||||
|
mediaUrl,
|
||||||
|
maxBytes,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
mediaLocalRoots,
|
||||||
|
});
|
||||||
|
return { channel: "signal", ...result };
|
||||||
|
},
|
||||||
|
};
|
||||||
250
extensions/slack/src/outbound-adapter.ts
Normal file
250
extensions/slack/src/outbound-adapter.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import {
|
||||||
|
resolvePayloadMediaUrls,
|
||||||
|
sendPayloadMediaSequence,
|
||||||
|
sendTextMediaPayload,
|
||||||
|
} from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||||
|
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||||
|
import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js";
|
||||||
|
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||||
|
import {
|
||||||
|
resolveInteractiveTextFallback,
|
||||||
|
type InteractiveReply,
|
||||||
|
} from "../../../src/interactive/payload.js";
|
||||||
|
import { getGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js";
|
||||||
|
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||||
|
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
|
||||||
|
import { sendMessageSlack, type SlackSendIdentity } from "./send.js";
|
||||||
|
|
||||||
|
const SLACK_MAX_BLOCKS = 50;
|
||||||
|
|
||||||
|
function resolveRenderedInteractiveBlocks(
|
||||||
|
interactive?: InteractiveReply,
|
||||||
|
): SlackBlock[] | undefined {
|
||||||
|
if (!interactive) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const blocks = buildSlackInteractiveBlocks(interactive);
|
||||||
|
return blocks.length > 0 ? blocks : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentity | undefined {
|
||||||
|
if (!identity) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const username = identity.name?.trim() || undefined;
|
||||||
|
const iconUrl = identity.avatarUrl?.trim() || undefined;
|
||||||
|
const rawEmoji = identity.emoji?.trim();
|
||||||
|
const iconEmoji = !iconUrl && rawEmoji && /^:[^:\s]+:$/.test(rawEmoji) ? rawEmoji : undefined;
|
||||||
|
if (!username && !iconUrl && !iconEmoji) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { username, iconUrl, iconEmoji };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySlackMessageSendingHooks(params: {
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
threadTs?: string;
|
||||||
|
accountId?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
}): Promise<{ cancelled: boolean; text: string }> {
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
|
if (!hookRunner?.hasHooks("message_sending")) {
|
||||||
|
return { cancelled: false, text: params.text };
|
||||||
|
}
|
||||||
|
const hookResult = await hookRunner.runMessageSending(
|
||||||
|
{
|
||||||
|
to: params.to,
|
||||||
|
content: params.text,
|
||||||
|
metadata: {
|
||||||
|
threadTs: params.threadTs,
|
||||||
|
channelId: params.to,
|
||||||
|
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ channelId: "slack", accountId: params.accountId ?? undefined },
|
||||||
|
);
|
||||||
|
if (hookResult?.cancel) {
|
||||||
|
return { cancelled: true, text: params.text };
|
||||||
|
}
|
||||||
|
return { cancelled: false, text: hookResult?.content ?? params.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSlackOutboundMessage(params: {
|
||||||
|
cfg: NonNullable<Parameters<typeof sendMessageSlack>[2]>["cfg"];
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mediaLocalRoots?: readonly string[];
|
||||||
|
blocks?: NonNullable<Parameters<typeof sendMessageSlack>[2]>["blocks"];
|
||||||
|
accountId?: string | null;
|
||||||
|
deps?: { [channelId: string]: unknown } | null;
|
||||||
|
replyToId?: string | null;
|
||||||
|
threadId?: string | number | null;
|
||||||
|
identity?: OutboundIdentity;
|
||||||
|
}) {
|
||||||
|
const send =
|
||||||
|
resolveOutboundSendDep<typeof sendMessageSlack>(params.deps, "slack") ?? sendMessageSlack;
|
||||||
|
const threadTs =
|
||||||
|
params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined);
|
||||||
|
const hookResult = await applySlackMessageSendingHooks({
|
||||||
|
to: params.to,
|
||||||
|
text: params.text,
|
||||||
|
threadTs,
|
||||||
|
mediaUrl: params.mediaUrl,
|
||||||
|
accountId: params.accountId ?? undefined,
|
||||||
|
});
|
||||||
|
if (hookResult.cancelled) {
|
||||||
|
return {
|
||||||
|
channel: "slack" as const,
|
||||||
|
messageId: "cancelled-by-hook",
|
||||||
|
channelId: params.to,
|
||||||
|
meta: { cancelled: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const slackIdentity = resolveSlackSendIdentity(params.identity);
|
||||||
|
const result = await send(params.to, hookResult.text, {
|
||||||
|
cfg: params.cfg,
|
||||||
|
threadTs,
|
||||||
|
accountId: params.accountId ?? undefined,
|
||||||
|
...(params.mediaUrl
|
||||||
|
? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots }
|
||||||
|
: {}),
|
||||||
|
...(params.blocks ? { blocks: params.blocks } : {}),
|
||||||
|
...(slackIdentity ? { identity: slackIdentity } : {}),
|
||||||
|
});
|
||||||
|
return { channel: "slack" as const, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlackBlocks(payload: {
|
||||||
|
channelData?: Record<string, unknown>;
|
||||||
|
interactive?: InteractiveReply;
|
||||||
|
}) {
|
||||||
|
const slackData = payload.channelData?.slack;
|
||||||
|
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
|
||||||
|
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
|
||||||
|
return renderedInteractive;
|
||||||
|
}
|
||||||
|
const existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as
|
||||||
|
| SlackBlock[]
|
||||||
|
| undefined;
|
||||||
|
const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])];
|
||||||
|
if (mergedBlocks.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (mergedBlocks.length > SLACK_MAX_BLOCKS) {
|
||||||
|
throw new Error(
|
||||||
|
`Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mergedBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const slackOutbound: ChannelOutboundAdapter = {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
chunker: null,
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
sendPayload: async (ctx) => {
|
||||||
|
const payload = {
|
||||||
|
...ctx.payload,
|
||||||
|
text:
|
||||||
|
resolveInteractiveTextFallback({
|
||||||
|
text: ctx.payload.text,
|
||||||
|
interactive: ctx.payload.interactive,
|
||||||
|
}) ?? "",
|
||||||
|
};
|
||||||
|
const blocks = resolveSlackBlocks(payload);
|
||||||
|
if (!blocks) {
|
||||||
|
return await sendTextMediaPayload({
|
||||||
|
channel: "slack",
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
adapter: slackOutbound,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const mediaUrls = resolvePayloadMediaUrls(payload);
|
||||||
|
if (mediaUrls.length === 0) {
|
||||||
|
return await sendSlackOutboundMessage({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
to: ctx.to,
|
||||||
|
text: payload.text ?? "",
|
||||||
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||||
|
blocks,
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
deps: ctx.deps,
|
||||||
|
replyToId: ctx.replyToId,
|
||||||
|
threadId: ctx.threadId,
|
||||||
|
identity: ctx.identity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await sendPayloadMediaSequence({
|
||||||
|
text: "",
|
||||||
|
mediaUrls,
|
||||||
|
send: async ({ text, mediaUrl }) =>
|
||||||
|
await sendSlackOutboundMessage({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
to: ctx.to,
|
||||||
|
text,
|
||||||
|
mediaUrl,
|
||||||
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
deps: ctx.deps,
|
||||||
|
replyToId: ctx.replyToId,
|
||||||
|
threadId: ctx.threadId,
|
||||||
|
identity: ctx.identity,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return await sendSlackOutboundMessage({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
to: ctx.to,
|
||||||
|
text: payload.text ?? "",
|
||||||
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||||
|
blocks,
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
deps: ctx.deps,
|
||||||
|
replyToId: ctx.replyToId,
|
||||||
|
threadId: ctx.threadId,
|
||||||
|
identity: ctx.identity,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => {
|
||||||
|
return await sendSlackOutboundMessage({
|
||||||
|
cfg,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
accountId,
|
||||||
|
deps,
|
||||||
|
replyToId,
|
||||||
|
threadId,
|
||||||
|
identity,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
sendMedia: async ({
|
||||||
|
cfg,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
mediaUrl,
|
||||||
|
mediaLocalRoots,
|
||||||
|
accountId,
|
||||||
|
deps,
|
||||||
|
replyToId,
|
||||||
|
threadId,
|
||||||
|
identity,
|
||||||
|
}) => {
|
||||||
|
return await sendSlackOutboundMessage({
|
||||||
|
cfg,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
mediaUrl,
|
||||||
|
mediaLocalRoots,
|
||||||
|
accountId,
|
||||||
|
deps,
|
||||||
|
replyToId,
|
||||||
|
threadId,
|
||||||
|
identity,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
210
src/plugin-sdk/allowlist-config-edit.ts
Normal file
210
src/plugin-sdk/allowlist-config-edit.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import type { ConfigWriteTarget } from "../channels/plugins/config-writes.js";
|
||||||
|
import type { ChannelAllowlistAdapter } from "../channels/plugins/types.adapters.js";
|
||||||
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
|
|
||||||
|
type AllowlistConfigPaths = {
|
||||||
|
readPaths: string[][];
|
||||||
|
writePath: string[];
|
||||||
|
cleanupPaths?: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveAccountScopedWriteTarget(
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
channelId: ChannelId,
|
||||||
|
accountId?: string | null,
|
||||||
|
) {
|
||||||
|
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
|
||||||
|
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
if (isBlockedObjectKey(normalizedAccountId)) {
|
||||||
|
return {
|
||||||
|
target: channel,
|
||||||
|
pathPrefix: `channels.${channelId}`,
|
||||||
|
writeTarget: { kind: "channel", scope: { channelId } } as const satisfies ConfigWriteTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
|
||||||
|
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
|
||||||
|
if (!useAccount) {
|
||||||
|
return {
|
||||||
|
target: channel,
|
||||||
|
pathPrefix: `channels.${channelId}`,
|
||||||
|
writeTarget: { kind: "channel", scope: { channelId } } as const satisfies ConfigWriteTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
|
||||||
|
const existingAccount = Object.hasOwn(accounts, normalizedAccountId)
|
||||||
|
? accounts[normalizedAccountId]
|
||||||
|
: undefined;
|
||||||
|
if (!existingAccount || typeof existingAccount !== "object") {
|
||||||
|
accounts[normalizedAccountId] = {};
|
||||||
|
}
|
||||||
|
const account = accounts[normalizedAccountId] as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
target: account,
|
||||||
|
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
|
||||||
|
writeTarget: {
|
||||||
|
kind: "account",
|
||||||
|
scope: { channelId, accountId: normalizedAccountId },
|
||||||
|
} as const satisfies ConfigWriteTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
|
||||||
|
let current: unknown = root;
|
||||||
|
for (const key of path) {
|
||||||
|
if (!current || typeof current !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNestedObject(
|
||||||
|
root: Record<string, unknown>,
|
||||||
|
path: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
let current = root;
|
||||||
|
for (const key of path) {
|
||||||
|
const existing = current[key];
|
||||||
|
if (!existing || typeof existing !== "object") {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
|
||||||
|
if (path.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.length === 1) {
|
||||||
|
root[path[0]] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = ensureNestedObject(root, path.slice(0, -1));
|
||||||
|
parent[path[path.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
|
||||||
|
if (path.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.length === 1) {
|
||||||
|
delete root[path[0]];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = getNestedValue(root, path.slice(0, -1));
|
||||||
|
if (!parent || typeof parent !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete (parent as Record<string, unknown>)[path[path.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccountScopedAllowlistConfigEdit(params: {
|
||||||
|
parsedConfig: Record<string, unknown>;
|
||||||
|
channelId: ChannelId;
|
||||||
|
accountId?: string | null;
|
||||||
|
action: "add" | "remove";
|
||||||
|
entry: string;
|
||||||
|
normalize: (values: Array<string | number>) => string[];
|
||||||
|
paths: AllowlistConfigPaths;
|
||||||
|
}): NonNullable<Awaited<ReturnType<NonNullable<ChannelAllowlistAdapter["applyConfigEdit"]>>>> {
|
||||||
|
const resolvedTarget = resolveAccountScopedWriteTarget(
|
||||||
|
params.parsedConfig,
|
||||||
|
params.channelId,
|
||||||
|
params.accountId,
|
||||||
|
);
|
||||||
|
const existing: string[] = [];
|
||||||
|
for (const path of params.paths.readPaths) {
|
||||||
|
const existingRaw = getNestedValue(resolvedTarget.target, path);
|
||||||
|
if (!Array.isArray(existingRaw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of existingRaw) {
|
||||||
|
const value = String(entry).trim();
|
||||||
|
if (!value || existing.includes(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEntry = params.normalize([params.entry]);
|
||||||
|
if (normalizedEntry.length === 0) {
|
||||||
|
return { kind: "invalid-entry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNormalized = params.normalize(existing);
|
||||||
|
const shouldMatch = (value: string) => normalizedEntry.includes(value);
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
let next = existing;
|
||||||
|
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
|
||||||
|
if (params.action === "add") {
|
||||||
|
if (!configHasEntry) {
|
||||||
|
next = [...existing, params.entry.trim()];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const keep: string[] = [];
|
||||||
|
for (const entry of existing) {
|
||||||
|
const normalized = params.normalize([entry]);
|
||||||
|
if (normalized.some((value) => shouldMatch(value))) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
keep.push(entry);
|
||||||
|
}
|
||||||
|
next = keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
if (next.length === 0) {
|
||||||
|
deleteNestedValue(resolvedTarget.target, params.paths.writePath);
|
||||||
|
} else {
|
||||||
|
setNestedValue(resolvedTarget.target, params.paths.writePath, next);
|
||||||
|
}
|
||||||
|
for (const path of params.paths.cleanupPaths ?? []) {
|
||||||
|
deleteNestedValue(resolvedTarget.target, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "ok",
|
||||||
|
changed,
|
||||||
|
pathLabel: `${resolvedTarget.pathPrefix}.${params.paths.writePath.join(".")}`,
|
||||||
|
writeTarget: resolvedTarget.writeTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccountScopedAllowlistConfigEditor(params: {
|
||||||
|
channelId: ChannelId;
|
||||||
|
normalize: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
values: Array<string | number>;
|
||||||
|
}) => string[];
|
||||||
|
resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null;
|
||||||
|
}): NonNullable<ChannelAllowlistAdapter["applyConfigEdit"]> {
|
||||||
|
return ({ cfg, parsedConfig, accountId, scope, action, entry }) => {
|
||||||
|
const paths = params.resolvePaths(scope);
|
||||||
|
if (!paths) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return applyAccountScopedAllowlistConfigEdit({
|
||||||
|
parsedConfig,
|
||||||
|
channelId: params.channelId,
|
||||||
|
accountId,
|
||||||
|
action,
|
||||||
|
entry,
|
||||||
|
normalize: (values) => params.normalize({ cfg, accountId, values }),
|
||||||
|
paths,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
6
test/channel-outbounds.ts
Normal file
6
test/channel-outbounds.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { discordOutbound } from "../extensions/discord/src/outbound-adapter.js";
|
||||||
|
export { imessageOutbound } from "../extensions/imessage/src/outbound-adapter.js";
|
||||||
|
export { signalOutbound } from "../extensions/signal/src/outbound-adapter.js";
|
||||||
|
export { slackOutbound } from "../extensions/slack/src/outbound-adapter.js";
|
||||||
|
export { telegramOutbound } from "../extensions/telegram/src/outbound-adapter.js";
|
||||||
|
export { whatsappOutbound } from "../extensions/whatsapp/src/outbound-adapter.js";
|
||||||
Loading…
x
Reference in New Issue
Block a user