mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-23 15:11:42 +00:00
refactor(plugins): move extension seams into extensions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
8
extensions/anthropic/contract-api.ts
Normal file
8
extensions/anthropic/contract-api.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
} from "./stream-wrappers.js";
|
||||
16
extensions/discord/contract-api.ts
Normal file
16
extensions/discord/contract-api.ts
Normal 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";
|
||||
292
extensions/discord/src/doctor-contract.ts
Normal file
292
extensions/discord/src/doctor-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
51
extensions/discord/src/preview-streaming.ts
Normal file
51
extensions/discord/src/preview-streaming.ts
Normal 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";
|
||||
}
|
||||
140
extensions/discord/src/secret-config-contract.ts
Normal file
140
extensions/discord/src/secret-config-contract.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
46
extensions/discord/src/security-contract.ts
Normal file
46
extensions/discord/src/security-contract.ts
Normal 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;
|
||||
}
|
||||
3
extensions/discord/src/session-contract.ts
Normal file
3
extensions/discord/src/session-contract.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined {
|
||||
return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined;
|
||||
}
|
||||
6
extensions/feishu/contract-api.ts
Normal file
6
extensions/feishu/contract-api.ts
Normal 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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
13
extensions/feishu/src/message-action-contract.ts
Normal file
13
extensions/feishu/src/message-action-contract.ts
Normal 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>>;
|
||||
140
extensions/feishu/src/secret-contract.ts
Normal file
140
extensions/feishu/src/secret-contract.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
70
extensions/feishu/src/security-audit.ts
Normal file
70
extensions/feishu/src/security-audit.ts
Normal 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.",
|
||||
},
|
||||
];
|
||||
}
|
||||
4
extensions/googlechat/contract-api.ts
Normal file
4
extensions/googlechat/contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -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),
|
||||
|
||||
57
extensions/googlechat/src/doctor.ts
Normal file
57
extensions/googlechat/src/doctor.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
156
extensions/googlechat/src/secret-contract.ts
Normal file
156
extensions/googlechat/src/secret-contract.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
10
extensions/imessage/contract-api.ts
Normal file
10
extensions/imessage/contract-api.ts
Normal 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";
|
||||
@@ -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,
|
||||
|
||||
31
extensions/imessage/src/media-contract.ts
Normal file
31
extensions/imessage/src/media-contract.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
111
extensions/imessage/src/test-plugin.ts
Normal file
111
extensions/imessage/src/test-plugin.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
53
extensions/irc/src/doctor.ts
Normal file
53
extensions/irc/src/doctor.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
5
extensions/line/contract-api.ts
Normal file
5
extensions/line/contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "./src/accounts.js";
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
317
extensions/line/src/reply-payload-transform.ts
Normal file
317
extensions/line/src/reply-payload-transform.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
16
extensions/matrix/contract-api.ts
Normal file
16
extensions/matrix/contract-api.ts
Normal 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";
|
||||
@@ -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[] = [];
|
||||
|
||||
1
extensions/matrix/runtime-heavy-api.ts
Normal file
1
extensions/matrix/runtime-heavy-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src/runtime-heavy-api.js";
|
||||
529
extensions/matrix/src/legacy-crypto.ts
Normal file
529
extensions/matrix/src/legacy-crypto.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
156
extensions/matrix/src/legacy-state.ts
Normal file
156
extensions/matrix/src/legacy-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -6,4 +6,4 @@ export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
|
||||
} from "./runtime-heavy-api.js";
|
||||
|
||||
@@ -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";
|
||||
|
||||
326
extensions/matrix/src/migration-config.ts
Normal file
326
extensions/matrix/src/migration-config.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
148
extensions/matrix/src/migration-snapshot.ts
Normal file
148
extensions/matrix/src/migration-snapshot.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
7
extensions/matrix/src/runtime-heavy-api.ts
Normal file
7
extensions/matrix/src/runtime-heavy-api.ts
Normal 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";
|
||||
169
extensions/matrix/src/secret-contract.ts
Normal file
169
extensions/matrix/src/secret-contract.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
89
extensions/matrix/src/setup-contract.ts
Normal file
89
extensions/matrix/src/setup-contract.ts
Normal 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;
|
||||
}
|
||||
42
extensions/matrix/src/test-helpers.ts
Normal file
42
extensions/matrix/src/test-helpers.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
4
extensions/mattermost/contract-api.ts
Normal file
4
extensions/mattermost/contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
36
extensions/mattermost/src/doctor.ts
Normal file
36
extensions/mattermost/src/doctor.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
54
extensions/mattermost/src/secret-contract.ts
Normal file
54
extensions/mattermost/src/secret-contract.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
27
extensions/msteams/src/doctor.ts
Normal file
27
extensions/msteams/src/doctor.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
3
extensions/signal/contract-api.ts
Normal file
3
extensions/signal/contract-api.ts
Normal 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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
303
extensions/signal/src/install-signal-cli.ts
Normal file
303
extensions/signal/src/install-signal-cli.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
11
extensions/slack/contract-api.ts
Normal file
11
extensions/slack/contract-api.ts
Normal 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";
|
||||
167
extensions/slack/src/doctor-contract.ts
Normal file
167
extensions/slack/src/doctor-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
50
extensions/slack/src/outbound-payload-harness.ts
Normal file
50
extensions/slack/src/outbound-payload-harness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
158
extensions/slack/src/secret-contract.ts
Normal file
158
extensions/slack/src/secret-contract.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
21
extensions/slack/src/security-doctor.ts
Normal file
21
extensions/slack/src/security-doctor.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
100
extensions/slack/src/streaming-compat.ts
Normal file
100
extensions/slack/src/streaming-compat.ts
Normal 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}).`;
|
||||
}
|
||||
1
extensions/synology-chat/contract-api.ts
Normal file
1
extensions/synology-chat/contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { collectSynologyChatSecurityAuditFindings } from "./src/security-audit.js";
|
||||
@@ -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";
|
||||
|
||||
13
extensions/telegram/contract-api.ts
Normal file
13
extensions/telegram/contract-api.ts
Normal 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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
199
extensions/telegram/src/doctor-contract.ts
Normal file
199
extensions/telegram/src/doctor-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
54
extensions/telegram/src/preview-streaming.ts
Normal file
54
extensions/telegram/src/preview-streaming.ts
Normal 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";
|
||||
}
|
||||
117
extensions/telegram/src/secret-contract.ts
Normal file
117
extensions/telegram/src/secret-contract.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
1
extensions/telegram/src/setup-contract.ts
Normal file
1
extensions/telegram/src/setup-contract.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const singleAccountKeysToMove = ["streaming"];
|
||||
36
extensions/telegram/src/state-migrations.ts
Normal file
36
extensions/telegram/src/state-migrations.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
53
extensions/whatsapp/contract-api.ts
Normal file
53
extensions/whatsapp/contract-api.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user