refactor(core): extract shared dedup helpers
This commit is contained in:
40
src/plugin-sdk/allowlist-resolution.test.ts
Normal file
40
src/plugin-sdk/allowlist-resolution.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapBasicAllowlistResolutionEntries,
|
||||
type BasicAllowlistResolutionEntry,
|
||||
} from "./allowlist-resolution.js";
|
||||
|
||||
describe("mapBasicAllowlistResolutionEntries", () => {
|
||||
it("maps entries to normalized allowlist resolver output", () => {
|
||||
const entries: BasicAllowlistResolutionEntry[] = [
|
||||
{
|
||||
input: "alice",
|
||||
resolved: true,
|
||||
id: "U123",
|
||||
name: "Alice",
|
||||
note: "ok",
|
||||
},
|
||||
{
|
||||
input: "bob",
|
||||
resolved: false,
|
||||
},
|
||||
];
|
||||
|
||||
expect(mapBasicAllowlistResolutionEntries(entries)).toEqual([
|
||||
{
|
||||
input: "alice",
|
||||
resolved: true,
|
||||
id: "U123",
|
||||
name: "Alice",
|
||||
note: "ok",
|
||||
},
|
||||
{
|
||||
input: "bob",
|
||||
resolved: false,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
note: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
19
src/plugin-sdk/allowlist-resolution.ts
Normal file
19
src/plugin-sdk/allowlist-resolution.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type BasicAllowlistResolutionEntry = {
|
||||
input: string;
|
||||
resolved: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export function mapBasicAllowlistResolutionEntries(
|
||||
entries: BasicAllowlistResolutionEntry[],
|
||||
): BasicAllowlistResolutionEntry[] {
|
||||
return entries.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
note: entry.note,
|
||||
}));
|
||||
}
|
||||
@@ -85,7 +85,11 @@ export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export { isAllowedParsedChatSender } from "./allow-from.js";
|
||||
export { readBooleanParam } from "./boolean-param.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { buildProbeChannelStatusSummary } from "./status-helpers.js";
|
||||
export { resolveRequestUrl } from "./request-url.js";
|
||||
export {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildProbeChannelStatusSummary,
|
||||
} from "./status-helpers.js";
|
||||
export { extractToolSend } from "./tool-send.js";
|
||||
export { normalizeWebhookPath } from "./webhook-path.js";
|
||||
export {
|
||||
|
||||
14
src/plugin-sdk/channel-send-result.ts
Normal file
14
src/plugin-sdk/channel-send-result.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type ChannelSendRawResult = {
|
||||
ok: boolean;
|
||||
messageId?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) {
|
||||
return {
|
||||
channel,
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
}
|
||||
33
src/plugin-sdk/discord-send.ts
Normal file
33
src/plugin-sdk/discord-send.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { DiscordSendResult } from "../discord/send.types.js";
|
||||
|
||||
type DiscordSendOptionInput = {
|
||||
replyToId?: string | null;
|
||||
accountId?: string | null;
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
type DiscordSendMediaOptionInput = DiscordSendOptionInput & {
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
};
|
||||
|
||||
export function buildDiscordSendOptions(input: DiscordSendOptionInput) {
|
||||
return {
|
||||
verbose: false,
|
||||
replyTo: input.replyToId ?? undefined,
|
||||
accountId: input.accountId ?? undefined,
|
||||
silent: input.silent ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) {
|
||||
return {
|
||||
...buildDiscordSendOptions(input),
|
||||
mediaUrl: input.mediaUrl,
|
||||
mediaLocalRoots: input.mediaLocalRoots,
|
||||
};
|
||||
}
|
||||
|
||||
export function tagDiscordChannelResult(result: DiscordSendResult) {
|
||||
return { channel: "discord" as const, ...result };
|
||||
}
|
||||
@@ -59,6 +59,8 @@ export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { createPersistentDedupe } from "./persistent-dedupe.js";
|
||||
export {
|
||||
buildBaseChannelStatusSummary,
|
||||
buildProbeChannelStatusSummary,
|
||||
buildRuntimeAccountStatusSnapshot,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "./status-helpers.js";
|
||||
export { withTempDownloadPath } from "./temp-path.js";
|
||||
|
||||
@@ -47,3 +47,4 @@ export { imessageOnboardingAdapter } from "../channels/plugins/onboarding/imessa
|
||||
export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js";
|
||||
|
||||
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
||||
export { collectStatusIssuesFromLastError } from "./status-helpers.js";
|
||||
|
||||
143
src/plugin-sdk/inbound-reply-dispatch.ts
Normal file
143
src/plugin-sdk/inbound-reply-dispatch.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { withReplyDispatcher } from "../auto-reply/dispatch.js";
|
||||
import {
|
||||
dispatchReplyFromConfig,
|
||||
type DispatchFromConfigResult,
|
||||
} from "../auto-reply/reply/dispatch-from-config.js";
|
||||
import type { ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
|
||||
import type { GetReplyOptions } from "../auto-reply/types.js";
|
||||
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
|
||||
|
||||
type ReplyOptionsWithoutModelSelected = Omit<
|
||||
Omit<GetReplyOptions, "onToolResult" | "onBlockReply">,
|
||||
"onModelSelected"
|
||||
>;
|
||||
type RecordInboundSessionFn = typeof import("../channels/session.js").recordInboundSession;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
type ReplyDispatchFromConfigOptions = Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
|
||||
|
||||
export async function dispatchReplyFromConfigWithSettledDispatcher(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctxPayload: FinalizedMsgContext;
|
||||
dispatcher: ReplyDispatcher;
|
||||
onSettled: () => void | Promise<void>;
|
||||
replyOptions?: ReplyDispatchFromConfigOptions;
|
||||
}): Promise<DispatchFromConfigResult> {
|
||||
return await withReplyDispatcher({
|
||||
dispatcher: params.dispatcher,
|
||||
onSettled: params.onSettled,
|
||||
run: () =>
|
||||
dispatchReplyFromConfig({
|
||||
ctx: params.ctxPayload,
|
||||
cfg: params.cfg,
|
||||
dispatcher: params.dispatcher,
|
||||
replyOptions: params.replyOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInboundReplyDispatchBase(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
route: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
storePath: string;
|
||||
ctxPayload: FinalizedMsgContext;
|
||||
core: {
|
||||
channel: {
|
||||
session: {
|
||||
recordInboundSession: RecordInboundSessionFn;
|
||||
};
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn;
|
||||
};
|
||||
};
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
agentId: params.route.agentId,
|
||||
routeSessionKey: params.route.sessionKey,
|
||||
storePath: params.storePath,
|
||||
ctxPayload: params.ctxPayload,
|
||||
recordInboundSession: params.core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
params.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
};
|
||||
}
|
||||
|
||||
type BuildInboundReplyDispatchBaseParams = Parameters<typeof buildInboundReplyDispatchBase>[0];
|
||||
type RecordInboundSessionAndDispatchReplyParams = Parameters<
|
||||
typeof recordInboundSessionAndDispatchReply
|
||||
>[0];
|
||||
|
||||
export async function dispatchInboundReplyWithBase(
|
||||
params: BuildInboundReplyDispatchBaseParams &
|
||||
Pick<
|
||||
RecordInboundSessionAndDispatchReplyParams,
|
||||
"deliver" | "onRecordError" | "onDispatchError" | "replyOptions"
|
||||
>,
|
||||
): Promise<void> {
|
||||
const dispatchBase = buildInboundReplyDispatchBase(params);
|
||||
await recordInboundSessionAndDispatchReply({
|
||||
...dispatchBase,
|
||||
deliver: params.deliver,
|
||||
onRecordError: params.onRecordError,
|
||||
onDispatchError: params.onDispatchError,
|
||||
replyOptions: params.replyOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordInboundSessionAndDispatchReply(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
agentId: string;
|
||||
routeSessionKey: string;
|
||||
storePath: string;
|
||||
ctxPayload: FinalizedMsgContext;
|
||||
recordInboundSession: RecordInboundSessionFn;
|
||||
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn;
|
||||
deliver: (payload: OutboundReplyPayload) => Promise<void>;
|
||||
onRecordError: (err: unknown) => void;
|
||||
onDispatchError: (err: unknown, info: { kind: string }) => void;
|
||||
replyOptions?: ReplyOptionsWithoutModelSelected;
|
||||
}): Promise<void> {
|
||||
await params.recordInboundSession({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
|
||||
ctx: params.ctxPayload,
|
||||
onRecordError: params.onRecordError,
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const deliver = createNormalizedOutboundDeliverer(params.deliver);
|
||||
|
||||
await params.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: params.ctxPayload,
|
||||
cfg: params.cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver,
|
||||
onError: params.onDispatchError,
|
||||
},
|
||||
replyOptions: {
|
||||
...params.replyOptions,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -132,6 +132,16 @@ export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matchin
|
||||
|
||||
export type { FileLockHandle, FileLockOptions } from "./file-lock.js";
|
||||
export { acquireFileLock, withFileLock } from "./file-lock.js";
|
||||
export {
|
||||
mapBasicAllowlistResolutionEntries,
|
||||
type BasicAllowlistResolutionEntry,
|
||||
} from "./allowlist-resolution.js";
|
||||
export { resolveRequestUrl } from "./request-url.js";
|
||||
export {
|
||||
buildDiscordSendMediaOptions,
|
||||
buildDiscordSendOptions,
|
||||
tagDiscordChannelResult,
|
||||
} from "./discord-send.js";
|
||||
export type { KeyedAsyncQueueHooks } from "./keyed-async-queue.js";
|
||||
export { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js";
|
||||
export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
|
||||
@@ -167,7 +177,9 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js";
|
||||
export {
|
||||
buildBaseAccountStatusSnapshot,
|
||||
buildBaseChannelStatusSummary,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildProbeChannelStatusSummary,
|
||||
buildRuntimeAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
@@ -178,6 +190,8 @@ export {
|
||||
} from "../channels/plugins/onboarding/helpers.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { buildChannelSendResult } from "./channel-send-result.js";
|
||||
export type { ChannelSendRawResult } from "./channel-send-result.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export { getChatChannelMeta } from "../channels/registry.js";
|
||||
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
|
||||
@@ -278,6 +292,7 @@ export {
|
||||
resolveInboundRouteEnvelopeBuilder,
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
} from "./inbound-envelope.js";
|
||||
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||
export {
|
||||
listConfiguredAccountIds,
|
||||
resolveAccountWithDefaultFallback,
|
||||
@@ -288,17 +303,29 @@ export { extractToolSend } from "./tool-send.js";
|
||||
export {
|
||||
createNormalizedOutboundDeliverer,
|
||||
formatTextWithAttachmentLinks,
|
||||
isNumericTargetId,
|
||||
normalizeOutboundReplyPayload,
|
||||
resolveOutboundMediaUrls,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
sendMediaWithLeadingCaption,
|
||||
} from "./reply-payload.js";
|
||||
export type { OutboundReplyPayload } from "./reply-payload.js";
|
||||
export {
|
||||
buildInboundReplyDispatchBase,
|
||||
dispatchInboundReplyWithBase,
|
||||
dispatchReplyFromConfigWithSettledDispatcher,
|
||||
recordInboundSessionAndDispatchReply,
|
||||
} from "./inbound-reply-dispatch.js";
|
||||
export type { OutboundMediaLoadOptions } from "./outbound-media.js";
|
||||
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
||||
export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
|
||||
export { buildMediaPayload } from "../channels/plugins/media-payload.js";
|
||||
export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js";
|
||||
export { createLoggerBackedRuntime } from "./runtime.js";
|
||||
export {
|
||||
createLoggerBackedRuntime,
|
||||
resolveRuntimeEnv,
|
||||
resolveRuntimeEnvWithUnavailableExit,
|
||||
} from "./runtime.js";
|
||||
export { chunkTextForOutbound } from "./text-chunking.js";
|
||||
export { readBooleanParam } from "./boolean-param.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||
@@ -487,6 +514,7 @@ export type { PollInput } from "../polls.js";
|
||||
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export {
|
||||
clearAccountEntryFields,
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../channels/plugins/config-helpers.js";
|
||||
@@ -589,12 +617,18 @@ export {
|
||||
normalizeIMessageMessagingTarget,
|
||||
} from "../channels/plugins/normalize/imessage.js";
|
||||
export {
|
||||
createAllowedChatSenderMatcher,
|
||||
parseChatAllowTargetPrefixes,
|
||||
parseChatTargetPrefixesOrThrow,
|
||||
resolveServicePrefixedChatTarget,
|
||||
resolveServicePrefixedAllowTarget,
|
||||
resolveServicePrefixedOrChatAllowTarget,
|
||||
resolveServicePrefixedTarget,
|
||||
} from "../imessage/target-parsing-helpers.js";
|
||||
export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js";
|
||||
export type {
|
||||
ChatSenderAllowParams,
|
||||
ParsedChatTarget,
|
||||
} from "../imessage/target-parsing-helpers.js";
|
||||
|
||||
// Channel: Slack
|
||||
export {
|
||||
|
||||
@@ -60,6 +60,7 @@ export {
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js";
|
||||
export type { OutboundReplyPayload } from "./reply-payload.js";
|
||||
export {
|
||||
createNormalizedOutboundDeliverer,
|
||||
|
||||
@@ -14,13 +14,17 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js";
|
||||
|
||||
export {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
} from "../config/runtime-group-policy.js";
|
||||
|
||||
export { buildTokenChannelStatusSummary } from "./status-helpers.js";
|
||||
export {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
} from "./status-helpers.js";
|
||||
|
||||
export { LineConfigSchema } from "../line/config-schema.js";
|
||||
export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js";
|
||||
|
||||
@@ -92,5 +92,10 @@ export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { runPluginCommandWithTimeout } from "./run-command.js";
|
||||
export { createLoggerBackedRuntime } from "./runtime.js";
|
||||
export { buildProbeChannelStatusSummary } from "./status-helpers.js";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||
export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js";
|
||||
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||
export {
|
||||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
} from "./status-helpers.js";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Keep this list additive and scoped to symbols used under extensions/minimax-portal-auth.
|
||||
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
ProviderAuthContext,
|
||||
|
||||
@@ -94,9 +94,11 @@ export { loadWebMedia } from "../web/media.js";
|
||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export { keepHttpServerTaskAlive } from "./channel-lifecycle.js";
|
||||
export { withFileLock } from "./file-lock.js";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||
export {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
||||
@@ -104,5 +106,7 @@ export {
|
||||
} from "./ssrf-policy.js";
|
||||
export {
|
||||
buildBaseChannelStatusSummary,
|
||||
buildProbeChannelStatusSummary,
|
||||
buildRuntimeAccountStatusSnapshot,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "./status-helpers.js";
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
} from "../channels/plugins/channel-config.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
clearAccountEntryFields,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../channels/plugins/config-helpers.js";
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
@@ -89,4 +90,9 @@ export {
|
||||
formatTextWithAttachmentLinks,
|
||||
resolveOutboundMediaUrls,
|
||||
} from "./reply-payload.js";
|
||||
export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js";
|
||||
export { createLoggerBackedRuntime } from "./runtime.js";
|
||||
export {
|
||||
buildBaseChannelStatusSummary,
|
||||
buildRuntimeAccountStatusSnapshot,
|
||||
} from "./status-helpers.js";
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
// Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth.
|
||||
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js";
|
||||
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js";
|
||||
|
||||
58
src/plugin-sdk/reply-payload.test.ts
Normal file
58
src/plugin-sdk/reply-payload.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js";
|
||||
|
||||
describe("sendPayloadWithChunkedTextAndMedia", () => {
|
||||
it("returns empty result when payload has no text and no media", async () => {
|
||||
const result = await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx: { payload: {} },
|
||||
sendText: async () => ({ channel: "test", messageId: "text" }),
|
||||
sendMedia: async () => ({ channel: "test", messageId: "media" }),
|
||||
emptyResult: { channel: "test", messageId: "" },
|
||||
});
|
||||
expect(result).toEqual({ channel: "test", messageId: "" });
|
||||
});
|
||||
|
||||
it("sends first media with text and remaining media without text", async () => {
|
||||
const calls: Array<{ text: string; mediaUrl: string }> = [];
|
||||
const result = await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx: {
|
||||
payload: { text: "hello", mediaUrls: ["https://a", "https://b"] },
|
||||
},
|
||||
sendText: async () => ({ channel: "test", messageId: "text" }),
|
||||
sendMedia: async (ctx) => {
|
||||
calls.push({ text: ctx.text, mediaUrl: ctx.mediaUrl });
|
||||
return { channel: "test", messageId: ctx.mediaUrl };
|
||||
},
|
||||
emptyResult: { channel: "test", messageId: "" },
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{ text: "hello", mediaUrl: "https://a" },
|
||||
{ text: "", mediaUrl: "https://b" },
|
||||
]);
|
||||
expect(result).toEqual({ channel: "test", messageId: "https://b" });
|
||||
});
|
||||
|
||||
it("chunks text and sends each chunk", async () => {
|
||||
const chunks: string[] = [];
|
||||
const result = await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx: { payload: { text: "alpha beta gamma" } },
|
||||
textChunkLimit: 5,
|
||||
chunker: () => ["alpha", "beta", "gamma"],
|
||||
sendText: async (ctx) => {
|
||||
chunks.push(ctx.text);
|
||||
return { channel: "test", messageId: ctx.text };
|
||||
},
|
||||
sendMedia: async () => ({ channel: "test", messageId: "media" }),
|
||||
emptyResult: { channel: "test", messageId: "" },
|
||||
});
|
||||
expect(chunks).toEqual(["alpha", "beta", "gamma"]);
|
||||
expect(result).toEqual({ channel: "test", messageId: "gamma" });
|
||||
});
|
||||
|
||||
it("detects numeric target IDs", () => {
|
||||
expect(isNumericTargetId("12345")).toBe(true);
|
||||
expect(isNumericTargetId(" 987 ")).toBe(true);
|
||||
expect(isNumericTargetId("ab12")).toBe(false);
|
||||
expect(isNumericTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,55 @@ export function resolveOutboundMediaUrls(payload: {
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function sendPayloadWithChunkedTextAndMedia<
|
||||
TContext extends { payload: object },
|
||||
TResult,
|
||||
>(params: {
|
||||
ctx: TContext;
|
||||
textChunkLimit?: number;
|
||||
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||
sendText: (ctx: TContext & { text: string }) => Promise<TResult>;
|
||||
sendMedia: (ctx: TContext & { text: string; mediaUrl: string }) => Promise<TResult>;
|
||||
emptyResult: TResult;
|
||||
}): Promise<TResult> {
|
||||
const payload = params.ctx.payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||
const text = payload.text ?? "";
|
||||
const urls = resolveOutboundMediaUrls(payload);
|
||||
if (!text && urls.length === 0) {
|
||||
return params.emptyResult;
|
||||
}
|
||||
if (urls.length > 0) {
|
||||
let lastResult = await params.sendMedia({
|
||||
...params.ctx,
|
||||
text,
|
||||
mediaUrl: urls[0],
|
||||
});
|
||||
for (let i = 1; i < urls.length; i++) {
|
||||
lastResult = await params.sendMedia({
|
||||
...params.ctx,
|
||||
text: "",
|
||||
mediaUrl: urls[i],
|
||||
});
|
||||
}
|
||||
return lastResult;
|
||||
}
|
||||
const limit = params.textChunkLimit;
|
||||
const chunks = limit && params.chunker ? params.chunker(text, limit) : [text];
|
||||
let lastResult: TResult;
|
||||
for (const chunk of chunks) {
|
||||
lastResult = await params.sendText({ ...params.ctx, text: chunk });
|
||||
}
|
||||
return lastResult!;
|
||||
}
|
||||
|
||||
export function isNumericTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
}
|
||||
|
||||
export function formatTextWithAttachmentLinks(
|
||||
text: string | undefined,
|
||||
mediaUrls: string[],
|
||||
|
||||
17
src/plugin-sdk/request-url.test.ts
Normal file
17
src/plugin-sdk/request-url.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRequestUrl } from "./request-url.js";
|
||||
|
||||
describe("resolveRequestUrl", () => {
|
||||
it("resolves string input", () => {
|
||||
expect(resolveRequestUrl("https://example.com/a")).toBe("https://example.com/a");
|
||||
});
|
||||
|
||||
it("resolves URL input", () => {
|
||||
expect(resolveRequestUrl(new URL("https://example.com/b"))).toBe("https://example.com/b");
|
||||
});
|
||||
|
||||
it("resolves object input with url field", () => {
|
||||
const requestLike = { url: "https://example.com/c" } as unknown as RequestInfo;
|
||||
expect(resolveRequestUrl(requestLike)).toBe("https://example.com/c");
|
||||
});
|
||||
});
|
||||
12
src/plugin-sdk/request-url.ts
Normal file
12
src/plugin-sdk/request-url.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function resolveRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
|
||||
return input.url;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
39
src/plugin-sdk/runtime.test.ts
Normal file
39
src/plugin-sdk/runtime.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveRuntimeEnv } from "./runtime.js";
|
||||
|
||||
describe("resolveRuntimeEnv", () => {
|
||||
it("returns provided runtime when present", () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const resolved = resolveRuntimeEnv({ runtime, logger });
|
||||
|
||||
expect(resolved).toBe(runtime);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates logger-backed runtime when runtime is missing", () => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const resolved = resolveRuntimeEnv({ logger });
|
||||
resolved.log?.("hello %s", "world");
|
||||
resolved.error?.("bad %d", 7);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("hello world");
|
||||
expect(logger.error).toHaveBeenCalledWith("bad 7");
|
||||
});
|
||||
});
|
||||
@@ -22,3 +22,23 @@ export function createLoggerBackedRuntime(params: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeEnv(params: {
|
||||
runtime?: RuntimeEnv;
|
||||
logger: LoggerLike;
|
||||
exitError?: (code: number) => Error;
|
||||
}): RuntimeEnv {
|
||||
return params.runtime ?? createLoggerBackedRuntime(params);
|
||||
}
|
||||
|
||||
export function resolveRuntimeEnvWithUnavailableExit(params: {
|
||||
runtime?: RuntimeEnv;
|
||||
logger: LoggerLike;
|
||||
unavailableMessage?: string;
|
||||
}): RuntimeEnv {
|
||||
return resolveRuntimeEnv({
|
||||
runtime: params.runtime,
|
||||
logger: params.logger,
|
||||
exitError: () => new Error(params.unavailableMessage ?? "Runtime exit not available"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBaseAccountStatusSnapshot,
|
||||
buildBaseChannelStatusSummary,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildRuntimeAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
@@ -88,6 +90,42 @@ describe("buildBaseAccountStatusSnapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComputedAccountStatusSnapshot", () => {
|
||||
it("builds account status when configured is computed outside resolver", () => {
|
||||
expect(
|
||||
buildComputedAccountStatusSnapshot({
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
}),
|
||||
).toEqual({
|
||||
accountId: "default",
|
||||
name: undefined,
|
||||
enabled: true,
|
||||
configured: false,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRuntimeAccountStatusSnapshot", () => {
|
||||
it("builds runtime lifecycle fields with defaults", () => {
|
||||
expect(buildRuntimeAccountStatusSnapshot({})).toEqual({
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
probe: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTokenChannelStatusSummary", () => {
|
||||
it("includes token/probe fields with mode by default", () => {
|
||||
expect(buildTokenChannelStatusSummary({})).toEqual({
|
||||
|
||||
@@ -81,13 +81,44 @@ export function buildBaseAccountStatusSnapshot(params: {
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
...buildRuntimeAccountStatusSnapshot({ runtime, probe }),
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildComputedAccountStatusSnapshot(params: {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
runtime?: RuntimeLifecycleSnapshot | null;
|
||||
probe?: unknown;
|
||||
}) {
|
||||
const { accountId, name, enabled, configured, runtime, probe } = params;
|
||||
return buildBaseAccountStatusSnapshot({
|
||||
account: {
|
||||
accountId,
|
||||
name,
|
||||
enabled,
|
||||
configured,
|
||||
},
|
||||
runtime,
|
||||
probe,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRuntimeAccountStatusSnapshot(params: {
|
||||
runtime?: RuntimeLifecycleSnapshot | null;
|
||||
probe?: unknown;
|
||||
}) {
|
||||
const { runtime, probe } = params;
|
||||
return {
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export {
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
clearAccountEntryFields,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../channels/plugins/config-helpers.js";
|
||||
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
||||
|
||||
@@ -66,9 +66,18 @@ export { evaluateSenderGroupAccess } from "./group-access.js";
|
||||
export type { SenderGroupAccessDecision } from "./group-access.js";
|
||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { buildChannelSendResult } from "./channel-send-result.js";
|
||||
export type { OutboundReplyPayload } from "./reply-payload.js";
|
||||
export { resolveOutboundMediaUrls, sendMediaWithLeadingCaption } from "./reply-payload.js";
|
||||
export { buildTokenChannelStatusSummary } from "./status-helpers.js";
|
||||
export {
|
||||
isNumericTargetId,
|
||||
resolveOutboundMediaUrls,
|
||||
sendMediaWithLeadingCaption,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "./reply-payload.js";
|
||||
export {
|
||||
buildBaseAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
} from "./status-helpers.js";
|
||||
export { chunkTextForOutbound } from "./text-chunking.js";
|
||||
export { extractToolSend } from "./tool-send.js";
|
||||
export {
|
||||
|
||||
@@ -57,7 +57,14 @@ export { resolveSenderCommandAuthorization } from "./command-auth.js";
|
||||
export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
|
||||
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||
export { buildChannelSendResult } from "./channel-send-result.js";
|
||||
export type { OutboundReplyPayload } from "./reply-payload.js";
|
||||
export { resolveOutboundMediaUrls, sendMediaWithLeadingCaption } from "./reply-payload.js";
|
||||
export {
|
||||
isNumericTargetId,
|
||||
resolveOutboundMediaUrls,
|
||||
sendMediaWithLeadingCaption,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "./reply-payload.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { buildBaseAccountStatusSnapshot } from "./status-helpers.js";
|
||||
export { chunkTextForOutbound } from "./text-chunking.js";
|
||||
|
||||
Reference in New Issue
Block a user