refactor(plugins): move extension seams into extensions

This commit is contained in:
Peter Steinberger
2026-04-04 00:08:13 +01:00
parent c19321ed9e
commit e4b5027c5e
234 changed files with 7726 additions and 5493 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicServiceTierWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
resolveAnthropicServiceTier,
} from "./stream-wrappers.js";

View File

@@ -0,0 +1,16 @@
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-config-contract.js";
export {
unsupportedSecretRefSurfacePatterns,
collectUnsupportedSecretRefConfigCandidates,
} from "./src/security-contract.js";
export { deriveLegacySessionChatType } from "./src/session-contract.js";
export type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -0,0 +1,292 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeDiscordStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveDiscordPreviewStreamMode(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
);
}
if (
params.pathPrefix.startsWith("channels.discord") &&
resolved === "off" &&
hadLegacyStreamMode
) {
params.changes.push(
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
);
}
return { entry: updated, changed };
}
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
);
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
function hasLegacyTtsProviderKeys(value: unknown): boolean {
const tts = asObjectRecord(value);
if (!tts) {
return false;
}
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
}
function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((accountValue) => {
const account = asObjectRecord(accountValue);
const voice = asObjectRecord(account?.voice);
return hasLegacyTtsProviderKeys(voice?.tts);
});
}
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
continue;
}
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (
existing &&
typeof existing === "object" &&
!Array.isArray(existing) &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
mergeMissing(existing as Record<string, unknown>, value as Record<string, unknown>);
}
}
}
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
const providers = asObjectRecord(tts.providers) ?? {};
tts.providers = providers;
return providers;
}
function mergeLegacyTtsProviderConfig(
tts: Record<string, unknown>,
legacyKey: string,
providerId: string,
): boolean {
const legacyValue = asObjectRecord(tts[legacyKey]);
if (!legacyValue) {
return false;
}
const providers = getOrCreateTtsProviders(tts);
const existing = asObjectRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, legacyValue);
providers[providerId] = merged;
delete tts[legacyKey];
return true;
}
function migrateLegacyTtsConfig(
tts: Record<string, unknown> | null,
pathLabel: string,
changes: string[],
): boolean {
if (!tts) {
return false;
}
let changed = false;
if (mergeLegacyTtsProviderConfig(tts, "openai", "openai")) {
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs")) {
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft")) {
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "edge", "microsoft")) {
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
changed = true;
}
return changed;
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
match: hasLegacyDiscordAccountStreamingAliases,
},
{
path: ["channels", "discord", "voice", "tts"],
message:
"channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider> (auto-migrated on load).",
match: hasLegacyTtsProviderKeys,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider> (auto-migrated on load).",
match: hasLegacyDiscordAccountTtsProviderKeys,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const streaming = normalizeDiscordStreamingAliases({
entry: updated,
pathPrefix: "channels.discord",
changes,
});
updated = streaming.entry;
changed = changed || streaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
const accountStreaming = normalizeDiscordStreamingAliases({
entry: account,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
});
if (accountStreaming.changed) {
accounts[accountId] = accountStreaming.entry;
accountsChanged = true;
}
const accountVoice = asObjectRecord(accountStreaming.entry.voice);
if (
accountVoice &&
migrateLegacyTtsConfig(
asObjectRecord(accountVoice.tts),
`channels.discord.accounts.${accountId}.voice.tts`,
changes,
)
) {
accounts[accountId] = {
...accountStreaming.entry,
voice: accountVoice,
};
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
const voice = asObjectRecord(updated.voice);
if (
voice &&
migrateLegacyTtsConfig(asObjectRecord(voice.tts), "channels.discord.voice.tts", changes)
) {
updated = { ...updated, voice };
changed = true;
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
discord: updated,
} as OpenClawConfig["channels"],
},
changes,
};
}

View File

@@ -1,15 +1,12 @@
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
type ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import {
resolveDiscordPreviewStreamMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
collectProviderDangerousNameMatchingScopes,
isDiscordMutableAllowEntry,
} from "openclaw/plugin-sdk/runtime";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
import { isDiscordMutableAllowEntry } from "./security-audit.js";
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
@@ -517,11 +514,48 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
];
}
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
);
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
match: hasLegacyDiscordAccountStreamingAliases,
},
];
export const discordDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
collectDiscordNumericIdWarnings({

View File

@@ -16,7 +16,6 @@ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pi
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
@@ -40,6 +39,7 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
import { createDiscordDraftStream } from "../draft-stream.js";
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
import { removeReactionDiscord } from "../send.js";
import { editMessageDiscord } from "../send.messages.js";
import {

View File

@@ -0,0 +1,51 @@
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
function normalizeStreamingMode(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
export function resolveDiscordPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): DiscordPreviewStreamMode {
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "off";
}

View File

@@ -0,0 +1,140 @@
import {
collectNestedChannelFieldAssignments,
collectNestedChannelTtsAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
isBaseFieldActiveForChannelSurface,
isEnabledFlag,
isRecord,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.discord.accounts.*.pluralkit.token",
targetType: "channels.discord.accounts.*.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.token",
targetType: "channels.discord.accounts.*.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
targetType: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 6,
},
{
id: "channels.discord.pluralkit.token",
targetType: "channels.discord.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.token",
targetType: "channels.discord.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.voice.tts.providers.*.apiKey",
targetType: "channels.discord.voice.tts.providers.*.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.voice.tts.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 4,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "discord");
if (!resolved) {
return;
}
const { channel: discord, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "discord",
field: "token",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Discord token.",
accountInactiveReason: "Discord account is disabled.",
});
collectNestedChannelFieldAssignments({
channelKey: "discord",
nestedKey: "pluralkit",
field: "token",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
isBaseFieldActiveForChannelSurface(surface, "pluralkit") &&
isRecord(discord.pluralkit) &&
isEnabledFlag(discord.pluralkit),
topInactiveReason:
"no enabled Discord surface inherits this top-level PluralKit config or PluralKit is disabled.",
accountActive: ({ account, enabled }) =>
enabled && isRecord(account.pluralkit) && isEnabledFlag(account.pluralkit),
accountInactiveReason: "Discord account is disabled or PluralKit is disabled for this account.",
});
collectNestedChannelTtsAssignments({
channelKey: "discord",
nestedKey: "voice",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
isBaseFieldActiveForChannelSurface(surface, "voice") &&
isRecord(discord.voice) &&
isEnabledFlag(discord.voice),
topInactiveReason:
"no enabled Discord surface inherits this top-level voice config or voice is disabled.",
accountActive: ({ account, enabled }) =>
enabled && isRecord(account.voice) && isEnabledFlag(account.voice),
accountInactiveReason: "Discord account is disabled or voice is disabled for this account.",
});
}

View File

@@ -21,7 +21,7 @@ function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
return undefined;
}
function isDiscordMutableAllowEntry(raw: string): boolean {
export function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;

View File

@@ -0,0 +1,46 @@
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export const unsupportedSecretRefSurfacePatterns = [
"channels.discord.threadBindings.webhookToken",
"channels.discord.accounts.*.threadBindings.webhookToken",
] as const;
export function collectUnsupportedSecretRefConfigCandidates(
raw: unknown,
): UnsupportedSecretRefConfigCandidate[] {
if (!isRecord(raw.channels) || !isRecord(raw.channels.discord)) {
return [];
}
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
const discord = raw.channels.discord;
const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null;
if (threadBindings) {
candidates.push({
path: "channels.discord.threadBindings.webhookToken",
value: threadBindings.webhookToken,
});
}
const accounts = isRecord(discord.accounts) ? discord.accounts : null;
if (!accounts) {
return candidates;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account) || !isRecord(account.threadBindings)) {
continue;
}
candidates.push({
path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`,
value: account.threadBindings.webhookToken,
});
}
return candidates;
}

View File

@@ -0,0 +1,3 @@
export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined {
return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined;
}

View File

@@ -0,0 +1,6 @@
export { createFeishuThreadBindingManager } from "./src/thread-bindings.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { messageActionTargetAliases } from "./src/message-action-contract.js";

View File

@@ -1,25 +1,48 @@
// Private runtime barrel for the bundled Feishu extension.
// Keep this barrel thin and aligned with the local extension surface.
// Keep this barrel thin and generic-only.
export type {
AllowlistMatch,
AnyAgentTool,
BaseProbeResult,
ChannelGroupContext,
ChannelMessageActionName,
ChannelMeta,
ChannelOutboundAdapter,
OpenClawConfig as ClawdbotConfig,
ChannelPlugin,
HistoryEntry,
OpenClawConfig,
OpenClawPluginApi,
OutboundIdentity,
PluginRuntime,
RuntimeEnv,
} from "openclaw/plugin-sdk/feishu";
ReplyPayload,
} from "openclaw/plugin-sdk/core";
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
export {
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
createActionGate,
createDedupeCache,
} from "openclaw/plugin-sdk/core";
export {
PAIRING_APPROVED_MESSAGE,
buildProbeChannelStatusSummary,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/feishu";
export * from "openclaw/plugin-sdk/feishu";
} from "openclaw/plugin-sdk/channel-status";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
export {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
resolveChannelContextVisibilityMode,
} from "openclaw/plugin-sdk/config-runtime";
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,

View File

@@ -51,6 +51,7 @@ import type {
import { createFeishuClient } from "./client.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
buildFeishuModelOverrideParentCandidates,
buildFeishuConversationId,
parseFeishuConversationId,
parseFeishuDirectConversationId,
@@ -59,6 +60,7 @@ import {
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
import {
resolveFeishuParentConversationCandidates,
resolveFeishuSessionConversation,
@@ -146,7 +148,9 @@ function describeFeishuMessageTool({
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const enabledAccounts = accountId
? [resolveFeishuAccount({ cfg, accountId })].filter((account) => account.enabled && account.configured)
? [resolveFeishuAccount({ cfg, accountId })].filter(
(account) => account.enabled && account.configured,
)
: listEnabledFeishuAccounts(cfg);
const enabled =
enabledAccounts.length > 0 ||
@@ -179,9 +183,9 @@ function describeFeishuMessageTool({
"channel-list",
]);
if (
(accountId
accountId
? enabledAccounts.some((account) => isFeishuReactionsActionEnabled({ cfg, account }))
: areAnyFeishuReactionActionsEnabled(cfg))
: areAnyFeishuReactionActionsEnabled(cfg)
) {
actions.add("react");
actions.add("reactions");
@@ -567,6 +571,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
},
conversationBindings: {
defaultTopLevelPlacement: "current",
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
buildFeishuModelOverrideParentCandidates(parentConversationId),
},
mentions: {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
@@ -1180,6 +1186,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
cfg: ClawdbotConfig;
accountId?: string | null;
}>(collectFeishuSecurityWarnings),
collectAuditFindings: ({ cfg }) => collectFeishuSecurityAuditFindings({ cfg }),
},
pairing: {
text: {

View File

@@ -166,3 +166,32 @@ export function parseFeishuConversationId(params: {
scope: "group",
};
}
export function buildFeishuModelOverrideParentCandidates(
parentConversationId?: string | null,
): string[] {
const rawId = normalizeText(parentConversationId);
if (!rawId) {
return [];
}
const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
if (topicSenderMatch) {
const chatId = topicSenderMatch[1]?.trim().toLowerCase();
const topicId = topicSenderMatch[2]?.trim().toLowerCase();
if (chatId && topicId) {
return [`${chatId}:topic:${topicId}`, chatId];
}
return [];
}
const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i);
if (topicMatch) {
const chatId = topicMatch[1]?.trim().toLowerCase();
return chatId ? [chatId] : [];
}
const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i);
if (senderMatch) {
const chatId = senderMatch[1]?.trim().toLowerCase();
return chatId ? [chatId] : [];
}
return [];
}

View File

@@ -0,0 +1,13 @@
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
type MessageActionTargetAliasSpec = {
aliases: string[];
};
export const messageActionTargetAliases = {
read: { aliases: ["messageId"] },
pin: { aliases: ["messageId"] },
unpin: { aliases: ["messageId"] },
"list-pins": { aliases: ["chatId"] },
"channel-info": { aliases: ["chatId"] },
} satisfies Partial<Record<ChannelMessageActionName, MessageActionTargetAliasSpec>>;

View File

@@ -0,0 +1,140 @@
import {
collectConditionalChannelFieldAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
normalizeSecretStringValue,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.feishu.accounts.*.appSecret",
targetType: "channels.feishu.accounts.*.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.encryptKey",
targetType: "channels.feishu.accounts.*.encryptKey",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.encryptKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.verificationToken",
targetType: "channels.feishu.accounts.*.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.appSecret",
targetType: "channels.feishu.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.encryptKey",
targetType: "channels.feishu.encryptKey",
configFile: "openclaw.json",
pathPattern: "channels.feishu.encryptKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.verificationToken",
targetType: "channels.feishu.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "feishu");
if (!resolved) {
return;
}
const { channel: feishu, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "feishu",
field: "appSecret",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Feishu appSecret.",
accountInactiveReason: "Feishu account is disabled.",
});
const baseConnectionMode =
normalizeSecretStringValue(feishu.connectionMode) === "webhook" ? "webhook" : "websocket";
const resolveAccountMode = (account: Record<string, unknown>) =>
hasOwnProperty(account, "connectionMode")
? normalizeSecretStringValue(account.connectionMode)
: baseConnectionMode;
collectConditionalChannelFieldAssignments({
channelKey: "feishu",
field: "encryptKey",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "encryptKey") &&
resolveAccountMode(account) === "webhook",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
topInactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.",
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
});
collectConditionalChannelFieldAssignments({
channelKey: "feishu",
field: "verificationToken",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "verificationToken") &&
resolveAccountMode(account) === "webhook",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
topInactiveReason:
"no enabled Feishu webhook-mode surface inherits this top-level verificationToken.",
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
});
}

View File

@@ -0,0 +1,70 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
const channels = asRecord(cfg.channels);
const feishu = asRecord(channels?.feishu);
if (!feishu || feishu.enabled === false) {
return false;
}
const baseTools = asRecord(feishu.tools);
const baseDocEnabled = baseTools?.doc !== false;
const baseAppId = hasNonEmptyString(feishu.appId);
const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret, cfg.secrets?.defaults);
const baseConfigured = baseAppId && baseAppSecret;
const accounts = asRecord(feishu.accounts);
if (!accounts || Object.keys(accounts).length === 0) {
return baseDocEnabled && baseConfigured;
}
for (const accountValue of Object.values(accounts)) {
const account = asRecord(accountValue) ?? {};
if (account.enabled === false) {
continue;
}
const accountTools = asRecord(account.tools);
const effectiveTools = accountTools ?? baseTools;
const docEnabled = effectiveTools?.doc !== false;
if (!docEnabled) {
continue;
}
const accountConfigured =
(hasNonEmptyString(account.appId) || baseAppId) &&
(hasConfiguredSecretInput(account.appSecret, cfg.secrets?.defaults) || baseAppSecret);
if (accountConfigured) {
return true;
}
}
return false;
}
export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) {
if (!isFeishuDocToolEnabled(params.cfg)) {
return [];
}
return [
{
checkId: "channels.feishu.doc_owner_open_id",
severity: "warn" as const,
title: "Feishu doc create can grant requester permissions",
detail:
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
remediation:
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
},
];
}

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -50,6 +50,7 @@ import {
type OpenClawConfig,
type ResolvedGoogleChatAccount,
} from "./channel.deps.runtime.js";
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { googlechatSetupAdapter } from "./setup-core.js";
@@ -218,6 +219,7 @@ export const googlechatPlugin = createChatChannelPlugin({
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
},
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),

View File

@@ -0,0 +1,57 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isGoogleChatMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
if (!withoutPrefix) {
return false;
}
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
return withoutUsers.includes("@");
}
export const collectGoogleChatMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "googlechat",
detector: isGoogleChatMutableAllowEntry,
collectLists: (scope) => {
const lists = [
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
];
const dm = asObjectRecord(scope.account.dm);
if (dm) {
lists.push({
pathLabel: `${scope.prefix}.dm.allowFrom`,
list: dm.allowFrom,
});
}
const groups = asObjectRecord(scope.account.groups);
if (groups) {
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
lists.push({
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
list: group.users,
});
}
}
return lists;
},
});

View File

@@ -0,0 +1,156 @@
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
import {
getChannelSurface,
hasOwnProperty,
pushAssignment,
pushInactiveSurfaceWarning,
pushWarning,
resolveChannelAccountSurface,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
type GoogleChatAccountLike = {
serviceAccount?: unknown;
serviceAccountRef?: unknown;
accounts?: Record<string, unknown>;
};
export const secretTargetRegistryEntries = [
{
id: "channels.googlechat.accounts.*.serviceAccount",
targetType: "channels.googlechat.serviceAccount",
targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"],
configFile: "openclaw.json",
pathPattern: "channels.googlechat.accounts.*.serviceAccount",
refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string-or-object",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
accountIdPathSegmentIndex: 3,
},
{
id: "channels.googlechat.serviceAccount",
targetType: "channels.googlechat.serviceAccount",
configFile: "openclaw.json",
pathPattern: "channels.googlechat.serviceAccount",
refPathPattern: "channels.googlechat.serviceAccountRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string-or-object",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
function resolveSecretInputRef(params: {
value: unknown;
refValue?: unknown;
defaults?: SecretDefaults;
}) {
const explicitRef = coerceSecretRef(params.refValue, params.defaults);
const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults);
return {
explicitRef,
inlineRef,
ref: explicitRef ?? inlineRef,
};
}
function collectGoogleChatAccountAssignment(params: {
target: GoogleChatAccountLike;
path: string;
defaults: SecretDefaults | undefined;
context: ResolverContext;
active?: boolean;
inactiveReason?: string;
}): void {
const { explicitRef, ref } = resolveSecretInputRef({
value: params.target.serviceAccount,
refValue: params.target.serviceAccountRef,
defaults: params.defaults,
});
if (!ref) {
return;
}
if (params.active === false) {
pushInactiveSurfaceWarning({
context: params.context,
path: `${params.path}.serviceAccount`,
details: params.inactiveReason,
});
return;
}
if (
explicitRef &&
params.target.serviceAccount !== undefined &&
!coerceSecretRef(params.target.serviceAccount, params.defaults)
) {
pushWarning(params.context, {
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path: params.path,
message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
});
}
pushAssignment(params.context, {
ref,
path: `${params.path}.serviceAccount`,
expected: "string-or-object",
apply: (value) => {
params.target.serviceAccount = value;
},
});
}
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "googlechat");
if (!resolved) {
return;
}
const googleChat = resolved.channel as GoogleChatAccountLike;
const surface = resolveChannelAccountSurface(googleChat as Record<string, unknown>);
const topLevelServiceAccountActive = !surface.channelEnabled
? false
: !surface.hasExplicitAccounts
? true
: surface.accounts.some(
({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "serviceAccount") &&
!hasOwnProperty(account, "serviceAccountRef"),
);
collectGoogleChatAccountAssignment({
target: googleChat,
path: "channels.googlechat",
defaults: params.defaults,
context: params.context,
active: topLevelServiceAccountActive,
inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.",
});
if (!surface.hasExplicitAccounts) {
return;
}
for (const { accountId, account, enabled } of surface.accounts) {
if (
!hasOwnProperty(account, "serviceAccount") &&
!hasOwnProperty(account, "serviceAccountRef")
) {
continue;
}
collectGoogleChatAccountAssignment({
target: account as GoogleChatAccountLike,
path: `channels.googlechat.accounts.${accountId}`,
defaults: params.defaults,
context: params.context,
active: enabled,
inactiveReason: "Google Chat account is disabled.",
});
}
}

View File

@@ -3,6 +3,7 @@ export * from "./src/accounts.js";
export * from "./src/conversation-bindings.js";
export * from "./src/conversation-id.js";
export * from "./src/group-policy.js";
export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js";
export { IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js";
export * from "./src/probe.js";
export * from "./src/target-parsing-helpers.js";

View File

@@ -0,0 +1,10 @@
export { createIMessageTestPlugin } from "./src/test-plugin.js";
export {
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -135,6 +135,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
doctor: {
groupAllowFromFallbackToAllowFrom: false,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>
@@ -195,7 +198,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
dbPath: snapshot.dbPath ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
await (await loadIMessageChannelRuntime()).probeIMessageAccount({
await (
await loadIMessageChannelRuntime()
).probeIMessageAccount({
timeoutMs,
cliPath: account.config.cliPath,
dbPath: account.config.dbPath,

View File

@@ -0,0 +1,31 @@
import { mergeInboundPathRoots } from "openclaw/plugin-sdk/media-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveIMessageAccount } from "./accounts.js";
export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
export function resolveIMessageAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}
export function resolveIMessageRemoteAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.remoteAttachmentRoots,
params.cfg.channels?.imessage?.remoteAttachmentRoots,
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}

View File

@@ -20,12 +20,7 @@ import {
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import {
isInboundPathAllowed,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "openclaw/plugin-sdk/media-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
@@ -40,6 +35,10 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveIMessageAccount } from "../accounts.js";
import { createIMessageRpcClient } from "../client.js";
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
import {
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "../media-contract.js";
import { probeIMessage } from "../probe.js";
import { sendMessageIMessage } from "../send.js";
import { normalizeIMessageHandle } from "../targets.js";

View File

@@ -0,0 +1,111 @@
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../../src/channels/plugins/types.js";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { collectStatusIssuesFromLastError } from "../../../src/plugin-sdk/status-helpers.js";
function normalizeIMessageTestHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) {
return normalizeIMessageTestHandle(trimmed.slice("imessage:".length));
}
if (lowered.startsWith("sms:")) {
return normalizeIMessageTestHandle(trimmed.slice("sms:".length));
}
if (lowered.startsWith("auto:")) {
return normalizeIMessageTestHandle(trimmed.slice("auto:".length));
}
if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) =>
match.toLowerCase(),
);
}
if (trimmed.includes("@")) {
return trimmed.toLowerCase();
}
const digits = trimmed.replace(/[^\d+]/g, "");
if (digits) {
return digits.startsWith("+") ? `+${digits.slice(1)}` : `+${digits}`;
}
return trimmed.replace(/\s+/g, "");
}
const defaultIMessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => {
const sendIMessage = resolveOutboundSendDep<
(
target: string,
content: string,
opts?: Record<string, unknown>,
) => Promise<{ messageId: string }>
>(deps, "imessage");
const result = await sendIMessage?.(to, text, {
config: cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
});
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, deps, cfg, mediaLocalRoots }) => {
const sendIMessage = resolveOutboundSendDep<
(
target: string,
content: string,
opts?: Record<string, unknown>,
) => Promise<{ messageId: string }>
>(deps, "imessage");
const result = await sendIMessage?.(to, text, {
config: cfg,
mediaUrl,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
mediaLocalRoots,
});
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
},
};
export const createIMessageTestPlugin = (params?: {
outbound?: ChannelOutboundAdapter;
}): ChannelPlugin => ({
id: "imessage",
meta: {
id: "imessage",
label: "iMessage",
selectionLabel: "iMessage (imsg)",
docsPath: "/channels/imessage",
blurb: "iMessage test stub.",
aliases: ["imsg"],
},
capabilities: { chatTypes: ["direct", "group"], media: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
status: {
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
},
outbound: params?.outbound ?? defaultIMessageOutbound,
messaging: {
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return /^\+?\d{3,}$/.test(trimmed);
},
hint: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageTestHandle(raw),
},
});

View File

@@ -28,6 +28,7 @@ import {
type ResolvedIrcAccount,
} from "./accounts.js";
import { IrcChannelConfigSchema } from "./config-schema.js";
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
import { monitorIrcProvider } from "./monitor.js";
import {
normalizeIrcMessagingTarget,
@@ -187,6 +188,10 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
},
}),
},
doctor: {
groupAllowFromFallbackToAllowFrom: false,
collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings,
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });

View File

@@ -0,0 +1,53 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isIrcMutableAllowEntry(raw: string): boolean {
const text = raw.trim().toLowerCase();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^irc:/, "")
.replace(/^user:/, "")
.trim();
return !normalized.includes("!") && !normalized.includes("@");
}
export const collectIrcMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "irc",
detector: isIrcMutableAllowEntry,
collectLists: (scope) => {
const lists = [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
];
const groups = asObjectRecord(scope.account.groups);
if (groups) {
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
lists.push({
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
list: group.allowFrom,
});
}
}
return lists;
},
});

View File

@@ -1,38 +1,51 @@
// Private runtime barrel for the bundled IRC extension.
// Keep this barrel thin and aligned with the local extension surface.
// Keep this barrel thin and generic-only.
export {
buildBaseChannelStatusSummary,
createAccountStatusSink,
chunkTextForOutbound,
createChannelPairingController,
DEFAULT_ACCOUNT_ID,
deliverFormattedTextWithAttachments,
dispatchInboundReplyWithBase,
getChatChannelMeta,
GROUP_POLICY_BLOCKED_LABEL,
isDangerousNameMatchingEnabled,
logInboundDrop,
PAIRING_APPROVED_MESSAGE,
readStoreAllowFromForDmPolicy,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveControlCommandGate,
resolveDefaultGroupPolicy,
resolveEffectiveAllowFromLists,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/irc";
export type {
BaseProbeResult,
BlockStreamingCoalesceConfig,
ChannelPlugin,
OpenClawConfig,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
GroupToolPolicyBySenderConfig,
GroupToolPolicyConfig,
MarkdownConfig,
OpenClawConfig,
OutboundReplyPayload,
PluginRuntime,
RuntimeEnv,
} from "openclaw/plugin-sdk/irc";
} from "openclaw/plugin-sdk/config-runtime";
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
getChatChannelMeta,
} from "openclaw/plugin-sdk/core";
export {
PAIRING_APPROVED_MESSAGE,
buildBaseChannelStatusSummary,
} from "openclaw/plugin-sdk/channel-status";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createAccountStatusSink } from "openclaw/plugin-sdk/compat";
export {
readStoreAllowFromForDmPolicy,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
deliverFormattedTextWithAttachments,
formatTextWithAttachmentLinks,
resolveOutboundMediaUrls,
} from "openclaw/plugin-sdk/reply-payload";
export {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
isDangerousNameMatchingEnabled,
} from "openclaw/plugin-sdk/config-runtime";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";

View File

@@ -1,12 +1,13 @@
export type {
ChannelAccountSnapshot,
ChannelPlugin,
OpenClawConfig,
OpenClawPluginApi,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
export {

View File

@@ -0,0 +1,5 @@
export {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "./src/accounts.js";

View File

@@ -2,16 +2,19 @@
// Keep this barrel thin and aligned with the local extension surface.
export type {
ChannelAccountSnapshot,
ChannelPlugin,
OpenClawConfig,
OpenClawPluginApi,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type {
ChannelGatewayContext,
ChannelStatusIssue,
} from "openclaw/plugin-sdk/channel-contract";
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
export {
buildComputedAccountStatusSnapshot,
@@ -46,6 +49,7 @@ export {
sendMessageLine,
} from "./src/send.js";
export { monitorLineProvider } from "./src/monitor.js";
export { hasLineDirectives, parseLineDirectives } from "./src/reply-payload-transform.js";
export * from "./src/accounts.js";
export * from "./src/bot-access.js";
@@ -55,6 +59,7 @@ export * from "./src/download.js";
export * from "./src/group-keys.js";
export * from "./src/markdown-to-line.js";
export * from "./src/probe.js";
export * from "./src/reply-payload-transform.js";
export * from "./src/send.js";
export * from "./src/signature.js";
export * from "./src/template-messages.js";

View File

@@ -8,6 +8,7 @@ import { lineChannelPluginCommon } from "./channel-shared.js";
import { lineGatewayAdapter } from "./gateway.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
import { lineOutboundAdapter } from "./outbound.js";
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
import { getLineRuntime } from "./runtime.js";
import { pushMessageLine } from "./send.js";
import { lineSetupAdapter } from "./setup-core.js";
@@ -74,6 +75,12 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
},
resolveInboundConversation: ({ to, conversationId }) =>
resolveLineInboundConversation({ to, conversationId }),
transformReplyPayload: ({ payload }) => {
if (!payload.text || !hasLineDirectives(payload.text)) {
return payload;
}
return parseLineDirectives(payload);
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();

View File

@@ -0,0 +1,317 @@
import type { ReplyPayload } from "../runtime-api.js";
import {
createAgendaCard,
createAppleTvRemoteCard,
createDeviceControlCard,
createEventCard,
createMediaPlayerCard,
} from "./flex-templates.js";
import type { LineChannelData } from "./types.js";
/**
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
*
* Supported directives:
* - [[quick_replies: option1, option2, option3]]
* - [[location: title | address | latitude | longitude]]
* - [[confirm: question | yes_label | no_label]]
* - [[buttons: title | text | btn1:data1, btn2:data2]]
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
* - [[event: title | date | time | location | description]]
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
* - [[appletv_remote: name | status]]
*/
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
let text = payload.text;
if (!text) {
return payload;
}
const result: ReplyPayload = { ...payload };
const lineData: LineChannelData = {
...(result.channelData?.line as LineChannelData | undefined),
};
const toSlug = (value: string): string =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "device";
const lineActionData = (action: string, extras?: Record<string, string>): string => {
const base = [`line.action=${encodeURIComponent(action)}`];
if (extras) {
for (const [key, value] of Object.entries(extras)) {
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return base.join("&");
};
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
if (quickRepliesMatch) {
const options = quickRepliesMatch[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (options.length > 0) {
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
}
text = text.replace(quickRepliesMatch[0], "").trim();
}
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
if (locationMatch && !lineData.location) {
const parts = locationMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 4) {
const [title, address, latStr, lonStr] = parts;
const latitude = parseFloat(latStr);
const longitude = parseFloat(lonStr);
if (!isNaN(latitude) && !isNaN(longitude)) {
lineData.location = {
title: title || "Location",
address: address || "",
latitude,
longitude,
};
}
}
text = text.replace(locationMatch[0], "").trim();
}
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
if (confirmMatch && !lineData.templateMessage) {
const parts = confirmMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [question, yesPart, noPart] = parts;
const [yesLabel, yesData] = yesPart.includes(":")
? yesPart.split(":").map((s) => s.trim())
: [yesPart, yesPart.toLowerCase()];
const [noLabel, noData] = noPart.includes(":")
? noPart.split(":").map((s) => s.trim())
: [noPart, noPart.toLowerCase()];
lineData.templateMessage = {
type: "confirm",
text: question,
confirmLabel: yesLabel,
confirmData: yesData,
cancelLabel: noLabel,
cancelData: noData,
altText: question,
};
}
text = text.replace(confirmMatch[0], "").trim();
}
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
if (buttonsMatch && !lineData.templateMessage) {
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [title, bodyText, actionsStr] = parts;
const actions = actionsStr.split(",").map((actionStr) => {
const trimmed = actionStr.trim();
const colonIndex = (() => {
const index = trimmed.indexOf(":");
if (index === -1) {
return -1;
}
const lower = trimmed.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return -1;
}
return index;
})();
let label: string;
let data: string;
if (colonIndex === -1) {
label = trimmed;
data = trimmed;
} else {
label = trimmed.slice(0, colonIndex).trim();
data = trimmed.slice(colonIndex + 1).trim();
}
if (data.startsWith("http://") || data.startsWith("https://")) {
return { type: "uri" as const, label, uri: data };
}
if (data.includes("=")) {
return { type: "postback" as const, label, data };
}
return { type: "message" as const, label, data: data || label };
});
if (actions.length > 0) {
lineData.templateMessage = {
type: "buttons",
title,
text: bodyText,
actions: actions.slice(0, 4),
altText: `${title}: ${bodyText}`,
};
}
}
text = text.replace(buttonsMatch[0], "").trim();
}
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
if (mediaPlayerMatch && !lineData.flexMessage) {
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [title, artist, source, imageUrl, statusStr] = parts;
const isPlaying = statusStr?.toLowerCase() === "playing";
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
const deviceKey = toSlug(source || title || "media");
const card = createMediaPlayerCard({
title: title || "Unknown Track",
subtitle: artist || undefined,
source: source || undefined,
imageUrl: validImageUrl,
isPlaying: statusStr ? isPlaying : undefined,
controls: {
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
play: { data: lineActionData("play", { "line.device": deviceKey }) },
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
next: { data: lineActionData("next", { "line.device": deviceKey }) },
},
});
lineData.flexMessage = {
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
contents: card,
};
}
text = text.replace(mediaPlayerMatch[0], "").trim();
}
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
if (eventMatch && !lineData.flexMessage) {
const parts = eventMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, date, time, location, description] = parts;
const card = createEventCard({
title: title || "Event",
date: date || "TBD",
time: time || undefined,
location: location || undefined,
description: description || undefined,
});
lineData.flexMessage = {
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
contents: card,
};
}
text = text.replace(eventMatch[0], "").trim();
}
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
if (appleTvMatch && !lineData.flexMessage) {
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, status] = parts;
const deviceKey = toSlug(deviceName || "apple_tv");
const card = createAppleTvRemoteCard({
deviceName: deviceName || "Apple TV",
status: status || undefined,
actionData: {
up: lineActionData("up", { "line.device": deviceKey }),
down: lineActionData("down", { "line.device": deviceKey }),
left: lineActionData("left", { "line.device": deviceKey }),
right: lineActionData("right", { "line.device": deviceKey }),
select: lineActionData("select", { "line.device": deviceKey }),
menu: lineActionData("menu", { "line.device": deviceKey }),
home: lineActionData("home", { "line.device": deviceKey }),
play: lineActionData("play", { "line.device": deviceKey }),
pause: lineActionData("pause", { "line.device": deviceKey }),
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
mute: lineActionData("mute", { "line.device": deviceKey }),
},
});
lineData.flexMessage = {
altText: `📺 ${deviceName || "Apple TV"} Remote`,
contents: card,
};
}
text = text.replace(appleTvMatch[0], "").trim();
}
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
if (agendaMatch && !lineData.flexMessage) {
const parts = agendaMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, eventsStr] = parts;
const events = eventsStr.split(",").map((eventStr) => {
const trimmed = eventStr.trim();
const colonIdx = trimmed.lastIndexOf(":");
if (colonIdx > 0) {
return {
title: trimmed.slice(0, colonIdx).trim(),
time: trimmed.slice(colonIdx + 1).trim(),
};
}
return { title: trimmed };
});
const card = createAgendaCard({
title: title || "Agenda",
events,
});
lineData.flexMessage = {
altText: `📋 ${title} (${events.length} events)`,
contents: card,
};
}
text = text.replace(agendaMatch[0], "").trim();
}
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
if (deviceMatch && !lineData.flexMessage) {
const parts = deviceMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, deviceType, status, controlsStr] = parts;
const deviceKey = toSlug(deviceName || "device");
const controls = controlsStr
? controlsStr.split(",").map((ctrlStr) => {
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
const action = data || label.toLowerCase().replace(/\s+/g, "_");
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
})
: [];
const card = createDeviceControlCard({
deviceName: deviceName || "Device",
deviceType: deviceType || undefined,
status: status || undefined,
controls,
});
lineData.flexMessage = {
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
contents: card,
};
}
text = text.replace(deviceMatch[0], "").trim();
}
text = text.replace(/\n{3,}/g, "\n\n").trim();
result.text = text || undefined;
if (Object.keys(lineData).length > 0) {
result.channelData = { ...result.channelData, line: lineData };
}
return result;
}
export function hasLineDirectives(text: string): boolean {
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
text,
);
}

View File

@@ -0,0 +1,16 @@
export {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export { setMatrixRuntime } from "./src/runtime.js";
export {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./src/setup-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { matrixSetupAdapter } from "./src/setup-core.js";
export { matrixSetupWizard } from "./src/setup-surface.js";

View File

@@ -30,10 +30,10 @@ export type {
OpenClawConfig,
PluginRuntime,
RuntimeLogger,
RuntimeEnv,
WizardPrompter,
} from "openclaw/plugin-sdk/matrix-runtime-shared";
export { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
} from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export { formatZonedTimestamp } from "openclaw/plugin-sdk/core";
export function chunkTextForOutbound(text: string, limit: number): string[] {
const chunks: string[] = [];

View File

@@ -0,0 +1 @@
export * from "./src/runtime-heavy-api.js";

View File

@@ -0,0 +1,529 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
} from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.";
type MatrixLegacyCryptoCounts = {
total: number;
backedUp: number;
};
type MatrixLegacyCryptoSummary = {
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoMigrationState = {
version: 1;
source: "matrix-bot-sdk-rust";
accountId: string;
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyImported: boolean;
restoreStatus: "pending" | "completed" | "manual-action-required";
detectedAt: string;
restoredAt?: string;
importedCount?: number;
totalCount?: number;
lastError?: string | null;
};
type MatrixLegacyCryptoPlan = {
accountId: string;
rootDir: string;
recoveryKeyPath: string;
statePath: string;
legacyCryptoPath: string;
homeserver: string;
userId: string;
accessToken: string;
deviceId: string | null;
};
type MatrixLegacyCryptoDetection = {
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
type MatrixLegacyCryptoPreparationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyCryptoPrepareDeps = {
inspectLegacyStore: MatrixLegacyCryptoInspector;
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
};
type MatrixLegacyCryptoInspectorParams = {
cryptoRootDir: string;
userId: string;
deviceId: string;
log?: (message: string) => void;
};
type MatrixLegacyCryptoInspectorResult = {
deviceId: string | null;
roomKeyCounts: {
total: number;
backedUp: number;
} | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoInspector = (
params: MatrixLegacyCryptoInspectorParams,
) => Promise<MatrixLegacyCryptoInspectorResult>;
type MatrixLegacyBotSdkMetadata = {
deviceId: string | null;
};
type MatrixStoredRecoveryKey = {
version: 1;
createdAt: string;
keyId?: string | null;
encodedPrivateKey?: string;
privateKeyBase64: string;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
};
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
return true;
}
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
const module = await import("./matrix/legacy-crypto-inspector.js");
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
}
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
detected: boolean;
warning?: string;
} {
try {
const stat = fs.statSync(cryptoRootDir);
if (!stat.isDirectory()) {
return {
detected: false,
warning:
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
} catch (err) {
return {
detected: false,
warning:
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
try {
return {
detected:
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
fs
.readdirSync(cryptoRootDir, { withFileTypes: true })
.some(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
),
};
} catch (err) {
return {
detected: false,
warning:
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
}
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
return resolveConfiguredMatrixAccountIds(cfg);
}
function resolveLegacyMatrixFlatStorePlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoPlan | { warning: string } | null {
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
if (!fs.existsSync(legacy.cryptoPath)) {
return null;
}
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
if (legacyStore.warning) {
return { warning: legacyStore.warning };
}
if (!legacyStore.detected) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.cryptoPath,
detectedKind: "encrypted state",
});
if ("warning" in target) {
return target;
}
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
return {
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath: legacy.cryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
};
}
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
try {
if (!fs.existsSync(metadataPath)) {
return fallback;
}
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
deviceId?: unknown;
};
return {
deviceId:
typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null,
};
} catch {
return fallback;
}
}
function resolveMatrixLegacyCryptoPlans(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const warnings: string[] = [];
const plans: MatrixLegacyCryptoPlan[] = [];
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
if (flatPlan) {
if ("warning" in flatPlan) {
warnings.push(flatPlan.warning);
} else {
plans.push(flatPlan);
}
}
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
continue;
}
const legacyCryptoPath = path.join(target.rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath)) {
continue;
}
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
if (detectedStore.warning) {
warnings.push(detectedStore.warning);
continue;
}
if (!detectedStore.detected) {
continue;
}
if (
plans.some(
(plan) =>
plan.accountId === accountId &&
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
)
) {
continue;
}
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
plans.push({
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
});
}
return { plans, warnings };
}
function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey;
} catch {
return null;
}
}
function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState;
} catch {
return null;
}
}
async function persistLegacyMigrationState(params: {
filePath: string;
state: MatrixLegacyCryptoMigrationState;
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
}): Promise<void> {
await params.writeJsonFileAtomically(params.filePath, params.state);
}
export function detectLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const detection = resolveMatrixLegacyCryptoPlans({
cfg: params.cfg,
env: params.env ?? process.env,
});
if (detection.plans.length > 0 && !isMatrixLegacyCryptoInspectorAvailable()) {
return {
plans: detection.plans,
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
};
}
return detection;
}
export async function autoPrepareLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
}): Promise<MatrixLegacyCryptoPreparationResult> {
const env = params.env ?? process.env;
const detection = params.deps?.inspectLegacyStore
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
const warnings = [...detection.warnings];
const changes: string[] = [];
const writeJsonFileAtomically =
params.deps?.writeJsonFileAtomically ?? writeJsonFileAtomicallyImpl;
if (detection.plans.length === 0) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
let inspectLegacyStore = params.deps?.inspectLegacyStore;
if (!inspectLegacyStore) {
try {
inspectLegacyStore = await loadMatrixLegacyCryptoInspector();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!warnings.includes(message)) {
warnings.push(message);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
}
if (!inspectLegacyStore) {
return {
migrated: false,
changes,
warnings,
};
}
for (const plan of detection.plans) {
const existingState = loadLegacyCryptoMigrationState(plan.statePath);
if (existingState?.version === 1) {
continue;
}
if (!plan.deviceId) {
warnings.push(
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
);
continue;
}
let summary: MatrixLegacyCryptoSummary;
try {
summary = await inspectLegacyStore({
cryptoRootDir: plan.legacyCryptoPath,
userId: plan.userId,
deviceId: plan.deviceId,
log: params.log?.info,
});
} catch (err) {
warnings.push(
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
);
continue;
}
let decryptionKeyImported = false;
if (summary.decryptionKeyBase64) {
const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath);
if (
existingRecoveryKey?.privateKeyBase64 &&
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
) {
warnings.push(
`Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`,
);
} else if (!existingRecoveryKey?.privateKeyBase64) {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: null,
privateKeyBase64: summary.decryptionKeyBase64,
};
try {
await writeJsonFileAtomically(plan.recoveryKeyPath, payload);
changes.push(
`Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`,
);
decryptionKeyImported = true;
} catch (err) {
warnings.push(
`Failed writing Matrix recovery key for account "${plan.accountId}" (${plan.recoveryKeyPath}): ${String(err)}`,
);
}
} else {
decryptionKeyImported = true;
}
}
const localOnlyKeys =
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
: 0;
if (localOnlyKeys > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
);
}
// If recovery-key persistence failed, leave the migration state absent so the next startup can retry.
if (
summary.decryptionKeyBase64 &&
!decryptionKeyImported &&
!loadStoredRecoveryKey(plan.recoveryKeyPath)
) {
continue;
}
const state: MatrixLegacyCryptoMigrationState = {
version: 1,
source: "matrix-bot-sdk-rust",
accountId: plan.accountId,
deviceId: summary.deviceId,
roomKeyCounts: summary.roomKeyCounts,
backupVersion: summary.backupVersion,
decryptionKeyImported,
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
detectedAt: new Date().toISOString(),
lastError: null,
};
try {
await persistLegacyMigrationState({
filePath: plan.statePath,
state,
writeJsonFileAtomically,
});
changes.push(
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`,
);
} catch (err) {
warnings.push(
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" (${plan.statePath}): ${String(err)}`,
);
}
}
if (changes.length > 0) {
params.log?.info?.(
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View File

@@ -0,0 +1,156 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyStatePlan = {
accountId: string;
legacyStoragePath: string;
legacyCryptoPath: string;
targetRootDir: string;
targetStoragePath: string;
targetCryptoPath: string;
selectionNote?: string;
};
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const stateDir = resolveStateDir(env, os.homedir);
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixMigrationPlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
const legacy = resolveLegacyMatrixPaths(params.env);
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.rootDir,
detectedKind: "state",
});
if ("warning" in target) {
return target;
}
return {
accountId: target.accountId,
legacyStoragePath: legacy.storagePath,
legacyCryptoPath: legacy.cryptoPath,
targetRootDir: target.rootDir,
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
targetCryptoPath: path.join(target.rootDir, "crypto"),
selectionNote: target.selectionNote,
};
}
export function detectLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
return resolveMatrixMigrationPlan({
cfg: params.cfg,
env: params.env ?? process.env,
});
}
function moveLegacyPath(params: {
sourcePath: string;
targetPath: string;
label: string;
changes: string[];
warnings: string[];
}): void {
if (!fs.existsSync(params.sourcePath)) {
return;
}
if (fs.existsSync(params.targetPath)) {
params.warnings.push(
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
);
return;
}
try {
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
fs.renameSync(params.sourcePath, params.targetPath);
params.changes.push(
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
);
} catch (err) {
params.warnings.push(
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
);
}
}
export async function autoMigrateLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixLegacyStateMigrationResult> {
const env = params.env ?? process.env;
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
if (!detection) {
return { migrated: false, changes: [], warnings: [] };
}
if ("warning" in detection) {
params.log?.warn?.(`matrix: ${detection.warning}`);
return { migrated: false, changes: [], warnings: [detection.warning] };
}
const changes: string[] = [];
const warnings: string[] = [];
moveLegacyPath({
sourcePath: detection.legacyStoragePath,
targetPath: detection.targetStoragePath,
label: "sync store",
changes,
warnings,
});
moveLegacyPath({
sourcePath: detection.legacyCryptoPath,
targetPath: detection.targetCryptoPath,
label: "crypto store",
changes,
warnings,
});
if (changes.length > 0) {
const details = [
...changes.map((entry) => `- ${entry}`),
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
"- No user action required.",
];
params.log?.info?.(
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}

View File

@@ -6,4 +6,4 @@ export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
} from "./runtime-heavy-api.js";

View File

@@ -2,30 +2,29 @@
// Keep monitor internals off the broad package runtime-api barrel so monitor
// tests and shared workers do not pull unrelated Matrix helper surfaces.
export type { NormalizedLocation, PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/core";
export type { BlockReplyContext, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export { ensureConfiguredAcpBindingReady } from "openclaw/plugin-sdk/core";
export {
addAllowlistUserEntriesFromConfigEntry,
buildAllowlistResolutionSummary,
buildChannelKeyCandidates,
canonicalizeAllowlistWithResolvedIds,
createReplyPrefixOptions,
createTypingCallbacks,
formatAllowlistMatchMeta,
formatLocationText,
getAgentScopedMediaLocalRoots,
logInboundDrop,
logTypingFailure,
patchAllowlistUsersInConfigEntries,
resolveAckReaction,
resolveChannelEntryMatch,
summarizeMapping,
} from "openclaw/plugin-sdk/allow-from";
export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createTypingCallbacks } from "openclaw/plugin-sdk/channel-reply-pipeline";
export {
formatLocationText,
logInboundDrop,
toLocationContext,
type BlockReplyContext,
type MarkdownTableMode,
type NormalizedLocation,
type OpenClawConfig,
type PluginRuntime,
type ReplyPayload,
type RuntimeEnv,
type RuntimeLogger,
} from "openclaw/plugin-sdk/matrix";
export { ensureConfiguredAcpBindingReady } from "openclaw/plugin-sdk/matrix-runtime-heavy";
} from "openclaw/plugin-sdk/channel-inbound";
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
export { logTypingFailure, resolveAckReaction } from "openclaw/plugin-sdk/channel-feedback";
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
} from "openclaw/plugin-sdk/channel-targets";

View File

@@ -0,0 +1,326 @@
import fs from "node:fs";
import os from "node:os";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./account-selection.js";
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
import { resolveMatrixAccountStorageRoot, resolveMatrixCredentialsPath } from "./storage-paths.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
};
export type MatrixMigrationAccountTarget = {
accountId: string;
homeserver: string;
userId: string;
accessToken: string;
rootDir: string;
storedDeviceId: string | null;
};
export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & {
selectionNote?: string;
};
type MatrixLegacyFlatStoreKind = "state" | "encrypted state";
type MatrixResolvedStringField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName";
type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
"userId",
"accessToken",
"password",
"deviceId",
]);
function clean(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveMatrixStringSourceValue(value: string | undefined): string {
return typeof value === "string" ? value : "";
}
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
return (
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
);
}
function resolveMatrixAccountStringValues(params: {
accountId: string;
account?: MatrixStringSourceMap;
scopedEnv?: MatrixStringSourceMap;
channel?: MatrixStringSourceMap;
globalEnv?: MatrixStringSourceMap;
}): MatrixResolvedStringValues {
const fields: MatrixResolvedStringField[] = [
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
];
const resolved = {} as MatrixResolvedStringValues;
for (const field of fields) {
resolved[field] =
resolveMatrixStringSourceValue(params.account?.[field]) ||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
(shouldAllowBaseAuthFallback(params.accountId, field)
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
: "");
}
return resolved;
}
function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv,
): {
homeserver: string;
userId: string;
accessToken: string;
} {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver]),
userId: clean(env[keys.userId]),
accessToken: clean(env[keys.accessToken]),
};
}
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): {
homeserver: string;
userId: string;
accessToken: string;
} {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN),
};
}
function resolveMatrixAccountConfigEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
return findMatrixAccountEntry(cfg, accountId);
}
function resolveMatrixFlatStoreSelectionNote(
cfg: OpenClawConfig,
accountId: string,
): string | undefined {
if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) {
return undefined;
}
return (
`Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` +
`account "${accountId}".`
);
}
export function resolveMatrixMigrationConfigFields(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): {
homeserver: string;
userId: string;
accessToken: string;
} {
const channel = resolveMatrixChannelConfig(params.cfg);
const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env);
const globalEnv = resolveGlobalMatrixEnvConfig(params.env);
const normalizedAccountId = normalizeAccountId(params.accountId);
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: clean(account?.homeserver),
userId: clean(account?.userId),
accessToken: clean(account?.accessToken),
},
scopedEnv,
channel: {
homeserver: clean(channel?.homeserver),
userId: clean(channel?.userId),
accessToken: clean(channel?.accessToken),
},
globalEnv,
});
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken,
};
}
export function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const stateDir = resolveStateDir(env, os.homedir);
const credentialsPath = resolveMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
};
} catch {
return null;
}
}
export function credentialsMatchResolvedIdentity(
stored: MatrixStoredCredentials | null,
identity: {
homeserver: string;
userId: string;
accessToken: string;
},
): stored is MatrixStoredCredentials {
if (!stored || !identity.homeserver) {
return false;
}
if (!identity.userId) {
if (!identity.accessToken) {
return false;
}
return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken;
}
return stored.homeserver === identity.homeserver && stored.userId === identity.userId;
}
export function resolveMatrixMigrationAccountTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): MatrixMigrationAccountTarget | null {
const stored = loadStoredMatrixCredentials(params.env, params.accountId);
const resolved = resolveMatrixMigrationConfigFields(params);
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
accessToken: resolved.accessToken,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return null;
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId: params.accountId,
});
return {
accountId: params.accountId,
homeserver,
userId,
accessToken,
rootDir,
storedDeviceId: matchingStored?.deviceId ?? null,
};
}
export function resolveLegacyMatrixFlatStoreTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
detectedPath: string;
detectedKind: MatrixLegacyFlatStoreKind;
}): MatrixLegacyFlatStoreTarget | { warning: string } {
const channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
if (requiresExplicitMatrixDefaultAccount(params.cfg)) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` +
'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
};
}
const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg);
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
const targetDescription =
params.detectedKind === "state"
? "the new account-scoped target"
: "the account-scoped target";
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} could not be resolved yet ` +
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
};
}
return {
...target,
selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId),
};
}

View File

@@ -0,0 +1,148 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/provider-auth";
import { createBackupArchive } from "openclaw/plugin-sdk/runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import { detectLegacyMatrixState } from "./legacy-state.js";
const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations";
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
return true;
}
type MatrixMigrationSnapshotMarker = {
version: 1;
createdAt: string;
archivePath: string;
trigger: string;
includeWorkspace: boolean;
};
export type MatrixMigrationSnapshotResult = {
created: boolean;
archivePath: string;
markerPath: string;
};
function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(filePath, "utf8"),
) as Partial<MatrixMigrationSnapshotMarker>;
if (
parsed.version !== 1 ||
typeof parsed.createdAt !== "string" ||
typeof parsed.archivePath !== "string" ||
typeof parsed.trigger !== "string"
) {
return null;
}
return {
version: 1,
createdAt: parsed.createdAt,
archivePath: parsed.archivePath,
trigger: parsed.trigger,
includeWorkspace: parsed.includeWorkspace === true,
};
} catch {
return null;
}
}
export function resolveMatrixMigrationSnapshotMarkerPath(
env: NodeJS.ProcessEnv = process.env,
): string {
const stateDir = resolveStateDir(env, os.homedir);
return path.join(stateDir, "matrix", "migration-snapshot.json");
}
export function resolveMatrixMigrationSnapshotOutputDir(
env: NodeJS.ProcessEnv = process.env,
): string {
const homeDir = resolveRequiredHomeDir(env, os.homedir);
return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME);
}
export function hasPendingMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0;
}
export function hasActionableMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState && !("warning" in legacyState)) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return legacyCrypto.plans.length > 0 && isMatrixLegacyCryptoInspectorAvailable();
}
export async function maybeCreateMatrixMigrationSnapshot(params: {
trigger: string;
env?: NodeJS.ProcessEnv;
outputDir?: string;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixMigrationSnapshotResult> {
const env = params.env ?? process.env;
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env);
const existingMarker = loadSnapshotMarker(markerPath);
if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) {
params.log?.info?.(
`matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`,
);
return {
created: false,
archivePath: existingMarker.archivePath,
markerPath,
};
}
if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) {
params.log?.warn?.(
`matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`,
);
}
const snapshot = await createBackupArchive({
output: (() => {
const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env);
fs.mkdirSync(outputDir, { recursive: true });
return outputDir;
})(),
includeWorkspace: false,
});
const marker: MatrixMigrationSnapshotMarker = {
version: 1,
createdAt: snapshot.createdAt,
archivePath: snapshot.archivePath,
trigger: params.trigger,
includeWorkspace: snapshot.includeWorkspace,
};
await writeJsonFileAtomically(markerPath, marker);
params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`);
return {
created: true,
archivePath: snapshot.archivePath,
markerPath,
};
}

View File

@@ -1,36 +1,96 @@
export {
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
createActionGate,
formatZonedTimestamp,
getChatChannelMeta,
jsonResult,
loadOutboundMediaFromUrl,
normalizeAccountId,
normalizeOptionalAccountId,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/matrix";
export * from "openclaw/plugin-sdk/matrix";
type PollInput,
type ReplyPayload,
} from "openclaw/plugin-sdk/core";
export type {
ChannelPlugin,
NormalizedLocation,
PluginRuntime,
RuntimeLogger,
} from "openclaw/plugin-sdk/core";
export type {
BaseProbeResult,
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
ChannelOutboundAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelToolSend,
} from "openclaw/plugin-sdk/channel-contract";
export { formatZonedTimestamp } from "openclaw/plugin-sdk/core";
export { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
export type { ChannelSetupInput } from "openclaw/plugin-sdk/core";
export type {
OpenClawConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
} from "openclaw/plugin-sdk/config-runtime";
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
export type { WizardPrompter } from "openclaw/plugin-sdk/core";
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
export {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
export {
addWildcardAllowFrom,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
moveSingleAccountChannelSectionToDefaultAccount,
promptAccountId,
promptChannelAccessConfig,
} from "openclaw/plugin-sdk/setup";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export {
assertHttpUrlTargetsPrivateNetwork,
closeDispatcher,
createPinnedDispatcher,
isPrivateOrLoopbackHost,
resolvePinnedHostnameWithPolicy,
ssrfPolicyFromAllowPrivateNetwork,
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
export { dispatchReplyFromConfigWithSettledDispatcher } from "openclaw/plugin-sdk/inbound-reply-dispatch";
export {
dispatchReplyFromConfigWithSettledDispatcher,
ensureConfiguredAcpBindingReady,
resolveConfiguredAcpBindingRecord,
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
} from "openclaw/plugin-sdk/core";
export {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/channel-status";
export {
getSessionBindingService,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
} from "openclaw/plugin-sdk/conversation-runtime";
export { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
export { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export { normalizePollInput } from "openclaw/plugin-sdk/media-runtime";
export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
// resolveMatrixAccountStringValues already comes from plugin-sdk/matrix.
// Re-exporting auth-precedence here makes Jiti try to define the same export twice.

View File

@@ -0,0 +1,7 @@
export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "./migration-snapshot.js";

View File

@@ -0,0 +1,169 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
collectSecretInputAssignment,
getChannelSurface,
hasConfiguredSecretInputValue,
hasOwnProperty,
normalizeSecretStringValue,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
export const secretTargetRegistryEntries = [
{
id: "channels.matrix.accounts.*.accessToken",
targetType: "channels.matrix.accounts.*.accessToken",
configFile: "openclaw.json",
pathPattern: "channels.matrix.accounts.*.accessToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.matrix.accounts.*.password",
targetType: "channels.matrix.accounts.*.password",
configFile: "openclaw.json",
pathPattern: "channels.matrix.accounts.*.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.matrix.accessToken",
targetType: "channels.matrix.accessToken",
configFile: "openclaw.json",
pathPattern: "channels.matrix.accessToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.matrix.password",
targetType: "channels.matrix.password",
configFile: "openclaw.json",
pathPattern: "channels.matrix.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "matrix");
if (!resolved) {
return;
}
const { channel: matrix, surface } = resolved;
const envAccessTokenConfigured =
normalizeSecretStringValue(params.context.env.MATRIX_ACCESS_TOKEN).length > 0;
const defaultScopedAccessTokenConfigured =
normalizeSecretStringValue(
params.context.env[getMatrixScopedEnvVarNames("default").accessToken],
).length > 0;
const defaultAccountAccessTokenConfigured = surface.accounts.some(
({ accountId, account }) =>
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID &&
hasConfiguredSecretInputValue(account.accessToken, params.defaults),
);
const baseAccessTokenConfigured = hasConfiguredSecretInputValue(
matrix.accessToken,
params.defaults,
);
collectSecretInputAssignment({
value: matrix.accessToken,
path: "channels.matrix.accessToken",
expected: "string",
defaults: params.defaults,
context: params.context,
active: surface.channelEnabled,
inactiveReason: "Matrix channel is disabled.",
apply: (value) => {
matrix.accessToken = value;
},
});
collectSecretInputAssignment({
value: matrix.password,
path: "channels.matrix.password",
expected: "string",
defaults: params.defaults,
context: params.context,
active:
surface.channelEnabled &&
!(
baseAccessTokenConfigured ||
envAccessTokenConfigured ||
defaultScopedAccessTokenConfigured ||
defaultAccountAccessTokenConfigured
),
inactiveReason:
"Matrix channel is disabled or access-token auth is configured for the default Matrix account.",
apply: (value) => {
matrix.password = value;
},
});
if (!surface.hasExplicitAccounts) {
return;
}
for (const { accountId, account, enabled } of surface.accounts) {
if (hasOwnProperty(account, "accessToken")) {
collectSecretInputAssignment({
value: account.accessToken,
path: `channels.matrix.accounts.${accountId}.accessToken`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: enabled,
inactiveReason: "Matrix account is disabled.",
apply: (value) => {
account.accessToken = value;
},
});
}
if (!hasOwnProperty(account, "password")) {
continue;
}
const accountAccessTokenConfigured = hasConfiguredSecretInputValue(
account.accessToken,
params.defaults,
);
const scopedEnvAccessTokenConfigured =
normalizeSecretStringValue(
params.context.env[getMatrixScopedEnvVarNames(accountId).accessToken],
).length > 0;
const inheritedDefaultAccountAccessTokenConfigured =
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID &&
(baseAccessTokenConfigured || envAccessTokenConfigured);
collectSecretInputAssignment({
value: account.password,
path: `channels.matrix.accounts.${accountId}.password`,
expected: "string",
defaults: params.defaults,
context: params.context,
active:
enabled &&
!(
accountAccessTokenConfigured ||
scopedEnvAccessTokenConfigured ||
inheritedDefaultAccountAccessTokenConfigured
),
inactiveReason: "Matrix account is disabled or this account has an accessToken configured.",
apply: (value) => {
account.password = value;
},
});
}
}

View File

@@ -0,0 +1,89 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/setup";
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [
"deviceId",
"avatarUrl",
"initialSyncLimit",
"encryption",
"allowlistOnly",
"allowBots",
"blockStreaming",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"ackReaction",
"ackReactionScope",
"reactionNotifications",
"threadBindings",
"startupVerification",
"startupVerificationCooldownHours",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
] as const;
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [
// When named accounts already exist, only move auth/bootstrap fields into the
// promoted account. Shared delivery-policy fields stay at the top level.
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"avatarUrl",
"initialSyncLimit",
"encryption",
] as const;
export const singleAccountKeysToMove = [...MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE];
export const namedAccountPromotionKeys = [...MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS];
export function resolveSingleAccountPromotionTarget(params: {
channel: Record<string, unknown>;
}): string {
const accounts =
typeof params.channel.accounts === "object" && params.channel.accounts
? (params.channel.accounts as Record<string, unknown>)
: {};
const normalizedDefaultAccount =
typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim()
? normalizeAccountId(params.channel.defaultAccount)
: undefined;
if (normalizedDefaultAccount) {
if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) {
const matchedAccountId = Object.entries(accounts).find(
([accountId, value]) =>
accountId &&
value &&
typeof value === "object" &&
normalizeAccountId(accountId) === normalizedDefaultAccount,
)?.[0];
if (matchedAccountId) {
return matchedAccountId;
}
}
return DEFAULT_ACCOUNT_ID;
}
const namedAccounts = Object.entries(accounts).filter(
([accountId, value]) => accountId && typeof value === "object" && value,
);
if (namedAccounts.length === 1) {
return namedAccounts[0][0];
}
if (
namedAccounts.length > 1 &&
accounts[DEFAULT_ACCOUNT_ID] &&
typeof accounts[DEFAULT_ACCOUNT_ID] === "object"
) {
return DEFAULT_ACCOUNT_ID;
}
return DEFAULT_ACCOUNT_ID;
}

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
export const MATRIX_TEST_HOMESERVER = "https://matrix.example.org";
export const MATRIX_DEFAULT_USER_ID = "@bot:example.org";
export const MATRIX_DEFAULT_ACCESS_TOKEN = "tok-123";
export const MATRIX_DEFAULT_DEVICE_ID = "DEVICE123";
export const MATRIX_OPS_ACCOUNT_ID = "ops";
export const MATRIX_OPS_USER_ID = "@ops-bot:example.org";
export const MATRIX_OPS_ACCESS_TOKEN = "tok-ops";
export const MATRIX_OPS_DEVICE_ID = "DEVICEOPS";
export function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf8");
}
export function writeMatrixCredentials(
stateDir: string,
params?: {
accountId?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
deviceId?: string;
},
) {
const accountId = params?.accountId ?? MATRIX_OPS_ACCOUNT_ID;
writeFile(
path.join(stateDir, "credentials", "matrix", `credentials-${accountId}.json`),
JSON.stringify(
{
homeserver: params?.homeserver ?? MATRIX_TEST_HOMESERVER,
userId: params?.userId ?? MATRIX_OPS_USER_ID,
accessToken: params?.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
deviceId: params?.deviceId ?? MATRIX_OPS_DEVICE_ID,
},
null,
2,
),
);
}

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -1,4 +1,88 @@
// Private runtime barrel for the bundled Mattermost extension.
// Keep this barrel thin and aligned with the local extension surface.
// Keep this barrel thin and generic-only.
export * from "openclaw/plugin-sdk/mattermost";
export type {
BaseProbeResult,
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionName,
ChannelPlugin,
ChatType,
HistoryEntry,
OpenClawConfig,
OpenClawPluginApi,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
export type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "openclaw/plugin-sdk/config-runtime";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
createDedupeCache,
parseStrictPositiveInteger,
resolveClientIp,
isTrustedProxyAddress,
} from "openclaw/plugin-sdk/core";
export { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/channel-status";
export { createAccountStatusSink } from "openclaw/plugin-sdk/compat";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export {
buildModelsProviderData,
listSkillCommandsForAgents,
resolveControlCommandGate,
resolveStoredModelOverride,
} from "openclaw/plugin-sdk/command-auth";
export {
GROUP_POLICY_BLOCKED_LABEL,
isDangerousNameMatchingEnabled,
loadSessionStore,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveStorePath,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export {
DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export { rawDataToString } from "openclaw/plugin-sdk/browser-support";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
DEFAULT_GROUP_HISTORY_LIMIT,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryIfEnabled,
} from "openclaw/plugin-sdk/reply-history";
export { normalizeAccountId, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from";
export { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-targets";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,
} from "openclaw/plugin-sdk/webhook-ingress";
export {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
} from "openclaw/plugin-sdk/setup";
export {
getAgentScopedMediaLocalRoots,
resolveChannelMediaMaxBytes,
} from "openclaw/plugin-sdk/media-runtime";
export { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";

View File

@@ -22,6 +22,7 @@ import {
} from "openclaw/plugin-sdk/status-helpers";
import { mattermostApprovalAuth } from "./approval-auth.js";
import { MattermostChannelConfigSchema } from "./config-surface.js";
import { collectMattermostMutableAllowlistWarnings } from "./doctor.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
import {
listMattermostAccountIds,
@@ -38,6 +39,7 @@ import { monitorMattermostProvider } from "./mattermost/monitor.js";
import { probeMattermost } from "./mattermost/probe.js";
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
import { sendMessageMattermost } from "./mattermost/send.js";
import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands.js";
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import {
@@ -328,6 +330,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
}),
},
auth: mattermostApprovalAuth,
doctor: {
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
},
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
@@ -401,6 +406,34 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
}),
}),
gateway: {
resolveGatewayAuthBypassPaths: ({ cfg }) => {
const base = cfg.channels?.mattermost;
const callbackPaths = new Set(
collectMattermostSlashCallbackPaths(base?.commands).filter(
(path) =>
path === "/api/channels/mattermost/command" ||
path.startsWith("/api/channels/mattermost/"),
),
);
const accounts = base?.accounts ?? {};
for (const account of Object.values(accounts)) {
const accountConfig =
account && typeof account === "object" && !Array.isArray(account)
? (account as {
commands?: Parameters<typeof collectMattermostSlashCallbackPaths>[0];
})
: undefined;
for (const path of collectMattermostSlashCallbackPaths(accountConfig?.commands)) {
if (
path === "/api/channels/mattermost/command" ||
path.startsWith("/api/channels/mattermost/")
) {
callbackPaths.add(path);
}
}
}
return [...callbackPaths];
},
startAccount: async (ctx) => {
const account = ctx.account;
const statusSink = createAccountStatusSink({

View File

@@ -0,0 +1,36 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function isMattermostMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
if (/^[a-z0-9]{26}$/.test(normalized)) {
return false;
}
return true;
}
export const collectMattermostMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "mattermost",
detector: isMattermostMutableAllowEntry,
collectLists: (scope) => [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
],
});

View File

@@ -534,6 +534,22 @@ export function isSlashCommandsEnabled(config: MattermostSlashCommandConfig): bo
return false;
}
export function collectMattermostSlashCallbackPaths(raw?: Partial<MattermostSlashCommandConfig>) {
const config = resolveSlashCommandConfig(raw);
const paths = new Set<string>([config.callbackPath]);
if (typeof config.callbackUrl === "string" && config.callbackUrl.trim()) {
try {
const pathname = new URL(config.callbackUrl).pathname;
if (pathname) {
paths.add(pathname);
}
} catch {
// Ignore invalid callback URLs and keep the normalized callback path only.
}
}
return [...paths];
}
/**
* Build the callback URL that Mattermost will POST to when a command is invoked.
*/

View File

@@ -0,0 +1,54 @@
import {
collectSimpleChannelFieldAssignments,
getChannelSurface,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.mattermost.accounts.*.botToken",
targetType: "channels.mattermost.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.mattermost.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.mattermost.botToken",
targetType: "channels.mattermost.botToken",
configFile: "openclaw.json",
pathPattern: "channels.mattermost.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "mattermost");
if (!resolved) {
return;
}
const { channel: mattermost, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "mattermost",
field: "botToken",
channel: mattermost,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Mattermost botToken.",
accountInactiveReason: "Mattermost account is disabled.",
});
}

View File

@@ -30,6 +30,7 @@ import {
} from "../runtime-api.js";
import { msTeamsApprovalAuth } from "./approval-auth.js";
import { MSTeamsChannelConfigSchema } from "./config-schema.js";
import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js";
import { formatUnknownError } from "./errors.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import type { ProbeMSTeamsResult } from "./probe.js";
@@ -388,6 +389,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
collectMutableAllowlistWarnings: collectMSTeamsMutableAllowlistWarnings,
},
setup: msteamsSetupAdapter,
messaging: {

View File

@@ -0,0 +1,27 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function isMSTeamsMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
}
export const collectMSTeamsMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "msteams",
detector: isMSTeamsMutableAllowEntry,
collectLists: (scope) => [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
],
});

View File

@@ -1,8 +1,10 @@
export * from "./src/accounts.js";
export * from "./src/format.js";
export * from "./src/identity.js";
export * from "./src/install-signal-cli.js";
export * from "./src/message-actions.js";
export * from "./src/monitor.js";
export * from "./src/normalize.js";
export * from "./src/outbound-session.js";
export * from "./src/probe.js";
export * from "./src/reaction-level.js";

View File

@@ -0,0 +1,3 @@
export * from "./src/install-signal-cli.js";
export * from "./src/normalize.js";
export { isSignalSenderAllowed, type SignalSender } from "./src/identity.js";

View File

@@ -2,9 +2,9 @@ import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-runtime";
import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-targets";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { resolveSignalAccount } from "./accounts.js";
import { normalizeSignalMessagingTarget } from "./normalize.js";
import { looksLikeUuid } from "./uuid.js";
function normalizeSignalApproverId(value: string | number): string | undefined {

View File

@@ -6,10 +6,6 @@ import {
attachChannelToResults,
} from "openclaw/plugin-sdk/channel-send-result";
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import {
looksLikeSignalTargetId,
normalizeSignalMessagingTarget,
} from "openclaw/plugin-sdk/channel-targets";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/core";
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
@@ -28,6 +24,7 @@ import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"
import { signalApprovalAuth } from "./approval-auth.js";
import { markdownToSignalTextChunks } from "./format.js";
import { signalMessageActions } from "./message-actions.js";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
import { resolveSignalOutboundTarget } from "./outbound-session.js";
import { resolveSignalReactionLevel } from "./reaction-level.js";
import { signalSetupAdapter } from "./setup-core.js";

View File

@@ -0,0 +1,303 @@
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import { request } from "node:https";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { CONFIG_DIR, extractArchive, resolveBrewExecutable } from "openclaw/plugin-sdk/setup-tools";
export type ReleaseAsset = {
name?: string;
browser_download_url?: string;
};
export type NamedAsset = {
name: string;
browser_download_url: string;
};
type ReleaseResponse = {
tag_name?: string;
assets?: ReleaseAsset[];
};
export type SignalInstallResult = {
ok: boolean;
cliPath?: string;
version?: string;
error?: string;
};
/** @internal Exported for testing. */
export async function extractSignalCliArchive(
archivePath: string,
installRoot: string,
timeoutMs: number,
): Promise<void> {
await extractArchive({ archivePath, destDir: installRoot, timeoutMs });
}
/** @internal Exported for testing. */
export function looksLikeArchive(name: string): boolean {
return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip");
}
/**
* Pick a native release asset from the official GitHub releases.
*
* The official signal-cli releases only publish native (GraalVM) binaries for
* x86-64 Linux. On architectures where no native asset is available this
* returns `undefined` so the caller can fall back to a different install
* strategy (e.g. Homebrew).
*/
/** @internal Exported for testing. */
export function pickAsset(
assets: ReleaseAsset[],
platform: NodeJS.Platform,
arch: string,
): NamedAsset | undefined {
const withName = assets.filter((asset): asset is NamedAsset =>
Boolean(asset.name && asset.browser_download_url),
);
// Archives only, excluding signature files (.asc)
const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase()));
const byName = (pattern: RegExp) =>
archives.find((asset) => pattern.test(asset.name.toLowerCase()));
if (platform === "linux") {
// The official "Linux-native" asset is an x86-64 GraalVM binary.
// On non-x64 architectures it will fail with "Exec format error",
// so only select it when the host architecture matches.
if (arch === "x64") {
return byName(/linux-native/) || byName(/linux/) || archives[0];
}
// No native release for this arch — caller should fall back.
return undefined;
}
if (platform === "darwin") {
return byName(/macos|osx|darwin/) || archives[0];
}
if (platform === "win32") {
return byName(/windows|win/) || archives[0];
}
return archives[0];
}
async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise<void> {
await new Promise<void>((resolve, reject) => {
const req = request(url, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
const location = res.headers.location;
if (!location || maxRedirects <= 0) {
reject(new Error("Redirect loop or missing Location header"));
return;
}
const redirectUrl = new URL(location, url).href;
resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1));
return;
}
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`));
return;
}
const out = createWriteStream(dest);
pipeline(res, out).then(resolve).catch(reject);
});
req.on("error", reject);
req.end();
});
}
async function findSignalCliBinary(root: string): Promise<string | null> {
const candidates: string[] = [];
const enqueue = async (dir: string, depth: number) => {
if (depth > 3) {
return;
}
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await enqueue(full, depth + 1);
} else if (entry.isFile() && entry.name === "signal-cli") {
candidates.push(full);
}
}
};
await enqueue(root, 0);
return candidates[0] ?? null;
}
// ---------------------------------------------------------------------------
// Brew-based install (used on architectures without an official native build)
// ---------------------------------------------------------------------------
async function resolveBrewSignalCliPath(brewExe: string): Promise<string | null> {
try {
const result = await runPluginCommandWithTimeout({
argv: [brewExe, "--prefix", "signal-cli"],
timeoutMs: 10_000,
});
if (result.code === 0 && result.stdout.trim()) {
const prefix = result.stdout.trim();
// Homebrew installs the wrapper script at <prefix>/bin/signal-cli
const candidate = path.join(prefix, "bin", "signal-cli");
try {
await fs.access(candidate);
return candidate;
} catch {
// Fall back to searching the prefix
return findSignalCliBinary(prefix);
}
}
} catch {
// ignore
}
return null;
}
async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const brewExe = resolveBrewExecutable();
if (!brewExe) {
return {
ok: false,
error:
`No native signal-cli build is available for ${process.arch}. ` +
"Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.",
};
}
runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`);
const result = await runPluginCommandWithTimeout({
argv: [brewExe, "install", "signal-cli"],
timeoutMs: 15 * 60_000, // brew builds from source; can take a while
});
if (result.code !== 0) {
return {
ok: false,
error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`,
};
}
const cliPath = await resolveBrewSignalCliPath(brewExe);
if (!cliPath) {
return {
ok: false,
error: "brew install succeeded but signal-cli binary was not found.",
};
}
// Extract version from the installed binary.
let version: string | undefined;
try {
const vResult = await runPluginCommandWithTimeout({
argv: [cliPath, "--version"],
timeoutMs: 10_000,
});
// Output is typically "signal-cli 0.13.24"
version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined;
} catch {
// non-critical; leave version undefined
}
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Direct download install (used when an official native asset is available)
// ---------------------------------------------------------------------------
async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
const response = await fetch(apiUrl, {
headers: {
"User-Agent": "openclaw",
Accept: "application/vnd.github+json",
},
});
if (!response.ok) {
return {
ok: false,
error: `Failed to fetch release info (${response.status})`,
};
}
const payload = (await response.json()) as ReleaseResponse;
const version = payload.tag_name?.replace(/^v/, "") ?? "unknown";
const assets = payload.assets ?? [];
const asset = pickAsset(assets, process.platform, process.arch);
if (!asset) {
return {
ok: false,
error: "No compatible release asset found for this platform.",
};
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-"));
const archivePath = path.join(tmpDir, asset.name);
runtime.log(`Downloading signal-cli ${version} (${asset.name})…`);
await downloadToFile(asset.browser_download_url, archivePath);
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
await fs.mkdir(installRoot, { recursive: true });
if (!looksLikeArchive(asset.name.toLowerCase())) {
return { ok: false, error: `Unsupported archive type: ${asset.name}` };
}
try {
await extractSignalCliArchive(archivePath, installRoot, 60_000);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: `Failed to extract ${asset.name}: ${message}`,
};
}
const cliPath = await findSignalCliBinary(installRoot);
if (!cliPath) {
return {
ok: false,
error: `signal-cli binary not found after extracting ${asset.name}`,
};
}
await fs.chmod(cliPath, 0o755).catch(() => {});
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInstallResult> {
if (process.platform === "win32") {
return {
ok: false,
error: "Signal CLI auto-install is not supported on Windows yet.",
};
}
// The official signal-cli GitHub releases only ship a native binary for
// x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate
// to Homebrew which builds from source and bundles the JRE automatically.
const hasNativeRelease = process.platform !== "linux" || process.arch === "x64";
if (hasNativeRelease) {
return installSignalCliFromRelease(runtime);
}
return installSignalCliViaBrew(runtime);
}

View File

@@ -14,7 +14,6 @@ import {
resolveMentionGatingWithBypass,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-targets";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import {
@@ -57,6 +56,7 @@ import {
resolveSignalSender,
type SignalSender,
} from "../identity.js";
import { normalizeSignalMessagingTarget } from "../normalize.js";
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
import type {

View File

@@ -23,7 +23,7 @@ export {
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
export { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
export { chunkText } from "openclaw/plugin-sdk/reply-runtime";
export { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools";
export { detectBinary } from "openclaw/plugin-sdk/setup-tools";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -35,10 +35,7 @@ export {
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
export { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
export {
looksLikeSignalTargetId,
normalizeSignalMessagingTarget,
} from "openclaw/plugin-sdk/channel-targets";
export { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
export {
listEnabledSignalAccounts,
listSignalAccountIds,
@@ -46,6 +43,7 @@ export {
resolveSignalAccount,
} from "./accounts.js";
export { monitorSignalProvider } from "./monitor.js";
export { installSignalCli } from "./install-signal-cli.js";
export { probeSignal } from "./probe.js";
export { resolveSignalReactionLevel } from "./reaction-level.js";
export { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";

View File

@@ -3,8 +3,9 @@ import {
setSetupChannelEnabled,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js";
import { installSignalCli } from "./install-signal-cli.js";
import {
createSignalCliPathTextInput,
normalizeSignalAccountInput,
@@ -28,7 +29,13 @@ export const signalSetupWizard: ChannelSetupWizard = {
unconfiguredHint: "signal-cli missing",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }).configured,
resolveConfigured: ({ cfg, accountId }) =>
accountId
? resolveSignalAccount({ cfg, accountId }).configured
: listSignalAccountIds(cfg).some(
(resolvedAccountId) =>
resolveSignalAccount({ cfg, accountId: resolvedAccountId }).configured,
),
resolveBinaryPath: ({ cfg, accountId }) =>
resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli",
detectBinary,

View File

@@ -0,0 +1,11 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload-harness.js";
export type {
SlackInteractiveHandlerContext,
SlackInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -0,0 +1,167 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
formatSlackStreamingBooleanMigrationMessage,
formatSlackStreamModeMigrationMessage,
resolveSlackNativeStreaming,
resolveSlackStreamingMode,
} from "./streaming-compat.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeSlackStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const legacyStreaming = updated.streaming;
const beforeStreaming = updated.streaming;
const beforeNativeStreaming = updated.nativeStreaming;
const resolvedStreaming = resolveSlackStreamingMode(updated);
const resolvedNativeStreaming = resolveSlackNativeStreaming(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof legacyStreaming === "boolean" ||
(typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolvedStreaming) {
updated = { ...updated, streaming: resolvedStreaming };
changed = true;
}
if (
typeof beforeNativeStreaming !== "boolean" ||
beforeNativeStreaming !== resolvedNativeStreaming
) {
updated = { ...updated, nativeStreaming: resolvedNativeStreaming };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(
formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming),
);
}
if (typeof legacyStreaming === "boolean") {
params.changes.push(
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
);
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`,
);
}
return { entry: updated, changed };
}
function hasLegacySlackStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry))
);
}
function hasLegacySlackAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacySlackStreamingAliases(account));
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "slack"],
message:
"channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.",
match: hasLegacySlackStreamingAliases,
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming and channels.slack.accounts.<id>.nativeStreaming.",
match: hasLegacySlackAccountStreamingAliases,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.slack);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const baseStreaming = normalizeSlackStreamingAliases({
entry: updated,
pathPrefix: "channels.slack",
changes,
});
updated = baseStreaming.entry;
changed = changed || baseStreaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
const streaming = normalizeSlackStreamingAliases({
entry: account,
pathPrefix: `channels.slack.accounts.${accountId}`,
changes,
});
if (streaming.changed) {
accounts[accountId] = streaming.entry;
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
slack: updated as unknown as NonNullable<OpenClawConfig["channels"]>["slack"],
} as OpenClawConfig["channels"],
},
changes,
};
}

View File

@@ -1,18 +1,17 @@
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
type ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
import { isSlackMutableAllowEntry } from "./security-doctor.js";
import {
formatSlackStreamingBooleanMigrationMessage,
formatSlackStreamModeMigrationMessage,
resolveSlackNativeStreaming,
resolveSlackStreamingMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
collectProviderDangerousNameMatchingScopes,
isSlackMutableAllowEntry,
} from "openclaw/plugin-sdk/runtime";
} from "./streaming-compat.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
@@ -294,11 +293,47 @@ export function collectSlackMutableAllowlistWarnings(cfg: OpenClawConfig): strin
];
}
function hasLegacySlackStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry))
);
}
function hasLegacySlackAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacySlackStreamingAliases(account));
}
const SLACK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "slack"],
message:
"channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.",
match: hasLegacySlackStreamingAliases,
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming and channels.slack.accounts.<id>.nativeStreaming.",
match: hasLegacySlackAccountStreamingAliases,
},
];
export const slackDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeSlackCompatibilityConfig(cfg),
collectMutableAllowlistWarnings: ({ cfg }) => collectSlackMutableAllowlistWarnings(cfg),
};

View File

@@ -0,0 +1,50 @@
import { vi, type Mock } from "vitest";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
type OutboundSendMock = Mock<(...args: unknown[]) => Promise<Record<string, unknown>>>;
type SlackOutboundPayloadHarness = {
run: () => Promise<Record<string, unknown>>;
sendMock: OutboundSendMock;
to: string;
};
let slackOutboundCache: ChannelOutboundAdapter | undefined;
function getSlackOutbound(): ChannelOutboundAdapter {
if (!slackOutboundCache) {
({ slackOutbound: slackOutboundCache } = loadBundledPluginTestApiSync<{
slackOutbound: ChannelOutboundAdapter;
}>("slack"));
}
return slackOutboundCache;
}
export function createSlackOutboundPayloadHarness(params: {
payload: ReplyPayload;
sendResults?: Array<{ messageId: string }>;
}): SlackOutboundPayloadHarness {
const sendSlack: OutboundSendMock = vi.fn();
primeChannelOutboundSendMock(
sendSlack,
{ messageId: "sl-1", channelId: "C12345", ts: "1234.5678" },
params.sendResults,
);
const ctx = {
cfg: {},
to: "C12345",
text: "",
payload: params.payload,
deps: {
sendSlack,
},
};
return {
run: async () => await getSlackOutbound().sendPayload!(ctx),
sendMock: sendSlack,
to: ctx.to,
};
}

View File

@@ -0,0 +1,158 @@
import {
collectConditionalChannelFieldAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.slack.accounts.*.appToken",
targetType: "channels.slack.accounts.*.appToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.appToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.botToken",
targetType: "channels.slack.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.signingSecret",
targetType: "channels.slack.accounts.*.signingSecret",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.signingSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.userToken",
targetType: "channels.slack.accounts.*.userToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.userToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.appToken",
targetType: "channels.slack.appToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.appToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.botToken",
targetType: "channels.slack.botToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.signingSecret",
targetType: "channels.slack.signingSecret",
configFile: "openclaw.json",
pathPattern: "channels.slack.signingSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.userToken",
targetType: "channels.slack.userToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.userToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "slack");
if (!resolved) {
return;
}
const { channel: slack, surface } = resolved;
const baseMode = slack.mode === "http" || slack.mode === "socket" ? slack.mode : "socket";
const fields = ["botToken", "userToken"] as const;
for (const field of fields) {
collectSimpleChannelFieldAssignments({
channelKey: "slack",
field,
channel: slack,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: `no enabled account inherits this top-level Slack ${field}.`,
accountInactiveReason: "Slack account is disabled.",
});
}
const resolveAccountMode = (account: Record<string, unknown>) =>
account.mode === "http" || account.mode === "socket" ? account.mode : baseMode;
collectConditionalChannelFieldAssignments({
channelKey: "slack",
field: "appToken",
channel: slack,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseMode !== "http",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled && !hasOwnProperty(account, "appToken") && resolveAccountMode(account) !== "http",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) !== "http",
topInactiveReason: "no enabled Slack socket-mode surface inherits this top-level appToken.",
accountInactiveReason: "Slack account is disabled or not running in socket mode.",
});
collectConditionalChannelFieldAssignments({
channelKey: "slack",
field: "signingSecret",
channel: slack,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseMode === "http",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "signingSecret") &&
resolveAccountMode(account) === "http",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "http",
topInactiveReason: "no enabled Slack HTTP-mode surface inherits this top-level signingSecret.",
accountInactiveReason: "Slack account is disabled or not running in HTTP mode.",
});
}

View File

@@ -0,0 +1,21 @@
export function isSlackMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const mentionMatch = text.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch && /^[A-Z0-9]{8,}$/i.test(mentionMatch[1] ?? "")) {
return false;
}
const withoutPrefix = text.replace(/^(slack|user):/i, "").trim();
if (/^[UWBCGDT][A-Z0-9]{2,}$/.test(withoutPrefix)) {
return false;
}
if (/^[A-Z0-9]{8,}$/i.test(withoutPrefix)) {
return false;
}
return true;
}

View File

@@ -4,7 +4,7 @@ import {
resolveSlackStreamingMode,
type SlackLegacyDraftStreamMode,
type StreamingMode,
} from "openclaw/plugin-sdk/config-runtime";
} from "./streaming-compat.js";
export type SlackStreamMode = SlackLegacyDraftStreamMode;
export type SlackStreamingMode = StreamingMode;

View File

@@ -0,0 +1,100 @@
export type StreamingMode = "off" | "partial" | "block" | "progress";
export type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append";
function normalizeStreamingMode(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function parseStreamingMode(value: unknown): StreamingMode | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
function parseSlackLegacyDraftStreamMode(value: unknown): SlackLegacyDraftStreamMode | null {
const normalized = normalizeStreamingMode(value);
if (normalized === "replace" || normalized === "status_final" || normalized === "append") {
return normalized;
}
return null;
}
function mapSlackLegacyDraftStreamModeToStreaming(mode: SlackLegacyDraftStreamMode): StreamingMode {
if (mode === "append") {
return "block";
}
if (mode === "status_final") {
return "progress";
}
return "partial";
}
export function mapStreamingModeToSlackLegacyDraftStreamMode(mode: StreamingMode) {
if (mode === "block") {
return "append" as const;
}
if (mode === "progress") {
return "status_final" as const;
}
return "replace" as const;
}
export function resolveSlackStreamingMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): StreamingMode {
const parsedStreaming = parseStreamingMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacyStreamMode = parseSlackLegacyDraftStreamMode(params.streamMode);
if (legacyStreamMode) {
return mapSlackLegacyDraftStreamModeToStreaming(legacyStreamMode);
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "partial";
}
export function resolveSlackNativeStreaming(
params: {
nativeStreaming?: unknown;
streaming?: unknown;
} = {},
): boolean {
if (typeof params.nativeStreaming === "boolean") {
return params.nativeStreaming;
}
if (typeof params.streaming === "boolean") {
return params.streaming;
}
return true;
}
export function formatSlackStreamModeMigrationMessage(
pathPrefix: string,
resolvedStreaming: string,
): string {
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
}
export function formatSlackStreamingBooleanMigrationMessage(
pathPrefix: string,
resolvedNativeStreaming: boolean,
): string {
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
}

View File

@@ -0,0 +1 @@
export { collectSynologyChatSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -4,6 +4,7 @@ export * from "./src/action-threading.js";
export * from "./src/allow-from.js";
export * from "./src/api-fetch.js";
export * from "./src/bot/helpers.js";
export * from "./src/command-config.js";
export {
buildCommandsPaginationKeyboard,
buildTelegramModelsProviderChannelData,
@@ -27,6 +28,7 @@ export * from "./src/security-audit.js";
export * from "./src/sticker-cache.js";
export * from "./src/status-issues.js";
export * from "./src/targets.js";
export * from "./src/topic-conversation.js";
export * from "./src/update-offset-store.js";
export type { TelegramButtonStyle, TelegramInlineButtons } from "./src/button-types.js";
export type { StickerMetadata } from "./src/bot/types.js";

View File

@@ -0,0 +1,13 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { parseTelegramTopicConversation } from "./src/topic-conversation.js";
export { singleAccountKeysToMove } from "./src/setup-contract.js";
export { buildTelegramModelsProviderChannelData } from "./src/command-ui.js";
export type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export { collectTelegramSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -3,14 +3,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Bot } from "grammy";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
export const TELEGRAM_MAX_COMMANDS = 100;
const TELEGRAM_COMMAND_RETRY_RATIO = 0.8;

View File

@@ -1,6 +1,5 @@
import type { Chat, Message } from "@grammyjs/types";
import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
import { resolveTelegramPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
@@ -10,6 +9,7 @@ import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runt
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
import { normalizeTelegramReplyToMessageId } from "../outbound-params.js";
import { resolveTelegramPreviewStreamMode } from "../preview-streaming.js";
import {
buildSenderLabel,
buildSenderName,

View File

@@ -80,6 +80,7 @@ import {
formatDuplicateTelegramTokenReason,
telegramConfigAdapter,
} from "./shared.js";
import { detectTelegramLegacyStateMigrations } from "./state-migrations.js";
import { collectTelegramStatusIssues } from "./status-issues.js";
import { parseTelegramTarget } from "./targets.js";
import {
@@ -724,6 +725,8 @@ export const telegramPlugin = createChatChannelPlugin({
await resolveTelegramTargets({ cfg, accountId, inputs, kind }),
},
lifecycle: {
detectLegacyStateMigrations: ({ cfg, env }) =>
detectTelegramLegacyStateMigrations({ cfg, env }),
onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => {
const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim();
const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim();

View File

@@ -20,7 +20,7 @@ export function normalizeTelegramCommandName(value: string): string {
return withoutSlash.trim().toLowerCase().replace(/-/g, "_");
}
function normalizeTelegramCommandDescription(value: string): string {
export function normalizeTelegramCommandDescription(value: string): string {
return value.trim();
}

View File

@@ -0,0 +1,199 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveTelegramPreviewStreamMode } from "./preview-streaming.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeTelegramStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveTelegramPreviewStreamMode(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
);
}
return { entry: updated, changed };
}
function hasLegacyTelegramStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveTelegramPreviewStreamMode(entry))
);
}
function hasLegacyTelegramAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyTelegramStreamingAliases(account));
}
function resolveCompatibleDefaultGroupEntry(section: Record<string, unknown>): {
groups: Record<string, unknown>;
entry: Record<string, unknown>;
} | null {
const existingGroups = section.groups;
if (existingGroups !== undefined && !asObjectRecord(existingGroups)) {
return null;
}
const groups = asObjectRecord(existingGroups) ?? {};
const defaultKey = "*";
const existingEntry = groups[defaultKey];
if (existingEntry !== undefined && !asObjectRecord(existingEntry)) {
return null;
}
const entry = asObjectRecord(existingEntry) ?? {};
return { groups, entry };
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "telegram", "groupMentionsOnly"],
message:
'channels.telegram.groupMentionsOnly was removed; use channels.telegram.groups."*".requireMention instead (auto-migrated on load).',
},
{
path: ["channels", "telegram"],
message:
'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".',
match: hasLegacyTelegramStreamingAliases,
},
{
path: ["channels", "telegram", "accounts"],
message:
'channels.telegram.accounts.<id>.streamMode and boolean channels.telegram.accounts.<id>.streaming are legacy; use channels.telegram.accounts.<id>.streaming="off|partial|block".',
match: hasLegacyTelegramAccountStreamingAliases,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.telegram);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
if (updated.groupMentionsOnly !== undefined) {
const defaultGroupEntry = resolveCompatibleDefaultGroupEntry(updated);
if (!defaultGroupEntry) {
changes.push(
"Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.",
);
} else {
const { groups, entry } = defaultGroupEntry;
if (entry.requireMention === undefined) {
entry.requireMention = updated.groupMentionsOnly;
groups["*"] = entry;
updated = { ...updated, groups };
changes.push(
'Moved channels.telegram.groupMentionsOnly → channels.telegram.groups."*".requireMention.',
);
} else {
changes.push(
'Removed channels.telegram.groupMentionsOnly (channels.telegram.groups."*" already set).',
);
}
const { groupMentionsOnly: _ignored, ...rest } = updated;
updated = rest;
changed = true;
}
}
const base = normalizeTelegramStreamingAliases({
entry: updated,
pathPrefix: "channels.telegram",
changes,
});
updated = base.entry;
changed = changed || base.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
const accountStreaming = normalizeTelegramStreamingAliases({
entry: account,
pathPrefix: `channels.telegram.accounts.${accountId}`,
changes,
});
if (accountStreaming.changed) {
accounts[accountId] = accountStreaming.entry;
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
telegram: updated as unknown as NonNullable<OpenClawConfig["channels"]>["telegram"],
} as OpenClawConfig["channels"],
},
changes,
};
}

View File

@@ -2,11 +2,9 @@ import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
type ChannelDoctorEmptyAllowlistAccountContext,
type ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import {
resolveTelegramPreviewStreamMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
getChannelsCommandSecretTargetIds,
resolveCommandSecretRefsViaGateway,
@@ -15,6 +13,7 @@ import { inspectTelegramAccount } from "./account-inspect.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js";
import { lookupTelegramChatId } from "./api-fetch.js";
import { resolveTelegramPreviewStreamMode } from "./preview-streaming.js";
type TelegramAllowFromUsernameHit = { path: string; entry: string };
type DoctorAllowFromList = Array<string | number>;
@@ -452,7 +451,44 @@ export function collectTelegramEmptyAllowlistExtraWarnings(
: [];
}
function hasLegacyTelegramStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveTelegramPreviewStreamMode(entry))
);
}
function hasLegacyTelegramAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyTelegramStreamingAliases(account));
}
const TELEGRAM_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "telegram"],
message:
'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".',
match: hasLegacyTelegramStreamingAliases,
},
{
path: ["channels", "telegram", "accounts"],
message:
'channels.telegram.accounts.<id>.streamMode and boolean channels.telegram.accounts.<id>.streaming are legacy; use channels.telegram.accounts.<id>.streaming="off|partial|block".',
match: hasLegacyTelegramAccountStreamingAliases,
},
];
export const telegramDoctor: ChannelDoctorAdapter = {
legacyConfigRules: TELEGRAM_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeTelegramCompatibilityConfig(cfg),
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
collectTelegramAllowFromUsernameWarnings({

View File

@@ -0,0 +1,54 @@
export type TelegramPreviewStreamMode = "off" | "partial" | "block";
function normalizeStreamingMode(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
function parseTelegramPreviewStreamMode(value: unknown): TelegramPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
export function resolveTelegramPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): TelegramPreviewStreamMode {
const parsedStreaming = parseStreamingMode(params.streaming);
if (parsedStreaming) {
if (parsedStreaming === "progress") {
return "partial";
}
return parsedStreaming;
}
const legacy = parseTelegramPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "partial";
}

View File

@@ -0,0 +1,117 @@
import {
collectConditionalChannelFieldAssignments,
getChannelSurface,
hasConfiguredSecretInputValue,
hasOwnProperty,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.telegram.accounts.*.botToken",
targetType: "channels.telegram.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.accounts.*.webhookSecret",
targetType: "channels.telegram.accounts.*.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.telegram.accounts.*.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.botToken",
targetType: "channels.telegram.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.webhookSecret",
targetType: "channels.telegram.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.telegram.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "telegram");
if (!resolved) {
return;
}
const { channel: telegram, surface } = resolved;
const baseTokenFile = typeof telegram.tokenFile === "string" ? telegram.tokenFile.trim() : "";
const accountTokenFile = (account: Record<string, unknown>) =>
typeof account.tokenFile === "string" ? account.tokenFile.trim() : "";
collectConditionalChannelFieldAssignments({
channelKey: "telegram",
field: "botToken",
channel: telegram,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseTokenFile.length === 0,
topLevelInheritedAccountActive: ({ account, enabled }) => {
if (!enabled || baseTokenFile.length > 0) {
return false;
}
const accountBotTokenConfigured = hasConfiguredSecretInputValue(
account.botToken,
params.defaults,
);
return !accountBotTokenConfigured && accountTokenFile(account).length === 0;
},
accountActive: ({ account, enabled }) => enabled && accountTokenFile(account).length === 0,
topInactiveReason:
"no enabled Telegram surface inherits this top-level botToken (tokenFile is configured).",
accountInactiveReason: "Telegram account is disabled or tokenFile is configured.",
});
const baseWebhookUrl = typeof telegram.webhookUrl === "string" ? telegram.webhookUrl.trim() : "";
const accountWebhookUrl = (account: Record<string, unknown>) =>
hasOwnProperty(account, "webhookUrl")
? typeof account.webhookUrl === "string"
? account.webhookUrl.trim()
: ""
: baseWebhookUrl;
collectConditionalChannelFieldAssignments({
channelKey: "telegram",
field: "webhookSecret",
channel: telegram,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0,
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0,
accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0,
topInactiveReason:
"no enabled Telegram webhook surface inherits this top-level webhookSecret (webhook mode is not active).",
accountInactiveReason:
"Telegram account is disabled or webhook mode is not active for this account.",
});
}

View File

@@ -0,0 +1 @@
export const singleAccountKeysToMove = ["streaming"];

View File

@@ -0,0 +1,36 @@
import fs from "node:fs";
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveDefaultTelegramAccountId } from "./accounts.js";
function fileExists(pathValue: string): boolean {
try {
return fs.existsSync(pathValue) && fs.statSync(pathValue).isFile();
} catch {
return false;
}
}
export function detectTelegramLegacyStateMigrations(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): ChannelLegacyStateMigrationPlan[] {
const legacyPath = resolveChannelAllowFromPath("telegram", params.env);
if (!fileExists(legacyPath)) {
return [];
}
const accountId = resolveDefaultTelegramAccountId(params.cfg);
const targetPath = resolveChannelAllowFromPath("telegram", params.env, accountId);
if (fileExists(targetPath)) {
return [];
}
return [
{
kind: "copy",
label: "Telegram pairing allowFrom",
sourcePath: legacyPath,
targetPath,
},
];
}

View File

@@ -3,6 +3,7 @@ export * from "./src/auto-reply/constants.js";
export { whatsappCommandPolicy } from "./src/command-policy.js";
export * from "./src/group-policy.js";
export { WHATSAPP_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js";
export * from "./src/text-runtime.js";
export type * from "./src/auto-reply/types.js";
export type * from "./src/inbound/types.js";
export {
@@ -14,6 +15,8 @@ export {
isWhatsAppGroupJid,
normalizeWhatsAppAllowFromEntries,
isWhatsAppUserTarget,
looksLikeWhatsAppTargetId,
normalizeWhatsAppMessagingTarget,
normalizeWhatsAppTarget,
} from "./src/normalize-target.js";
export { resolveWhatsAppGroupIntroHint } from "./src/runtime-api.js";

View File

@@ -0,0 +1,53 @@
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export const unsupportedSecretRefSurfacePatterns = [
"channels.whatsapp.creds.json",
"channels.whatsapp.accounts.*.creds.json",
] as const;
export { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
export { createWhatsAppPollFixture, expectWhatsAppPollSent } from "./src/outbound-test-support.js";
export { whatsappCommandPolicy } from "./src/command-policy.js";
export { resolveLegacyGroupSessionKey } from "./src/group-session-contract.js";
export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./src/normalize-target.js";
export { __testing as whatsappAccessControlTesting } from "./src/inbound/access-control.js";
export function collectUnsupportedSecretRefConfigCandidates(
raw: unknown,
): UnsupportedSecretRefConfigCandidate[] {
if (!isRecord(raw.channels) || !isRecord(raw.channels.whatsapp)) {
return [];
}
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
const whatsapp = raw.channels.whatsapp;
const creds = isRecord(whatsapp.creds) ? whatsapp.creds : null;
if (creds) {
candidates.push({
path: "channels.whatsapp.creds.json",
value: creds.json,
});
}
const accounts = isRecord(whatsapp.accounts) ? whatsapp.accounts : null;
if (!accounts) {
return candidates;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account) || !isRecord(account.creds)) {
continue;
}
candidates.push({
path: `channels.whatsapp.accounts.${accountId}.creds.json`,
value: account.creds.json,
});
}
return candidates;
}

View File

@@ -6,11 +6,10 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { info, success } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { WebChannel } from "openclaw/plugin-sdk/text-runtime";
import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
import { resolveOAuthDir } from "./auth-store.runtime.js";
import { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath } from "./creds-files.js";
import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js";
import { resolveUserPath, type WebChannel } from "./text-runtime.js";
export { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath };
export function resolveDefaultWebAuthDir(): string {

View File

@@ -6,12 +6,11 @@ import {
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
import { markdownToWhatsApp } from "openclaw/plugin-sdk/text-runtime";
import { sleep } from "openclaw/plugin-sdk/text-runtime";
import { loadWebMedia } from "../media.js";
import { newConnectionId } from "../reconnect.js";
import { formatError } from "../session.js";
import { convertMarkdownTables, sleep } from "../text-runtime.js";
import { markdownToWhatsApp } from "../text-runtime.js";
import { whatsappOutboundLog } from "./loggers.js";
import type { WebInboundMsg } from "./types.js";
import { elide } from "./util.js";

View File

@@ -1,6 +1,5 @@
import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-inbound";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { isSelfChatMode, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import {
getComparableIdentityValues,
getMentionIdentities,
@@ -8,6 +7,7 @@ import {
identitiesOverlap,
type WhatsAppIdentity,
} from "../identity.js";
import { isSelfChatMode, normalizeE164 } from "../text-runtime.js";
import type { WebInboundMsg } from "./types.js";
export type MentionConfig = {

View File

@@ -2,4 +2,4 @@ export { resolveMentionGating } from "openclaw/plugin-sdk/channel-inbound";
export { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
export { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history";
export { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime";
export { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
export { normalizeE164 } from "../../text-runtime.js";

View File

@@ -1,4 +1,4 @@
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { normalizeE164 } from "../../text-runtime.js";
function appendNormalizedUnique(entries: Iterable<string>, seen: Set<string>, ordered: string[]) {
for (const entry of entries) {

View File

@@ -2,13 +2,13 @@ import {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
} from "openclaw/plugin-sdk/security-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import {
getComparableIdentityValues,
getReplyContext,
type WhatsAppIdentity,
type WhatsAppReplyContext,
} from "../../identity.js";
import { normalizeE164 } from "../../text-runtime.js";
import type { WebInboundMsg } from "../types.js";
export type GroupHistoryEntry = {

View File

@@ -3,8 +3,8 @@ import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { buildGroupHistoryKey } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { getPrimaryIdentityId, getSenderIdentity } from "../../identity.js";
import { normalizeE164 } from "../../text-runtime.js";
import { loadConfig } from "../config.runtime.js";
import type { MentionConfig } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";

View File

@@ -1,5 +1,5 @@
import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { getSenderIdentity } from "../../identity.js";
import { jidToE164, normalizeE164 } from "../../text-runtime.js";
import type { WebInboundMsg } from "../types.js";
export function resolvePeerId(msg: WebInboundMsg) {

View File

@@ -36,4 +36,4 @@ export {
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
export { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
export { jidToE164, normalizeE164 } from "../../text-runtime.js";

View File

@@ -44,6 +44,7 @@ import {
loadWhatsAppChannelRuntime,
whatsappSetupWizardProxy,
} from "./shared.js";
import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js";
import { collectWhatsAppStatusIssues } from "./status-issues.js";
function parseWhatsAppExplicitTarget(raw: string) {
@@ -145,6 +146,10 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
).loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId);
},
},
lifecycle: {
detectLegacyStateMigrations: ({ oauthDir }) =>
detectWhatsAppLegacyStateMigrations({ oauthDir }),
},
heartbeat: {
checkReady: async ({ cfg, accountId, deps }) => {
if (cfg.web?.enabled === false) {

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