mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 19:32:27 +00:00
refactor: share webhook channel status helpers
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
@@ -58,12 +58,11 @@ export const bluebubblesConfigAdapter =
|
||||
});
|
||||
|
||||
export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) {
|
||||
return describeAccountSnapshot({
|
||||
return describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
baseUrl: account.baseUrl,
|
||||
mode: "webhook",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import type { ChannelPlugin } from "../api.js";
|
||||
import {
|
||||
resolveLineAccount,
|
||||
@@ -37,14 +38,14 @@ export const lineChannelPluginCommon = {
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account: ResolvedLineAccount) => hasLineCredentials(account),
|
||||
describeAccount: (account: ResolvedLineAccount) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: hasLineCredentials(account),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
mode: "webhook",
|
||||
}),
|
||||
describeAccount: (account: ResolvedLineAccount) =>
|
||||
describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: hasLineCredentials(account),
|
||||
extra: {
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
},
|
||||
}),
|
||||
},
|
||||
} satisfies Pick<
|
||||
ChannelPlugin<ResolvedLineAccount>,
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createAttachedChannelResultAdapter,
|
||||
createEmptyChannelResult,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
buildTokenChannelStatusSummary,
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
type LineConfig,
|
||||
type LineChannelData,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineGatewayAdapter } from "./gateway.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { probeLineBot } from "./probe.js";
|
||||
import { lineOutboundAdapter } from "./outbound.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
import { lineStatusAdapter } from "./status.js";
|
||||
|
||||
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
@@ -77,152 +59,8 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
},
|
||||
directory: createEmptyChannelDirectoryAdapter(),
|
||||
setup: lineSetupAdapter,
|
||||
status: createComputedAccountStatusAdapter<ResolvedLineAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts) => {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const account of accounts) {
|
||||
const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
if (account.configured === false) {
|
||||
const hasToken = account.tokenSource != null && account.tokenSource !== "none";
|
||||
issues.push({
|
||||
channel: "line",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: hasToken
|
||||
? "LINE channel secret not configured"
|
||||
: "LINE channel access token not configured",
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
resolveAccountSnapshot: ({ account }) => {
|
||||
const configured = Boolean(
|
||||
account.channelAccessToken?.trim() && account.channelSecret?.trim(),
|
||||
);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
mode: "webhook",
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.channelAccessToken.trim();
|
||||
const secret = account.channelSecret.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
let lineBotLabel = "";
|
||||
try {
|
||||
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||
if (displayName) {
|
||||
lineBotLabel = ` (${displayName})`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||
|
||||
return await getLineRuntime().channel.line.monitorLineProvider({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
const nextLine = { ...lineConfig };
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (
|
||||
nextLine.channelAccessToken ||
|
||||
nextLine.channelSecret ||
|
||||
nextLine.tokenFile ||
|
||||
nextLine.secretFile
|
||||
) {
|
||||
delete nextLine.channelAccessToken;
|
||||
delete nextLine.channelSecret;
|
||||
delete nextLine.tokenFile;
|
||||
delete nextLine.secretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accountCleanup = clearAccountEntryFields({
|
||||
accounts: nextLine.accounts,
|
||||
accountId,
|
||||
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
|
||||
markClearedOnFieldPresence: true,
|
||||
});
|
||||
if (accountCleanup.changed) {
|
||||
changed = true;
|
||||
if (accountCleanup.cleared) {
|
||||
cleared = true;
|
||||
}
|
||||
if (accountCleanup.nextAccounts) {
|
||||
nextLine.accounts = accountCleanup.nextAccounts;
|
||||
} else {
|
||||
delete nextLine.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (Object.keys(nextLine).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).line;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels;
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||
cfg: changed ? nextCfg : cfg,
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
},
|
||||
status: lineStatusAdapter,
|
||||
gateway: lineGatewayAdapter,
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
@@ -293,227 +131,5 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
},
|
||||
},
|
||||
security: lineSecurityAdapter,
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
|
||||
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||
|
||||
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||
const quickReplies = lineData.quickReplies ?? [];
|
||||
const hasQuickReplies = quickReplies.length > 0;
|
||||
const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined;
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
// LINE SDK expects Message[] but we build dynamically
|
||||
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
|
||||
const result = await sendBatch(to, batch, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||
}
|
||||
};
|
||||
|
||||
const processed = payload.text
|
||||
? processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
const chunkLimit =
|
||||
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
|
||||
fallbackLimit: 5000,
|
||||
}) ?? 5000;
|
||||
|
||||
const chunks = processed.text
|
||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||
: [];
|
||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
const sendMediaMessages = async () => {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
// LINE SDK expects FlexContainer but we receive contents as unknown
|
||||
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
lastResult = await sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
lastResult = await sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
// LINE SDK expects FlexContainer but we receive contents as unknown
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
if (isLast && hasQuickReplies) {
|
||||
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
lastResult = await sendText(to, chunks[i], {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (shouldSendQuickRepliesInline) {
|
||||
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||
if (lineData.flexMessage) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||
contents: lineData.flexMessage.contents,
|
||||
});
|
||||
}
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
quickReplyMessages.push(template);
|
||||
}
|
||||
}
|
||||
if (lineData.location) {
|
||||
quickReplyMessages.push({
|
||||
type: "location",
|
||||
title: lineData.location.title.slice(0, 100),
|
||||
address: lineData.location.address.slice(0, 100),
|
||||
latitude: lineData.location.latitude,
|
||||
longitude: lineData.location.longitude,
|
||||
});
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: flexMsg.altText.slice(0, 400),
|
||||
contents: flexMsg.contents,
|
||||
});
|
||||
}
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
quickReplyMessages.push({
|
||||
type: "image",
|
||||
originalContentUrl: trimmed,
|
||||
previewImageUrl: trimmed,
|
||||
});
|
||||
}
|
||||
if (quickReplyMessages.length > 0 && quickReply) {
|
||||
const lastIndex = quickReplyMessages.length - 1;
|
||||
quickReplyMessages[lastIndex] = {
|
||||
...quickReplyMessages[lastIndex],
|
||||
quickReply,
|
||||
};
|
||||
await sendMessageBatch(quickReplyMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (lastResult) {
|
||||
return createEmptyChannelResult("line", { ...lastResult });
|
||||
}
|
||||
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "line",
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const processed = processLineMessage(text);
|
||||
let result: { messageId: string; chatId: string };
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
result = { messageId: "processed", chatId: to };
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
|
||||
await getLineRuntime().channel.line.sendMessageLine(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
outbound: lineOutboundAdapter,
|
||||
});
|
||||
|
||||
117
extensions/line/src/gateway.ts
Normal file
117
extensions/line/src/gateway.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type ChannelPlugin,
|
||||
type LineConfig,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
export const lineGatewayAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["gateway"]> = {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.channelAccessToken.trim();
|
||||
const secret = account.channelSecret.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
let lineBotLabel = "";
|
||||
try {
|
||||
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||
if (displayName) {
|
||||
lineBotLabel = ` (${displayName})`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||
|
||||
return await getLineRuntime().channel.line.monitorLineProvider({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
const nextLine = { ...lineConfig };
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (
|
||||
nextLine.channelAccessToken ||
|
||||
nextLine.channelSecret ||
|
||||
nextLine.tokenFile ||
|
||||
nextLine.secretFile
|
||||
) {
|
||||
delete nextLine.channelAccessToken;
|
||||
delete nextLine.channelSecret;
|
||||
delete nextLine.tokenFile;
|
||||
delete nextLine.secretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accountCleanup = clearAccountEntryFields({
|
||||
accounts: nextLine.accounts,
|
||||
accountId,
|
||||
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
|
||||
markClearedOnFieldPresence: true,
|
||||
});
|
||||
if (accountCleanup.changed) {
|
||||
changed = true;
|
||||
if (accountCleanup.cleared) {
|
||||
cleared = true;
|
||||
}
|
||||
if (accountCleanup.nextAccounts) {
|
||||
nextLine.accounts = accountCleanup.nextAccounts;
|
||||
} else {
|
||||
delete nextLine.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (Object.keys(nextLine).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).line;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels;
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||
cfg: changed ? nextCfg : cfg,
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
};
|
||||
233
extensions/line/src/outbound.ts
Normal file
233
extensions/line/src/outbound.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
createAttachedChannelResultAdapter,
|
||||
createEmptyChannelResult,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type LineChannelData,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 5000,
|
||||
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||
|
||||
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||
const quickReplies = lineData.quickReplies ?? [];
|
||||
const hasQuickReplies = quickReplies.length > 0;
|
||||
const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined;
|
||||
|
||||
// LINE SDK expects Message[] but we build dynamically.
|
||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
|
||||
const result = await sendBatch(to, batch, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||
}
|
||||
};
|
||||
|
||||
const processed = payload.text
|
||||
? processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
const chunkLimit =
|
||||
runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, {
|
||||
fallbackLimit: 5000,
|
||||
}) ?? 5000;
|
||||
|
||||
const chunks = processed.text
|
||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||
: [];
|
||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
const sendMediaMessages = async () => {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
lastResult = await sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
lastResult = await sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
if (isLast && hasQuickReplies) {
|
||||
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
lastResult = await sendText(to, chunks[i], {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (shouldSendQuickRepliesInline) {
|
||||
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||
if (lineData.flexMessage) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||
contents: lineData.flexMessage.contents,
|
||||
});
|
||||
}
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
quickReplyMessages.push(template);
|
||||
}
|
||||
}
|
||||
if (lineData.location) {
|
||||
quickReplyMessages.push({
|
||||
type: "location",
|
||||
title: lineData.location.title.slice(0, 100),
|
||||
address: lineData.location.address.slice(0, 100),
|
||||
latitude: lineData.location.latitude,
|
||||
longitude: lineData.location.longitude,
|
||||
});
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: flexMsg.altText.slice(0, 400),
|
||||
contents: flexMsg.contents,
|
||||
});
|
||||
}
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
quickReplyMessages.push({
|
||||
type: "image",
|
||||
originalContentUrl: trimmed,
|
||||
previewImageUrl: trimmed,
|
||||
});
|
||||
}
|
||||
if (quickReplyMessages.length > 0 && quickReply) {
|
||||
const lastIndex = quickReplyMessages.length - 1;
|
||||
quickReplyMessages[lastIndex] = {
|
||||
...quickReplyMessages[lastIndex],
|
||||
quickReply,
|
||||
};
|
||||
await sendMessageBatch(quickReplyMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (lastResult) {
|
||||
return createEmptyChannelResult("line", { ...lastResult });
|
||||
}
|
||||
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "line",
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const processed = processLineMessage(text);
|
||||
let result: { messageId: string; chatId: string };
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
result = { messageId: "processed", chatId: to };
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) =>
|
||||
await getLineRuntime().channel.line.sendMessageLine(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
35
extensions/line/src/status.ts
Normal file
35
extensions/line/src/status.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
buildTokenChannelStatusSummary,
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
createDependentCredentialStatusIssueCollector,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID, type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
|
||||
import { hasLineCredentials } from "./account-helpers.js";
|
||||
import { probeLineBot } from "./probe.js";
|
||||
|
||||
const collectLineStatusIssues = createDependentCredentialStatusIssueCollector({
|
||||
channel: "line",
|
||||
dependencySourceKey: "tokenSource",
|
||||
missingPrimaryMessage: "LINE channel access token not configured",
|
||||
missingDependentMessage: "LINE channel secret not configured",
|
||||
});
|
||||
|
||||
export const lineStatusAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["status"]> =
|
||||
createComputedAccountStatusAdapter<ResolvedLineAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: collectLineStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
resolveAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: hasLineCredentials(account),
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
mode: "webhook",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
@@ -14,11 +14,11 @@ import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
buildWebhookChannelStatusSummary,
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
buildBaseChannelStatusSummary,
|
||||
buildChannelConfigSchema,
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
@@ -130,7 +130,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
...nextcloudTalkConfigAdapter,
|
||||
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
||||
extra: {
|
||||
@@ -173,9 +173,8 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
status: createComputedAccountStatusAdapter<ResolvedNextcloudTalkAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildBaseChannelStatusSummary(snapshot, {
|
||||
buildWebhookChannelStatusSummary(snapshot, {
|
||||
secretSource: snapshot.secretSource ?? "none",
|
||||
mode: "webhook",
|
||||
}),
|
||||
resolveAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
@@ -172,12 +172,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
|
||||
...zaloConfigAdapter,
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account): ChannelAccountSnapshot =>
|
||||
describeAccountSnapshot({
|
||||
describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: Boolean(account.token?.trim()),
|
||||
mode: account.config.webhookUrl ? "webhook" : "polling",
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
mode: account.config.webhookUrl ? "webhook" : "polling",
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
describeAccountSnapshot,
|
||||
describeWebhookAccountSnapshot,
|
||||
listCombinedAccountIds,
|
||||
mergeAccountConfig,
|
||||
resolveListedDefaultAccountId,
|
||||
@@ -276,6 +277,47 @@ describe("describeAccountSnapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeWebhookAccountSnapshot", () => {
|
||||
it("defaults mode to webhook while preserving caller extras", () => {
|
||||
expect(
|
||||
describeWebhookAccountSnapshot({
|
||||
account: {
|
||||
accountId: "work",
|
||||
name: "Work",
|
||||
},
|
||||
configured: true,
|
||||
extra: {
|
||||
tokenSource: "config",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
accountId: "work",
|
||||
name: "Work",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
mode: "webhook",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows callers to override the mode when the transport is not always webhook", () => {
|
||||
expect(
|
||||
describeWebhookAccountSnapshot({
|
||||
account: {
|
||||
accountId: "work",
|
||||
},
|
||||
mode: "polling",
|
||||
}),
|
||||
).toEqual({
|
||||
accountId: "work",
|
||||
name: undefined,
|
||||
enabled: true,
|
||||
configured: undefined,
|
||||
mode: "polling",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeAccountConfig", () => {
|
||||
type MergeAccountConfigShape = {
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -197,3 +197,25 @@ export function describeAccountSnapshot<
|
||||
...params.extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeWebhookAccountSnapshot<
|
||||
TAccount extends {
|
||||
accountId?: string | null;
|
||||
enabled?: boolean | null;
|
||||
name?: string | null | undefined;
|
||||
},
|
||||
>(params: {
|
||||
account: TAccount;
|
||||
configured?: boolean | undefined;
|
||||
mode?: string | undefined;
|
||||
extra?: Record<string, unknown> | undefined;
|
||||
}): ChannelAccountSnapshot {
|
||||
return describeAccountSnapshot({
|
||||
account: params.account,
|
||||
configured: params.configured,
|
||||
extra: {
|
||||
mode: params.mode ?? "webhook",
|
||||
...params.extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,13 +201,13 @@ describe("server-channels auto restart", () => {
|
||||
expect(account?.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards described mode into runtime snapshots", () => {
|
||||
it("applies described config fields into runtime snapshots", () => {
|
||||
installTestRegistry(
|
||||
createTestPlugin({
|
||||
describeAccount: (resolved) => ({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
enabled: resolved.enabled !== false,
|
||||
configured: resolved.configured !== false,
|
||||
configured: false,
|
||||
mode: "webhook",
|
||||
}),
|
||||
}),
|
||||
@@ -215,6 +215,7 @@ describe("server-channels auto restart", () => {
|
||||
const manager = createManager();
|
||||
const snapshot = manager.getRuntimeSnapshot();
|
||||
const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
||||
expect(account?.configured).toBe(false);
|
||||
expect(account?.mode).toBe("webhook");
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,24 @@ function cloneDefaultRuntime(channelId: ChannelId, accountId: string): ChannelAc
|
||||
return { ...resolveDefaultRuntime(channelId), accountId };
|
||||
}
|
||||
|
||||
function applyDescribedAccountFields(
|
||||
next: ChannelAccountSnapshot,
|
||||
described: ChannelAccountSnapshot | undefined,
|
||||
) {
|
||||
if (!described) {
|
||||
return next;
|
||||
}
|
||||
if (typeof described.configured === "boolean") {
|
||||
next.configured = described.configured;
|
||||
} else {
|
||||
next.configured ??= true;
|
||||
}
|
||||
if (described.mode !== undefined) {
|
||||
next.mode = described.mode;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
type ChannelManagerOptions = {
|
||||
loadConfig: () => OpenClawConfig;
|
||||
channelLogs: Record<ChannelId, SubsystemLogger>;
|
||||
@@ -550,14 +568,11 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
: isAccountEnabled(account);
|
||||
const described = plugin.config.describeAccount?.(account, cfg);
|
||||
const configured = described?.configured;
|
||||
const current = store.runtimes.get(id) ?? cloneDefaultRuntime(plugin.id, id);
|
||||
const next = { ...current, accountId: id };
|
||||
next.enabled = enabled;
|
||||
next.configured = typeof configured === "boolean" ? configured : (next.configured ?? true);
|
||||
if (described?.mode !== undefined) {
|
||||
next.mode = described.mode;
|
||||
}
|
||||
applyDescribedAccountFields(next, described);
|
||||
const configured = described?.configured;
|
||||
if (!next.running) {
|
||||
if (!enabled) {
|
||||
next.lastError ??= plugin.config.disabledReason?.(account, cfg) ?? "disabled";
|
||||
|
||||
@@ -48,7 +48,7 @@ function listExtensionFiles(): {
|
||||
continue;
|
||||
}
|
||||
const source = readFileSync(channelPath, "utf8");
|
||||
if (source.includes("outbound:")) {
|
||||
if (/\boutbound\s*:\s*\{/.test(source)) {
|
||||
inlineChannelEntrypoints.push(toPosix(path.join("extensions", entry.name, "src/channel.ts")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
createAccountListHelpers,
|
||||
describeAccountSnapshot,
|
||||
describeWebhookAccountSnapshot,
|
||||
mergeAccountConfig,
|
||||
resolveMergedAccountConfig,
|
||||
} from "../channels/plugins/account-helpers.js";
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildRuntimeAccountStatusSnapshot,
|
||||
createComputedAccountStatusAdapter,
|
||||
buildWebhookChannelStatusSummary,
|
||||
buildTokenChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDependentCredentialStatusIssueCollector,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "./status-helpers.js";
|
||||
|
||||
@@ -351,6 +353,62 @@ describe("buildTokenChannelStatusSummary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWebhookChannelStatusSummary", () => {
|
||||
it("defaults mode to webhook and keeps supplied extras", () => {
|
||||
expect(
|
||||
buildWebhookChannelStatusSummary(
|
||||
{
|
||||
configured: true,
|
||||
running: true,
|
||||
},
|
||||
{
|
||||
secretSource: "env",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
configured: true,
|
||||
running: true,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
mode: "webhook",
|
||||
secretSource: "env",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDependentCredentialStatusIssueCollector", () => {
|
||||
it("uses source metadata from sanitized snapshots to pick the missing field", () => {
|
||||
const collect = createDependentCredentialStatusIssueCollector({
|
||||
channel: "line",
|
||||
dependencySourceKey: "tokenSource",
|
||||
missingPrimaryMessage: "LINE channel access token not configured",
|
||||
missingDependentMessage: "LINE channel secret not configured",
|
||||
});
|
||||
|
||||
expect(
|
||||
collect([
|
||||
{ accountId: "default", configured: false, tokenSource: "none" },
|
||||
{ accountId: "work", configured: false, tokenSource: "env" },
|
||||
{ accountId: "ok", configured: true, tokenSource: "env" },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "LINE channel access token not configured",
|
||||
},
|
||||
{
|
||||
channel: "line",
|
||||
accountId: "work",
|
||||
kind: "config",
|
||||
message: "LINE channel secret not configured",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectStatusIssuesFromLastError", () => {
|
||||
it("returns runtime issues only for non-empty string lastError values", () => {
|
||||
expect(
|
||||
|
||||
@@ -40,6 +40,11 @@ type ComputedAccountStatusAdapterParams<ResolvedAccount, Probe, Audit> = {
|
||||
type ComputedAccountStatusSnapshot<TExtra extends StatusSnapshotExtra = StatusSnapshotExtra> =
|
||||
ComputedAccountStatusBase & { extra?: TExtra };
|
||||
|
||||
type ConfigIssueAccount = {
|
||||
accountId?: string | null;
|
||||
configured?: boolean | null;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
/** Create the baseline runtime snapshot shape used by channel/account status stores. */
|
||||
export function createDefaultChannelRuntimeState<T extends Record<string, unknown>>(
|
||||
accountId: string,
|
||||
@@ -102,6 +107,24 @@ export function buildProbeChannelStatusSummary<TExtra extends Record<string, unk
|
||||
};
|
||||
}
|
||||
|
||||
/** Build webhook channel summaries with a stable default mode. */
|
||||
export function buildWebhookChannelStatusSummary<TExtra extends StatusSnapshotExtra>(
|
||||
snapshot: {
|
||||
configured?: boolean | null;
|
||||
mode?: string | null;
|
||||
running?: boolean | null;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
},
|
||||
extra?: TExtra,
|
||||
) {
|
||||
return buildBaseChannelStatusSummary(snapshot, {
|
||||
mode: snapshot.mode ?? "webhook",
|
||||
...(extra ?? ({} as TExtra)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the standard per-account status payload from config metadata plus runtime state. */
|
||||
export function buildBaseAccountStatusSnapshot<TExtra extends StatusSnapshotExtra>(
|
||||
params: {
|
||||
@@ -290,6 +313,36 @@ export function buildTokenChannelStatusSummary(
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a config-issue collector from snapshot-safe source metadata only. */
|
||||
export function createDependentCredentialStatusIssueCollector(options: {
|
||||
channel: string;
|
||||
dependencySourceKey: string;
|
||||
missingPrimaryMessage: string;
|
||||
missingDependentMessage: string;
|
||||
isDependencyConfigured?: ((value: unknown) => boolean) | undefined;
|
||||
}) {
|
||||
const isDependencyConfigured =
|
||||
options.isDependencyConfigured ??
|
||||
((value: unknown) => typeof value === "string" && value.trim().length > 0 && value !== "none");
|
||||
|
||||
return (accounts: ConfigIssueAccount[]): ChannelStatusIssue[] =>
|
||||
accounts.flatMap((account) => {
|
||||
if (account.configured !== false) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
channel: options.channel,
|
||||
accountId: account.accountId ?? "",
|
||||
kind: "config",
|
||||
message: isDependencyConfigured(account[options.dependencySourceKey])
|
||||
? options.missingDependentMessage
|
||||
: options.missingPrimaryMessage,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Convert account runtime errors into the generic channel status issue format. */
|
||||
export function collectStatusIssuesFromLastError(
|
||||
channel: string,
|
||||
|
||||
Reference in New Issue
Block a user