diff --git a/extensions/bluebubbles/src/channel-shared.ts b/extensions/bluebubbles/src/channel-shared.ts index 066ce57e81d..2f1f854a2ab 100644 --- a/extensions/bluebubbles/src/channel-shared.ts +++ b/extensions/bluebubbles/src/channel-shared.ts @@ -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", }, }); } diff --git a/extensions/line/src/channel-shared.ts b/extensions/line/src/channel-shared.ts index 70fa3b38a7f..da2212db879 100644 --- a/extensions/line/src/channel-shared.ts +++ b/extensions/line/src/channel-shared.ts @@ -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, diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 5dee0c75d27..8a1817f258c 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -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({ channelKey: "line", @@ -77,152 +59,8 @@ export const linePlugin: ChannelPlugin = createChatChannelP }, directory: createEmptyChannelDirectoryAdapter(), setup: lineSetupAdapter, - status: createComputedAccountStatusAdapter({ - 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).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 = 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>) => { - 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[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[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[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> = []; - 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[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, }); diff --git a/extensions/line/src/gateway.ts b/extensions/line/src/gateway.ts new file mode 100644 index 00000000000..fe0083657a7 --- /dev/null +++ b/extensions/line/src/gateway.ts @@ -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["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).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 }; + }, +}; diff --git a/extensions/line/src/outbound.ts b/extensions/line/src/outbound.ts new file mode 100644 index 00000000000..137f6316ecb --- /dev/null +++ b/extensions/line/src/outbound.ts @@ -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["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>) => { + 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[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[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[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> = []; + 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[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, + }), + }), +}; diff --git a/extensions/line/src/status.ts b/extensions/line/src/status.ts new file mode 100644 index 00000000000..9d6a7d8514d --- /dev/null +++ b/extensions/line/src/status.ts @@ -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["status"]> = + createComputedAccountStatusAdapter({ + 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", + }, + }), + }); diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 03943921a63..226c6f1d07d 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -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 = ...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 = status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), buildChannelSummary: ({ snapshot }) => - buildBaseChannelStatusSummary(snapshot, { + buildWebhookChannelStatusSummary(snapshot, { secretSource: snapshot.secretSource ?? "none", - mode: "webhook", }), resolveAccountSnapshot: ({ account }) => ({ accountId: account.accountId, diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 754a5db1bab..4a52abbe180 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -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 = ...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", }, }), }, diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 599b61e50e2..949814d9032 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -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; diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 77ef9b6e5bd..d7b4e6e627c 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -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 | undefined; +}): ChannelAccountSnapshot { + return describeAccountSnapshot({ + account: params.account, + configured: params.configured, + extra: { + mode: params.mode ?? "webhook", + ...params.extra, + }, + }); +} diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index c96810dfa5a..eb5c019a1bf 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -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"); }); diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 3165d4c4aec..346ba4ea3ec 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -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; @@ -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"; diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index cfdbc892db4..7dfc8ca5e4f 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -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"))); } } diff --git a/src/plugin-sdk/account-helpers.ts b/src/plugin-sdk/account-helpers.ts index b35826585a1..bfdb24c78c1 100644 --- a/src/plugin-sdk/account-helpers.ts +++ b/src/plugin-sdk/account-helpers.ts @@ -1,6 +1,7 @@ export { createAccountListHelpers, describeAccountSnapshot, + describeWebhookAccountSnapshot, mergeAccountConfig, resolveMergedAccountConfig, } from "../channels/plugins/account-helpers.js"; diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index 60786ba5f2e..ff9bb994e18 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -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( diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 12ae8e07f00..96314a501bb 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -40,6 +40,11 @@ type ComputedAccountStatusAdapterParams = { type ComputedAccountStatusSnapshot = ComputedAccountStatusBase & { extra?: TExtra }; +type ConfigIssueAccount = { + accountId?: string | null; + configured?: boolean | null; +} & Record; + /** Create the baseline runtime snapshot shape used by channel/account status stores. */ export function createDefaultChannelRuntimeState>( accountId: string, @@ -102,6 +107,24 @@ export function buildProbeChannelStatusSummary( + 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( 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,