Merge remote-tracking branch 'upstream/main' into feat/gigachat

# Conflicts:
#	extensions/whatsapp/src/outbound-adapter.poll.test.ts
This commit is contained in:
Alexander Davydov 2026-03-18 22:10:49 +03:00
commit c875368c84
275 changed files with 8401 additions and 4216 deletions

3
.npmrc
View File

@ -1 +1,4 @@
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker.
# Keep the workspace on a hoisted layout so pnpm check/build stay stable.
node-linker=hoisted

View File

@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
### Fixes

View File

@ -83,7 +83,8 @@ Welcome to the lobster tank! 🦞
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first.
4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Before You PR
@ -96,6 +97,7 @@ Welcome to the lobster tank! 🦞
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why

View File

@ -1 +0,0 @@
docs.openclaw.ai

View File

@ -52,6 +52,10 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Runs in CI
- No real keys required
- Should be fast and stable
- Scheduler note:
- `pnpm test` now keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files.
- Shared unit coverage stays on, but the wrapper peels the heaviest measured files into dedicated lanes instead of relying on a growing hand-maintained exclusion list.
- Refresh the timing snapshot with `pnpm test:perf:update-timings` after major suite shape changes.
- Embedded runner note:
- When you change message-tool discovery inputs or compaction runtime context,
keep both levels of coverage.

View File

@ -12,9 +12,10 @@ title: "Tests"
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
- `pnpm test`: runs the fast core unit lane by default for quick local feedback.
- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes.
- `pnpm test:channels`: runs channel-heavy suites.
- `pnpm test:extensions`: runs extension/plugin suites.
- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`.
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.

View File

@ -4,7 +4,15 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import {
createOpenGroupPolicyRestrictSendersWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
createPairingPrefixStripper,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
listBlueBubblesAccountIds,
@ -68,6 +76,17 @@ const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBu
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
const collectBlueBubblesSecurityWarnings =
createOpenGroupPolicyRestrictSendersWarningCollector<ResolvedBlueBubblesAccount>({
resolveGroupPolicy: (account) => account.config.groupPolicy,
defaultGroupPolicy: "allowlist",
surface: "BlueBubbles groups",
openScope: "any member",
groupPolicyPath: "channels.bluebubbles.groupPolicy",
groupAllowFromPath: "channels.bluebubbles.groupAllowFrom",
mentionGated: false,
});
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
@ -123,17 +142,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: resolveBlueBubblesDmPolicy,
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
return collectOpenGroupPolicyRestrictSendersWarnings({
groupPolicy,
surface: "BlueBubbles groups",
openScope: "any member",
groupPolicyPath: "channels.bluebubbles.groupPolicy",
groupAllowFromPath: "channels.bluebubbles.groupAllowFrom",
mentionGated: false,
});
},
collectWarnings: projectWarningCollector(
({ account }: { account: ResolvedBlueBubblesAccount }) => account,
collectBlueBubblesSecurityWarnings,
),
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
@ -226,17 +238,18 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
},
setup: blueBubblesSetupAdapter,
pairing: {
pairing: createTextPairingAdapter({
idLabel: "bluebubblesSenderId",
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => {
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^bluebubbles:/i, normalizeBlueBubblesHandle),
notify: async ({ cfg, id, message }) => {
await (
await loadBlueBubblesChannelRuntime()
).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
).sendMessageBlueBubbles(id, message, {
cfg: cfg,
});
},
},
}),
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
@ -250,46 +263,44 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const result = await runtime.sendMessageBlueBubbles(to, text, {
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
return { channel: "bluebubbles", ...result };
},
sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
const resolvedCaption = caption ?? text;
const result = await runtime.sendBlueBubblesMedia({
cfg: cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: resolvedCaption ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});
return { channel: "bluebubbles", ...result };
},
...createAttachedChannelResultAdapter({
channel: "bluebubbles",
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
return await runtime.sendMessageBlueBubbles(to, text, {
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
},
sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
return await runtime.sendBlueBubblesMedia({
cfg: cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: caption ?? text ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});
},
}),
},
status: {
defaultRuntime: {

View File

@ -1,3 +1,8 @@
import {
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { fetchBlueBubblesHistory } from "./history.js";
@ -1243,11 +1248,7 @@ export async function processMessage(
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const mediaList = resolveOutboundMediaUrls(payload);
if (mediaList.length > 0) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
@ -1257,43 +1258,44 @@ export async function processMessage(
const text = sanitizeReplyDirectiveText(
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
);
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
first = false;
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
const pendingId = rememberPendingOutboundMessageId({
accountId: account.accountId,
sessionKey: route.sessionKey,
outboundTarget,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
snippet: cachedBody,
});
let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
try {
result = await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
replyToId: replyToMessageGuid || null,
await sendMediaWithLeadingCaption({
mediaUrls: mediaList,
caption: text,
send: async ({ mediaUrl, caption }) => {
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
const pendingId = rememberPendingOutboundMessageId({
accountId: account.accountId,
sessionKey: route.sessionKey,
outboundTarget,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
snippet: cachedBody,
});
} catch (err) {
forgetPendingOutboundMessageId(pendingId);
throw err;
}
if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) {
forgetPendingOutboundMessageId(pendingId);
}
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
if (info.kind === "block") {
restartTypingSoon();
}
}
let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
try {
result = await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
replyToId: replyToMessageGuid || null,
accountId: account.accountId,
});
} catch (err) {
forgetPendingOutboundMessageId(pendingId);
throw err;
}
if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) {
forgetPendingOutboundMessageId(pendingId);
}
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
if (info.kind === "block") {
restartTypingSoon();
}
},
});
return;
}
@ -1312,11 +1314,14 @@ export async function processMessage(
);
const chunks =
chunkMode === "newline"
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
: core.channel.text.chunkMarkdownText(text, textLimit);
if (!chunks.length && text) {
chunks.push(text);
}
? resolveTextChunksWithFallback(
text,
core.channel.text.chunkTextWithMode(text, textLimit, chunkMode),
)
: resolveTextChunksWithFallback(
text,
core.channel.text.chunkMarkdownText(text, textLimit),
);
if (!chunks.length) {
return;
}

View File

@ -1,15 +1,22 @@
import { Separator, TextDisplay } from "@buape/carbon";
import {
buildAccountScopedAllowlistConfigEditor,
resolveLegacyDmAllowlistConfigPaths,
buildLegacyDmAccountAllowlistAdapter,
createAccountScopedAllowlistNameResolver,
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
collectOpenGroupPolicyConfiguredRouteWarnings,
collectOpenProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime";
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
createPairingPrefixStripper,
createTopLevelChannelReplyToModeResolver,
createRuntimeDirectoryLiveAdapter,
createTextPairingAdapter,
normalizeMessageChannel,
resolveOutboundSendDep,
resolveTargetsWithOptionalToken,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
@ -131,42 +138,40 @@ function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
});
}
function readDiscordAllowlistConfig(account: ResolvedDiscordAccount) {
const groupOverrides: Array<{ label: string; entries: string[] }> = [];
for (const [guildKey, guildCfg] of Object.entries(account.config.guilds ?? {})) {
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: `guild ${guildKey}`, entries });
}
for (const [channelKey, channelCfg] of Object.entries(guildCfg?.channels ?? {})) {
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
if (channelEntries.length > 0) {
groupOverrides.push({
label: `guild ${guildKey} / channel ${channelKey}`,
entries: channelEntries,
});
}
}
}
return {
dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String),
groupPolicy: account.config.groupPolicy,
groupOverrides,
};
}
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds,
outerLabel: (guildKey) => `guild ${guildKey}`,
resolveOuterEntries: (guildCfg) => guildCfg?.users,
resolveChildren: (guildCfg) => guildCfg?.channels,
innerLabel: (guildKey, channelKey) => `guild ${guildKey} / channel ${channelKey}`,
resolveInnerEntries: (channelCfg) => channelCfg?.users,
});
async function resolveDiscordAllowlistNames(params: {
cfg: Parameters<typeof resolveDiscordAccount>[0]["cfg"];
accountId?: string | null;
entries: string[];
}) {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.token?.trim();
if (!token) {
return [];
}
return await resolveDiscordUserAllowlist({ token, entries: params.entries });
}
const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveToken: (account: ResolvedDiscordAccount) => account.token,
resolveNames: ({ token, entries }) => resolveDiscordUserAllowlist({ token, entries }),
});
const collectDiscordSecurityWarnings =
createOpenProviderConfiguredRouteWarningCollector<ResolvedDiscordAccount>({
providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Object.keys(account.config.guilds ?? {}).length > 0,
configureRouteAllowlist: {
surface: "Discord guilds",
openScope: "any channel not explicitly denied",
groupPolicyPath: "channels.discord.groupPolicy",
routeAllowlistPath: "channels.discord.guilds.<id>.channels",
},
missingRouteAllowlist: {
surface: "Discord guilds",
openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)",
remediation:
'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels',
},
});
function normalizeDiscordAcpConversationId(conversationId: string) {
const normalized = conversationId.trim();
@ -288,60 +293,29 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
...createDiscordPluginBase({
setup: discordSetupAdapter,
}),
pairing: {
pairing: createTextPairingAdapter({
idLabel: "discordUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
notifyApproval: async ({ id }) => {
await getDiscordRuntime().channel.discord.sendMessageDiscord(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
);
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(discord|user):/i),
notify: async ({ id, message }) => {
await getDiscordRuntime().channel.discord.sendMessageDiscord(`user:${id}`, message);
},
},
}),
allowlist: {
supportsScope: ({ scope }) => scope === "dm",
readConfig: ({ cfg, accountId }) =>
readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })),
resolveNames: async ({ cfg, accountId, entries }) =>
await resolveDiscordAllowlistNames({ cfg, accountId, entries }),
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
...buildLegacyDmAccountAllowlistAdapter({
channelId: "discord",
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
normalize: ({ cfg, accountId, values }) =>
discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveGroupOverrides: resolveDiscordAllowlistGroupOverrides,
}),
resolveNames: resolveDiscordAllowlistNames,
},
security: {
resolveDmPolicy: resolveDiscordDmPolicy,
collectWarnings: ({ account, cfg }) => {
const guildEntries = account.config.guilds ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;
const channelAllowlistConfigured = guildsConfigured;
return collectOpenProviderGroupPolicyWarnings({
cfg,
providerConfigPresent: cfg.channels?.discord !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
collectOpenGroupPolicyConfiguredRouteWarnings({
groupPolicy,
routeAllowlistConfigured: channelAllowlistConfigured,
configureRouteAllowlist: {
surface: "Discord guilds",
openScope: "any channel not explicitly denied",
groupPolicyPath: "channels.discord.groupPolicy",
routeAllowlistPath: "channels.discord.guilds.<id>.channels",
},
missingRouteAllowlist: {
surface: "Discord guilds",
openBehavior:
"with no guild/channel allowlist; any channel can trigger (mention-gated)",
remediation:
'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels',
},
}),
});
},
collectWarnings: collectDiscordSecurityWarnings,
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
@ -351,7 +325,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"),
},
agentPrompt: {
messageToolHints: () => [
@ -387,53 +361,57 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
(normalizeMessageChannel(target.channel) ?? target.channel) === "discord" &&
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }),
},
directory: {
self: async () => null,
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
listPeersLive: async (params) =>
getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
listGroupsLive: async (params) =>
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
},
...createRuntimeDirectoryLiveAdapter({
getRuntime: () => getDiscordRuntime().channel.discord,
listPeersLive: (runtime) => runtime.listDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive,
}),
}),
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const account = resolveDiscordAccount({ cfg, accountId });
const token = account.token?.trim();
if (!token) {
return inputs.map((input) => ({
input,
resolved: false,
note: "missing Discord token",
}));
}
if (kind === "group") {
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
token,
entries: inputs,
return resolveTargetsWithOptionalToken({
token: account.token,
inputs,
missingTokenNote: "missing Discord token",
resolveWithToken: ({ token, inputs }) =>
getDiscordRuntime().channel.discord.resolveChannelAllowlist({
token,
entries: inputs,
}),
mapResolved: (entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.channelId ?? entry.guildId,
name:
entry.channelName ??
entry.guildName ??
(entry.guildId && !entry.channelId ? entry.guildId : undefined),
note: entry.note,
}),
});
return resolved.map((entry) => ({
}
return resolveTargetsWithOptionalToken({
token: account.token,
inputs,
missingTokenNote: "missing Discord token",
resolveWithToken: ({ token, inputs }) =>
getDiscordRuntime().channel.discord.resolveUserAllowlist({
token,
entries: inputs,
}),
mapResolved: (entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.channelId ?? entry.guildId,
name:
entry.channelName ??
entry.guildName ??
(entry.guildId && !entry.channelId ? entry.guildId : undefined),
id: entry.id,
name: entry.name,
note: entry.note,
}));
}
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
token,
entries: inputs,
}),
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}));
},
},
actions: discordMessageActions,
@ -444,50 +422,51 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
textChunkLimit: 2000,
pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => {
const send =
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
cfg,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
return { channel: "discord", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
silent,
}) => {
const send =
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
...createAttachedChannelResultAdapter({
channel: "discord",
sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => {
const send =
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
return await send(to, text, {
verbose: false,
cfg,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId, silent }) =>
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
cfg,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
}),
accountId,
deps,
replyToId,
silent,
}) => {
const send =
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
return await send(to, text, {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
},
sendPoll: async ({ cfg, to, poll, accountId, silent }) =>
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
cfg,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
}),
}),
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>

View File

@ -1,54 +1,43 @@
import {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
toDirectoryEntries,
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js";
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
const account: InspectedDiscordAccount = inspectDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (!account.config) {
return [];
}
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
const ids = collectNormalizedDirectoryIds({
sources: [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers],
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "user",
inspectAccount: (cfg, accountId) =>
inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null,
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
const account: InspectedDiscordAccount = inspectDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (!account.config) {
return [];
}
const ids = collectNormalizedDirectoryIds({
sources: Object.values(account.config.guilds ?? {}).map((guild) =>
Object.keys(guild.channels ?? {}),
),
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "group",
inspectAccount: (cfg, accountId) =>
inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null,
resolveSources: (account) =>
Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}

View File

@ -16,6 +16,7 @@ import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runt
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import {
@ -610,7 +611,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}
if (draftStream && isFinal) {
await flushDraft();
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
const finalText = payload.text;
const previewFinalText = resolvePreviewFinalText(finalText);
const previewMessageId = draftStream.messageId();

View File

@ -25,6 +25,10 @@ import {
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import {
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
} from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import type {
ChatCommandDefinition,
@ -232,13 +236,7 @@ function isDiscordUnknownInteraction(error: unknown): boolean {
}
function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
if ((payload.text ?? "").trim()) {
return true;
}
if ((payload.mediaUrl ?? "").trim()) {
return true;
}
if (payload.mediaUrls?.some((entry) => entry.trim())) {
if (resolveSendableOutboundReplyParts(payload).hasContent) {
return true;
}
const discordData = payload.channelData?.discord as
@ -887,8 +885,7 @@ async function deliverDiscordInteractionReply(params: {
chunkMode: "length" | "newline";
}) {
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const reply = resolveSendableOutboundReplyParts(payload);
const discordData = payload.channelData?.discord as
| { components?: TopLevelComponents[] }
| undefined;
@ -933,9 +930,9 @@ async function deliverDiscordInteractionReply(params: {
});
};
if (mediaList.length > 0) {
if (reply.hasMedia) {
const media = await Promise.all(
mediaList.map(async (url) => {
reply.mediaUrls.map(async (url) => {
const loaded = await loadWebMedia(url, {
localRoots: params.mediaLocalRoots,
});
@ -945,14 +942,14 @@ async function deliverDiscordInteractionReply(params: {
};
}),
);
const chunks = chunkDiscordTextWithMode(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && text) {
chunks.push(text);
}
const chunks = resolveTextChunksWithFallback(
reply.text,
chunkDiscordTextWithMode(reply.text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
}),
);
const caption = chunks[0] ?? "";
await sendMessage(caption, media, firstMessageComponents);
for (const chunk of chunks.slice(1)) {
@ -964,17 +961,20 @@ async function deliverDiscordInteractionReply(params: {
return;
}
if (!text.trim() && !firstMessageComponents) {
if (!reply.hasText && !firstMessageComponents) {
return;
}
const chunks = chunkDiscordTextWithMode(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && (text || firstMessageComponents)) {
chunks.push(text);
}
const chunks =
reply.text || firstMessageComponents
? resolveTextChunksWithFallback(
reply.text,
chunkDiscordTextWithMode(reply.text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
}),
)
: [];
for (const chunk of chunks) {
if (!chunk.trim() && !firstMessageComponents) {
continue;

View File

@ -8,6 +8,11 @@ import {
retryAsync,
type RetryConfig,
} from "openclaw/plugin-sdk/infra-runtime";
import {
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@ -209,35 +214,6 @@ async function sendDiscordChunkWithFallback(params: {
);
}
async function sendAdditionalDiscordMedia(params: {
cfg: OpenClawConfig;
target: string;
token: string;
rest?: RequestClient;
accountId?: string;
mediaUrls: string[];
mediaLocalRoots?: readonly string[];
resolveReplyTo: () => string | undefined;
retryConfig: ResolvedRetryConfig;
}) {
for (const mediaUrl of params.mediaUrls) {
const replyTo = params.resolveReplyTo();
await sendWithRetry(
() =>
sendMessageDiscord(params.target, "", {
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo,
}),
params.retryConfig,
);
}
}
export async function deliverDiscordReply(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
@ -292,23 +268,23 @@ export async function deliverDiscordReply(params: {
: undefined;
let deliveredAny = false;
for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? "";
const tableMode = params.tableMode ?? "code";
const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) {
const reply = resolveSendableOutboundReplyParts(payload, {
text: convertMarkdownTables(payload.text ?? "", tableMode),
});
if (!reply.hasContent) {
continue;
}
if (mediaList.length === 0) {
if (!reply.hasMedia) {
const mode = params.chunkMode ?? "length";
const chunks = chunkDiscordTextWithMode(text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
chunkMode: mode,
});
if (!chunks.length && text) {
chunks.push(text);
}
const chunks = resolveTextChunksWithFallback(
reply.text,
chunkDiscordTextWithMode(reply.text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
chunkMode: mode,
}),
);
for (const chunk of chunks) {
if (!chunk.trim()) {
continue;
@ -336,23 +312,10 @@ export async function deliverDiscordReply(params: {
continue;
}
const firstMedia = mediaList[0];
const firstMedia = reply.mediaUrls[0];
if (!firstMedia) {
continue;
}
const sendRemainingMedia = () =>
sendAdditionalDiscordMedia({
cfg: params.cfg,
target: params.target,
token: params.token,
rest: params.rest,
accountId: params.accountId,
mediaUrls: mediaList.slice(1),
mediaLocalRoots: params.mediaLocalRoots,
resolveReplyTo,
retryConfig,
});
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord.
if (payload.audioAsVoice) {
const replyTo = resolveReplyTo();
@ -368,7 +331,7 @@ export async function deliverDiscordReply(params: {
await sendDiscordChunkWithFallback({
cfg: params.cfg,
target: params.target,
text,
text: reply.text,
token: params.token,
rest: params.rest,
accountId: params.accountId,
@ -383,22 +346,50 @@ export async function deliverDiscordReply(params: {
retryConfig,
});
// Additional media items are sent as regular attachments (voice is single-file only).
await sendRemainingMedia();
await sendMediaWithLeadingCaption({
mediaUrls: reply.mediaUrls.slice(1),
caption: "",
send: async ({ mediaUrl }) => {
const replyTo = resolveReplyTo();
await sendWithRetry(
() =>
sendMessageDiscord(params.target, "", {
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo,
}),
retryConfig,
);
},
});
continue;
}
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, text, {
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl: firstMedia,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo,
await sendMediaWithLeadingCaption({
mediaUrls: reply.mediaUrls,
caption: reply.text,
send: async ({ mediaUrl, caption }) => {
const replyTo = resolveReplyTo();
await sendWithRetry(
() =>
sendMessageDiscord(params.target, caption ?? "", {
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo,
}),
retryConfig,
);
},
});
deliveredAny = true;
await sendRemainingMedia();
}
if (binding && deliveredAny) {

View File

@ -3,11 +3,13 @@ import { normalizeDiscordOutboundTarget } from "./normalize.js";
const hoisted = vi.hoisted(() => {
const sendMessageDiscordMock = vi.fn();
const sendDiscordComponentMessageMock = vi.fn();
const sendPollDiscordMock = vi.fn();
const sendWebhookMessageDiscordMock = vi.fn();
const getThreadBindingManagerMock = vi.fn();
return {
sendMessageDiscordMock,
sendDiscordComponentMessageMock,
sendPollDiscordMock,
sendWebhookMessageDiscordMock,
getThreadBindingManagerMock,
@ -19,6 +21,8 @@ vi.mock("./send.js", async (importOriginal) => {
return {
...actual,
sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args),
sendDiscordComponentMessage: (...args: unknown[]) =>
hoisted.sendDiscordComponentMessageMock(...args),
sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args),
sendWebhookMessageDiscord: (...args: unknown[]) =>
hoisted.sendWebhookMessageDiscordMock(...args),
@ -114,6 +118,10 @@ describe("discordOutbound", () => {
messageId: "msg-1",
channelId: "ch-1",
});
hoisted.sendDiscordComponentMessageMock.mockClear().mockResolvedValue({
messageId: "component-1",
channelId: "ch-1",
});
hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({
messageId: "poll-1",
channelId: "ch-1",
@ -249,8 +257,61 @@ describe("discordOutbound", () => {
}),
);
expect(result).toEqual({
channel: "discord",
messageId: "poll-1",
channelId: "ch-1",
});
});
it("sends component payload media sequences with the component message first", async () => {
hoisted.sendDiscordComponentMessageMock.mockResolvedValueOnce({
messageId: "component-1",
channelId: "ch-1",
});
hoisted.sendMessageDiscordMock.mockResolvedValueOnce({
messageId: "msg-2",
channelId: "ch-1",
});
const result = await discordOutbound.sendPayload?.({
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
discord: {
components: { text: "hello", components: [] },
},
},
},
accountId: "default",
mediaLocalRoots: ["/tmp/media"],
});
expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith(
"channel:123456",
expect.objectContaining({ text: "hello" }),
expect.objectContaining({
mediaUrl: "https://example.com/1.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
}),
);
expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:123456",
"",
expect.objectContaining({
mediaUrl: "https://example.com/2.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
}),
);
expect(result).toEqual({
channel: "discord",
messageId: "msg-2",
channelId: "ch-1",
});
});
});

View File

@ -1,10 +1,14 @@
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequence,
sendPayloadMediaSequenceOrFallback,
sendTextMediaPayload,
} from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import type { DiscordComponentMessageSpec } from "./components.js";
@ -123,18 +127,17 @@ export const discordOutbound: ChannelOutboundAdapter = {
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
const mediaUrls = resolvePayloadMediaUrls(payload);
if (mediaUrls.length === 0) {
const result = await sendDiscordComponentMessage(target, componentSpec, {
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
});
return { channel: "discord", ...result };
}
const lastResult = await sendPayloadMediaSequence({
const result = await sendPayloadMediaSequenceOrFallback({
text: payload.text ?? "",
mediaUrls,
fallbackResult: { messageId: "", channelId: target },
sendNoMedia: async () =>
await sendDiscordComponentMessage(target, componentSpec, {
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
}),
send: async ({ text, mediaUrl, isFirst }) => {
if (isFirst) {
return await sendDiscordComponentMessage(target, componentSpec, {
@ -157,68 +160,63 @@ export const discordOutbound: ChannelOutboundAdapter = {
});
},
});
return lastResult
? { channel: "discord", ...lastResult }
: { channel: "discord", messageId: "" };
return attachChannelToResult("discord", result);
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
if (!silent) {
const webhookResult = await maybeSendDiscordWebhookText({
cfg,
text,
threadId,
accountId,
identity,
replyToId,
}).catch(() => null);
if (webhookResult) {
return { channel: "discord", ...webhookResult };
...createAttachedChannelResultAdapter({
channel: "discord",
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
if (!silent) {
const webhookResult = await maybeSendDiscordWebhookText({
cfg,
text,
threadId,
accountId,
identity,
replyToId,
}).catch(() => null);
if (webhookResult) {
return webhookResult;
}
}
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
},
sendMedia: async ({
cfg,
});
return { channel: "discord", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,
to,
text,
mediaUrl,
mediaLocalRoots,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => {
const target = resolveDiscordOutboundTarget({ to, threadId });
return await sendPollDiscord(target, poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
},
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
verbose: false,
mediaUrl,
mediaLocalRoots,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
},
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) =>
await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
}),
}),
};

View File

@ -17,6 +17,7 @@ import {
normalizePollInput,
type PollInput,
} from "openclaw/plugin-sdk/media-runtime";
import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { resolveDiscordAccount } from "./accounts.js";
@ -276,10 +277,7 @@ export function buildDiscordTextChunks(
maxLines: opts.maxLinesPerMessage,
chunkMode: opts.chunkMode,
});
if (!chunks.length && text) {
chunks.push(text);
}
return chunks;
return resolveTextChunksWithFallback(text, chunks);
}
function hasV2Components(components?: TopLevelComponents[]): boolean {

View File

@ -1,7 +1,17 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import {
createAllowlistProviderGroupPolicyWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
createMessageToolCardSchema,
createPairingPrefixStripper,
createRuntimeDirectoryLiveAdapter,
createRuntimeOutboundDelegates,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
@ -53,6 +63,24 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
"feishuChannelRuntime",
);
const collectFeishuSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{
cfg: ClawdbotConfig;
accountId?: string | null;
}>({
providerConfigPresent: (cfg) => cfg.channels?.feishu !== undefined,
resolveGroupPolicy: ({ cfg, accountId }) =>
resolveFeishuAccount({ cfg, accountId }).config?.groupPolicy,
collect: ({ cfg, accountId, groupPolicy }) => {
if (groupPolicy !== "open") {
return [];
}
const account = resolveFeishuAccount({ cfg, accountId });
return [
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];
},
});
function describeFeishuMessageTool({
cfg,
}: Parameters<
@ -355,18 +383,19 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
meta: {
...meta,
},
pairing: {
pairing: createTextPairingAdapter({
idLabel: "feishuUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
notifyApproval: async ({ cfg, id }) => {
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(feishu|user|open_id):/i),
notify: async ({ cfg, id, message }) => {
const { sendMessageFeishu } = await loadFeishuChannelRuntime();
await sendMessageFeishu({
cfg,
to: id,
text: PAIRING_APPROVED_MESSAGE,
text: message,
});
},
},
}),
capabilities: {
chatTypes: ["direct", "channel"],
polls: false,
@ -839,19 +868,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
},
},
security: {
collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });
const feishuCfg = account.config;
return collectAllowlistProviderRestrictSendersWarnings({
collectWarnings: projectWarningCollector(
({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string | null }) => ({
cfg,
providerConfigPresent: cfg.channels?.feishu !== undefined,
configuredGroupPolicy: feishuCfg?.groupPolicy,
surface: `Feishu[${account.accountId}] groups`,
openScope: "any member",
groupPolicyPath: "channels.feishu.groupPolicy",
groupAllowFromPath: "channels.feishu.groupAllowFrom",
});
},
accountId,
}),
collectFeishuSecurityWarnings,
),
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
@ -873,8 +896,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
hint: "<chatId|user:openId|chat:chatId>",
},
},
directory: {
self: async () => null,
directory: createChannelDirectoryAdapter({
listPeers: async ({ cfg, query, limit, accountId }) =>
listFeishuDirectoryPeers({
cfg,
@ -889,29 +911,38 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listPeersLive: async ({ cfg, query, limit, accountId }) =>
(await loadFeishuChannelRuntime()).listFeishuDirectoryPeersLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listGroupsLive: async ({ cfg, query, limit, accountId }) =>
(await loadFeishuChannelRuntime()).listFeishuDirectoryGroupsLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
},
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadFeishuChannelRuntime,
listPeersLive:
(runtime) =>
async ({ cfg, query, limit, accountId }) =>
await runtime.listFeishuDirectoryPeersLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
listGroupsLive:
(runtime) =>
async ({ cfg, query, limit, accountId }) =>
await runtime.listFeishuDirectoryGroupsLive({
cfg,
query: query ?? undefined,
limit: limit ?? undefined,
accountId: accountId ?? undefined,
}),
}),
}),
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async (params) => (await loadFeishuChannelRuntime()).feishuOutbound.sendText!(params),
sendMedia: async (params) =>
(await loadFeishuChannelRuntime()).feishuOutbound.sendMedia!(params),
...createRuntimeOutboundDelegates({
getRuntime: loadFeishuChannelRuntime,
sendText: { resolve: (runtime) => runtime.feishuOutbound.sendText },
sendMedia: { resolve: (runtime) => runtime.feishuOutbound.sendMedia },
}),
},
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),

View File

@ -1,5 +1,6 @@
import fs from "fs";
import path from "path";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import type { ChannelOutboundAdapter } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { sendMediaFeishu } from "./media.js";
@ -81,128 +82,124 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({
cfg,
to,
text,
accountId,
replyToId,
threadId,
mediaLocalRoots,
identity,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
// auto-upload and send as Feishu image message instead of leaking path text.
const localImagePath = normalizePossibleLocalImagePath(text);
if (localImagePath) {
try {
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl: localImagePath,
accountId: accountId ?? undefined,
replyToMessageId,
mediaLocalRoots,
});
return { channel: "feishu", ...result };
} catch (err) {
console.error(`[feishu] local image path auto-send failed:`, err);
// fall through to plain text as last resort
}
}
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (useCard) {
const header = identity
? {
title: identity.emoji
? `${identity.emoji} ${identity.name ?? ""}`.trim()
: (identity.name ?? ""),
template: "blue" as const,
}
: undefined;
const result = await sendStructuredCardFeishu({
cfg,
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
return { channel: "feishu", ...result };
}
const result = await sendOutboundText({
...createAttachedChannelResultAdapter({
channel: "feishu",
sendText: async ({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
accountId,
mediaLocalRoots,
replyToId,
threadId,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Send text first if provided
if (text?.trim()) {
await sendOutboundText({
accountId,
replyToId,
threadId,
mediaLocalRoots,
identity,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
// auto-upload and send as Feishu image message instead of leaking path text.
const localImagePath = normalizePossibleLocalImagePath(text);
if (localImagePath) {
try {
return await sendMediaFeishu({
cfg,
to,
mediaUrl: localImagePath,
accountId: accountId ?? undefined,
replyToMessageId,
mediaLocalRoots,
});
} catch (err) {
console.error(`[feishu] local image path auto-send failed:`, err);
// fall through to plain text as last resort
}
}
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (useCard) {
const header = identity
? {
title: identity.emoji
? `${identity.emoji} ${identity.name ?? ""}`.trim()
: (identity.name ?? ""),
template: "blue" as const,
}
: undefined;
return await sendStructuredCardFeishu({
cfg,
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
}
return await sendOutboundText({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageId,
});
}
// Upload and send media if URL or local path provided
if (mediaUrl) {
try {
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl,
accountId: accountId ?? undefined,
mediaLocalRoots,
replyToMessageId,
});
return { channel: "feishu", ...result };
} catch (err) {
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);
// Fallback to URL link if upload fails
const fallbackText = `📎 ${mediaUrl}`;
const result = await sendOutboundText({
cfg,
to,
text: fallbackText,
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
}
}
// No media URL, just return text result
const result = await sendOutboundText({
},
sendMedia: async ({
cfg,
to,
text: text ?? "",
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
},
text,
mediaUrl,
accountId,
mediaLocalRoots,
replyToId,
threadId,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Send text first if provided
if (text?.trim()) {
await sendOutboundText({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageId,
});
}
// Upload and send media if URL or local path provided
if (mediaUrl) {
try {
return await sendMediaFeishu({
cfg,
to,
mediaUrl,
accountId: accountId ?? undefined,
mediaLocalRoots,
replyToMessageId,
});
} catch (err) {
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);
// Fallback to URL link if upload fails
return await sendOutboundText({
cfg,
to,
text: `📎 ${mediaUrl}`,
accountId: accountId ?? undefined,
replyToMessageId,
});
}
}
// No media URL, just return text result
return await sendOutboundText({
cfg,
to,
text: text ?? "",
accountId: accountId ?? undefined,
replyToMessageId,
});
},
}),
};

View File

@ -1,3 +1,8 @@
import {
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import {
createReplyPrefixContext,
createTypingCallbacks,
@ -13,12 +18,7 @@ import { sendMediaFeishu } from "./media.js";
import type { MentionTarget } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import {
sendMarkdownCardFeishu,
sendMessageFeishu,
sendStructuredCardFeishu,
type CardHeaderConfig,
} from "./send.js";
import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js";
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
import { resolveReceiveIdType } from "./targets.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
@ -300,37 +300,43 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text: string;
useCard: boolean;
infoKind?: string;
sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise<void>;
}) => {
let first = true;
const chunkSource = params.useCard
? params.text
: core.channel.text.convertMarkdownTables(params.text, tableMode);
for (const chunk of core.channel.text.chunkTextWithMode(
const chunks = resolveTextChunksWithFallback(
chunkSource,
textChunkLimit,
chunkMode,
)) {
const message = {
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: first ? mentionTargets : undefined,
accountId,
};
if (params.useCard) {
await sendMarkdownCardFeishu(message);
} else {
await sendMessageFeishu(message);
}
first = false;
core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode),
);
for (const [index, chunk] of chunks.entries()) {
await params.sendChunk({
chunk,
isFirst: index === 0,
});
}
if (params.infoKind === "final") {
deliveredFinalTexts.add(params.text);
}
};
const sendMediaReplies = async (payload: ReplyPayload) => {
await sendMediaWithLeadingCaption({
mediaUrls: resolveSendableOutboundReplyParts(payload).mediaUrls,
caption: "",
send: async ({ mediaUrl }) => {
await sendMediaFeishu({
cfg,
to: chatId,
mediaUrl,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
accountId,
});
},
});
};
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
@ -344,15 +350,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
void typingCallbacks.onReplyStart?.();
},
deliver: async (payload: ReplyPayload, info) => {
const text = payload.text ?? "";
const mediaList =
payload.mediaUrls && payload.mediaUrls.length > 0
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const hasText = Boolean(text.trim());
const hasMedia = mediaList.length > 0;
const reply = resolveSendableOutboundReplyParts(payload);
const text = reply.text;
const hasText = reply.hasText;
const hasMedia = reply.hasMedia;
const skipTextForDuplicateFinal =
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
@ -363,7 +364,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (shouldDeliverText) {
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
let first = true;
if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as
@ -397,16 +397,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
// Send media even when streaming handled the text
if (hasMedia) {
for (const mediaUrl of mediaList) {
await sendMediaFeishu({
cfg,
to: chatId,
mediaUrl,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
accountId,
});
}
await sendMediaReplies(payload);
}
return;
}
@ -414,43 +405,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (useCard) {
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
for (const chunk of core.channel.text.chunkTextWithMode(
await sendChunkedTextReply({
text,
textChunkLimit,
chunkMode,
)) {
await sendStructuredCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: first ? mentionTargets : undefined,
accountId,
header: cardHeader,
note: cardNote,
});
first = false;
}
if (info?.kind === "final") {
deliveredFinalTexts.add(text);
}
useCard: true,
infoKind: info?.kind,
sendChunk: async ({ chunk, isFirst }) => {
await sendStructuredCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: isFirst ? mentionTargets : undefined,
accountId,
header: cardHeader,
note: cardNote,
});
},
});
} else {
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
await sendChunkedTextReply({
text,
useCard: false,
infoKind: info?.kind,
sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: isFirst ? mentionTargets : undefined,
accountId,
});
},
});
}
}
if (hasMedia) {
for (const mediaUrl of mediaList) {
await sendMediaFeishu({
cfg,
to: chatId,
mediaUrl,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
accountId,
});
}
await sendMediaReplies(payload);
}
},
onError: async (error, info) => {

View File

@ -0,0 +1,58 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat";
import { describe, expect, it } from "vitest";
import {
createDirectoryTestRuntime,
expectDirectorySurface,
} from "../../../test/helpers/extensions/directory.ts";
import { googlechatPlugin } from "./channel.js";
describe("googlechat directory", () => {
const runtimeEnv = createDirectoryTestRuntime() as never;
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: ["users/alice", "googlechat:bob"] },
groups: {
"spaces/AAA": {},
"spaces/BBB": {},
},
},
},
} as unknown as OpenClawConfig;
const directory = expectDirectorySurface(googlechatPlugin.directory);
await expect(
directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "users/alice" },
{ kind: "user", id: "bob" },
]),
);
await expect(
directory.listGroups({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "spaces/AAA" },
{ kind: "group", id: "spaces/BBB" },
]),
);
});
});

View File

@ -4,9 +4,21 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
collectAllowlistProviderGroupPolicyWarnings,
composeWarningCollectors,
createAllowlistProviderGroupPolicyWarningCollector,
createConditionalWarningCollector,
createAllowlistProviderOpenWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
createTopLevelChannelReplyToModeResolver,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import {
listResolvedDirectoryGroupEntriesFromMapKeys,
listResolvedDirectoryUserEntriesFromAllowFrom,
} from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import {
@ -15,8 +27,6 @@ import {
DEFAULT_ACCOUNT_ID,
createAccountStatusSink,
getChatChannelMeta,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFrom,
missingTargetError,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
@ -103,15 +113,40 @@ const googlechatActions: ChannelMessageActionAdapter = {
},
};
const collectGoogleChatGroupPolicyWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedGoogleChatAccount>({
providerConfigPresent: (cfg) => cfg.channels?.googlechat !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
buildOpenWarning: {
surface: "Google Chat spaces",
openBehavior: "allows any space to trigger (mention-gated)",
remediation:
'Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups',
},
});
const collectGoogleChatSecurityWarnings = composeWarningCollectors<{
cfg: OpenClawConfig;
account: ResolvedGoogleChatAccount;
}>(
collectGoogleChatGroupPolicyWarnings,
createConditionalWarningCollector(
({ account }) =>
account.config.dm?.policy === "open" &&
'- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".',
),
);
export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
id: "googlechat",
meta: { ...meta },
setup: googlechatSetupAdapter,
setupWizard: googlechatSetupWizard,
pairing: {
pairing: createTextPairingAdapter({
idLabel: "googlechatUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
notifyApproval: async ({ cfg, id }) => {
notify: async ({ cfg, id, message }) => {
const account = resolveGoogleChatAccount({ cfg: cfg });
if (account.credentialSource === "none") {
return;
@ -123,10 +158,10 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
await sendGoogleChatMessage({
account,
space,
text: PAIRING_APPROVED_MESSAGE,
text: message,
});
},
},
}),
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
@ -153,36 +188,13 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
},
security: {
resolveDmPolicy: resolveGoogleChatDmPolicy,
collectWarnings: ({ account, cfg }) => {
const warnings = collectAllowlistProviderGroupPolicyWarnings({
cfg,
providerConfigPresent: cfg.channels?.googlechat !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
groupPolicy === "open"
? [
buildOpenGroupPolicyConfigureRouteAllowlistWarning({
surface: "Google Chat spaces",
openScope: "any space",
groupPolicyPath: "channels.googlechat.groupPolicy",
routeAllowlistPath: "channels.googlechat.groups",
}),
]
: [],
});
if (account.config.dm?.policy === "open") {
warnings.push(
`- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`,
);
}
return warnings;
},
collectWarnings: collectGoogleChatSecurityWarnings,
},
groups: {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"),
},
messaging: {
normalizeTarget: normalizeGoogleChatTarget,
@ -194,32 +206,21 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
hint: "<spaces/{space}|users/{user}>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
});
return listDirectoryUserEntriesFromAllowFrom({
allowFrom: account.config.dm?.allowFrom,
query,
limit,
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryUserEntriesFromAllowFrom({
...params,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
});
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
});
return listDirectoryGroupEntriesFromMapKeys({
groups: account.config.groups,
query,
limit,
});
},
},
}),
listGroups: async (params) =>
listResolvedDirectoryGroupEntriesFromMapKeys({
...params,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
resolveGroups: (account) => account.config.groups,
}),
}),
resolver: {
resolveTargets: async ({ inputs, kind }) => {
const resolved = inputs.map((input) => {
@ -267,91 +268,97 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
};
},
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
const result = await sendGoogleChatMessage({
account,
space,
...createAttachedChannelResultAdapter({
channel: "googlechat",
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
});
return {
messageId: result?.messageName ?? "",
chatId: space,
};
},
sendMedia: async ({
cfg,
to,
text,
thread,
});
return {
channel: "googlechat",
messageId: result?.messageName ?? "",
chatId: space,
};
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
replyToId,
threadId,
}) => {
if (!mediaUrl) {
throw new Error("Google Chat mediaUrl is required.");
}
const account = resolveGoogleChatAccount({
cfg: cfg,
mediaUrl,
mediaLocalRoots,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
const runtime = getGoogleChatRuntime();
const maxBytes = resolveChannelMediaMaxBytes({
cfg: cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
(
cfg.channels?.["googlechat"] as
| { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number }
| undefined
)?.accounts?.[accountId]?.mediaMaxMb ??
(cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
accountId,
});
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = /^https?:\/\//i.test(mediaUrl)
? await runtime.channel.media.fetchRemoteMedia({
url: mediaUrl,
maxBytes: effectiveMaxBytes,
})
: await runtime.media.loadWebMedia(mediaUrl, {
maxBytes: effectiveMaxBytes,
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
});
const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
await loadGoogleChatChannelRuntime();
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: loaded.fileName ?? "attachment",
buffer: loaded.buffer,
contentType: loaded.contentType,
});
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
attachments: upload.attachmentUploadToken
? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }]
: undefined,
});
return {
channel: "googlechat",
messageId: result?.messageName ?? "",
chatId: space,
};
},
replyToId,
threadId,
}) => {
if (!mediaUrl) {
throw new Error("Google Chat mediaUrl is required.");
}
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
const runtime = getGoogleChatRuntime();
const maxBytes = resolveChannelMediaMaxBytes({
cfg: cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
(
cfg.channels?.["googlechat"] as
| { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number }
| undefined
)?.accounts?.[accountId]?.mediaMaxMb ??
(cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
accountId,
});
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = /^https?:\/\//i.test(mediaUrl)
? await runtime.channel.media.fetchRemoteMedia({
url: mediaUrl,
maxBytes: effectiveMaxBytes,
})
: await runtime.media.loadWebMedia(mediaUrl, {
maxBytes: effectiveMaxBytes,
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
});
const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
await loadGoogleChatChannelRuntime();
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: loaded.fileName ?? "attachment",
buffer: loaded.buffer,
contentType: loaded.contentType,
});
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
attachments: upload.attachmentUploadToken
? [
{
attachmentUploadToken: upload.attachmentUploadToken,
contentName: loaded.fileName,
},
]
: undefined,
});
return {
messageId: result?.messageName ?? "",
chatId: space,
};
},
}),
},
status: {
defaultRuntime: {

View File

@ -1,4 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig } from "../runtime-api.js";
import {
createWebhookInFlightLimiter,
@ -375,14 +379,14 @@ async function deliverGoogleChatReply(params: {
}): Promise<void> {
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } =
params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const reply = resolveSendableOutboundReplyParts(payload);
const mediaCount = reply.mediaCount;
const hasMedia = reply.hasMedia;
const text = reply.text;
let firstTextChunk = true;
let suppressCaption = false;
if (mediaList.length > 0) {
let suppressCaption = false;
if (hasMedia) {
if (typingMessageName) {
try {
await deleteGoogleChatMessage({
@ -391,9 +395,9 @@ async function deliverGoogleChatReply(params: {
});
} catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
const fallbackText = payload.text?.trim()
? payload.text
: mediaList.length > 1
const fallbackText = reply.hasText
? text
: mediaCount > 1
? "Sent attachments."
: "Sent attachment.";
try {
@ -402,16 +406,43 @@ async function deliverGoogleChatReply(params: {
messageName: typingMessageName,
text: fallbackText,
});
suppressCaption = Boolean(payload.text?.trim());
suppressCaption = Boolean(text.trim());
} catch (updateErr) {
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
}
}
}
let first = true;
for (const mediaUrl of mediaList) {
const caption = first && !suppressCaption ? payload.text : undefined;
first = false;
}
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
await deliverTextOrMediaReply({
payload,
text: suppressCaption ? "" : reply.text,
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
sendText: async (chunk) => {
try {
if (firstTextChunk && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
await sendGoogleChatMessage({
account,
space: spaceId,
text: chunk,
thread: payload.replyToId,
});
}
firstTextChunk = false;
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
}
},
sendMedia: async ({ mediaUrl, caption }) => {
try {
const loaded = await core.channel.media.fetchRemoteMedia({
url: mediaUrl,
@ -440,38 +471,8 @@ async function deliverGoogleChatReply(params: {
} catch (err) {
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
}
}
return;
}
if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
// Edit typing message with first chunk if available
if (i === 0 && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
await sendGoogleChatMessage({
account,
space: spaceId,
text: chunk,
thread: payload.replyToId,
});
}
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
}
}
}
},
});
}
async function uploadAttachmentForReply(params: {

View File

@ -1,5 +1,8 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import {
createAttachedChannelResultAdapter,
resolveOutboundSendDep,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { type RoutePeer } from "openclaw/plugin-sdk/routing";
@ -21,6 +24,7 @@ import { imessageSetupAdapter } from "./setup-core.js";
import {
collectIMessageSecurityWarnings,
createIMessagePluginBase,
imessageConfigAdapter,
imessageResolveDmPolicy,
imessageSetupWizard,
} from "./shared.js";
@ -113,26 +117,15 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
notifyApproval: async ({ id }) =>
await (await loadIMessageChannelRuntime()).notifyIMessageApproval(id),
},
allowlist: {
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
readConfig: ({ cfg, accountId }) => {
const account = resolveIMessageAccount({ cfg, accountId });
return {
dmAllowFrom: (account.config.allowFrom ?? []).map(String),
groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String),
dmPolicy: account.config.dmPolicy,
groupPolicy: account.config.groupPolicy,
};
},
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "imessage",
normalize: ({ values }) => formatTrimmedAllowFromEntries(values),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "imessage",
resolveAccount: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }),
normalize: ({ values }) => formatTrimmedAllowFromEntries(values),
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupAllowFrom: (account) => account.config.groupAllowFrom,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
}),
security: {
resolveDmPolicy: imessageResolveDmPolicy,
collectWarnings: collectIMessageSecurityWarnings,
@ -170,34 +163,33 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
const result = await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg,
to,
text,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,
});
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => {
const result = await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,
});
return { channel: "imessage", ...result };
},
...createAttachedChannelResultAdapter({
channel: "imessage",
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) =>
await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg,
to,
text,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,
}),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) =>
await (
await loadIMessageChannelRuntime()
).sendIMessageOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,
}),
}),
},
status: {
defaultRuntime: {

View File

@ -1,5 +1,9 @@
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@ -30,15 +34,18 @@ export async function deliverReplies(params: {
});
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = sanitizeOutboundText(payload.text ?? "");
const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) {
continue;
const reply = resolveSendableOutboundReplyParts(payload, {
text: convertMarkdownTables(rawText, tableMode),
});
if (!reply.hasMedia && reply.hasText) {
sentMessageCache?.remember(scope, { text: reply.text });
}
if (mediaList.length === 0) {
sentMessageCache?.remember(scope, { text });
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
const delivered = await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
sendText: async (chunk) => {
const sent = await sendMessageIMessage(target, chunk, {
maxBytes,
client,
@ -46,14 +53,10 @@ export async function deliverReplies(params: {
replyToId: payload.replyToId,
});
sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId });
}
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? text : "";
first = false;
const sent = await sendMessageIMessage(target, caption, {
mediaUrl: url,
},
sendMedia: async ({ mediaUrl, caption }) => {
const sent = await sendMessageIMessage(target, caption ?? "", {
mediaUrl,
maxBytes,
client,
accountId,
@ -63,8 +66,10 @@ export async function deliverReplies(params: {
text: caption || undefined,
messageId: sent.messageId,
});
}
},
});
if (delivered !== "empty") {
runtime.log?.(`imessage: delivered reply to ${target}`);
}
runtime.log?.(`imessage: delivered reply to ${target}`);
}
}

View File

@ -1,13 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as onboardHelpers from "../../../src/commands/onboard-helpers.js";
import * as execModule from "../../../src/process/exec.js";
import * as processRuntime from "../../../src/plugin-sdk/process-runtime.js";
import * as setupRuntime from "../../../src/plugin-sdk/setup.js";
import * as clientModule from "./client.js";
import { probeIMessage } from "./probe.js";
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(onboardHelpers, "detectBinary").mockResolvedValue(true);
vi.spyOn(execModule, "runCommandWithTimeout").mockResolvedValue({
vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true);
vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
stdout: "",
stderr: 'unknown command "rpc" for "imsg"',
code: 1,
@ -25,7 +25,7 @@ describe("probeIMessage", () => {
request: vi.fn(),
stop: vi.fn(),
} as unknown as Awaited<ReturnType<typeof clientModule.createIMessageRpcClient>>);
const result = await probeIMessage(1000, { cliPath: "imsg" });
const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" });
expect(result.ok).toBe(false);
expect(result.fatal).toBe(true);
expect(result.error).toMatch(/rpc/i);

View File

@ -1,9 +1,9 @@
import {
collectAllowlistProviderRestrictSendersWarnings,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
formatTrimmedAllowFromEntries,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import {
buildChannelConfigSchema,
@ -47,21 +47,16 @@ export const imessageResolveDmPolicy = createScopedDmSecurityResolver<ResolvedIM
policyPathSuffix: "dmPolicy",
});
export function collectIMessageSecurityWarnings(params: {
account: ResolvedIMessageAccount;
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
}) {
return collectAllowlistProviderRestrictSendersWarnings({
cfg: params.cfg,
providerConfigPresent: params.cfg.channels?.imessage !== undefined,
configuredGroupPolicy: params.account.config.groupPolicy,
export const collectIMessageSecurityWarnings =
createAllowlistProviderRestrictSendersWarningCollector<ResolvedIMessageAccount>({
providerConfigPresent: (cfg) => cfg.channels?.imessage !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "iMessage groups",
openScope: "any member",
groupPolicyPath: "channels.imessage.groupPolicy",
groupAllowFromPath: "channels.imessage.groupAllowFrom",
mentionGated: false,
});
}
export function createIMessagePluginBase(params: {
setupWizard?: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setupWizard"]>;

View File

@ -10,9 +10,13 @@ import {
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({
spawn: (...args: unknown[]) => spawnMock(...args),
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawn: (...args: unknown[]) => spawnMock(...args),
};
});
describe("imessage targets", () => {
it("parses chat_id targets", () => {

View File

@ -4,9 +4,16 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildOpenGroupPolicyWarning,
collectAllowlistProviderGroupPolicyWarnings,
composeWarningCollectors,
createAllowlistProviderOpenWarningCollector,
createConditionalWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/channel-runtime";
import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js";
import {
listIrcAccountIds,
@ -88,6 +95,36 @@ const resolveIrcDmPolicy = createScopedDmSecurityResolver<ResolvedIrcAccount>({
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
});
const collectIrcGroupPolicyWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedIrcAccount>({
providerConfigPresent: (cfg) => cfg.channels?.irc !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
buildOpenWarning: {
surface: "IRC channels",
openBehavior: "allows all channels and senders (mention-gated)",
remediation: 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups',
},
});
const collectIrcSecurityWarnings = composeWarningCollectors<{
account: ResolvedIrcAccount;
cfg: CoreConfig;
}>(
collectIrcGroupPolicyWarnings,
createConditionalWarningCollector(
({ account }) =>
!account.config.tls &&
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
({ account }) =>
account.config.nickserv?.register &&
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
({ account }) =>
account.config.nickserv?.register &&
!account.config.nickserv.password?.trim() &&
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
),
);
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
id: "irc",
meta: {
@ -96,17 +133,18 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
},
setup: ircSetupAdapter,
setupWizard: ircSetupWizard,
pairing: {
pairing: createTextPairingAdapter({
idLabel: "ircUser",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry),
notifyApproval: async ({ id }) => {
notify: async ({ id, message }) => {
const target = normalizePairingTarget(id);
if (!target) {
throw new Error(`invalid IRC pairing id: ${id}`);
}
await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE);
await sendMessageIrc(target, message);
},
},
}),
capabilities: {
chatTypes: ["direct", "group"],
media: true,
@ -131,40 +169,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
},
security: {
resolveDmPolicy: resolveIrcDmPolicy,
collectWarnings: ({ account, cfg }) => {
const warnings = collectAllowlistProviderGroupPolicyWarnings({
cfg,
providerConfigPresent: cfg.channels?.irc !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
groupPolicy === "open"
? [
buildOpenGroupPolicyWarning({
surface: "IRC channels",
openBehavior: "allows all channels and senders (mention-gated)",
remediation:
'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups',
}),
]
: [],
});
if (!account.config.tls) {
warnings.push(
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
);
}
if (account.config.nickserv?.register) {
warnings.push(
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
);
if (!account.config.nickserv.password?.trim()) {
warnings.push(
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
);
}
}
return warnings;
},
collectWarnings: collectIrcSecurityWarnings,
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
@ -230,88 +235,58 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
});
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() ?? "";
const ids = new Set<string>();
for (const entry of account.config.allowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
for (const entry of account.config.groupAllowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
for (const group of Object.values(account.config.groups ?? {})) {
for (const entry of group.allowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
}
return Array.from(ids)
.filter((id) => (q ? id.includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }));
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryEntriesFromSources({
...params,
kind: "user",
resolveAccount: (cfg, accountId) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
resolveSources: (account) => [
account.config.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []),
],
normalizeId: (entry) => normalizePairingTarget(entry) || null,
}),
listGroups: async (params) => {
const entries = listResolvedDirectoryEntriesFromSources({
...params,
kind: "group",
resolveAccount: (cfg, accountId) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
resolveSources: (account) => [
account.config.channels ?? [],
Object.keys(account.config.groups ?? {}),
],
normalizeId: (entry) => {
const normalized = normalizeIrcMessagingTarget(entry);
return normalized && isChannelTarget(normalized) ? normalized : null;
},
});
return entries.map((entry) => ({ ...entry, name: entry.id }));
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() ?? "";
const groupIds = new Set<string>();
for (const channel of account.config.channels ?? []) {
const normalized = normalizeIrcMessagingTarget(channel);
if (normalized && isChannelTarget(normalized)) {
groupIds.add(normalized);
}
}
for (const group of Object.keys(account.config.groups ?? {})) {
if (group === "*") {
continue;
}
const normalized = normalizeIrcMessagingTarget(group);
if (normalized && isChannelTarget(normalized)) {
groupIds.add(normalized);
}
}
return Array.from(groupIds)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id, name: id }));
},
},
}),
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 350,
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const result = await sendMessageIrc(to, text, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "irc", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
const result = await sendMessageIrc(to, combined, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "irc", ...result };
},
...createAttachedChannelResultAdapter({
channel: "irc",
sendText: async ({ cfg, to, text, accountId, replyToId }) =>
await sendMessageIrc(to, text, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
}),
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
}),
}),
},
status: {
defaultRuntime: {

View File

@ -10,14 +10,13 @@ import {
import {
GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess,
deliverFormattedTextWithAttachments,
dispatchInboundReplyWithBase,
formatTextWithAttachmentLinks,
issuePairingChallenge,
logInboundDrop,
isDangerousNameMatchingEnabled,
readStoreAllowFromForDmPolicy,
resolveControlCommandGate,
resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveEffectiveAllowFromLists,
@ -61,23 +60,23 @@ async function deliverIrcReply(params: {
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}) {
const combined = formatTextWithAttachmentLinks(
params.payload.text,
resolveOutboundMediaUrls(params.payload),
);
if (!combined) {
const delivered = await deliverFormattedTextWithAttachments({
payload: params.payload,
send: async ({ text, replyToId }) => {
if (params.sendReply) {
await params.sendReply(params.target, text, replyToId);
} else {
await sendMessageIrc(params.target, text, {
accountId: params.accountId,
replyTo: replyToId,
});
}
params.statusSink?.({ lastOutboundAt: Date.now() });
},
});
if (!delivered) {
return;
}
if (params.sendReply) {
await params.sendReply(params.target, combined, params.payload.replyToId);
} else {
await sendMessageIrc(params.target, combined, {
accountId: params.accountId,
replyTo: params.payload.replyToId,
});
}
params.statusSink?.({ lastOutboundAt: Date.now() });
}
export async function handleIrcInbound(params: {

View File

@ -1,5 +1,13 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
createEmptyChannelDirectoryAdapter,
createEmptyChannelResult,
createPairingPrefixStripper,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import {
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
@ -42,29 +50,39 @@ const resolveLineDmPolicy = createScopedDmSecurityResolver<ResolvedLineAccount>(
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
});
const collectLineSecurityWarnings =
createAllowlistProviderRestrictSendersWarningCollector<ResolvedLineAccount>({
providerConfigPresent: (cfg) => cfg.channels?.line !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "LINE groups",
openScope: "any member in groups",
groupPolicyPath: "channels.line.groupPolicy",
groupAllowFromPath: "channels.line.groupAllowFrom",
mentionGated: false,
});
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
...meta,
quickstartAllowFrom: true,
},
pairing: {
pairing: createTextPairingAdapter({
idLabel: "lineUserId",
normalizeAllowEntry: (entry) => {
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
return entry.replace(/^line:(?:user:)?/i, "");
},
notifyApproval: async ({ cfg, id }) => {
message: "OpenClaw: your access has been approved.",
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
notify: async ({ cfg, id, message }) => {
const line = getLineRuntime().channel.line;
const account = line.resolveLineAccount({ cfg });
if (!account.channelAccessToken) {
throw new Error("LINE channel access token not configured");
}
await line.pushMessageLine(id, "OpenClaw: your access has been approved.", {
await line.pushMessageLine(id, message, {
channelAccessToken: account.channelAccessToken,
});
},
},
}),
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
@ -90,18 +108,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
},
security: {
resolveDmPolicy: resolveLineDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.line !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "LINE groups",
openScope: "any member in groups",
groupPolicyPath: "channels.line.groupPolicy",
groupAllowFromPath: "channels.line.groupAllowFrom",
mentionGated: false,
});
},
collectWarnings: collectLineSecurityWarnings,
},
groups: {
resolveRequireMention: resolveLineGroupRequireMention,
@ -128,11 +135,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
hint: "<userId|groupId|roomId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
directory: createEmptyChannelDirectoryAdapter(),
setup: lineSetupAdapter,
outbound: {
deliveryMode: "direct",
@ -184,7 +187,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const mediaUrls = resolveOutboundMediaUrls(payload);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
const sendMediaMessages = async () => {
for (const url of mediaUrls) {
@ -317,54 +320,45 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
}
if (lastResult) {
return { channel: "line", ...lastResult };
return createEmptyChannelResult("line", { ...lastResult });
}
return { channel: "line", messageId: "empty", chatId: to };
return createEmptyChannelResult("line", { messageId: "empty", chatId: to });
},
sendText: async ({ cfg, to, text, accountId }) => {
const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
// Process markdown: extract tables/code blocks, strip formatting
const processed = processLineMessage(text);
// Send cleaned text first (if non-empty)
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
...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,
});
} else {
// If text is empty after processing, still need a result
result = { messageId: "processed", chatId: to };
}
// Send flex messages for tables/code blocks
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];
await sendFlex(to, flexMsg.altText, flexContents, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
});
}
return { channel: "line", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
const send = getLineRuntime().channel.line.sendMessageLine;
const result = await send(to, text, {
verbose: false,
mediaUrl,
cfg,
accountId: accountId ?? undefined,
});
return { channel: "line", ...result };
},
}),
}),
},
status: {
defaultRuntime: {

View File

@ -1,13 +1,11 @@
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import type { OpenClawConfig, ResolvedLineAccount } from "../api.js";
import { getLineRuntime } from "./runtime.js";
function resolveLineRuntimeAccount(cfg: OpenClawConfig, accountId?: string | null) {
return getLineRuntime().channel.line.resolveLineAccount({
cfg,
accountId: accountId ?? undefined,
});
}
import {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
type OpenClawConfig,
type ResolvedLineAccount,
} from "../runtime-api.js";
export function normalizeLineAllowFrom(entry: string): string {
return entry.replace(/^line:(?:user:)?/i, "");
@ -19,9 +17,10 @@ export const lineConfigAdapter = createScopedChannelConfigAdapter<
OpenClawConfig
>({
sectionKey: "line",
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveLineRuntimeAccount(cfg, accountId),
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
listAccountIds: listLineAccountIds,
resolveAccount: (cfg, accountId) =>
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
defaultAccountId: resolveDefaultLineAccountId,
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>

View File

@ -3,9 +3,18 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildOpenGroupPolicyWarning,
collectAllowlistProviderGroupPolicyWarnings,
createAllowlistProviderOpenWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
createPairingPrefixStripper,
createScopedAccountReplyToModeResolver,
createRuntimeDirectoryLiveAdapter,
createRuntimeOutboundDelegates,
createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/channel-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
import {
@ -100,18 +109,31 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccou
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
});
const collectMatrixSecurityWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedMatrixAccount>({
providerConfigPresent: (cfg) => (cfg as CoreConfig).channels?.matrix !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
buildOpenWarning: {
surface: "Matrix rooms",
openBehavior: "allows any room to trigger (mention-gated)",
remediation:
'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms',
},
});
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
meta,
setupWizard: matrixSetupWizard,
pairing: {
pairing: createTextPairingAdapter({
idLabel: "matrixUserId",
normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
notifyApproval: async ({ id }) => {
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i),
notify: async ({ id, message }) => {
const { sendMessageMatrix } = await loadMatrixChannelRuntime();
await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE);
await sendMessageMatrix(`user:${id}`, message);
},
},
}),
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
@ -134,32 +156,24 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
},
security: {
resolveDmPolicy: resolveMatrixDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderGroupPolicyWarnings({
collectWarnings: projectWarningCollector(
({ account, cfg }: { account: ResolvedMatrixAccount; cfg: unknown }) => ({
account,
cfg: cfg as CoreConfig,
providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
groupPolicy === "open"
? [
buildOpenGroupPolicyWarning({
surface: "Matrix rooms",
openBehavior: "allows any room to trigger (mention-gated)",
remediation:
'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms',
}),
]
: [],
});
},
}),
collectMatrixSecurityWarnings,
),
},
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
resolveReplyToMode: createScopedAccountReplyToModeResolver({
resolveAccount: (cfg, accountId) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }),
resolveReplyToMode: (account) => account.replyToMode,
}),
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
@ -187,101 +201,63 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
hint: "<room|alias|user>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const entry of account.config.groupAllowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
const groups = account.config.groups ?? account.config.rooms ?? {};
for (const room of Object.values(groups)) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
directory: createChannelDirectoryAdapter({
listPeers: async (params) => {
const entries = listResolvedDirectoryEntriesFromSources({
...params,
kind: "user",
resolveAccount: (cfg, accountId) =>
resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
resolveSources: (account) => [
account.config.dm?.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
(room) => room.users ?? [],
),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
continue;
return null;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
if (cleaned.startsWith("@")) {
return `user:${cleaned}`;
}
return cleaned;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => {
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return {
kind: "user",
id,
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
};
});
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
},
});
return entries.map((entry) => {
const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return incomplete ? { ...entry, name: "incomplete id; expected @user:server" } : entry;
});
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = account.config.groups ?? account.config.rooms ?? {};
const ids = Object.keys(groups)
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
.map((raw) => {
listGroups: async (params) =>
listResolvedDirectoryEntriesFromSources({
...params,
kind: "group",
resolveAccount: (cfg, accountId) =>
resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
resolveSources: (account) => [
Object.keys(account.config.groups ?? account.config.rooms ?? {}),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
if (raw.startsWith("!")) {
return `room:${raw}`;
}
return raw;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
listPeersLive: async ({ cfg, accountId, query, limit }) =>
(await loadMatrixChannelRuntime()).listMatrixDirectoryPeersLive({
cfg,
accountId,
query,
limit,
return raw.startsWith("!") ? `room:${raw}` : raw;
},
}),
listGroupsLive: async ({ cfg, accountId, query, limit }) =>
(await loadMatrixChannelRuntime()).listMatrixDirectoryGroupsLive({
cfg,
accountId,
query,
limit,
}),
},
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMatrixChannelRuntime,
listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listMatrixDirectoryGroupsLive,
}),
}),
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
(await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }),
@ -293,27 +269,21 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async (params) => {
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
if (!outbound.sendText) {
throw new Error("Matrix outbound text delivery is unavailable");
}
return await outbound.sendText(params);
},
sendMedia: async (params) => {
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
if (!outbound.sendMedia) {
throw new Error("Matrix outbound media delivery is unavailable");
}
return await outbound.sendMedia(params);
},
sendPoll: async (params) => {
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
if (!outbound.sendPoll) {
throw new Error("Matrix outbound poll delivery is unavailable");
}
return await outbound.sendPoll(params);
},
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
sendText: {
resolve: (runtime) => runtime.matrixOutbound.sendText,
unavailableMessage: "Matrix outbound text delivery is unavailable",
},
sendMedia: {
resolve: (runtime) => runtime.matrixOutbound.sendMedia,
unavailableMessage: "Matrix outbound media delivery is unavailable",
},
sendPoll: {
resolve: (runtime) => runtime.matrixOutbound.sendPoll,
unavailableMessage: "Matrix outbound poll delivery is unavailable",
},
}),
},
status: {
defaultRuntime: {

View File

@ -1,4 +1,8 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js";
import { getMatrixRuntime } from "../../runtime.js";
import { sendMessageMatrix } from "../send.js";
@ -32,8 +36,10 @@ export async function deliverMatrixReplies(params: {
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const replyContent = resolveSendableOutboundReplyParts(reply, { text });
if (!replyContent.hasContent) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
@ -48,57 +54,39 @@ export async function deliverMatrixReplies(params: {
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
? [reply.mediaUrl]
: [];
const shouldIncludeReply = (id?: string) =>
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
if (mediaList.length === 0) {
let sentTextChunk = false;
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
text,
chunkLimit,
chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed) {
continue;
}
const delivered = await deliverTextOrMediaReply({
payload: reply,
text: replyContent.text,
chunkText: (value) =>
core.channel.text
.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode)
.map((chunk) => chunk.trim())
.filter(Boolean),
sendText: async (trimmed) => {
await sendMessageMatrix(params.roomId, trimmed, {
client: params.client,
replyToId: replyToIdForReply,
threadId: params.threadId,
accountId: params.accountId,
});
sentTextChunk = true;
}
if (replyToIdForReply && !hasReplied && sentTextChunk) {
hasReplied = true;
}
continue;
}
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
await sendMessageMatrix(params.roomId, caption, {
client: params.client,
mediaUrl,
replyToId: replyToIdForReply,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
first = false;
}
if (replyToIdForReply && !hasReplied) {
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendMessageMatrix(params.roomId, caption ?? "", {
client: params.client,
mediaUrl,
replyToId: replyToIdForReply,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
},
});
if (replyToIdForReply && !hasReplied && delivered !== "empty") {
hasReplied = true;
}
}

View File

@ -3,9 +3,15 @@ import {
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
createLoggedPairingApprovalNotifier,
createMessageToolButtonsSchema,
createScopedAccountReplyToModeResolver,
type ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { MattermostConfigSchema } from "./config-schema.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
@ -42,6 +48,16 @@ import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
import { mattermostSetupAdapter } from "./setup-core.js";
import { mattermostSetupWizard } from "./setup-surface.js";
const collectMattermostSecurityWarnings =
createAllowlistProviderRestrictSendersWarningCollector<ResolvedMattermostAccount>({
providerConfigPresent: (cfg) => cfg.channels?.mattermost !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "Mattermost channels",
openScope: "any member",
groupPolicyPath: "channels.mattermost.groupPolicy",
groupAllowFromPath: "channels.mattermost.groupAllowFrom",
});
function describeMattermostMessageTool({
cfg,
}: Parameters<
@ -279,9 +295,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
pairing: {
idLabel: "mattermostUserId",
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
notifyApproval: async ({ id }) => {
console.log(`[mattermost] User ${id} approved for pairing`);
},
notifyApproval: createLoggedPairingApprovalNotifier(
({ id }) => `[mattermost] User ${id} approved for pairing`,
),
},
capabilities: {
chatTypes: ["direct", "channel", "group", "thread"],
@ -294,14 +310,17 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => {
const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
const kind =
chatType === "direct" || chatType === "group" || chatType === "channel"
? chatType
: "channel";
return resolveMattermostReplyToMode(account, kind);
},
resolveReplyToMode: createScopedAccountReplyToModeResolver({
resolveAccount: (cfg, accountId) =>
resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }),
resolveReplyToMode: (account, chatType) =>
resolveMattermostReplyToMode(
account,
chatType === "direct" || chatType === "group" || chatType === "channel"
? chatType
: "channel",
),
}),
},
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
@ -319,28 +338,18 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
},
security: {
resolveDmPolicy: resolveMattermostDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.mattermost !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "Mattermost channels",
openScope: "any member",
groupPolicyPath: "channels.mattermost.groupPolicy",
groupAllowFromPath: "channels.mattermost.groupAllowFrom",
});
},
collectWarnings: collectMattermostSecurityWarnings,
},
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
directory: {
directory: createChannelDirectoryAdapter({
listGroups: async (params) => listMattermostDirectoryGroups(params),
listGroupsLive: async (params) => listMattermostDirectoryGroups(params),
listPeers: async (params) => listMattermostDirectoryPeers(params),
listPeersLive: async (params) => listMattermostDirectoryPeers(params),
},
}),
messaging: {
normalizeTarget: normalizeMattermostMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params),
@ -381,33 +390,32 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const result = await sendMessageMattermost(to, text, {
...createAttachedChannelResultAdapter({
channel: "mattermost",
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
await sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
sendMedia: async ({
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
});
return { channel: "mattermost", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
replyToId,
threadId,
}) => {
const result = await sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
to,
text,
mediaUrl,
mediaLocalRoots,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
});
return { channel: "mattermost", ...result };
},
accountId,
replyToId,
threadId,
}) =>
await sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
mediaUrl,
mediaLocalRoots,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
}),
},
status: {
defaultRuntime: {

View File

@ -1,3 +1,7 @@
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js";
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
@ -26,46 +30,36 @@ export async function deliverMattermostReplyPayload(params: {
tableMode: MarkdownTableMode;
sendMessage: SendMattermostMessage;
}): Promise<void> {
const mediaUrls =
params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []);
const text = params.core.channel.text.convertMarkdownTables(
params.payload.text ?? "",
params.tableMode,
const reply = resolveSendableOutboundReplyParts(params.payload, {
text: params.core.channel.text.convertMarkdownTables(
params.payload.text ?? "",
params.tableMode,
),
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
const chunkMode = params.core.channel.text.resolveChunkMode(
params.cfg,
"mattermost",
params.accountId,
);
if (mediaUrls.length === 0) {
const chunkMode = params.core.channel.text.resolveChunkMode(
params.cfg,
"mattermost",
params.accountId,
);
const chunks = params.core.channel.text.chunkMarkdownTextWithMode(
text,
params.textLimit,
chunkMode,
);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) {
continue;
}
await deliverTextOrMediaReply({
payload: params.payload,
text: reply.text,
chunkText: (value) =>
params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode),
sendText: async (chunk) => {
await params.sendMessage(params.to, chunk, {
accountId: params.accountId,
replyToId: params.replyToId,
});
}
return;
}
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
let first = true;
for (const mediaUrl of mediaUrls) {
const caption = first ? text : "";
first = false;
await params.sendMessage(params.to, caption, {
accountId: params.accountId,
mediaUrl,
mediaLocalRoots,
replyToId: params.replyToId,
});
}
},
sendMedia: async ({ mediaUrl, caption }) => {
await params.sendMessage(params.to, caption ?? "", {
accountId: params.accountId,
mediaUrl,
mediaLocalRoots,
replyToId: params.replyToId,
});
},
});
}

View File

@ -23,7 +23,7 @@ import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-cat
const API_PROVIDER_ID = "minimax";
const PORTAL_PROVIDER_ID = "minimax-portal";
const PROVIDER_LABEL = "MiniMax";
const DEFAULT_MODEL = "MiniMax-M2.5";
const DEFAULT_MODEL = "MiniMax-M2.7";
const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic";
const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic";
@ -40,7 +40,8 @@ function portalModelRef(modelId: string): string {
}
function isModernMiniMaxModel(modelId: string): boolean {
return modelId.trim().toLowerCase().startsWith("minimax-m2.5");
const lower = modelId.trim().toLowerCase();
return lower.startsWith("minimax-m2.7") || lower.startsWith("minimax-m2.5");
}
function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) {
@ -129,6 +130,10 @@ function createOAuthHandler(region: MiniMaxRegion) {
agents: {
defaults: {
models: {
[portalModelRef("MiniMax-M2.7")]: { alias: "minimax-m2.7" },
[portalModelRef("MiniMax-M2.7-highspeed")]: {
alias: "minimax-m2.7-highspeed",
},
[portalModelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" },
[portalModelRef("MiniMax-M2.5-highspeed")]: {
alias: "minimax-m2.5-highspeed",
@ -190,7 +195,7 @@ export default definePluginEntry({
choiceHint: "Global endpoint - api.minimax.io",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
groupHint: "M2.7 (recommended)",
},
}),
createProviderApiKeyAuthMethod({
@ -214,7 +219,7 @@ export default definePluginEntry({
choiceHint: "CN endpoint - api.minimaxi.com",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
groupHint: "M2.7 (recommended)",
},
}),
],
@ -253,7 +258,7 @@ export default definePluginEntry({
choiceHint: "Global endpoint - api.minimax.io",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
groupHint: "M2.7 (recommended)",
},
run: createOAuthHandler("global"),
},
@ -268,7 +273,7 @@ export default definePluginEntry({
choiceHint: "CN endpoint - api.minimaxi.com",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
groupHint: "M2.7 (recommended)",
},
run: createOAuthHandler("cn"),
},

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
buildMinimaxApiModelDefinition,
buildMinimaxModelDefinition,
DEFAULT_MINIMAX_CONTEXT_WINDOW,
DEFAULT_MINIMAX_MAX_TOKENS,
MINIMAX_API_COST,
MINIMAX_HOSTED_MODEL_ID,
} from "./model-definitions.js";
describe("minimax model definitions", () => {
it("uses M2.7 as default hosted model", () => {
expect(MINIMAX_HOSTED_MODEL_ID).toBe("MiniMax-M2.7");
});
it("builds catalog model with name and reasoning from catalog", () => {
const model = buildMinimaxModelDefinition({
id: "MiniMax-M2.7",
cost: MINIMAX_API_COST,
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
});
expect(model).toMatchObject({
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: true,
});
});
it("builds API model definition with standard cost", () => {
const model = buildMinimaxApiModelDefinition("MiniMax-M2.7");
expect(model.cost).toEqual(MINIMAX_API_COST);
expect(model.contextWindow).toBe(DEFAULT_MINIMAX_CONTEXT_WINDOW);
expect(model.maxTokens).toBe(DEFAULT_MINIMAX_MAX_TOKENS);
});
it("falls back to generated name for unknown model id", () => {
const model = buildMinimaxApiModelDefinition("MiniMax-Future");
expect(model.name).toBe("MiniMax MiniMax-Future");
expect(model.reasoning).toBe(false);
});
});

View File

@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"
export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic";
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5";
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.7";
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
export const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
@ -28,6 +28,8 @@ export const MINIMAX_LM_STUDIO_COST = {
};
const MINIMAX_MODEL_CATALOG = {
"MiniMax-M2.7": { name: "MiniMax M2.7", reasoning: true },
"MiniMax-M2.7-highspeed": { name: "MiniMax M2.7 Highspeed", reasoning: true },
"MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true },
"MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true },
} as const;

View File

@ -61,7 +61,7 @@ function applyMinimaxApiConfigWithBaseUrl(
export function applyMinimaxApiProviderConfig(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
modelId: string = "MiniMax-M2.7",
): OpenClawConfig {
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, {
providerId: "minimax",
@ -72,7 +72,7 @@ export function applyMinimaxApiProviderConfig(
export function applyMinimaxApiConfig(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
modelId: string = "MiniMax-M2.7",
): OpenClawConfig {
return applyMinimaxApiConfigWithBaseUrl(cfg, {
providerId: "minimax",
@ -83,7 +83,7 @@ export function applyMinimaxApiConfig(
export function applyMinimaxApiProviderConfigCn(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
modelId: string = "MiniMax-M2.7",
): OpenClawConfig {
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, {
providerId: "minimax",
@ -94,7 +94,7 @@ export function applyMinimaxApiProviderConfigCn(
export function applyMinimaxApiConfigCn(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
modelId: string = "MiniMax-M2.7",
): OpenClawConfig {
return applyMinimaxApiConfigWithBaseUrl(cfg, {
providerId: "minimax",

View File

@ -14,7 +14,7 @@
"choiceHint": "Global endpoint - api.minimax.io",
"groupId": "minimax",
"groupLabel": "MiniMax",
"groupHint": "M2.5 (recommended)"
"groupHint": "M2.7 (recommended)"
},
{
"provider": "minimax",
@ -24,7 +24,7 @@
"choiceHint": "Global endpoint - api.minimax.io",
"groupId": "minimax",
"groupLabel": "MiniMax",
"groupHint": "M2.5 (recommended)",
"groupHint": "M2.7 (recommended)",
"optionKey": "minimaxApiKey",
"cliFlag": "--minimax-api-key",
"cliOption": "--minimax-api-key <key>",
@ -38,7 +38,7 @@
"choiceHint": "CN endpoint - api.minimaxi.com",
"groupId": "minimax",
"groupLabel": "MiniMax",
"groupHint": "M2.5 (recommended)"
"groupHint": "M2.7 (recommended)"
},
{
"provider": "minimax",
@ -48,7 +48,7 @@
"choiceHint": "CN endpoint - api.minimaxi.com",
"groupId": "minimax",
"groupLabel": "MiniMax",
"groupHint": "M2.5 (recommended)",
"groupHint": "M2.7 (recommended)",
"optionKey": "minimaxApiKey",
"cliFlag": "--minimax-api-key",
"cliOption": "--minimax-api-key <key>",

View File

@ -4,7 +4,7 @@ import type {
} from "openclaw/plugin-sdk/provider-models";
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5";
export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.7";
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
@ -50,6 +50,16 @@ function buildMinimaxCatalog(): ModelDefinitionConfig[] {
}),
buildMinimaxTextModel({
id: MINIMAX_DEFAULT_MODEL_ID,
name: "MiniMax M2.7",
reasoning: true,
}),
buildMinimaxTextModel({
id: "MiniMax-M2.7-highspeed",
name: "MiniMax M2.7 Highspeed",
reasoning: true,
}),
buildMinimaxTextModel({
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
reasoning: true,
}),

View File

@ -5,7 +5,8 @@
"type": "module",
"dependencies": {
"@microsoft/agents-hosting": "^1.3.1",
"express": "^5.2.1"
"express": "^5.2.1",
"uuid": "^11.1.0"
},
"openclaw": {
"extensions": [

View File

@ -1,11 +1,22 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import {
createAllowlistProviderGroupPolicyWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
createMessageToolCardSchema,
createPairingPrefixStripper,
createRuntimeDirectoryLiveAdapter,
createRuntimeOutboundDelegates,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-runtime";
import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js";
import {
@ -60,6 +71,19 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record<string, string> = {
"Files.Read.All": "files (OneDrive)",
};
const collectMSTeamsSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{
cfg: OpenClawConfig;
}>({
providerConfigPresent: (cfg) => cfg.channels?.msteams !== undefined,
resolveGroupPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy,
collect: ({ groupPolicy }) =>
groupPolicy === "open"
? [
'- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.',
]
: [],
});
const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"msTeamsChannelRuntime",
@ -117,18 +141,19 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
aliases: [...meta.aliases],
},
setupWizard: msteamsSetupWizard,
pairing: {
pairing: createTextPairingAdapter({
idLabel: "msteamsUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""),
notifyApproval: async ({ cfg, id }) => {
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(msteams|user):/i),
notify: async ({ cfg, id, message }) => {
const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime();
await sendMessageMSTeams({
cfg,
to: id,
text: PAIRING_APPROVED_MESSAGE,
text: message,
});
},
},
}),
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
@ -163,17 +188,10 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}),
},
security: {
collectWarnings: ({ cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.msteams !== undefined,
configuredGroupPolicy: cfg.channels?.msteams?.groupPolicy,
surface: "MS Teams groups",
openScope: "any member",
groupPolicyPath: "channels.msteams.groupPolicy",
groupAllowFromPath: "channels.msteams.groupAllowFrom",
});
},
collectWarnings: projectWarningCollector(
({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }),
collectMSTeamsSecurityWarnings,
),
},
setup: msteamsSetupAdapter,
messaging: {
@ -198,66 +216,43 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
hint: "<conversationId|user:ID|conversation:ID>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, query, limit }) => {
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) {
ids.add(trimmed);
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("user:")) {
return raw;
directory: createChannelDirectoryAdapter({
listPeers: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "user",
sources: [
cfg.channels?.msteams?.allowFrom ?? [],
Object.keys(cfg.channels?.msteams?.dms ?? {}),
],
query,
limit,
normalizeId: (raw) => {
const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw;
const lowered = normalized.toLowerCase();
if (lowered.startsWith("user:") || lowered.startsWith("conversation:")) {
return normalized;
}
if (lowered.startsWith("conversation:")) {
return raw;
}
return `user:${raw}`;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
},
listGroups: async ({ cfg, query, limit }) => {
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
for (const channelId of Object.keys(team.channels ?? {})) {
const trimmed = channelId.trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => raw.replace(/^conversation:/i, "").trim())
.map((id) => `conversation:${id}`)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
},
listPeersLive: async ({ cfg, query, limit }) =>
(await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryPeersLive({ cfg, query, limit }),
listGroupsLive: async ({ cfg, query, limit }) =>
(await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryGroupsLive({ cfg, query, limit }),
},
return `user:${normalized}`;
},
}),
listGroups: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "group",
sources: [
Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) =>
Object.keys(team.channels ?? {}),
),
],
query,
limit,
normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`,
}),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMSTeamsChannelRuntime,
listPeersLive: (runtime) => runtime.listMSTeamsDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listMSTeamsDirectoryGroupsLive,
}),
}),
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) => {
const results = inputs.map((input) => ({
@ -436,12 +431,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 12,
sendText: async (params) =>
(await loadMSTeamsChannelRuntime()).msteamsOutbound.sendText!(params),
sendMedia: async (params) =>
(await loadMSTeamsChannelRuntime()).msteamsOutbound.sendMedia!(params),
sendPoll: async (params) =>
(await loadMSTeamsChannelRuntime()).msteamsOutbound.sendPoll!(params),
...createRuntimeOutboundDelegates({
getRuntime: loadMSTeamsChannelRuntime,
sendText: { resolve: (runtime) => runtime.msteamsOutbound.sendText },
sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia },
sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll },
}),
},
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),

View File

@ -5,6 +5,7 @@ import {
type MarkdownTableMode,
type MSTeamsReplyStyle,
type ReplyPayload,
resolveSendableOutboundReplyParts,
SILENT_REPLY_TOKEN,
sleep,
} from "../runtime-api.js";
@ -216,41 +217,39 @@ export function renderReplyPayloadsToMessages(
});
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
payload.text ?? "",
tableMode,
);
const reply = resolveSendableOutboundReplyParts(payload, {
text: getMSTeamsRuntime().channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
});
if (!text && mediaList.length === 0) {
if (!reply.hasContent) {
continue;
}
if (mediaList.length === 0) {
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
if (!reply.hasMedia) {
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
continue;
}
if (mediaMode === "inline") {
// For inline mode, combine text with first media as attachment
const firstMedia = mediaList[0];
const firstMedia = reply.mediaUrls[0];
if (firstMedia) {
out.push({ text: text || undefined, mediaUrl: firstMedia });
out.push({ text: reply.text || undefined, mediaUrl: firstMedia });
// Additional media URLs as separate messages
for (let i = 1; i < mediaList.length; i++) {
if (mediaList[i]) {
out.push({ mediaUrl: mediaList[i] });
for (let i = 1; i < reply.mediaUrls.length; i++) {
if (reply.mediaUrls[i]) {
out.push({ mediaUrl: reply.mediaUrls[i] });
}
}
} else {
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
}
continue;
}
// mediaMode === "split"
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
for (const mediaUrl of mediaList) {
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
for (const mediaUrl of reply.mediaUrls) {
if (!mediaUrl) {
continue;
}

View File

@ -1,4 +1,5 @@
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import type { ChannelOutboundAdapter } from "../runtime-api.js";
import { createMSTeamsPollStoreFs } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
@ -10,56 +11,57 @@ export const msteamsOutbound: ChannelOutboundAdapter = {
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => {
type SendFn = (
to: string,
text: string,
) => Promise<{ messageId: string; conversationId: string }>;
const send =
resolveOutboundSendDep<SendFn>(deps, "msteams") ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => {
type SendFn = (
to: string,
text: string,
opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] },
) => Promise<{ messageId: string; conversationId: string }>;
const send =
resolveOutboundSendDep<SendFn>(deps, "msteams") ??
((to, text, opts) =>
sendMessageMSTeams({
cfg,
to,
text,
mediaUrl: opts?.mediaUrl,
mediaLocalRoots: opts?.mediaLocalRoots,
}));
const result = await send(to, text, { mediaUrl, mediaLocalRoots });
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({
cfg,
to,
question: poll.question,
options: poll.options,
maxSelections,
});
const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({
id: result.pollId,
question: poll.question,
options: poll.options,
maxSelections,
createdAt: new Date().toISOString(),
conversationId: result.conversationId,
messageId: result.messageId,
votes: {},
});
return result;
},
...createAttachedChannelResultAdapter({
channel: "msteams",
sendText: async ({ cfg, to, text, deps }) => {
type SendFn = (
to: string,
text: string,
) => Promise<{ messageId: string; conversationId: string }>;
const send =
resolveOutboundSendDep<SendFn>(deps, "msteams") ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
return await send(to, text);
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => {
type SendFn = (
to: string,
text: string,
opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] },
) => Promise<{ messageId: string; conversationId: string }>;
const send =
resolveOutboundSendDep<SendFn>(deps, "msteams") ??
((to, text, opts) =>
sendMessageMSTeams({
cfg,
to,
text,
mediaUrl: opts?.mediaUrl,
mediaLocalRoots: opts?.mediaLocalRoots,
}));
return await send(to, text, { mediaUrl, mediaLocalRoots });
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({
cfg,
to,
question: poll.question,
options: poll.options,
maxSelections,
});
const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({
id: result.pollId,
question: poll.question,
options: poll.options,
maxSelections,
createdAt: new Date().toISOString(),
conversationId: result.conversationId,
messageId: result.messageId,
votes: {},
});
return result;
},
}),
};

View File

@ -4,10 +4,12 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
} from "openclaw/plugin-sdk/channel-policy";
createAttachedChannelResultAdapter,
createLoggedPairingApprovalNotifier,
createPairingPrefixStripper,
} from "openclaw/plugin-sdk/channel-runtime";
import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js";
import {
buildBaseChannelStatusSummary,
@ -76,17 +78,40 @@ const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver<ResolvedNext
normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
});
const collectNextcloudTalkSecurityWarnings =
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedNextcloudTalkAccount>({
providerConfigPresent: (cfg) =>
(cfg.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.config.rooms) && Object.keys(account.config.rooms ?? {}).length > 0,
restrictSenders: {
surface: "Nextcloud Talk rooms",
openScope: "any member in allowed rooms",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
noRouteAllowlist: {
surface: "Nextcloud Talk rooms",
routeAllowlistPath: "channels.nextcloud-talk.rooms",
routeScope: "room",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
});
export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> = {
id: "nextcloud-talk",
meta,
setupWizard: nextcloudTalkSetupWizard,
pairing: {
idLabel: "nextcloudUserId",
normalizeAllowEntry: (entry) =>
entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
notifyApproval: async ({ id }) => {
console.log(`[nextcloud-talk] User ${id} approved for pairing`);
},
normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) =>
entry.toLowerCase(),
),
notifyApproval: createLoggedPairingApprovalNotifier(
({ id }) => `[nextcloud-talk] User ${id} approved for pairing`,
),
},
capabilities: {
chatTypes: ["direct", "group"],
@ -112,34 +137,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
},
security: {
resolveDmPolicy: resolveNextcloudTalkDmPolicy,
collectWarnings: ({ account, cfg }) => {
const roomAllowlistConfigured =
account.config.rooms && Object.keys(account.config.rooms).length > 0;
return collectAllowlistProviderGroupPolicyWarnings({
cfg,
providerConfigPresent:
(cfg.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
collectOpenGroupPolicyRouteAllowlistWarnings({
groupPolicy,
routeAllowlistConfigured: Boolean(roomAllowlistConfigured),
restrictSenders: {
surface: "Nextcloud Talk rooms",
openScope: "any member in allowed rooms",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
noRouteAllowlist: {
surface: "Nextcloud Talk rooms",
routeAllowlistPath: "channels.nextcloud-talk.rooms",
routeScope: "room",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
}),
});
},
collectWarnings: collectNextcloudTalkSecurityWarnings,
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
@ -177,23 +175,21 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const result = await sendMessageNextcloudTalk(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
cfg: cfg as CoreConfig,
});
return { channel: "nextcloud-talk", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
cfg: cfg as CoreConfig,
});
return { channel: "nextcloud-talk", ...result };
},
...createAttachedChannelResultAdapter({
channel: "nextcloud-talk",
sendText: async ({ cfg, to, text, accountId, replyToId }) =>
await sendMessageNextcloudTalk(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
cfg: cfg as CoreConfig,
}),
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
cfg: cfg as CoreConfig,
}),
}),
},
status: {
defaultRuntime: {

View File

@ -1,13 +1,12 @@
import {
GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess,
deliverFormattedTextWithAttachments,
dispatchInboundReplyWithBase,
formatTextWithAttachmentLinks,
issuePairingChallenge,
logInboundDrop,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithCommandGate,
resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
@ -38,16 +37,16 @@ async function deliverNextcloudTalkReply(params: {
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, roomToken, accountId, statusSink } = params;
const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload));
if (!combined) {
return;
}
await sendMessageNextcloudTalk(roomToken, combined, {
accountId,
replyTo: payload.replyToId,
await deliverFormattedTextWithAttachments({
payload,
send: async ({ text, replyToId }) => {
await sendMessageNextcloudTalk(roomToken, text, {
accountId,
replyTo: replyToId,
});
statusSink?.({ lastOutboundAt: Date.now() });
},
});
statusSink?.({ lastOutboundAt: Date.now() });
}
export async function handleNextcloudTalkInbound(params: {

View File

@ -1,2 +1 @@
export * from "openclaw/plugin-sdk/nostr";
export * from "./setup-api.js";

View File

@ -2,6 +2,7 @@ import {
createScopedDmSecurityResolver,
createTopLevelChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
import {
buildPassiveChannelStatusSummary,
buildTrafficStatusSummary,
@ -176,11 +177,10 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, message);
return {
channel: "nostr" as const,
return attachChannelToResult("nostr", {
to: normalizedTo,
messageId: `nostr-${Date.now()}`,
};
});
},
},

View File

@ -1,5 +1,12 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
createPairingPrefixStripper,
createTextPairingAdapter,
resolveOutboundSendDep,
} from "openclaw/plugin-sdk/channel-runtime";
import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
@ -219,9 +226,9 @@ async function sendFormattedSignalText(ctx: {
textMode: "plain",
textStyles: chunk.styles,
});
results.push({ channel: "signal" as const, ...result });
results.push(result);
}
return results;
return attachChannelToResults("signal", results);
}
async function sendFormattedSignalMedia(ctx: {
@ -260,7 +267,7 @@ async function sendFormattedSignalMedia(ctx: {
textMode: "plain",
textStyles: formatted.styles,
});
return { channel: "signal" as const, ...result };
return attachChannelToResult("signal", result);
}
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
@ -268,35 +275,25 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
setupWizard: signalSetupWizard,
setup: signalSetupAdapter,
}),
pairing: {
pairing: createTextPairingAdapter({
idLabel: "signalNumber",
normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""),
notifyApproval: async ({ id }) => {
await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i),
notify: async ({ id, message }) => {
await getSignalRuntime().channel.signal.sendMessageSignal(id, message);
},
},
}),
actions: signalMessageActions,
allowlist: {
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
readConfig: ({ cfg, accountId }) => {
const account = resolveSignalAccount({ cfg, accountId });
return {
dmAllowFrom: (account.config.allowFrom ?? []).map(String),
groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String),
dmPolicy: account.config.dmPolicy,
groupPolicy: account.config.groupPolicy,
};
},
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "signal",
normalize: ({ cfg, accountId, values }) =>
signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "signal",
resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }),
normalize: ({ cfg, accountId, values }) =>
signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupAllowFrom: (account) => account.config.groupAllowFrom,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
}),
security: {
resolveDmPolicy: signalResolveDmPolicy,
collectWarnings: collectSignalSecurityWarnings,
@ -346,28 +343,27 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
deps,
abortSignal,
}),
sendText: async ({ cfg, to, text, accountId, deps }) => {
const result = await sendSignalOutbound({
cfg,
to,
text,
accountId: accountId ?? undefined,
deps,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
const result = await sendSignalOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
deps,
});
return { channel: "signal", ...result };
},
...createAttachedChannelResultAdapter({
channel: "signal",
sendText: async ({ cfg, to, text, accountId, deps }) =>
await sendSignalOutbound({
cfg,
to,
text,
accountId: accountId ?? undefined,
deps,
}),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) =>
await sendSignalOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
deps,
}),
}),
},
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),

View File

@ -9,6 +9,10 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import {
chunkTextWithMode,
resolveChunkMode,
@ -296,35 +300,32 @@ async function deliverReplies(params: {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
params;
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) {
continue;
}
if (mediaList.length === 0) {
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
const reply = resolveSendableOutboundReplyParts(payload);
const delivered = await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
sendText: async (chunk) => {
await sendMessageSignal(target, chunk, {
baseUrl,
account,
maxBytes,
accountId,
});
}
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? text : "";
first = false;
await sendMessageSignal(target, caption, {
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendMessageSignal(target, caption ?? "", {
baseUrl,
account,
mediaUrl: url,
mediaUrl,
maxBytes,
accountId,
});
}
},
});
if (delivered !== "empty") {
runtime.log?.(`delivered reply to ${target}`);
}
runtime.log?.(`delivered reply to ${target}`);
}
}

View File

@ -1,6 +1,11 @@
import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime";
import {
attachChannelToResult,
attachChannelToResults,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { markdownToSignalTextChunks } from "./format.js";
@ -53,9 +58,9 @@ export const signalOutbound: ChannelOutboundAdapter = {
textMode: "plain",
textStyles: chunk.styles,
});
results.push({ channel: "signal" as const, ...result });
results.push(result);
}
return results;
return attachChannelToResults("signal", results);
},
sendFormattedMedia: async ({
cfg,
@ -89,34 +94,35 @@ export const signalOutbound: ChannelOutboundAdapter = {
textStyles: formatted.styles,
mediaLocalRoots,
});
return { channel: "signal", ...result };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const result = await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const result = await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
});
return { channel: "signal", ...result };
return attachChannelToResult("signal", result);
},
...createAttachedChannelResultAdapter({
channel: "signal",
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
return await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
});
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
return await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
});
},
}),
};

View File

@ -1,8 +1,8 @@
import {
collectAllowlistProviderRestrictSendersWarnings,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import {
listSignalAccountIds,
@ -53,21 +53,16 @@ export const signalResolveDmPolicy = createScopedDmSecurityResolver<ResolvedSign
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
});
export function collectSignalSecurityWarnings(params: {
account: ResolvedSignalAccount;
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
}) {
return collectAllowlistProviderRestrictSendersWarnings({
cfg: params.cfg,
providerConfigPresent: params.cfg.channels?.signal !== undefined,
configuredGroupPolicy: params.account.config.groupPolicy,
export const collectSignalSecurityWarnings =
createAllowlistProviderRestrictSendersWarningCollector<ResolvedSignalAccount>({
providerConfigPresent: (cfg) => cfg.channels?.signal !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "Signal groups",
openScope: "any member",
groupPolicyPath: "channels.signal.groupPolicy",
groupAllowFromPath: "channels.signal.groupAllowFrom",
mentionGated: false,
});
}
export function createSignalPluginBase(params: {
setupWizard?: NonNullable<ChannelPlugin<ResolvedSignalAccount>["setupWizard"]>;

View File

@ -1,5 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/slack";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { slackOutbound } from "./outbound-adapter.js";
const handleSlackActionMock = vi.fn();
@ -169,6 +171,101 @@ describe("slackPlugin outbound", () => {
);
expect(result).toEqual({ channel: "slack", messageId: "m-media-local" });
});
it("sends block payload media first, then the final block message", async () => {
const sendSlack = vi
.fn()
.mockResolvedValueOnce({ messageId: "m-media-1" })
.mockResolvedValueOnce({ messageId: "m-media-2" })
.mockResolvedValueOnce({ messageId: "m-final" });
const sendPayload = slackOutbound.sendPayload;
expect(sendPayload).toBeDefined();
const result = await sendPayload!({
cfg,
to: "C999",
text: "",
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
slack: {
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: "Block body",
},
},
],
},
},
},
accountId: "default",
deps: { sendSlack },
mediaLocalRoots: ["/tmp/media"],
});
expect(sendSlack).toHaveBeenCalledTimes(3);
expect(sendSlack).toHaveBeenNthCalledWith(
1,
"C999",
"",
expect.objectContaining({
mediaUrl: "https://example.com/1.png",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(sendSlack).toHaveBeenNthCalledWith(
2,
"C999",
"",
expect.objectContaining({
mediaUrl: "https://example.com/2.png",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(sendSlack).toHaveBeenNthCalledWith(
3,
"C999",
"hello",
expect.objectContaining({
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: "Block body",
},
},
],
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-final" });
});
});
describe("slackPlugin directory", () => {
it("lists configured peers without throwing a ReferenceError", async () => {
const listPeers = slackPlugin.directory?.listPeers;
expect(listPeers).toBeDefined();
await expect(
listPeers!({
cfg: {
channels: {
slack: {
dms: {
U123: {},
},
},
},
},
runtime: createRuntimeEnv(),
}),
).resolves.toEqual([{ id: "user:u123", kind: "user" }]);
});
});
describe("slackPlugin agentPrompt", () => {

View File

@ -1,13 +1,20 @@
import {
buildAccountScopedAllowlistConfigEditor,
resolveLegacyDmAllowlistConfigPaths,
buildLegacyDmAccountAllowlistAdapter,
createAccountScopedAllowlistNameResolver,
createFlatAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
createScopedDmSecurityResolver,
collectOpenGroupPolicyConfiguredRouteWarnings,
collectOpenProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
createPairingPrefixStripper,
createScopedAccountReplyToModeResolver,
createRuntimeDirectoryLiveAdapter,
createTextPairingAdapter,
resolveOutboundSendDep,
resolveTargetsWithOptionalToken,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
@ -21,6 +28,10 @@ import type { SlackActionContext } from "./action-runtime.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { createSlackActions } from "./channel-actions.js";
import { createSlackWebClient } from "./client.js";
import {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./directory-config.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
@ -29,8 +40,6 @@ import { resolveSlackUserAllowlist } from "./resolve-users.js";
import {
buildComputedAccountStatusSnapshot,
DEFAULT_ACCOUNT_ID,
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
PAIRING_APPROVED_MESSAGE,
@ -286,41 +295,49 @@ function formatSlackScopeDiagnostic(params: {
} as const;
}
function readSlackAllowlistConfig(account: ResolvedSlackAccount) {
return {
dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String),
groupPolicy: account.groupPolicy,
groupOverrides: Object.entries(account.channels ?? {})
.map(([key, value]) => {
const entries = (value?.users ?? []).map(String).filter(Boolean);
return entries.length > 0 ? { label: key, entries } : null;
})
.filter(Boolean) as Array<{ label: string; entries: string[] }>,
};
}
const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({
resolveRecord: (account: ResolvedSlackAccount) => account.channels,
label: (key) => key,
resolveEntries: (value) => value?.users,
});
async function resolveSlackAllowlistNames(params: {
cfg: Parameters<typeof resolveSlackAccount>[0]["cfg"];
accountId?: string | null;
entries: string[];
}) {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.config.userToken?.trim() || account.botToken?.trim();
if (!token) {
return [];
}
return await resolveSlackUserAllowlist({ token, entries: params.entries });
}
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }),
resolveToken: (account: ResolvedSlackAccount) =>
account.config.userToken?.trim() || account.botToken?.trim(),
resolveNames: ({ token, entries }) => resolveSlackUserAllowlist({ token, entries }),
});
const collectSlackSecurityWarnings =
createOpenProviderConfiguredRouteWarningCollector<ResolvedSlackAccount>({
providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0,
configureRouteAllowlist: {
surface: "Slack channels",
openScope: "any channel not explicitly denied",
groupPolicyPath: "channels.slack.groupPolicy",
routeAllowlistPath: "channels.slack.channels",
},
missingRouteAllowlist: {
surface: "Slack channels",
openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)",
remediation:
'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels',
},
});
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
...createSlackPluginBase({
setupWizard: slackSetupWizard,
setup: slackSetupAdapter,
}),
pairing: {
pairing: createTextPairingAdapter({
idLabel: "slackUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
notifyApproval: async ({ id }) => {
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(slack|user):/i),
notify: async ({ id, message }) => {
const cfg = getSlackRuntime().config.loadConfig();
const account = resolveSlackAccount({
cfg,
@ -330,71 +347,39 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
if (tokenOverride) {
await getSlackRuntime().channel.slack.sendMessageSlack(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
{
token: tokenOverride,
},
);
await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message, {
token: tokenOverride,
});
} else {
await getSlackRuntime().channel.slack.sendMessageSlack(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
);
await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message);
}
},
},
}),
allowlist: {
supportsScope: ({ scope }) => scope === "dm",
readConfig: ({ cfg, accountId }) =>
readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })),
resolveNames: async ({ cfg, accountId, entries }) =>
await resolveSlackAllowlistNames({ cfg, accountId, entries }),
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
...buildLegacyDmAccountAllowlistAdapter({
channelId: "slack",
resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }),
normalize: ({ cfg, accountId, values }) =>
slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom,
resolveGroupPolicy: (account) => account.groupPolicy,
resolveGroupOverrides: resolveSlackAllowlistGroupOverrides,
}),
resolveNames: resolveSlackAllowlistNames,
},
security: {
resolveDmPolicy: resolveSlackDmPolicy,
collectWarnings: ({ account, cfg }) => {
const channelAllowlistConfigured =
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0;
return collectOpenProviderGroupPolicyWarnings({
cfg,
providerConfigPresent: cfg.channels?.slack !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
collectOpenGroupPolicyConfiguredRouteWarnings({
groupPolicy,
routeAllowlistConfigured: channelAllowlistConfigured,
configureRouteAllowlist: {
surface: "Slack channels",
openScope: "any channel not explicitly denied",
groupPolicyPath: "channels.slack.groupPolicy",
routeAllowlistPath: "channels.slack.channels",
},
missingRouteAllowlist: {
surface: "Slack channels",
openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)",
remediation:
'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels',
},
}),
});
},
collectWarnings: collectSlackSecurityWarnings,
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
resolveReplyToMode: createScopedAccountReplyToModeResolver({
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType),
}),
allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) =>
@ -435,14 +420,15 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
hint: "<channelId|user:ID|channel:ID>",
},
},
directory: {
self: async () => null,
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listSlackDirectoryPeersFromConfig(params),
listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params),
listPeersLive: async (params) => getSlackRuntime().channel.slack.listDirectoryPeersLive(params),
listGroupsLive: async (params) =>
getSlackRuntime().channel.slack.listDirectoryGroupsLive(params),
},
...createRuntimeDirectoryLiveAdapter({
getRuntime: () => getSlackRuntime().channel.slack,
listPeersLive: (runtime) => runtime.listDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive,
}),
}),
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const toResolvedTarget = <
@ -458,28 +444,30 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
note,
});
const account = resolveSlackAccount({ cfg, accountId });
const token = account.config.userToken?.trim() || account.botToken?.trim();
if (!token) {
return inputs.map((input) => ({
input,
resolved: false,
note: "missing Slack token",
}));
}
if (kind === "group") {
const resolved = await getSlackRuntime().channel.slack.resolveChannelAllowlist({
token,
entries: inputs,
return resolveTargetsWithOptionalToken({
token: account.config.userToken?.trim() || account.botToken?.trim(),
inputs,
missingTokenNote: "missing Slack token",
resolveWithToken: ({ token, inputs }) =>
getSlackRuntime().channel.slack.resolveChannelAllowlist({
token,
entries: inputs,
}),
mapResolved: (entry) => toResolvedTarget(entry, entry.archived ? "archived" : undefined),
});
return resolved.map((entry) =>
toResolvedTarget(entry, entry.archived ? "archived" : undefined),
);
}
const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({
token,
entries: inputs,
return resolveTargetsWithOptionalToken({
token: account.config.userToken?.trim() || account.botToken?.trim(),
inputs,
missingTokenNote: "missing Slack token",
resolveWithToken: ({ token, inputs }) =>
getSlackRuntime().channel.slack.resolveUserAllowlist({
token,
entries: inputs,
}),
mapResolved: (entry) => toResolvedTarget(entry, entry.note),
});
return resolved.map((entry) => toResolvedTarget(entry, entry.note));
},
},
actions: createSlackActions(SLACK_CHANNEL, {
@ -495,50 +483,51 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
const result = await send(to, text, {
cfg,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
return { channel: "slack", ...result };
},
sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
cfg,
}) => {
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
const result = await send(to, text, {
cfg,
...createAttachedChannelResultAdapter({
channel: "slack",
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
return { channel: "slack", ...result };
},
accountId,
deps,
replyToId,
threadId,
cfg,
}) => {
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
mediaUrl,
mediaLocalRoots,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
}),
},
status: {
defaultRuntime: {

View File

@ -1,28 +1,23 @@
import {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
listDirectoryGroupEntriesFromMapKeys,
toDirectoryEntries,
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectSlackAccount, type InspectedSlackAccount } from "../api.js";
import { parseSlackTarget } from "./targets.js";
export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) {
const account: InspectedSlackAccount = inspectSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (!account.config) {
return [];
}
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
const ids = collectNormalizedDirectoryIds({
sources: [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers],
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "user",
inspectAccount: (cfg, accountId) =>
inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null,
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
@ -34,21 +29,15 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP
return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null;
},
});
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
const account: InspectedSlackAccount = inspectSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (!account.config) {
return [];
}
return listDirectoryGroupEntriesFromMapKeys({
groups: account.config.channels,
query: params.query,
limit: params.limit,
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "group",
inspectAccount: (cfg, accountId) =>
inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null,
resolveSources: (account) => [Object.keys(account.config.channels ?? {})],
normalizeId: (raw) => {
const normalized = parseSlackTarget(raw, { defaultKind: "channel" });
return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null;

View File

@ -5,6 +5,7 @@ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime";
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
@ -33,7 +34,7 @@ import {
import type { PreparedSlackMessage } from "./types.js";
function hasMedia(payload: ReplyPayload): boolean {
return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
return resolveSendableOutboundReplyParts(payload).hasMedia;
}
export function isSlackStreamingEnabled(params: {
@ -250,17 +251,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
};
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
if (
streamFailed ||
hasMedia(payload) ||
readSlackReplyBlocks(payload)?.length ||
!payload.text?.trim()
) {
const reply = resolveSendableOutboundReplyParts(payload);
if (streamFailed || reply.hasMedia || readSlackReplyBlocks(payload)?.length || !reply.hasText) {
await deliverNormally(payload, streamSession?.threadTs);
return;
}
const text = payload.text.trim();
const text = reply.trimmedText;
let plannedThreadTs: string | undefined;
try {
if (!streamSession) {
@ -311,16 +308,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
const reply = resolveSendableOutboundReplyParts(payload);
const slackBlocks = readSlackReplyBlocks(payload);
const draftMessageId = draftStream?.messageId();
const draftChannelId = draftStream?.channelId();
const finalText = payload.text ?? "";
const trimmedFinalText = finalText.trim();
const finalText = reply.text;
const trimmedFinalText = reply.trimmedText;
const canFinalizeViaPreviewEdit =
previewStreamingEnabled &&
streamMode !== "status_final" &&
mediaCount === 0 &&
!reply.hasMedia &&
!payload.isError &&
(trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) &&
typeof draftMessageId === "string" &&
@ -361,7 +358,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
} catch (err) {
logVerbose(`slack: status_final completion update failed (${String(err)})`);
}
} else if (mediaCount > 0) {
} else if (reply.hasMedia) {
await draftStream?.clear();
hasStreamedMessage = false;
}

View File

@ -1,4 +1,8 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
@ -37,15 +41,14 @@ export async function deliverReplies(params: {
// must not force threading.
const inlineReplyToId = params.replyToMode === "off" ? undefined : payload.replyToId;
const threadTs = inlineReplyToId ?? params.replyThreadTs;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const reply = resolveSendableOutboundReplyParts(payload);
const slackBlocks = readSlackReplyBlocks(payload);
if (!text && mediaList.length === 0 && !slackBlocks?.length) {
if (!reply.hasContent && !slackBlocks?.length) {
continue;
}
if (mediaList.length === 0) {
const trimmed = text.trim();
if (!reply.hasMedia && slackBlocks?.length) {
const trimmed = reply.trimmedText;
if (!trimmed && !slackBlocks?.length) {
continue;
}
@ -59,21 +62,43 @@ export async function deliverReplies(params: {
...(slackBlocks?.length ? { blocks: slackBlocks } : {}),
...(params.identity ? { identity: params.identity } : {}),
});
} else {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
first = false;
await sendMessageSlack(params.target, caption, {
params.runtime.log?.(`delivered reply to ${params.target}`);
continue;
}
const delivered = await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: !reply.hasMedia
? (value) => {
const trimmed = value.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
return [];
}
return [trimmed];
}
: undefined,
sendText: async (trimmed) => {
await sendMessageSlack(params.target, trimmed, {
token: params.token,
threadTs,
accountId: params.accountId,
...(params.identity ? { identity: params.identity } : {}),
});
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendMessageSlack(params.target, caption ?? "", {
token: params.token,
mediaUrl,
threadTs,
accountId: params.accountId,
...(params.identity ? { identity: params.identity } : {}),
});
}
},
});
if (delivered !== "empty") {
params.runtime.log?.(`delivered reply to ${params.target}`);
}
params.runtime.log?.(`delivered reply to ${params.target}`);
}
}
@ -165,12 +190,12 @@ export async function deliverSlackSlashReplies(params: {
const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000);
for (const payload of params.replies) {
const textRaw = payload.text?.trim() ?? "";
const text = textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN) ? textRaw : undefined;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const combined = [text ?? "", ...mediaList.map((url) => url.trim()).filter(Boolean)]
.filter(Boolean)
.join("\n");
const reply = resolveSendableOutboundReplyParts(payload);
const text =
reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN)
? reply.trimmedText
: undefined;
const combined = [text ?? "", ...reply.mediaUrls].filter(Boolean).join("\n");
if (!combined) {
continue;
}

View File

@ -1,10 +1,14 @@
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequence,
sendPayloadMediaSequenceAndFinalize,
sendTextMediaPayload,
} from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import {
resolveInteractiveTextFallback,
@ -96,7 +100,6 @@ async function sendSlackOutboundMessage(params: {
});
if (hookResult.cancelled) {
return {
channel: "slack" as const,
messageId: "cancelled-by-hook",
channelId: params.to,
meta: { cancelled: true },
@ -114,7 +117,7 @@ async function sendSlackOutboundMessage(params: {
...(params.blocks ? { blocks: params.blocks } : {}),
...(slackIdentity ? { identity: slackIdentity } : {}),
});
return { channel: "slack" as const, ...result };
return result;
}
function resolveSlackBlocks(payload: {
@ -166,75 +169,54 @@ export const slackOutbound: ChannelOutboundAdapter = {
});
}
const mediaUrls = resolvePayloadMediaUrls(payload);
if (mediaUrls.length === 0) {
return await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text: payload.text ?? "",
mediaLocalRoots: ctx.mediaLocalRoots,
blocks,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
});
}
await sendPayloadMediaSequence({
text: "",
mediaUrls,
send: async ({ text, mediaUrl }) =>
await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
}),
});
return await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text: payload.text ?? "",
mediaLocalRoots: ctx.mediaLocalRoots,
blocks,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
});
return attachChannelToResult(
"slack",
await sendPayloadMediaSequenceAndFinalize({
text: "",
mediaUrls,
send: async ({ text, mediaUrl }) =>
await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
}),
finalize: async () =>
await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text: payload.text ?? "",
mediaLocalRoots: ctx.mediaLocalRoots,
blocks,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
}),
}),
);
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => {
return await sendSlackOutboundMessage({
cfg,
to,
text,
accountId,
deps,
replyToId,
threadId,
identity,
});
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
identity,
}) => {
return await sendSlackOutboundMessage({
...createAttachedChannelResultAdapter({
channel: "slack",
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) =>
await sendSlackOutboundMessage({
cfg,
to,
text,
accountId,
deps,
replyToId,
threadId,
identity,
}),
sendMedia: async ({
cfg,
to,
text,
@ -245,6 +227,18 @@ export const slackOutbound: ChannelOutboundAdapter = {
replyToId,
threadId,
identity,
});
},
}) =>
await sendSlackOutboundMessage({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
identity,
}),
}),
};

View File

@ -5,6 +5,7 @@ import {
fetchWithSsrFGuard,
withTrustedEnvProxyGuardedFetchMode,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload";
import {
chunkMarkdownTextWithMode,
resolveChunkMode,
@ -310,9 +311,7 @@ export async function sendMessageSlack(
const chunks = markdownChunks.flatMap((markdown) =>
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }),
);
if (!chunks.length && trimmedMessage) {
chunks.push(trimmedMessage);
}
const resolvedChunks = resolveTextChunksWithFallback(trimmedMessage, chunks);
const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024
@ -320,7 +319,7 @@ export async function sendMessageSlack(
let lastMessageId = "";
if (opts.mediaUrl) {
const [firstChunk, ...rest] = chunks;
const [firstChunk, ...rest] = resolvedChunks;
lastMessageId = await uploadSlackFile({
client,
channelId,
@ -341,7 +340,7 @@ export async function sendMessageSlack(
lastMessageId = response.ts ?? lastMessageId;
}
} else {
for (const chunk of chunks.length ? chunks : [""]) {
for (const chunk of resolvedChunks.length ? resolvedChunks : [""]) {
const response = await postSlackMessageBestEffort({
client,
channelId,

View File

@ -97,8 +97,11 @@ describe("createSynologyChatPlugin", () => {
it("has notifyApproval and normalizeAllowEntry", () => {
const plugin = createSynologyChatPlugin();
expect(plugin.pairing.idLabel).toBe("synologyChatUserId");
expect(typeof plugin.pairing.normalizeAllowEntry).toBe("function");
expect(plugin.pairing.normalizeAllowEntry(" USER1 ")).toBe("user1");
const normalize = plugin.pairing.normalizeAllowEntry;
expect(typeof normalize).toBe("function");
if (normalize) {
expect(normalize(" USER1 ")).toBe("user1");
}
expect(typeof plugin.pairing.notifyApproval).toBe("function");
});
});
@ -160,9 +163,10 @@ describe("createSynologyChatPlugin", () => {
describe("directory", () => {
it("returns empty stubs", async () => {
const plugin = createSynologyChatPlugin();
expect(await plugin.directory.self()).toBeNull();
expect(await plugin.directory.listPeers()).toEqual([]);
expect(await plugin.directory.listGroups()).toEqual([]);
const params = { cfg: {}, runtime: {} as never };
expect(await plugin.directory.self?.(params)).toBeNull();
expect(await plugin.directory.listPeers?.(params)).toEqual([]);
expect(await plugin.directory.listGroups?.(params)).toEqual([]);
});
});

View File

@ -8,6 +8,15 @@ import {
createHybridChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
createConditionalWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
attachChannelToResult,
createEmptyChannelDirectoryAdapter,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import { z } from "zod";
import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js";
import { listAccountIds, resolveAccount } from "./accounts.js";
@ -53,6 +62,26 @@ const synologyChatConfigAdapter = createHybridChannelConfigAdapter<ResolvedSynol
allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
});
const collectSynologyChatSecurityWarnings =
createConditionalWarningCollector<ResolvedSynologyChatAccount>(
(account) =>
!account.token &&
"- Synology Chat: token is not configured. The webhook will reject all requests.",
(account) =>
!account.incomingUrl &&
"- Synology Chat: incomingUrl is not configured. The bot cannot send replies.",
(account) =>
account.allowInsecureSsl &&
"- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.",
(account) =>
account.dmPolicy === "open" &&
'- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.',
(account) =>
account.dmPolicy === "allowlist" &&
account.allowedUserIds.length === 0 &&
'- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".',
);
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
return new Promise((resolve) => {
const complete = () => {
@ -106,52 +135,23 @@ export function createSynologyChatPlugin() {
...synologyChatConfigAdapter,
},
pairing: {
pairing: createTextPairingAdapter({
idLabel: "synologyChatUserId",
message: "OpenClaw: your access has been approved.",
normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(),
notifyApproval: async ({ cfg, id }: { cfg: any; id: string }) => {
notify: async ({ cfg, id, message }) => {
const account = resolveAccount(cfg);
if (!account.incomingUrl) return;
await sendMessage(
account.incomingUrl,
"OpenClaw: your access has been approved.",
id,
account.allowInsecureSsl,
);
await sendMessage(account.incomingUrl, message, id, account.allowInsecureSsl);
},
},
}),
security: {
resolveDmPolicy: resolveSynologyChatDmPolicy,
collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => {
const warnings: string[] = [];
if (!account.token) {
warnings.push(
"- Synology Chat: token is not configured. The webhook will reject all requests.",
);
}
if (!account.incomingUrl) {
warnings.push(
"- Synology Chat: incomingUrl is not configured. The bot cannot send replies.",
);
}
if (account.allowInsecureSsl) {
warnings.push(
"- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.",
);
}
if (account.dmPolicy === "open") {
warnings.push(
'- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.',
);
}
if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) {
warnings.push(
'- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".',
);
}
return warnings;
},
collectWarnings: projectWarningCollector(
({ account }: { account: ResolvedSynologyChatAccount }) => account,
collectSynologyChatSecurityWarnings,
),
},
messaging: {
@ -172,11 +172,7 @@ export function createSynologyChatPlugin() {
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
directory: createEmptyChannelDirectoryAdapter(),
outbound: {
deliveryMode: "gateway" as const,
@ -193,7 +189,7 @@ export function createSynologyChatPlugin() {
if (!ok) {
throw new Error("Failed to send message to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
},
sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => {
@ -210,7 +206,7 @@ export function createSynologyChatPlugin() {
if (!ok) {
throw new Error("Failed to send media to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
},
},

View File

@ -22,6 +22,7 @@ import type {
TelegramAccountConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
@ -567,7 +568,8 @@ export const dispatchTelegramMessage = async ({
)?.buttons;
const split = splitTextIntoLaneSegments(payload.text);
const segments = split.segments;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
const flushBufferedFinalAnswer = async () => {
const buffered = reasoningStepState.takeBufferedFinalAnswer();
@ -631,7 +633,7 @@ export const dispatchTelegramMessage = async ({
return;
}
if (split.suppressedReasoningOnly) {
if (hasMedia) {
if (reply.hasMedia) {
const payloadWithoutSuppressedReasoning =
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
await sendPayload(payloadWithoutSuppressedReasoning);
@ -647,8 +649,7 @@ export const dispatchTelegramMessage = async ({
await reasoningLane.stream?.stop();
reasoningStepState.resetForNextStep();
}
const canSendAsIs =
hasMedia || (typeof payload.text === "string" && payload.text.length > 0);
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
if (!canSendAsIs) {
if (info.kind === "final") {
await flushBufferedFinalAnswer();

View File

@ -1,11 +1,20 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
import {
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { type OutboundSendDeps, resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime";
buildDmGroupAccountAllowlistAdapter,
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
createPairingPrefixStripper,
createTopLevelChannelReplyToModeResolver,
createTextPairingAdapter,
normalizeMessageChannel,
type OutboundSendDeps,
resolveOutboundSendDep,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime";
@ -273,65 +282,66 @@ const resolveTelegramDmPolicy = createScopedDmSecurityResolver<ResolvedTelegramA
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
});
function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) {
const groupOverrides: Array<{ label: string; entries: string[] }> = [];
for (const [groupId, groupCfg] of Object.entries(account.config.groups ?? {})) {
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: groupId, entries });
}
for (const [topicId, topicCfg] of Object.entries(groupCfg?.topics ?? {})) {
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (topicEntries.length > 0) {
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
}
}
}
return {
dmAllowFrom: (account.config.allowFrom ?? []).map(String),
groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String),
dmPolicy: account.config.dmPolicy,
groupPolicy: account.config.groupPolicy,
groupOverrides,
};
}
const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
resolveRecord: (account: ResolvedTelegramAccount) => account.config.groups,
outerLabel: (groupId) => groupId,
resolveOuterEntries: (groupCfg) => groupCfg?.allowFrom,
resolveChildren: (groupCfg) => groupCfg?.topics,
innerLabel: (groupId, topicId) => `${groupId} topic ${topicId}`,
resolveInnerEntries: (topicCfg) => topicCfg?.allowFrom,
});
const collectTelegramSecurityWarnings =
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedTelegramAccount>({
providerConfigPresent: (cfg) => cfg.channels?.telegram !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.config.groups) && Object.keys(account.config.groups ?? {}).length > 0,
restrictSenders: {
surface: "Telegram groups",
openScope: "any member in allowed groups",
groupPolicyPath: "channels.telegram.groupPolicy",
groupAllowFromPath: "channels.telegram.groupAllowFrom",
},
noRouteAllowlist: {
surface: "Telegram groups",
routeAllowlistPath: "channels.telegram.groups",
routeScope: "group",
groupPolicyPath: "channels.telegram.groupPolicy",
groupAllowFromPath: "channels.telegram.groupAllowFrom",
},
});
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
...createTelegramPluginBase({
setupWizard: telegramSetupWizard,
setup: telegramSetupAdapter,
}),
pairing: {
pairing: createTextPairingAdapter({
idLabel: "telegramUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""),
notifyApproval: async ({ cfg, id }) => {
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i),
notify: async ({ cfg, id, message }) => {
const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg);
if (!token) {
throw new Error("telegram token not configured");
}
await getTelegramRuntime().channel.telegram.sendMessageTelegram(
id,
PAIRING_APPROVED_MESSAGE,
{
token,
},
);
await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, message, {
token,
});
},
},
allowlist: {
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
readConfig: ({ cfg, accountId }) =>
readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })),
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "telegram",
normalize: ({ cfg, accountId, values }) =>
telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
}),
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "telegram",
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
normalize: ({ cfg, accountId, values }) =>
telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupAllowFrom: (account) => account.config.groupAllowFrom,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveGroupOverrides: resolveTelegramAllowlistGroupOverrides,
}),
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeTelegramAcpConversationId(conversationId),
@ -344,40 +354,14 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
},
security: {
resolveDmPolicy: resolveTelegramDmPolicy,
collectWarnings: ({ account, cfg }) => {
const groupAllowlistConfigured =
account.config.groups && Object.keys(account.config.groups).length > 0;
return collectAllowlistProviderGroupPolicyWarnings({
cfg,
providerConfigPresent: cfg.channels?.telegram !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) =>
collectOpenGroupPolicyRouteAllowlistWarnings({
groupPolicy,
routeAllowlistConfigured: Boolean(groupAllowlistConfigured),
restrictSenders: {
surface: "Telegram groups",
openScope: "any member in allowed groups",
groupPolicyPath: "channels.telegram.groupPolicy",
groupAllowFromPath: "channels.telegram.groupAllowFrom",
},
noRouteAllowlist: {
surface: "Telegram groups",
routeAllowlistPath: "channels.telegram.groups",
routeScope: "group",
groupPolicyPath: "channels.telegram.groupPolicy",
groupAllowFromPath: "channels.telegram.groupAllowFrom",
},
}),
});
},
collectWarnings: collectTelegramSecurityWarnings,
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
resolveToolPolicy: resolveTelegramGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"),
resolveAutoThreadId: ({ to, toolContext, replyToId }) =>
replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }),
},
@ -471,11 +455,10 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
}).catch(() => {});
},
},
directory: {
self: async () => null,
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),
listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params),
},
}),
actions: telegramMessageActions,
setup: telegramSetupAdapter,
outbound: {
@ -516,34 +499,22 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
forceDocument,
}),
});
return { channel: "telegram", ...result };
return attachChannelToResult("telegram", result);
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
const result = await sendTelegramOutbound({
cfg,
to,
text,
accountId,
deps,
replyToId,
threadId,
silent,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const result = await sendTelegramOutbound({
...createAttachedChannelResultAdapter({
channel: "telegram",
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) =>
await sendTelegramOutbound({
cfg,
to,
text,
accountId,
deps,
replyToId,
threadId,
silent,
}),
sendMedia: async ({
cfg,
to,
text,
@ -554,17 +525,28 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId,
threadId,
silent,
});
return { channel: "telegram", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) =>
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
cfg,
accountId: accountId ?? undefined,
messageThreadId: parseTelegramThreadId(threadId),
silent: silent ?? undefined,
isAnonymous: isAnonymous ?? undefined,
}),
}) =>
await sendTelegramOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}),
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) =>
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
cfg,
accountId: accountId ?? undefined,
messageThreadId: parseTelegramThreadId(threadId),
silent: silent ?? undefined,
isAnonymous: isAnonymous ?? undefined,
}),
}),
},
status: {
defaultRuntime: {

View File

@ -1,24 +1,20 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
listDirectoryGroupEntriesFromMapKeys,
toDirectoryEntries,
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectTelegramAccount, type InspectedTelegramAccount } from "../api.js";
export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) {
const account: InspectedTelegramAccount = inspectTelegramAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (!account.config) {
return [];
}
const ids = collectNormalizedDirectoryIds({
sources: [mapAllowFromEntries(account.config.allowFrom), Object.keys(account.config.dms ?? {})],
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "user",
inspectAccount: (cfg, accountId) =>
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
resolveSources: (account) => [
mapAllowFromEntries(account.config.allowFrom),
Object.keys(account.config.dms ?? {}),
],
normalizeId: (entry) => {
const trimmed = entry.replace(/^(telegram|tg):/i, "").trim();
if (!trimmed) {
@ -30,20 +26,15 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
},
});
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
const account: InspectedTelegramAccount = inspectTelegramAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (!account.config) {
return [];
}
return listDirectoryGroupEntriesFromMapKeys({
groups: account.config.groups,
query: params.query,
limit: params.limit,
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "group",
inspectAccount: (cfg, accountId) =>
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
resolveSources: (account) => [Object.keys(account.config.groups ?? {})],
normalizeId: (entry) => entry.trim() || null,
});
}

View File

@ -1,3 +1,4 @@
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
import type { TelegramDraftStream } from "./draft-stream.js";
@ -459,7 +460,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
allowPreviewUpdateForNonFinal = false,
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
const lane = params.lanes[laneName];
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const reply = resolveSendableOutboundReplyParts(payload, { text });
const hasMedia = reply.hasMedia;
const canEditViaPreview =
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;

View File

@ -1,9 +1,13 @@
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequence,
sendPayloadMediaSequenceOrFallback,
} from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
@ -75,17 +79,16 @@ export async function sendTelegramPayloadMessages(params: {
quoteText,
};
if (mediaUrls.length === 0) {
return await params.send(params.to, text, {
...payloadOpts,
buttons,
});
}
// Telegram allows reply_markup on media; attach buttons only to the first send.
const finalResult = await sendPayloadMediaSequence({
return await sendPayloadMediaSequenceOrFallback({
text,
mediaUrls,
fallbackResult: { messageId: "unknown", chatId: params.to },
sendNoMedia: async () =>
await params.send(params.to, text, {
...payloadOpts,
buttons,
}),
send: async ({ text, mediaUrl, isFirst }) =>
await params.send(params.to, text, {
...payloadOpts,
@ -93,7 +96,6 @@ export async function sendTelegramPayloadMessages(params: {
...(isFirst ? { buttons } : {}),
}),
});
return finalResult ?? { messageId: "unknown", chatId: params.to };
}
export const telegramOutbound: ChannelOutboundAdapter = {
@ -104,46 +106,47 @@ export const telegramOutbound: ChannelOutboundAdapter = {
shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
const { send, baseOpts } = resolveTelegramSendContext({
...createAttachedChannelResultAdapter({
channel: "telegram",
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
return await send(to, text, {
...baseOpts,
});
},
sendMedia: async ({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await send(to, text, {
...baseOpts,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
forceDocument,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
const result = await send(to, text, {
...baseOpts,
to,
text,
mediaUrl,
mediaLocalRoots,
forceDocument: forceDocument ?? false,
});
return { channel: "telegram", ...result };
},
accountId,
deps,
replyToId,
threadId,
forceDocument,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
return await send(to, text, {
...baseOpts,
mediaUrl,
mediaLocalRoots,
forceDocument: forceDocument ?? false,
});
},
}),
sendPayload: async ({
cfg,
to,
@ -172,6 +175,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
forceDocument: forceDocument ?? false,
},
});
return { channel: "telegram", ...result };
return attachChannelToResult("telegram", result);
},
};

View File

@ -1,2 +1 @@
export * from "openclaw/plugin-sdk/tlon";
export * from "./setup-api.js";

View File

@ -1,5 +1,9 @@
import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime";
import {
createRuntimeOutboundDelegates,
type ChannelAccountSnapshot,
type ChannelPlugin,
} from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { tlonChannelConfigSchema } from "./config-schema.js";
@ -107,14 +111,11 @@ export const tlonPlugin: ChannelPlugin = {
deliveryMode: "direct",
textChunkLimit: 10000,
resolveTarget: ({ to }) => resolveTlonOutboundTarget(to),
sendText: async (params) =>
await (
await loadTlonChannelRuntime()
).tlonRuntimeOutbound.sendText!(params),
sendMedia: async (params) =>
await (
await loadTlonChannelRuntime()
).tlonRuntimeOutbound.sendMedia!(params),
...createRuntimeOutboundDelegates({
getRuntime: loadTlonChannelRuntime,
sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText },
sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia },
}),
},
status: {
defaultRuntime: {

View File

@ -1,4 +1,8 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import {
resolveOutboundMediaUrls,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
@ -52,11 +56,7 @@ export async function deliverWebReply(params: {
convertMarkdownTables(replyResult.text || "", tableMode),
);
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
const mediaList = resolveOutboundMediaUrls(replyResult);
const sendWithRetry = async (fn: () => Promise<unknown>, label: string, maxAttempts = 3) => {
let lastErr: unknown;
@ -114,9 +114,11 @@ export async function deliverWebReply(params: {
const remainingText = [...textChunks];
// Media (with optional caption on first item)
for (const [index, mediaUrl] of mediaList.entries()) {
const caption = index === 0 ? remainingText.shift() || undefined : undefined;
try {
const leadingCaption = remainingText.shift() || "";
await sendMediaWithLeadingCaption({
mediaUrls: mediaList,
caption: leadingCaption,
send: async ({ mediaUrl, caption }) => {
const media = await loadWebMedia(mediaUrl, {
maxBytes: maxMediaBytes,
localRoots: params.mediaLocalRoots,
@ -189,21 +191,24 @@ export async function deliverWebReply(params: {
},
"auto-reply sent (media)",
);
} catch (err) {
whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(err)}`);
replyLogger.warn({ err, mediaUrl }, "failed to send web media reply");
if (index === 0) {
const warning =
err instanceof Error ? `⚠️ Media failed: ${err.message}` : "⚠️ Media failed.";
const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean);
const fallbackText = fallbackTextParts.join("\n");
if (fallbackText) {
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`);
await msg.reply(fallbackText);
}
},
onError: async ({ error, mediaUrl, caption, isFirst }) => {
whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(error)}`);
replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply");
if (!isFirst) {
return;
}
}
}
const warning =
error instanceof Error ? `⚠️ Media failed: ${error.message}` : "⚠️ Media failed.";
const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean);
const fallbackText = fallbackTextParts.join("\n");
if (!fallbackText) {
return;
}
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`);
await msg.reply(fallbackText);
},
});
// Remaining text chunks after media
for (const chunk of remainingText) {

View File

@ -9,6 +9,10 @@ import {
} from "openclaw/plugin-sdk/config-runtime";
import { emitHeartbeatEvent, resolveIndicatorType } from "openclaw/plugin-sdk/infra-runtime";
import { resolveHeartbeatVisibility } from "openclaw/plugin-sdk/infra-runtime";
import {
hasOutboundReplyContent,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { resolveHeartbeatReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
@ -178,10 +182,7 @@ export async function runWebHeartbeatOnce(opts: {
);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
if (
!replyPayload ||
(!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length)
) {
if (!replyPayload || !hasOutboundReplyContent(replyPayload)) {
heartbeatLogger.info(
{
to: redactedTo,
@ -201,7 +202,8 @@ export async function runWebHeartbeatOnce(opts: {
return;
}
const hasMedia = Boolean(replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0);
const reply = resolveSendableOutboundReplyParts(replyPayload);
const hasMedia = reply.hasMedia;
const ackMaxChars = Math.max(
0,
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
@ -250,7 +252,7 @@ export async function runWebHeartbeatOnce(opts: {
);
}
const finalText = stripped.text || replyPayload.text || "";
const finalText = stripped.text || reply.text;
// Check if alerts are disabled for WhatsApp
if (!visibility.showAlerts) {

View File

@ -6,6 +6,7 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime";
import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime";
@ -429,10 +430,11 @@ export async function processMessage(params: {
});
const fromDisplay =
params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown");
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`);
if (shouldLogVerbose()) {
const preview = payload.text != null ? elide(payload.text, 400) : "<media>";
const preview = payload.text != null ? elide(reply.text, 400) : "<media>";
whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`);
}
},

View File

@ -0,0 +1,62 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp";
import { describe, expect, it } from "vitest";
import {
createDirectoryTestRuntime,
expectDirectorySurface,
} from "../../../test/helpers/extensions/directory.ts";
import { whatsappPlugin } from "./channel.js";
describe("whatsapp directory", () => {
const runtimeEnv = createDirectoryTestRuntime() as never;
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
whatsapp: {
authDir: "/tmp/wa-auth",
allowFrom: [
"whatsapp:+15551230001",
"15551230002@s.whatsapp.net",
"120363999999999999@g.us",
],
groups: {
"120363111111111111@g.us": {},
"120363222222222222@g.us": {},
},
},
},
} as unknown as OpenClawConfig;
const directory = expectDirectorySurface(whatsappPlugin.directory);
await expect(
directory.listPeers({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "+15551230001" },
{ kind: "user", id: "+15551230002" },
]),
);
await expect(
directory.listGroups({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "120363111111111111@g.us" },
{ kind: "group", id: "120363222222222222@g.us" },
]),
);
});
});

View File

@ -1,4 +1,4 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/)
import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js";
import {
@ -67,26 +67,15 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
pairing: {
idLabel: "whatsappSenderId",
},
allowlist: {
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
readConfig: ({ cfg, accountId }) => {
const account = resolveWhatsAppAccount({ cfg, accountId });
return {
dmAllowFrom: (account.allowFrom ?? []).map(String),
groupAllowFrom: (account.groupAllowFrom ?? []).map(String),
dmPolicy: account.dmPolicy,
groupPolicy: account.groupPolicy,
};
},
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "whatsapp",
normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "whatsapp",
resolveAccount: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }),
normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values),
resolveDmAllowFrom: (account) => account.allowFrom,
resolveGroupAllowFrom: (account) => account.groupAllowFrom,
resolveDmPolicy: (account) => account.dmPolicy,
resolveGroupPolicy: (account) => account.groupPolicy,
}),
mentions: {
stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx),
},

View File

@ -1,17 +1,16 @@
import {
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFrom,
listResolvedDirectoryGroupEntriesFromMapKeys,
listResolvedDirectoryUserEntriesFromAllowFrom,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { resolveWhatsAppAccount } from "./accounts.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js";
export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
return listDirectoryUserEntriesFromAllowFrom({
allowFrom: account.allowFrom,
query: params.query,
limit: params.limit,
return listResolvedDirectoryUserEntriesFromAllowFrom({
...params,
resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }),
resolveAllowFrom: (account) => account.allowFrom,
normalizeId: (entry) => {
const normalized = normalizeWhatsAppTarget(entry);
if (!normalized || isWhatsAppGroupJid(normalized)) {
@ -23,10 +22,9 @@ export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConf
}
export async function listWhatsAppDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
return listDirectoryGroupEntriesFromMapKeys({
groups: account.groups,
query: params.query,
limit: params.limit,
return listResolvedDirectoryGroupEntriesFromMapKeys({
...params,
resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }),
resolveGroups: (account) => account.groups,
});
}

View File

@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../../src/config/config.js";
const hoisted = vi.hoisted(() => ({
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })),
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../../../src/globals.js", () => ({
@ -14,6 +15,7 @@ vi.mock("./send.js", async (importOriginal) => {
return {
...actual,
sendPollWhatsApp: hoisted.sendPollWhatsApp,
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
};
});
@ -40,6 +42,10 @@ describe("whatsappOutbound sendPoll", () => {
accountId: "work",
cfg,
});
expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" });
expect(result).toEqual({
channel: "whatsapp",
messageId: "poll-1",
toJid: "1555@s.whatsapp.net",
});
});
});

View File

@ -1,6 +1,11 @@
import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import {
createAttachedChannelResultAdapter,
createEmptyChannelResult,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppOutboundTarget } from "./runtime-api.js";
@ -20,9 +25,9 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
sendPayload: async (ctx) => {
const text = trimLeadingWhitespace(ctx.payload.text);
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
const hasMedia = resolveSendableOutboundReplyParts(ctx.payload).hasMedia;
if (!text && !hasMedia) {
return { channel: "whatsapp", messageId: "" };
return createEmptyChannelResult("whatsapp");
}
return await sendTextMediaPayload({
channel: "whatsapp",
@ -36,41 +41,51 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
adapter: whatsappOutbound,
});
},
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
if (!normalizedText) {
return { channel: "whatsapp", messageId: "" };
}
const send =
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
(await import("./send.js")).sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
const send =
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
(await import("./send.js")).sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
...createAttachedChannelResultAdapter({
channel: "whatsapp",
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
if (!normalizedText) {
return createEmptyChannelResult("whatsapp");
}
const send =
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
(await import("./send.js")).sendMessageWhatsApp;
return await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
accountId,
deps,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}) => {
const normalizedText = trimLeadingWhitespace(text);
const send =
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
(await import("./send.js")).sendMessageWhatsApp;
return await send(to, normalizedText, {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
gifPlayback,
});
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}),
};

View File

@ -1,9 +1,8 @@
import {
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup";
import {
@ -93,21 +92,28 @@ export function createWhatsAppPluginBase(params: {
setupWizard: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setup"]>;
isConfigured: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["config"]>["isConfigured"];
}): Pick<
ChannelPlugin<ResolvedWhatsAppAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "reload"
| "gatewayMethods"
| "configSchema"
| "config"
| "security"
| "setup"
| "groups"
> {
return {
}) {
const collectWhatsAppSecurityWarnings =
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedWhatsAppAccount>({
providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined,
resolveGroupPolicy: (account) => account.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0,
restrictSenders: {
surface: "WhatsApp groups",
openScope: "any member in allowed groups",
groupPolicyPath: "channels.whatsapp.groupPolicy",
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
},
noRouteAllowlist: {
surface: "WhatsApp groups",
routeAllowlistPath: "channels.whatsapp.groups",
routeScope: "group",
groupPolicyPath: "channels.whatsapp.groupPolicy",
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
},
});
const base = createChannelPluginBase({
id: WHATSAPP_CHANNEL,
meta: {
...getChatChannelMeta(WHATSAPP_CHANNEL),
@ -144,35 +150,33 @@ export function createWhatsAppPluginBase(params: {
},
security: {
resolveDmPolicy: whatsappResolveDmPolicy,
collectWarnings: ({ account, cfg }) => {
const groupAllowlistConfigured =
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0;
return collectAllowlistProviderGroupPolicyWarnings({
cfg,
providerConfigPresent: cfg.channels?.whatsapp !== undefined,
configuredGroupPolicy: account.groupPolicy,
collect: (groupPolicy) =>
collectOpenGroupPolicyRouteAllowlistWarnings({
groupPolicy,
routeAllowlistConfigured: groupAllowlistConfigured,
restrictSenders: {
surface: "WhatsApp groups",
openScope: "any member in allowed groups",
groupPolicyPath: "channels.whatsapp.groupPolicy",
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
},
noRouteAllowlist: {
surface: "WhatsApp groups",
routeAllowlistPath: "channels.whatsapp.groups",
routeScope: "group",
groupPolicyPath: "channels.whatsapp.groupPolicy",
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
},
}),
});
},
collectWarnings: collectWhatsAppSecurityWarnings,
},
setup: params.setup,
groups: params.groups,
};
});
return {
...base,
setupWizard: base.setupWizard!,
capabilities: base.capabilities!,
reload: base.reload!,
gatewayMethods: base.gatewayMethods!,
configSchema: base.configSchema!,
config: base.config!,
security: base.security!,
groups: base.groups!,
} satisfies Pick<
ChannelPlugin<ResolvedWhatsAppAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "reload"
| "gatewayMethods"
| "configSchema"
| "config"
| "security"
| "setup"
| "groups"
>;
}

View File

@ -6,8 +6,15 @@ import {
import {
buildOpenGroupPolicyRestrictSendersWarning,
buildOpenGroupPolicyWarning,
collectOpenProviderGroupPolicyWarnings,
createOpenProviderGroupPolicyWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
createEmptyChannelResult,
createRawChannelSendResultAdapter,
createStaticReplyToModeResolver,
} from "openclaw/plugin-sdk/channel-runtime";
import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
listZaloAccountIds,
@ -21,7 +28,6 @@ import {
buildBaseAccountStatusSnapshot,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
buildChannelSendResult,
DEFAULT_ACCOUNT_ID,
chunkTextForOutbound,
formatAllowFromLowercase,
@ -78,6 +84,41 @@ const resolveZaloDmPolicy = createScopedDmSecurityResolver<ResolvedZaloAccount>(
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
});
const collectZaloSecurityWarnings = createOpenProviderGroupPolicyWarningCollector<{
cfg: OpenClawConfig;
account: ResolvedZaloAccount;
}>({
providerConfigPresent: (cfg) => cfg.channels?.zalo !== undefined,
resolveGroupPolicy: ({ account }) => account.config.groupPolicy,
collect: ({ account, groupPolicy }) => {
if (groupPolicy !== "open") {
return [];
}
const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
const effectiveAllowFrom =
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
if (effectiveAllowFrom.length > 0) {
return [
buildOpenGroupPolicyRestrictSendersWarning({
surface: "Zalo groups",
openScope: "any member",
groupPolicyPath: "channels.zalo.groupPolicy",
groupAllowFromPath: "channels.zalo.groupAllowFrom",
}),
];
}
return [
buildOpenGroupPolicyWarning({
surface: "Zalo groups",
openBehavior:
"with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
remediation: 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
}),
];
},
});
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
id: "zalo",
meta,
@ -107,47 +148,13 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
},
security: {
resolveDmPolicy: resolveZaloDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectOpenProviderGroupPolicyWarnings({
cfg,
providerConfigPresent: cfg.channels?.zalo !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
collect: (groupPolicy) => {
if (groupPolicy !== "open") {
return [];
}
const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
const effectiveAllowFrom =
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
if (effectiveAllowFrom.length > 0) {
return [
buildOpenGroupPolicyRestrictSendersWarning({
surface: "Zalo groups",
openScope: "any member",
groupPolicyPath: "channels.zalo.groupPolicy",
groupAllowFromPath: "channels.zalo.groupAllowFrom",
}),
];
}
return [
buildOpenGroupPolicyWarning({
surface: "Zalo groups",
openBehavior:
"with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
remediation:
'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
}),
];
},
});
},
collectWarnings: collectZaloSecurityWarnings,
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
resolveReplyToMode: createStaticReplyToModeResolver("off"),
},
actions: zaloMessageActions,
messaging: {
@ -158,19 +165,16 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
hint: "<chatId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveZaloAccount({ cfg: cfg, accountId });
return listDirectoryUserEntriesFromAllowFrom({
allowFrom: account.config.allowFrom,
query,
limit,
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryUserEntriesFromAllowFrom({
...params,
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }),
resolveAllowFrom: (account) => account.config.allowFrom,
normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""),
});
},
}),
listGroups: async () => [],
},
}),
pairing: {
idLabel: "zaloUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
@ -189,31 +193,30 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
chunker: zaloPlugin.outbound!.chunker,
sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalo", messageId: "" },
emptyResult: createEmptyChannelResult("zalo"),
}),
sendText: async ({ to, text, accountId, cfg }) => {
const result = await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
cfg: cfg,
});
return buildChannelSendResult("zalo", result);
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const result = await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
mediaUrl,
cfg: cfg,
});
return buildChannelSendResult("zalo", result);
},
...createRawChannelSendResultAdapter({
channel: "zalo",
sendText: async ({ to, text, accountId, cfg }) =>
await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
cfg: cfg,
}),
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) =>
await (
await loadZaloChannelRuntime()
).sendZaloText({
to,
text,
accountId: accountId ?? undefined,
mediaUrl,
cfg: cfg,
}),
}),
},
status: {
defaultRuntime: {

View File

@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
@ -32,15 +33,14 @@ import {
createTypingCallbacks,
createScopedPairingAccess,
createReplyPrefixOptions,
deliverTextOrMediaReply,
issuePairingChallenge,
logTypingFailure,
resolveDirectDmAuthorizationOutcome,
resolveSenderCommandAuthorizationWithRuntime,
resolveOutboundMediaUrls,
resolveDefaultGroupPolicy,
resolveInboundRouteEnvelopeBuilderWithRuntime,
sendMediaWithLeadingCaption,
resolveWebhookPath,
logTypingFailure,
resolveDefaultGroupPolicy,
resolveDirectDmAuthorizationOutcome,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveSenderCommandAuthorizationWithRuntime,
waitForAbortSignal,
warnMissingProviderGroupPolicyFallbackOnce,
} from "./runtime-api.js";
@ -580,34 +580,31 @@ async function deliverZaloReply(params: {
}): Promise<void> {
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
caption: text,
send: async ({ mediaUrl, caption }) => {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
},
onError: (error) => {
runtime.error?.(`Zalo photo send failed: ${String(error)}`);
},
const reply = resolveSendableOutboundReplyParts(payload, {
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
});
if (sentMedia) {
return;
}
if (text) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode);
for (const chunk of chunks) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: (value) =>
core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
sendText: async (chunk) => {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Zalo message send failed: ${String(err)}`);
}
}
}
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
},
onMediaError: (error) => {
runtime.error?.(`Zalo photo send failed: ${String(error)}`);
},
});
}
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {

View File

@ -1,5 +1,12 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import {
createEmptyChannelResult,
createPairingPrefixStripper,
createRawChannelSendResultAdapter,
createStaticReplyToModeResolver,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import type {
ChannelAccountSnapshot,
@ -11,7 +18,6 @@ import type {
GroupToolPolicyConfig,
} from "../runtime-api.js";
import {
buildChannelSendResult,
buildBaseAccountStatusSnapshot,
DEFAULT_ACCOUNT_ID,
isDangerousNameMatchingEnabled,
@ -308,7 +314,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
resolveToolPolicy: resolveZalouserGroupToolPolicy,
},
threading: {
resolveReplyToMode: () => "off",
resolveReplyToMode: createStaticReplyToModeResolver("off"),
},
actions: zalouserMessageActions,
messaging: {
@ -431,20 +437,21 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
return results;
},
},
pairing: {
pairing: createTextPairingAdapter({
idLabel: "zalouserUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
notifyApproval: async ({ cfg, id }) => {
message: "Your pairing request has been approved.",
normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i),
notify: async ({ cfg, id, message }) => {
const account = resolveZalouserAccountSync({ cfg: cfg });
const authenticated = await checkZcaAuthenticated(account.profile);
if (!authenticated) {
throw new Error("Zalouser not authenticated");
}
await sendMessageZalouser(id, "Your pairing request has been approved.", {
await sendMessageZalouser(id, message, {
profile: account.profile,
});
},
},
}),
auth: {
login: async ({ cfg, accountId, runtime }) => {
const account = resolveZalouserAccountSync({
@ -488,34 +495,35 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
ctx,
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalouser", messageId: "" },
emptyResult: createEmptyChannelResult("zalouser"),
}),
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const target = parseZalouserOutboundTarget(to);
const result = await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const target = parseZalouserOutboundTarget(to);
const result = await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
mediaUrl,
mediaLocalRoots,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
...createRawChannelSendResultAdapter({
channel: "zalouser",
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const target = parseZalouserOutboundTarget(to);
return await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const target = parseZalouserOutboundTarget(to);
return await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
mediaUrl,
mediaLocalRoots,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
},
}),
},
status: {
defaultRuntime: {

View File

@ -21,17 +21,17 @@ import {
createTypingCallbacks,
createScopedPairingAccess,
createReplyPrefixOptions,
deliverTextOrMediaReply,
evaluateGroupRouteAccessForPolicy,
isDangerousNameMatchingEnabled,
issuePairingChallenge,
resolveOutboundMediaUrls,
mergeAllowlist,
resolveMentionGatingWithBypass,
resolveOpenProviderRuntimeGroupPolicy,
resolveSendableOutboundReplyParts,
resolveDefaultGroupPolicy,
resolveSenderCommandAuthorization,
resolveSenderScopedGroupPolicy,
sendMediaWithLeadingCaption,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
} from "../runtime-api.js";
@ -707,16 +707,31 @@ async function deliverZalouserReply(params: {
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const reply = resolveSendableOutboundReplyParts(payload, {
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
});
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
fallbackLimit: ZALOUSER_TEXT_LIMIT,
});
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
caption: text,
send: async ({ mediaUrl, caption }) => {
await deliverTextOrMediaReply({
payload,
text: reply.text,
sendText: async (chunk) => {
try {
await sendMessageZalouser(chatId, chunk, {
profile,
isGroup,
textMode: "markdown",
textChunkMode: chunkMode,
textChunkLimit,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser message send failed: ${String(err)}`);
}
},
sendMedia: async ({ mediaUrl, caption }) => {
logVerbose(core, runtime, `Sending media to ${chatId}`);
await sendMessageZalouser(chatId, caption ?? "", {
profile,
@ -728,28 +743,10 @@ async function deliverZalouserReply(params: {
});
statusSink?.({ lastOutboundAt: Date.now() });
},
onError: (error) => {
onMediaError: (error) => {
runtime.error(`Zalouser media send failed: ${String(error)}`);
},
});
if (sentMedia) {
return;
}
if (text) {
try {
await sendMessageZalouser(chatId, text, {
profile,
isGroup,
textMode: "markdown",
textChunkMode: chunkMode,
textChunkLimit,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser message send failed: ${String(err)}`);
}
}
}
export async function monitorZalouserProvider(

View File

@ -94,6 +94,10 @@
"types": "./dist/plugin-sdk/reply-runtime.d.ts",
"default": "./dist/plugin-sdk/reply-runtime.js"
},
"./plugin-sdk/reply-payload": {
"types": "./dist/plugin-sdk/reply-payload.d.ts",
"default": "./dist/plugin-sdk/reply-payload.js"
},
"./plugin-sdk/channel-runtime": {
"types": "./dist/plugin-sdk/channel-runtime.d.ts",
"default": "./dist/plugin-sdk/channel-runtime.js"
@ -206,6 +210,14 @@
"types": "./dist/plugin-sdk/whatsapp.d.ts",
"default": "./dist/plugin-sdk/whatsapp.js"
},
"./plugin-sdk/whatsapp-action-runtime": {
"types": "./dist/plugin-sdk/whatsapp-action-runtime.d.ts",
"default": "./dist/plugin-sdk/whatsapp-action-runtime.js"
},
"./plugin-sdk/whatsapp-login-qr": {
"types": "./dist/plugin-sdk/whatsapp-login-qr.d.ts",
"default": "./dist/plugin-sdk/whatsapp-login-qr.js"
},
"./plugin-sdk/whatsapp-core": {
"types": "./dist/plugin-sdk/whatsapp-core.d.ts",
"default": "./dist/plugin-sdk/whatsapp-core.js"
@ -394,6 +406,10 @@
"types": "./dist/plugin-sdk/channel-policy.d.ts",
"default": "./dist/plugin-sdk/channel-policy.js"
},
"./plugin-sdk/channel-send-result": {
"types": "./dist/plugin-sdk/channel-send-result.d.ts",
"default": "./dist/plugin-sdk/channel-send-result.js"
},
"./plugin-sdk/group-access": {
"types": "./dist/plugin-sdk/group-access.d.ts",
"default": "./dist/plugin-sdk/group-access.js"
@ -410,6 +426,10 @@
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
"default": "./dist/plugin-sdk/keyed-async-queue.js"
},
"./plugin-sdk/windows-spawn": {
"types": "./dist/plugin-sdk/windows-spawn.d.ts",
"default": "./dist/plugin-sdk/windows-spawn.js"
},
"./plugin-sdk/provider-auth": {
"types": "./dist/plugin-sdk/provider-auth.d.ts",
"default": "./dist/plugin-sdk/provider-auth.js"
@ -486,6 +506,10 @@
"types": "./dist/plugin-sdk/state-paths.d.ts",
"default": "./dist/plugin-sdk/state-paths.js"
},
"./plugin-sdk/temp-path": {
"types": "./dist/plugin-sdk/temp-path.d.ts",
"default": "./dist/plugin-sdk/temp-path.js"
},
"./plugin-sdk/tool-send": {
"types": "./dist/plugin-sdk/tool-send.d.ts",
"default": "./dist/plugin-sdk/tool-send.js"
@ -511,7 +535,8 @@
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check": "pnpm check:host-env-policy:swift && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
@ -634,6 +659,7 @@
"test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh",
"test:perf:budget": "node scripts/test-perf-budget.mjs",
"test:perf:hotspots": "node scripts/test-hotspots.mjs",
"test:perf:update-timings": "node scripts/test-update-timings.mjs",
"test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
"test:startup:memory": "node scripts/check-cli-startup-memory.mjs",
"test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test",
@ -701,6 +727,7 @@
"tar": "7.5.11",
"tslog": "^4.10.2",
"undici": "^7.24.4",
"uuid": "^11.1.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"

6
pnpm-lock.yaml generated
View File

@ -199,6 +199,9 @@ importers:
undici:
specifier: ^7.24.4
version: 7.24.4
uuid:
specifier: ^11.1.0
version: 11.1.0
ws:
specifier: ^8.19.0
version: 8.19.0
@ -480,6 +483,9 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
uuid:
specifier: ^11.1.0
version: 11.1.0
extensions/nextcloud-talk:
dependencies:

View File

@ -4,6 +4,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcRoot = path.join(repoRoot, "src");
@ -78,6 +79,18 @@ function normalizePluginSdkFamily(resolvedPath) {
return relative.replace(/\.(m|c)?[jt]sx?$/, "");
}
function resolveOptionalClusterFromPath(resolvedPath) {
if (resolvedPath.startsWith("extensions/")) {
const cluster = resolvedPath.split("/")[1];
return optionalBundledClusterSet.has(cluster) ? cluster : null;
}
if (resolvedPath.startsWith("src/plugin-sdk/")) {
const cluster = normalizePluginSdkFamily(resolvedPath).split("/")[0];
return optionalBundledClusterSet.has(cluster) ? cluster : null;
}
return null;
}
function compareImports(left, right) {
return (
left.family.localeCompare(right.family) ||
@ -152,6 +165,79 @@ async function collectCorePluginSdkImports() {
return inventory.toSorted(compareImports);
}
function collectOptionalClusterStaticImports(filePath, sourceFile) {
const entries = [];
function push(kind, specifierNode, specifier) {
if (!specifier.startsWith(".")) {
return;
}
const resolvedPath = resolveRelativeSpecifier(specifier, filePath);
if (!resolvedPath) {
return;
}
const cluster = resolveOptionalClusterFromPath(resolvedPath);
if (!cluster) {
return;
}
entries.push({
cluster,
file: normalizePath(filePath),
kind,
line: toLine(sourceFile, specifierNode),
resolvedPath,
specifier,
});
}
function visit(node) {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return entries;
}
async function collectOptionalClusterStaticLeaks() {
const files = await walkCodeFiles(srcRoot);
const inventory = [];
for (const filePath of files) {
const relativePath = normalizePath(filePath);
if (relativePath.startsWith("src/plugin-sdk/")) {
continue;
}
const source = await fs.readFile(filePath, "utf8");
const scriptKind =
filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
scriptKind,
);
inventory.push(...collectOptionalClusterStaticImports(filePath, sourceFile));
}
return inventory.toSorted((left, right) => {
return (
left.cluster.localeCompare(right.cluster) ||
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier)
);
});
}
function buildDuplicatedSeamFamilies(inventory) {
const grouped = new Map();
for (const entry of inventory) {
@ -207,6 +293,30 @@ function buildOverlapFiles(inventory) {
});
}
function buildOptionalClusterStaticLeaks(inventory) {
const grouped = new Map();
for (const entry of inventory) {
const bucket = grouped.get(entry.cluster) ?? [];
bucket.push(entry);
grouped.set(entry.cluster, bucket);
}
return Object.fromEntries(
[...grouped.entries()]
.map(([cluster, entries]) => [
cluster,
{
count: entries.length,
files: [...new Set(entries.map((entry) => entry.file))].toSorted(compareStrings),
imports: entries,
},
])
.toSorted((left, right) => {
return right[1].count - left[1].count || left[0].localeCompare(right[0]);
}),
);
}
function packageClusterMeta(relativePackagePath) {
if (relativePackagePath === "ui/package.json") {
return {
@ -227,6 +337,35 @@ function packageClusterMeta(relativePackagePath) {
};
}
function classifyMissingPackageCluster(params) {
if (optionalBundledClusterSet.has(params.cluster)) {
if (params.cluster === "ui") {
return {
decision: "optional",
reason:
"Private UI workspace. Repo-wide CLI/plugin CI should not require UI-only packages.",
};
}
if (params.pluginSdkEntries.length > 0) {
return {
decision: "optional",
reason:
"Public plugin-sdk entry exists, but repo-wide default check/build should isolate this optional cluster from the static graph.",
};
}
return {
decision: "optional",
reason:
"Workspace package is intentionally not mirrored into the root dependency set by default CI policy.",
};
}
return {
decision: "required",
reason:
"Cluster is statically visible to repo-wide check/build and has not been classified optional.",
};
}
async function buildMissingPackages() {
const rootPackage = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"));
const rootDeps = new Set([
@ -264,15 +403,27 @@ async function buildMissingPackages() {
continue;
}
const meta = packageClusterMeta(relativePackagePath);
const rootDependencyMirrorAllowlist = (
pkg.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? []
).toSorted(compareStrings);
const pluginSdkEntries = [...(pluginSdkReachability.get(meta.cluster) ?? new Set())].toSorted(
compareStrings,
);
const classification = classifyMissingPackageCluster({
cluster: meta.cluster,
pluginSdkEntries,
});
output.push({
cluster: meta.cluster,
decision: classification.decision,
decisionReason: classification.reason,
packageName: pkg.name ?? meta.packageName,
packagePath: relativePackagePath,
npmSpec: pkg.openclaw?.install?.npmSpec ?? null,
private: pkg.private === true,
rootDependencyMirrorAllowlist,
mirrorAllowlistMatchesMissing:
missing.join("\n") === rootDependencyMirrorAllowlist.join("\n"),
pluginSdkReachability:
pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined,
missing,
@ -286,9 +437,11 @@ async function buildMissingPackages() {
await collectWorkspacePackagePaths();
const inventory = await collectCorePluginSdkImports();
const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks();
const result = {
duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory),
overlapFiles: buildOverlapFiles(inventory),
optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks),
missingPackages: await buildMissingPackages(),
};

View File

@ -0,0 +1,272 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import {
collectTypeScriptFilesFromRoots,
resolveSourceRoots,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = resolveSourceRoots(repoRoot, ["src/plugin-sdk", "src/plugins/runtime"]);
function normalizePath(filePath) {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function compareEntries(left, right) {
return (
left.category.localeCompare(right.category) ||
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.reason.localeCompare(right.reason)
);
}
function resolveSpecifier(specifier, importerFile) {
if (specifier.startsWith(".")) {
return normalizePath(path.resolve(path.dirname(importerFile), specifier));
}
if (specifier.startsWith("/")) {
return normalizePath(specifier);
}
return null;
}
function pushEntry(entries, entry) {
entries.push(entry);
}
function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) {
const relativeFile = normalizePath(filePath);
if (!relativeFile.startsWith("src/plugin-sdk/")) {
return [];
}
const entries = [];
function visit(node) {
if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const specifier = node.moduleSpecifier.text;
const resolvedPath = resolveSpecifier(specifier, filePath);
if (resolvedPath?.startsWith("extensions/")) {
pushEntry(entries, {
category: "plugin-sdk-extension-facade",
file: relativeFile,
line: toLine(sourceFile, node.moduleSpecifier),
kind: "export",
specifier,
resolvedPath,
reason: "plugin-sdk public surface re-exports extension-owned implementation",
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return entries;
}
function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
const relativeFile = normalizePath(filePath);
if (!/^src\/plugins\/runtime\/types(?:-[^/]+)?\.ts$/.test(relativeFile)) {
return [];
}
const entries = [];
function visit(node) {
if (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteral(node.argument.literal)
) {
const specifier = node.argument.literal.text;
const resolvedPath = resolveSpecifier(specifier, filePath);
if (
resolvedPath &&
(/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) ||
/^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath))
) {
pushEntry(entries, {
category: "runtime-type-implementation-edge",
file: relativeFile,
line: toLine(sourceFile, node.argument.literal),
kind: "import-type",
specifier,
resolvedPath,
reason: "runtime type file references implementation shim directly",
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return entries;
}
function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
const relativeFile = normalizePath(filePath);
if (
!relativeFile.startsWith("src/plugin-sdk/") &&
!relativeFile.startsWith("src/plugins/runtime/")
) {
return [];
}
const entries = [];
const exportedNames = new Set();
const runtimeStoreCalls = [];
const mutableStateNodes = [];
for (const statement of sourceFile.statements) {
if (ts.isFunctionDeclaration(statement) && statement.name) {
const isExported = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
if (isExported) {
exportedNames.add(statement.name.text);
}
} else if (ts.isVariableStatement(statement)) {
const isExported = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && isExported) {
exportedNames.add(declaration.name.text);
}
if (
!isExported &&
(statement.declarationList.flags & ts.NodeFlags.Let) !== 0 &&
ts.isIdentifier(declaration.name)
) {
mutableStateNodes.push(declaration.name);
}
}
}
}
function visit(node) {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "createPluginRuntimeStore"
) {
runtimeStoreCalls.push(node.expression);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
const getterNames = [...exportedNames].filter((name) => /^get[A-Z]/.test(name));
const setterNames = [...exportedNames].filter((name) => /^set[A-Z]/.test(name));
if (runtimeStoreCalls.length > 0 && getterNames.length > 0 && setterNames.length > 0) {
for (const callNode of runtimeStoreCalls) {
pushEntry(entries, {
category: "runtime-service-locator",
file: relativeFile,
line: toLine(sourceFile, callNode),
kind: "runtime-store",
specifier: "createPluginRuntimeStore",
resolvedPath: relativeFile,
reason: `exports paired runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")}) over module-global store state`,
});
}
}
if (mutableStateNodes.length > 0 && getterNames.length > 0 && setterNames.length > 0) {
for (const identifier of mutableStateNodes) {
pushEntry(entries, {
category: "runtime-service-locator",
file: relativeFile,
line: toLine(sourceFile, identifier),
kind: "mutable-state",
specifier: identifier.text,
resolvedPath: relativeFile,
reason: `module-global mutable state backs exported runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")})`,
});
}
}
return entries;
}
export async function collectArchitectureSmells() {
const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) =>
normalizePath(left).localeCompare(normalizePath(right)),
);
const inventory = [];
for (const filePath of files) {
const source = await fs.readFile(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
inventory.push(...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath));
inventory.push(...scanRuntimeTypeImplementationSmells(sourceFile, filePath));
inventory.push(...scanRuntimeServiceLocatorSmells(sourceFile, filePath));
}
return inventory.toSorted(compareEntries);
}
function formatInventoryHuman(inventory) {
if (inventory.length === 0) {
return "No architecture smells found for the configured checks.";
}
const lines = ["Architecture smell inventory:"];
let activeCategory = "";
let activeFile = "";
for (const entry of inventory) {
if (entry.category !== activeCategory) {
activeCategory = entry.category;
activeFile = "";
lines.push(entry.category);
}
if (entry.file !== activeFile) {
activeFile = entry.file;
lines.push(` ${activeFile}`);
}
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
lines.push(` specifier: ${entry.specifier}`);
lines.push(` resolved: ${entry.resolvedPath}`);
}
return lines.join("\n");
}
export async function main(argv = process.argv.slice(2)) {
const json = argv.includes("--json");
const inventory = await collectArchitectureSmells();
if (json) {
process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`);
return;
}
console.log(formatInventoryHuman(inventory));
console.log(`${inventory.length} smell${inventory.length === 1 ? "" : "s"} found.`);
}
runAsScript(import.meta.url, main);

View File

@ -0,0 +1,17 @@
export function collectBundledProviderAuthEnvVars(params?: {
repoRoot?: string;
}): Record<string, readonly string[]>;
export function renderBundledProviderAuthEnvVarModule(
entries: Record<string, readonly string[]>,
): string;
export function writeBundledProviderAuthEnvVarModule(params?: {
repoRoot?: string;
outputPath?: string;
check?: boolean;
}): {
changed: boolean;
wrote: boolean;
outputPath: string;
};

View File

@ -0,0 +1,131 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
const GENERATED_BY = "scripts/generate-bundled-provider-auth-env-vars.mjs";
const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-provider-auth-env-vars.generated.ts";
function readIfExists(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function normalizeProviderAuthEnvVars(providerAuthEnvVars) {
if (
!providerAuthEnvVars ||
typeof providerAuthEnvVars !== "object" ||
Array.isArray(providerAuthEnvVars)
) {
return [];
}
return Object.entries(providerAuthEnvVars)
.map(([providerId, envVars]) => {
const normalizedProviderId = providerId.trim();
const normalizedEnvVars = Array.isArray(envVars)
? envVars.map((value) => String(value).trim()).filter(Boolean)
: [];
if (!normalizedProviderId || normalizedEnvVars.length === 0) {
return null;
}
return [normalizedProviderId, normalizedEnvVars];
})
.filter(Boolean)
.toSorted(([left], [right]) => left.localeCompare(right));
}
export function collectBundledProviderAuthEnvVars(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const extensionsRoot = path.join(repoRoot, "extensions");
if (!fs.existsSync(extensionsRoot)) {
return {};
}
const entries = new Map();
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const manifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
continue;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const [providerId, envVars] of normalizeProviderAuthEnvVars(
manifest.providerAuthEnvVars,
)) {
entries.set(providerId, envVars);
}
}
return Object.fromEntries(
[...entries.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
);
}
export function renderBundledProviderAuthEnvVarModule(entries) {
const renderedEntries = Object.entries(entries)
.map(([providerId, envVars]) => {
const renderedKey = /^[$A-Z_a-z][\w$]*$/u.test(providerId)
? providerId
: JSON.stringify(providerId);
const renderedEnvVars = envVars.map((value) => JSON.stringify(value)).join(", ");
return ` ${renderedKey}: [${renderedEnvVars}],`;
})
.join("\n");
return `// Auto-generated by ${GENERATED_BY}. Do not edit directly.
export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
${renderedEntries}
} as const satisfies Record<string, readonly string[]>;
`;
}
export function writeBundledProviderAuthEnvVarModule(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputPath = path.resolve(repoRoot, params.outputPath ?? DEFAULT_OUTPUT_PATH);
const next = renderBundledProviderAuthEnvVarModule(
collectBundledProviderAuthEnvVars({ repoRoot }),
);
const current = readIfExists(outputPath);
const changed = current !== next;
if (params.check) {
return {
changed,
wrote: false,
outputPath,
};
}
return {
changed,
wrote: writeTextFileIfChanged(outputPath, next),
outputPath,
};
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const result = writeBundledProviderAuthEnvVarModule({
check: process.argv.includes("--check"),
});
if (result.changed) {
if (process.argv.includes("--check")) {
console.error(
`[bundled-provider-auth-env-vars] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`,
);
process.exitCode = 1;
} else {
console.log(
`[bundled-provider-auth-env-vars] wrote ${path.relative(process.cwd(), result.outputPath)}`,
);
}
}
}

View File

@ -0,0 +1,6 @@
export const optionalBundledClusters: string[];
export const optionalBundledClusterSet: Set<string>;
export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED";
export function isOptionalBundledCluster(cluster: string): boolean;
export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean;
export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean;

View File

@ -0,0 +1,6 @@
export const optionalBundledClusters: string[];
export const optionalBundledClusterSet: Set<string>;
export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED";
export function isOptionalBundledCluster(cluster: string): boolean;
export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean;
export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean;

View File

@ -0,0 +1,30 @@
export const optionalBundledClusters = [
"acpx",
"diagnostics-otel",
"diffs",
"googlechat",
"matrix",
"memory-lancedb",
"msteams",
"nostr",
"tlon",
"twitch",
"ui",
"zalouser",
];
export const optionalBundledClusterSet = new Set(optionalBundledClusters);
export const OPTIONAL_BUNDLED_BUILD_ENV = "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED";
export function isOptionalBundledCluster(cluster) {
return optionalBundledClusterSet.has(cluster);
}
export function shouldIncludeOptionalBundledClusters(env = process.env) {
return env[OPTIONAL_BUNDLED_BUILD_ENV] === "1";
}
export function shouldBuildBundledCluster(cluster, env = process.env) {
return shouldIncludeOptionalBundledClusters(env) || !isOptionalBundledCluster(cluster);
}

View File

@ -13,6 +13,7 @@
"setup-tools",
"config-runtime",
"reply-runtime",
"reply-payload",
"channel-runtime",
"interactive-runtime",
"infra-runtime",
@ -41,6 +42,8 @@
"imessage",
"imessage-core",
"whatsapp",
"whatsapp-action-runtime",
"whatsapp-login-qr",
"whatsapp-core",
"line",
"line-core",
@ -88,10 +91,12 @@
"channel-config-schema",
"channel-lifecycle",
"channel-policy",
"channel-send-result",
"group-access",
"directory-runtime",
"json-store",
"keyed-async-queue",
"windows-spawn",
"provider-auth",
"provider-auth-api-key",
"provider-auth-login",
@ -111,6 +116,7 @@
"web-media",
"speech",
"state-paths",
"temp-path",
"tool-send",
"secret-input-schema"
]

View File

@ -3,127 +3,30 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { channelTestPrefixes } from "../vitest.channel-paths.mjs";
import {
loadTestRunnerBehavior,
loadUnitTimingManifest,
packFilesByDuration,
selectTimedHeavyFiles,
} from "./test-runner-manifest.mjs";
// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
// (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm.
const pnpm = "pnpm";
const unitIsolatedFilesRaw = [
"src/plugins/loader.test.ts",
"src/plugins/tools.optional.test.ts",
"src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts",
"src/security/fix.test.ts",
// Runtime source guard scans are sensitive to filesystem contention.
"src/security/temp-path-guard.test.ts",
"src/security/audit.test.ts",
"src/utils.test.ts",
"src/auto-reply/tool-meta.test.ts",
"src/auto-reply/envelope.test.ts",
"src/commands/auth-choice.test.ts",
// Provider runtime contract imports plugin runtimes plus async ESM mocks;
// keep it off the shared fast lane to avoid teardown stalls on this host.
"src/plugins/contracts/runtime.contract.test.ts",
// Process supervision + docker setup suites are stable but setup-heavy.
"src/process/supervisor/supervisor.test.ts",
"src/docker-setup.test.ts",
// Filesystem-heavy skills sync suite.
"src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts",
// Real git hook integration test; keep signal, move off unit-fast critical path.
"test/git-hooks-pre-commit.test.ts",
// Setup-heavy doctor command suites; keep them off the unit-fast critical path.
"src/commands/doctor.warns-state-directory-is-missing.test.ts",
"src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts",
"src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts",
// Setup-heavy CLI update flow suite; move off unit-fast critical path.
"src/cli/update-cli.test.ts",
// Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes.
"src/infra/git-commit.test.ts",
// Expensive schema build/bootstrap checks; keep coverage but run in isolated lane.
"src/config/schema.test.ts",
"src/config/schema.tags.test.ts",
// CLI smoke/agent flows are stable but setup-heavy.
"src/cli/program.smoke.test.ts",
"src/commands/agent.test.ts",
"src/media/store.test.ts",
"src/media/store.header-ext.test.ts",
"extensions/whatsapp/src/media.test.ts",
"extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts",
"src/browser/server.covers-additional-endpoint-branches.test.ts",
"src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts",
"src/browser/server.agent-contract-snapshot-endpoints.test.ts",
"src/browser/server.agent-contract-form-layout-act-commands.test.ts",
"src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts",
"src/browser/server.auth-token-gates-http.test.ts",
// Keep this high-variance heavy file off the unit-fast critical path.
"src/auto-reply/reply.block-streaming.test.ts",
// Archive extraction/fixture-heavy suite; keep off unit-fast critical path.
"src/hooks/install.test.ts",
// Download/extraction safety cases can spike under unit-fast contention.
"src/agents/skills-install.download.test.ts",
// Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes.
"src/agents/skills.test.ts",
"src/agents/skills.buildworkspaceskillsnapshot.test.ts",
"extensions/acpx/src/runtime.test.ts",
// Shell-heavy script harness can contend under vmForks startup bursts.
"test/scripts/ios-team-id.test.ts",
// Heavy runner/exec/archive suites are stable but contend on shared resources under vmForks.
"src/agents/pi-embedded-runner.test.ts",
"src/agents/bash-tools.test.ts",
"src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts",
"src/agents/bash-tools.exec.background-abort.test.ts",
"src/agents/subagent-announce.format.test.ts",
"src/infra/archive.test.ts",
"src/cli/daemon-cli.coverage.test.ts",
// Model normalization test imports config/model discovery stack; keep off unit-fast critical path.
"src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts",
// Auth profile rotation suite is retry-heavy and high-variance under vmForks contention.
"src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts",
// Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise.
"src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts",
"src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts",
"src/auto-reply/reply.triggers.group-intro-prompts.test.ts",
"src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts",
"extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts",
// Setup-heavy bot bootstrap suite.
"extensions/telegram/src/bot.create-telegram-bot.test.ts",
// Medium-heavy bot behavior suite; move off unit-fast critical path.
"extensions/telegram/src/bot.test.ts",
// Slack slash registration tests are setup-heavy and can bottleneck unit-fast.
"extensions/slack/src/monitor/slash.test.ts",
// Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage.
"extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts",
// Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane.
"src/infra/git-commit.test.ts",
];
const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file));
const unitSingletonIsolatedFilesRaw = [
// These pass clean in isolation but can hang on fork shutdown after sharing
// the broad unit-fast lane on this host; keep them in dedicated processes.
"src/cli/command-secret-gateway.test.ts",
];
const unitSingletonIsolatedFiles = unitSingletonIsolatedFilesRaw.filter((file) =>
fs.existsSync(file),
);
const unitThreadSingletonFilesRaw = [
// These suites terminate cleanly under the threads pool but can hang during
// forks worker shutdown on this host.
"src/channels/plugins/actions/actions.test.ts",
"src/infra/outbound/deliver.test.ts",
"src/infra/outbound/deliver.lifecycle.test.ts",
"src/infra/outbound/message.channels.test.ts",
"src/infra/outbound/message-action-runner.poll.test.ts",
"src/tts/tts.test.ts",
];
const unitThreadSingletonFiles = unitThreadSingletonFilesRaw.filter((file) => fs.existsSync(file));
const unitVmForkSingletonFilesRaw = [
"src/channels/plugins/contracts/inbound.telegram.contract.test.ts",
];
const unitVmForkSingletonFiles = unitVmForkSingletonFilesRaw.filter((file) => fs.existsSync(file));
const groupedUnitIsolatedFiles = unitIsolatedFiles.filter(
(file) => !unitSingletonIsolatedFiles.includes(file) && !unitThreadSingletonFiles.includes(file),
);
const channelSingletonFilesRaw = [];
const channelSingletonFiles = channelSingletonFilesRaw.filter((file) => fs.existsSync(file));
const behaviorManifest = loadTestRunnerBehavior();
const existingFiles = (entries) =>
entries.map((entry) => entry.file).filter((file) => fs.existsSync(file));
const unitBehaviorIsolatedFiles = existingFiles(behaviorManifest.unit.isolated);
const unitSingletonIsolatedFiles = existingFiles(behaviorManifest.unit.singletonIsolated);
const unitThreadSingletonFiles = existingFiles(behaviorManifest.unit.threadSingleton);
const unitVmForkSingletonFiles = existingFiles(behaviorManifest.unit.vmForkSingleton);
const unitBehaviorOverrideSet = new Set([
...unitBehaviorIsolatedFiles,
...unitSingletonIsolatedFiles,
...unitThreadSingletonFiles,
...unitVmForkSingletonFiles,
]);
const channelSingletonFiles = [];
const children = new Set();
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
@ -158,117 +61,7 @@ const testProfile =
// Even on low-memory hosts, keep the isolated lane split so files like
// git-commit.test.ts still get the worker/process isolation they require.
const shouldSplitUnitRuns = testProfile !== "serial";
const runs = [
...(shouldSplitUnitRuns
? [
{
name: "unit-fast",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
...[
...unitIsolatedFiles,
...unitSingletonIsolatedFiles,
...unitThreadSingletonFiles,
...unitVmForkSingletonFiles,
].flatMap((file) => ["--exclude", file]),
],
},
...(groupedUnitIsolatedFiles.length > 0
? [
{
name: "unit-isolated",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
"--pool=forks",
...groupedUnitIsolatedFiles,
],
},
]
: []),
...unitSingletonIsolatedFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-isolated`,
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
file,
],
})),
...unitThreadSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-threads`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file],
})),
...unitVmForkSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-vmforks`,
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
file,
],
})),
...channelSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-channels-isolated`,
args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file],
})),
]
: [
{
name: "unit",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
],
},
]),
...(includeExtensionsSuite
? [
{
name: "extensions",
args: [
"vitest",
"run",
"--config",
"vitest.extensions.config.ts",
...(useVmForks ? ["--pool=vmForks"] : []),
],
},
]
: []),
...(includeGatewaySuite
? [
{
name: "gateway",
args: [
"vitest",
"run",
"--config",
"vitest.gateway.config.ts",
// Gateway tests are sensitive to vmForks behavior (global state + env stubs).
// Keep them on process forks for determinism even when other suites use vmForks.
"--pool=forks",
],
},
]
: []),
];
let runs = [];
const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10);
const configuredShardCount =
Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : null;
@ -414,7 +207,7 @@ const allKnownTestFiles = [
]),
];
const inferTarget = (fileFilter) => {
const isolated = unitIsolatedFiles.includes(fileFilter);
const isolated = unitBehaviorIsolatedFiles.includes(fileFilter);
if (fileFilter.endsWith(".live.test.ts")) {
return { owner: "live", isolated };
}
@ -438,6 +231,160 @@ const inferTarget = (fileFilter) => {
}
return { owner: "base", isolated };
};
const unitTimingManifest = loadUnitTimingManifest();
const parseEnvNumber = (name, fallback) => {
const parsed = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const allKnownUnitFiles = allKnownTestFiles.filter((file) => {
if (file.endsWith(".live.test.ts") || file.endsWith(".e2e.test.ts")) {
return false;
}
return inferTarget(file).owner !== "gateway";
});
const defaultHeavyUnitFileLimit =
testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60;
const defaultHeavyUnitLaneCount =
testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4;
const heavyUnitFileLimit = parseEnvNumber(
"OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT",
defaultHeavyUnitFileLimit,
);
const heavyUnitLaneCount = parseEnvNumber(
"OPENCLAW_TEST_HEAVY_UNIT_LANES",
defaultHeavyUnitLaneCount,
);
const heavyUnitMinDurationMs = parseEnvNumber("OPENCLAW_TEST_HEAVY_UNIT_MIN_MS", 1200);
const timedHeavyUnitFiles =
shouldSplitUnitRuns && heavyUnitFileLimit > 0
? selectTimedHeavyFiles({
candidates: allKnownUnitFiles,
limit: heavyUnitFileLimit,
minDurationMs: heavyUnitMinDurationMs,
exclude: unitBehaviorOverrideSet,
timings: unitTimingManifest,
})
: [];
const unitFastExcludedFiles = [
...new Set([...unitBehaviorOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]),
];
const estimateUnitDurationMs = (file) =>
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
const heavyUnitBuckets = packFilesByDuration(
timedHeavyUnitFiles,
heavyUnitLaneCount,
estimateUnitDurationMs,
);
const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({
name: `unit-heavy-${String(index + 1)}`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
}));
const baseRuns = [
...(shouldSplitUnitRuns
? [
{
name: "unit-fast",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
...unitFastExcludedFiles.flatMap((file) => ["--exclude", file]),
],
},
...(unitBehaviorIsolatedFiles.length > 0
? [
{
name: "unit-isolated",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
"--pool=forks",
...unitBehaviorIsolatedFiles,
],
},
]
: []),
...unitHeavyEntries,
...unitSingletonIsolatedFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-isolated`,
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
file,
],
})),
...unitThreadSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-threads`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file],
})),
...unitVmForkSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-vmforks`,
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
file,
],
})),
...channelSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-channels-isolated`,
args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file],
})),
]
: [
{
name: "unit",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
`--pool=${useVmForks ? "vmForks" : "forks"}`,
...(disableIsolation ? ["--isolate=false"] : []),
],
},
]),
...(includeExtensionsSuite
? [
{
name: "extensions",
args: [
"vitest",
"run",
"--config",
"vitest.extensions.config.ts",
...(useVmForks ? ["--pool=vmForks"] : []),
],
},
]
: []),
...(includeGatewaySuite
? [
{
name: "gateway",
args: ["vitest", "run", "--config", "vitest.gateway.config.ts", "--pool=forks"],
},
]
: []),
];
runs = baseRuns;
const formatEntrySummary = (entry) => {
const explicitFilters = countExplicitEntryFilters(entry.args) ?? 0;
return `${entry.name} filters=${String(explicitFilters || "all")} maxWorkers=${String(
maxWorkersForRun(entry.name) ?? "default",
)}`;
};
const resolveFilterMatches = (fileFilter) => {
const normalizedFilter = normalizeRepoPath(fileFilter);
if (fs.existsSync(fileFilter)) {
@ -640,8 +587,10 @@ const defaultWorkerBudget =
}
: highMemLocalHost
? {
// High-memory local hosts can prioritize wall-clock speed.
unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))),
// After peeling measured hotspots into dedicated lanes, the shared
// unit-fast lane shuts down more reliably with a slightly smaller
// worker fan-out than the old "max it out" local default.
unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))),
unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)),
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))),
@ -674,7 +623,13 @@ const maxWorkersForRun = (name) => {
if (isCI && isMacOS) {
return 1;
}
if (name === "unit-isolated" || name.endsWith("-isolated")) {
if (name.endsWith("-threads") || name.endsWith("-vmforks")) {
return 1;
}
if (name.endsWith("-isolated") && name !== "unit-isolated") {
return 1;
}
if (name === "unit-isolated" || name.startsWith("unit-heavy-")) {
return defaultWorkerBudget.unitIsolated;
}
if (name === "extensions") {
@ -706,9 +661,12 @@ const maxOldSpaceSizeMb = (() => {
}
return null;
})();
const formatElapsedMs = (elapsedMs) =>
elapsedMs >= 1000 ? `${(elapsedMs / 1000).toFixed(1)}s` : `${Math.round(elapsedMs)}ms`;
const runOnce = (entry, extraArgs = []) =>
new Promise((resolve) => {
const startedAt = Date.now();
const maxWorkers = maxWorkersForRun(entry.name);
// vmForks with a single worker has shown cross-file leakage in extension suites.
// Fall back to process forks when we intentionally clamp that lane to one worker.
@ -726,6 +684,11 @@ const runOnce = (entry, extraArgs = []) =>
...extraArgs,
]
: [...entryArgs, ...silentArgs, ...windowsCiArgs, ...extraArgs];
console.log(
`[test-parallel] start ${entry.name} workers=${maxWorkers ?? "default"} filters=${String(
countExplicitEntryFilters(entryArgs) ?? "all",
)}`,
);
const nodeOptions = process.env.NODE_OPTIONS ?? "";
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
@ -756,6 +719,11 @@ const runOnce = (entry, extraArgs = []) =>
});
child.on("exit", (code, signal) => {
children.delete(child);
console.log(
`[test-parallel] done ${entry.name} code=${String(code ?? (signal ? 1 : 0))} elapsed=${formatElapsedMs(
Date.now() - startedAt,
)}`,
);
resolve(code ?? (signal ? 1 : 0));
});
});
@ -823,6 +791,14 @@ const shutdown = (signal) => {
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
if (process.env.OPENCLAW_TEST_LIST_LANES === "1") {
const entriesToPrint = targetedEntries.length > 0 ? targetedEntries : runs;
for (const entry of entriesToPrint) {
console.log(formatEntrySummary(entry));
}
process.exit(0);
}
if (targetedEntries.length > 0) {
if (passthroughRequiresSingleRun && targetedEntries.length > 1) {
console.error(

View File

@ -0,0 +1,129 @@
import fs from "node:fs";
import path from "node:path";
export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json";
export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json";
const defaultTimingManifest = {
config: "vitest.unit.config.ts",
defaultDurationMs: 250,
files: {},
};
const readJson = (filePath, fallback) => {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
};
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
const normalizeManifestEntries = (entries) =>
entries
.map((entry) =>
typeof entry === "string"
? { file: normalizeRepoPath(entry), reason: "" }
: {
file: normalizeRepoPath(String(entry?.file ?? "")),
reason: typeof entry?.reason === "string" ? entry.reason : "",
},
)
.filter((entry) => entry.file.length > 0);
export function loadTestRunnerBehavior() {
const raw = readJson(behaviorManifestPath, {});
const unit = raw.unit ?? {};
return {
unit: {
isolated: normalizeManifestEntries(unit.isolated ?? []),
singletonIsolated: normalizeManifestEntries(unit.singletonIsolated ?? []),
threadSingleton: normalizeManifestEntries(unit.threadSingleton ?? []),
vmForkSingleton: normalizeManifestEntries(unit.vmForkSingleton ?? []),
},
};
}
export function loadUnitTimingManifest() {
const raw = readJson(unitTimingManifestPath, defaultTimingManifest);
const defaultDurationMs =
Number.isFinite(raw.defaultDurationMs) && raw.defaultDurationMs > 0
? raw.defaultDurationMs
: defaultTimingManifest.defaultDurationMs;
const files = Object.fromEntries(
Object.entries(raw.files ?? {})
.map(([file, value]) => {
const normalizedFile = normalizeRepoPath(file);
const durationMs =
Number.isFinite(value?.durationMs) && value.durationMs >= 0 ? value.durationMs : null;
const testCount =
Number.isFinite(value?.testCount) && value.testCount >= 0 ? value.testCount : null;
if (!durationMs) {
return [normalizedFile, null];
}
return [
normalizedFile,
{
durationMs,
...(testCount !== null ? { testCount } : {}),
},
];
})
.filter(([, value]) => value !== null),
);
return {
config:
typeof raw.config === "string" && raw.config ? raw.config : defaultTimingManifest.config,
generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : "",
defaultDurationMs,
files,
};
}
export function selectTimedHeavyFiles({
candidates,
limit,
minDurationMs,
exclude = new Set(),
timings,
}) {
return candidates
.filter((file) => !exclude.has(file))
.map((file) => ({
file,
durationMs: timings.files[file]?.durationMs ?? timings.defaultDurationMs,
known: Boolean(timings.files[file]),
}))
.filter((entry) => entry.known && entry.durationMs >= minDurationMs)
.toSorted((a, b) => b.durationMs - a.durationMs)
.slice(0, limit)
.map((entry) => entry.file);
}
export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
const normalizedBucketCount = Math.max(0, Math.floor(bucketCount));
if (normalizedBucketCount <= 0 || files.length === 0) {
return [];
}
const buckets = Array.from({ length: Math.min(normalizedBucketCount, files.length) }, () => ({
totalMs: 0,
files: [],
}));
const sortedFiles = [...files].toSorted((left, right) => {
return estimateDurationMs(right) - estimateDurationMs(left);
});
for (const file of sortedFiles) {
const bucket = buckets.reduce((lightest, current) =>
current.totalMs < lightest.totalMs ? current : lightest,
);
bucket.files.push(file);
bucket.totalMs += estimateDurationMs(file);
}
return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0);
}

View File

@ -0,0 +1,109 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { unitTimingManifestPath } from "./test-runner-manifest.mjs";
function parseArgs(argv) {
const args = {
config: "vitest.unit.config.ts",
out: unitTimingManifestPath,
reportPath: "",
limit: 128,
defaultDurationMs: 250,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--config") {
args.config = argv[i + 1] ?? args.config;
i += 1;
continue;
}
if (arg === "--out") {
args.out = argv[i + 1] ?? args.out;
i += 1;
continue;
}
if (arg === "--report") {
args.reportPath = argv[i + 1] ?? "";
i += 1;
continue;
}
if (arg === "--limit") {
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
if (Number.isFinite(parsed) && parsed > 0) {
args.limit = parsed;
}
i += 1;
continue;
}
if (arg === "--default-duration-ms") {
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
if (Number.isFinite(parsed) && parsed > 0) {
args.defaultDurationMs = parsed;
}
i += 1;
continue;
}
}
return args;
}
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
const opts = parseArgs(process.argv.slice(2));
const reportPath =
opts.reportPath || path.join(os.tmpdir(), `openclaw-vitest-timings-${Date.now()}.json`);
if (!(opts.reportPath && fs.existsSync(reportPath))) {
const run = spawnSync(
"pnpm",
["vitest", "run", "--config", opts.config, "--reporter=json", "--outputFile", reportPath],
{
stdio: "inherit",
env: process.env,
},
);
if (run.status !== 0) {
process.exit(run.status ?? 1);
}
}
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
const files = Object.fromEntries(
(report.testResults ?? [])
.map((result) => {
const file = typeof result.name === "string" ? normalizeRepoPath(result.name) : "";
const start = typeof result.startTime === "number" ? result.startTime : 0;
const end = typeof result.endTime === "number" ? result.endTime : 0;
const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0;
return {
file,
durationMs: Math.max(0, end - start),
testCount,
};
})
.filter((entry) => entry.file.length > 0 && entry.durationMs > 0)
.toSorted((a, b) => b.durationMs - a.durationMs)
.slice(0, opts.limit)
.map((entry) => [
entry.file,
{
durationMs: entry.durationMs,
testCount: entry.testCount,
},
]),
);
const output = {
config: opts.config,
generatedAt: new Date().toISOString(),
defaultDurationMs: opts.defaultDurationMs,
files,
};
fs.writeFileSync(opts.out, `${JSON.stringify(output, null, 2)}\n`);
console.log(
`[test-update-timings] wrote ${String(Object.keys(files).length)} timings to ${opts.out}`,
);

View File

@ -1,6 +1,8 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn";
const extraArgs = process.argv.slice(2);
@ -8,6 +10,38 @@ const INEFFECTIVE_DYNAMIC_IMPORT_RE = /\[INEFFECTIVE_DYNAMIC_IMPORT\]/;
const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/;
const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
function removeDistPluginNodeModulesSymlinks(rootDir) {
const extensionsDir = path.join(rootDir, "extensions");
if (!fs.existsSync(extensionsDir)) {
return;
}
for (const dirent of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const nodeModulesPath = path.join(extensionsDir, dirent.name, "node_modules");
try {
if (fs.lstatSync(nodeModulesPath).isSymbolicLink()) {
fs.rmSync(nodeModulesPath, { force: true, recursive: true });
}
} catch {
// Skip missing or unreadable paths so the build can proceed.
}
}
}
function pruneStaleRuntimeSymlinks() {
const cwd = process.cwd();
// runtime-postbuild links dist/dist-runtime plugin node_modules back into the
// source extensions. Remove only those symlinks up front so tsdown's clean
// step cannot traverse into the active pnpm install tree on rebuilds.
removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist"));
removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime"));
}
pruneStaleRuntimeSymlinks();
function findFatalUnresolvedImport(lines) {
for (const line of lines) {
if (!UNRESOLVED_IMPORT_RE.test(line)) {

View File

@ -13,12 +13,12 @@ import {
type RequestPermissionResponse,
type SessionNotification,
} from "@agentclientprotocol/sdk";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
} from "openclaw/plugin-sdk/windows-spawn";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import {
listKnownProviderAuthEnvVarNames,
omitEnvKeysCaseInsensitive,

View File

@ -1,4 +1,4 @@
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
export class SessionActorQueue {
private readonly queue = new KeyedAsyncQueue();

View File

@ -5,11 +5,10 @@ import type {
SetSessionConfigOptionRequest,
SetSessionModeRequest,
} from "@agentclientprotocol/sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { listThinkingLevels } from "../auto-reply/thinking.js";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
@ -121,10 +120,6 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text:
sessionStore.clearAllSessionsForTest();
}
beforeEach(() => {
resetProviderRuntimeHookCacheForTest();
});
describe("acp session creation rate limit", () => {
it("rate limits excessive newSession bursts", async () => {
const sessionStore = createInMemorySessionStore();

View File

@ -4,10 +4,10 @@ import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { CliBackendConfig } from "../../config/types.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
import { isRecord } from "../../utils.js";
import { buildModelAliasLines } from "../model-alias-lines.js";

Some files were not shown because too many files have changed in this diff Show More