Merge remote-tracking branch 'upstream/main' into feat/gigachat
# Conflicts: # extensions/whatsapp/src/outbound-adapter.poll.test.ts
This commit is contained in:
commit
c875368c84
3
.npmrc
3
.npmrc
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1 +0,0 @@
|
||||
docs.openclaw.ai
|
||||
@ -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.
|
||||
|
||||
@ -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 don’t 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.
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
58
extensions/googlechat/src/channel.directory.test.ts
Normal file
58
extensions/googlechat/src/channel.directory.test.ts
Normal 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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]>;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
|
||||
42
extensions/minimax/model-definitions.test.ts
Normal file
42
extensions/minimax/model-definitions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>",
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from "openclaw/plugin-sdk/nostr";
|
||||
export * from "./setup-api.js";
|
||||
|
||||
@ -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()}`,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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"]>;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from "openclaw/plugin-sdk/tlon";
|
||||
export * from "./setup-api.js";
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)" : ""}`);
|
||||
}
|
||||
},
|
||||
|
||||
62
extensions/whatsapp/src/channel.directory.test.ts
Normal file
62
extensions/whatsapp/src/channel.directory.test.ts
Normal 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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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"
|
||||
>;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(
|
||||
|
||||
29
package.json
29
package.json
@ -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
6
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
|
||||
272
scripts/check-architecture-smells.mjs
Normal file
272
scripts/check-architecture-smells.mjs
Normal 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);
|
||||
17
scripts/generate-bundled-provider-auth-env-vars.d.mts
Normal file
17
scripts/generate-bundled-provider-auth-env-vars.d.mts
Normal 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;
|
||||
};
|
||||
131
scripts/generate-bundled-provider-auth-env-vars.mjs
Normal file
131
scripts/generate-bundled-provider-auth-env-vars.mjs
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
scripts/lib/optional-bundled-clusters.d.mts
Normal file
6
scripts/lib/optional-bundled-clusters.d.mts
Normal 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;
|
||||
6
scripts/lib/optional-bundled-clusters.d.ts
vendored
Normal file
6
scripts/lib/optional-bundled-clusters.d.ts
vendored
Normal 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;
|
||||
30
scripts/lib/optional-bundled-clusters.mjs
Normal file
30
scripts/lib/optional-bundled-clusters.mjs
Normal 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);
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
|
||||
@ -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(
|
||||
|
||||
129
scripts/test-runner-manifest.mjs
Normal file
129
scripts/test-runner-manifest.mjs
Normal 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);
|
||||
}
|
||||
109
scripts/test-update-timings.mjs
Normal file
109
scripts/test-update-timings.mjs
Normal 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}`,
|
||||
);
|
||||
@ -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)) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user