refactor(core): extract shared dedup helpers

This commit is contained in:
Peter Steinberger
2026-03-07 10:40:49 +00:00
parent 14c61bb33f
commit 3c71e2bd48
114 changed files with 3400 additions and 2040 deletions

View 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,
},
]);
});
});

View 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,
}));
}

View File

@@ -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 {

View 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,
};
}

View 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 };
}

View File

@@ -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";

View File

@@ -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";

View 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,
},
});
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View 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);
});
});

View File

@@ -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[],

View 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");
});
});

View 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 "";
}

View 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");
});
});

View File

@@ -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"),
});
}

View File

@@ -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({

View File

@@ -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,
};
}

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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";