Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
267 lines
9.8 KiB
TypeScript
267 lines
9.8 KiB
TypeScript
// Imessage plugin module implements accounts behavior.
|
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
import {
|
|
createAccountListHelpers,
|
|
normalizeAccountId,
|
|
resolveMergedAccountConfig,
|
|
type OpenClawConfig,
|
|
} from "openclaw/plugin-sdk/account-resolution";
|
|
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
|
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import type { IMessageAccountConfig } from "./account-types.js";
|
|
|
|
export type ResolvedIMessageAccount = {
|
|
accountId: string;
|
|
enabled: boolean;
|
|
name?: string;
|
|
config: IMessageAccountConfig;
|
|
configured: boolean;
|
|
};
|
|
|
|
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage", {
|
|
implicitDefaultAccount: {
|
|
channelKeys: ["cliPath", "dbPath"],
|
|
},
|
|
});
|
|
export const listIMessageAccountIds = listAccountIds;
|
|
export const resolveDefaultIMessageAccountId = resolveDefaultAccountId;
|
|
|
|
function resolveIMessageAccountConfig(
|
|
cfg: OpenClawConfig,
|
|
accountId: string,
|
|
): IMessageAccountConfig | undefined {
|
|
return resolveAccountEntry(cfg.channels?.imessage?.accounts, accountId);
|
|
}
|
|
|
|
type IMessageStreamingConfig = NonNullable<IMessageAccountConfig["streaming"]>;
|
|
|
|
function asStreamingConfigObject(value: unknown): IMessageStreamingConfig | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as IMessageStreamingConfig)
|
|
: undefined;
|
|
}
|
|
|
|
function asOwnBooleanProperty(value: unknown, key: string): boolean | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
return Object.hasOwn(record, key) && typeof record[key] === "boolean" ? record[key] : undefined;
|
|
}
|
|
|
|
function mergeIMessageStreamingConfig(
|
|
base: unknown,
|
|
account: unknown,
|
|
accountFlatBlockStreaming: unknown,
|
|
): IMessageStreamingConfig | undefined {
|
|
const baseConfig = asStreamingConfigObject(base);
|
|
const accountConfig = asStreamingConfigObject(account);
|
|
const accountBlockEnabled = asOwnBooleanProperty(accountConfig?.block, "enabled");
|
|
const flatAccountBlockEnabled =
|
|
accountBlockEnabled === undefined && typeof accountFlatBlockStreaming === "boolean"
|
|
? accountFlatBlockStreaming
|
|
: undefined;
|
|
const applyFlatAccountBlockEnabled = (
|
|
config: IMessageStreamingConfig | undefined,
|
|
): IMessageStreamingConfig | undefined => {
|
|
if (flatAccountBlockEnabled === undefined || config === undefined) {
|
|
return config;
|
|
}
|
|
return {
|
|
...config,
|
|
block: {
|
|
...config.block,
|
|
enabled: flatAccountBlockEnabled,
|
|
},
|
|
};
|
|
};
|
|
if (!baseConfig || !accountConfig) {
|
|
return applyFlatAccountBlockEnabled(accountConfig ?? baseConfig);
|
|
}
|
|
return applyFlatAccountBlockEnabled({
|
|
...baseConfig,
|
|
...accountConfig,
|
|
...(baseConfig.block || accountConfig.block
|
|
? {
|
|
block: {
|
|
...baseConfig.block,
|
|
...accountConfig.block,
|
|
...(baseConfig.block?.coalesce || accountConfig.block?.coalesce
|
|
? {
|
|
coalesce: {
|
|
...baseConfig.block?.coalesce,
|
|
...accountConfig.block?.coalesce,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig {
|
|
const accountConfig = resolveIMessageAccountConfig(cfg, accountId);
|
|
const merged = resolveMergedAccountConfig<IMessageAccountConfig>({
|
|
channelConfig: cfg.channels?.imessage as IMessageAccountConfig | undefined,
|
|
accounts: cfg.channels?.imessage?.accounts as
|
|
| Record<string, Partial<IMessageAccountConfig>>
|
|
| undefined,
|
|
accountId,
|
|
});
|
|
const streaming = mergeIMessageStreamingConfig(
|
|
(cfg.channels?.imessage as Record<string, unknown> | undefined)?.streaming,
|
|
(accountConfig as Record<string, unknown> | undefined)?.streaming,
|
|
(accountConfig as Record<string, unknown> | undefined)?.blockStreaming,
|
|
);
|
|
return streaming !== undefined ? ({ ...merged, streaming } as IMessageAccountConfig) : merged;
|
|
}
|
|
|
|
export function resolveIMessageAccount(params: {
|
|
cfg: OpenClawConfig;
|
|
accountId?: string | null;
|
|
}): ResolvedIMessageAccount {
|
|
const accountId = normalizeAccountId(
|
|
params.accountId ?? resolveDefaultIMessageAccountId(params.cfg),
|
|
);
|
|
const baseEnabled = params.cfg.channels?.imessage?.enabled !== false;
|
|
const merged = mergeIMessageAccountConfig(params.cfg, accountId);
|
|
const accountEnabled = merged.enabled !== false;
|
|
const configured = Boolean(
|
|
merged.cliPath?.trim() ||
|
|
merged.dbPath?.trim() ||
|
|
merged.service ||
|
|
merged.sendTransport ||
|
|
merged.region?.trim() ||
|
|
(merged.allowFrom && merged.allowFrom.length > 0) ||
|
|
(merged.groupAllowFrom && merged.groupAllowFrom.length > 0) ||
|
|
merged.dmPolicy ||
|
|
merged.groupPolicy ||
|
|
typeof merged.includeAttachments === "boolean" ||
|
|
(merged.attachmentRoots && merged.attachmentRoots.length > 0) ||
|
|
(merged.remoteAttachmentRoots && merged.remoteAttachmentRoots.length > 0) ||
|
|
typeof merged.mediaMaxMb === "number" ||
|
|
typeof merged.textChunkLimit === "number" ||
|
|
(merged.groups && Object.keys(merged.groups).length > 0),
|
|
);
|
|
return {
|
|
accountId,
|
|
enabled: baseEnabled && accountEnabled,
|
|
name: normalizeOptionalString(merged.name),
|
|
config: merged,
|
|
configured,
|
|
};
|
|
}
|
|
|
|
function normalizeIMessageCliPath(value: string | undefined | null): string {
|
|
return value?.trim() || "imsg";
|
|
}
|
|
|
|
function normalizeIMessageDbPath(value: string | undefined | null): string {
|
|
return value?.trim() ?? "";
|
|
}
|
|
|
|
// Stable signature for the local Messages backend an iMessage account targets.
|
|
// Two enabled accounts that share a signature watch the same source, which
|
|
// caused duplicate inbound handling in openclaw/openclaw#65141.
|
|
export function resolveIMessageAccountSourceSignature(account: ResolvedIMessageAccount): string {
|
|
return JSON.stringify([
|
|
normalizeIMessageCliPath(account.config.cliPath),
|
|
normalizeIMessageDbPath(account.config.dbPath),
|
|
]);
|
|
}
|
|
|
|
function resolveIMessageAccountSourceOwner(params: {
|
|
cfg: OpenClawConfig;
|
|
signature: string;
|
|
}): string | undefined {
|
|
// Prefer an explicit named account over the implicit "default" so that
|
|
// bindings tied to the named account keep working (openclaw/openclaw#65141).
|
|
let defaultOwner: string | undefined;
|
|
for (const candidateAccountId of listIMessageAccountIds(params.cfg)) {
|
|
const candidate = resolveIMessageAccount({
|
|
cfg: params.cfg,
|
|
accountId: candidateAccountId,
|
|
});
|
|
if (!candidate.enabled) {
|
|
continue;
|
|
}
|
|
if (resolveIMessageAccountSourceSignature(candidate) !== params.signature) {
|
|
continue;
|
|
}
|
|
if (candidate.accountId === DEFAULT_ACCOUNT_ID) {
|
|
defaultOwner ??= candidate.accountId;
|
|
continue;
|
|
}
|
|
return candidate.accountId;
|
|
}
|
|
return defaultOwner;
|
|
}
|
|
|
|
/**
|
|
* Returns the owner account id when `account` is an enabled duplicate of
|
|
* another enabled account that targets the same local Messages source. Used
|
|
* by the iMessage gateway lifecycle to skip starting redundant `imsg rpc`
|
|
* watchers (openclaw/openclaw#65141) without otherwise marking the duplicate
|
|
* disabled — outbound selection, status surfaces, and capability listings
|
|
* keep treating both accounts normally.
|
|
*/
|
|
export function resolveIMessageDuplicateSourceOwner(params: {
|
|
cfg: OpenClawConfig;
|
|
account: ResolvedIMessageAccount;
|
|
}): string | undefined {
|
|
if (!params.account.enabled) {
|
|
return undefined;
|
|
}
|
|
const owner = resolveIMessageAccountSourceOwner({
|
|
cfg: params.cfg,
|
|
signature: resolveIMessageAccountSourceSignature(params.account),
|
|
});
|
|
return owner && owner !== params.account.accountId ? owner : undefined;
|
|
}
|
|
|
|
export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] {
|
|
return listIMessageAccountIds(cfg)
|
|
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
|
|
.filter((account) => account.enabled);
|
|
}
|
|
|
|
export function collectIMessageDuplicateAccountSourceWarnings(params: {
|
|
cfg: OpenClawConfig;
|
|
}): string[] {
|
|
const groups = new Map<string, ResolvedIMessageAccount[]>();
|
|
for (const accountId of listIMessageAccountIds(params.cfg)) {
|
|
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
|
|
if (!account.enabled) {
|
|
continue;
|
|
}
|
|
const signature = resolveIMessageAccountSourceSignature(account);
|
|
const existing = groups.get(signature);
|
|
if (existing) {
|
|
existing.push(account);
|
|
} else {
|
|
groups.set(signature, [account]);
|
|
}
|
|
}
|
|
const warnings: string[] = [];
|
|
for (const collisions of groups.values()) {
|
|
if (collisions.length < 2) {
|
|
continue;
|
|
}
|
|
const ownerId = resolveIMessageAccountSourceOwner({
|
|
cfg: params.cfg,
|
|
signature: resolveIMessageAccountSourceSignature(collisions[0]),
|
|
});
|
|
const owner = collisions.find((a) => a.accountId === ownerId) ?? collisions[0];
|
|
const duplicates = collisions.filter((a) => a.accountId !== owner.accountId);
|
|
const dupIds = duplicates.map((a) => `"${a.accountId}"`).join(", ");
|
|
const cliPath = normalizeIMessageCliPath(owner.config.cliPath);
|
|
const dbPath = normalizeIMessageDbPath(owner.config.dbPath);
|
|
const where = dbPath ? `cliPath=${cliPath}, dbPath=${dbPath}` : `cliPath=${cliPath}`;
|
|
warnings.push(
|
|
`- channels.imessage: accounts "${owner.accountId}" and ${dupIds} watch the same local Messages source (${where}). OpenClaw runs one watcher (owner: "${owner.accountId}") and idles the duplicate; the other accounts stay enabled for outbound sends and status. Inbound messages arrive tagged with accountId="${owner.accountId}", so bindings pinned to ${dupIds} should be re-pointed at "${owner.accountId}" (or set "enabled": false on "${owner.accountId}" to flip ownership). Set "enabled": false on the unused duplicates to silence this warning.`,
|
|
);
|
|
}
|
|
return warnings;
|
|
}
|