Files
adolf/extensions/imessage/src/accounts.ts
alvis bedb527145
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
Vendor OpenClaw source as Adolf fork baseline
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
2026-07-05 09:36:54 +00:00

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;
}