refactor: share webhook channel status helpers

This commit is contained in:
Peter Steinberger 2026-03-29 02:10:58 +01:00
parent 2afc655bd5
commit 148a65fe90
16 changed files with 610 additions and 418 deletions

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
export {
createAccountListHelpers,
describeAccountSnapshot,
describeWebhookAccountSnapshot,
mergeAccountConfig,
resolveMergedAccountConfig,
} from "../channels/plugins/account-helpers.js";

View File

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

View File

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