refactor: move iMessage channel to extensions/imessage (#45539)
This commit is contained in:
parent
4540c6b3bc
commit
0ce23dc62d
70
extensions/imessage/src/accounts.ts
Normal file
70
extensions/imessage/src/accounts.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { IMessageAccountConfig } from "../../../src/config/types.js";
|
||||
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
|
||||
export type ResolvedIMessageAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
config: IMessageAccountConfig;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage");
|
||||
export const listIMessageAccountIds = listAccountIds;
|
||||
export const resolveDefaultIMessageAccountId = resolveDefaultAccountId;
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): IMessageAccountConfig | undefined {
|
||||
return resolveAccountEntry(cfg.channels?.imessage?.accounts, accountId);
|
||||
}
|
||||
|
||||
function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.imessage ??
|
||||
{}) as IMessageAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveIMessageAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedIMessageAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
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.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: merged.name?.trim() || undefined,
|
||||
config: merged,
|
||||
configured,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] {
|
||||
return listIMessageAccountIds(cfg)
|
||||
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
255
extensions/imessage/src/client.ts
Normal file
255
extensions/imessage/src/client.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { createInterface, type Interface } from "node:readline";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { resolveUserPath } from "../../../src/utils.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
export type IMessageRpcError = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type IMessageRpcResponse<T> = {
|
||||
jsonrpc?: string;
|
||||
id?: string | number | null;
|
||||
result?: T;
|
||||
error?: IMessageRpcError;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
export type IMessageRpcNotification = {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
export type IMessageRpcClientOptions = {
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
onNotification?: (msg: IMessageRpcNotification) => void;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer?: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
function isTestEnv(): boolean {
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return true;
|
||||
}
|
||||
const vitest = process.env.VITEST?.trim().toLowerCase();
|
||||
return Boolean(vitest);
|
||||
}
|
||||
|
||||
export class IMessageRpcClient {
|
||||
private readonly cliPath: string;
|
||||
private readonly dbPath?: string;
|
||||
private readonly runtime?: RuntimeEnv;
|
||||
private readonly onNotification?: (msg: IMessageRpcNotification) => void;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly closed: Promise<void>;
|
||||
private closedResolve: (() => void) | null = null;
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
private reader: Interface | null = null;
|
||||
private nextId = 1;
|
||||
|
||||
constructor(opts: IMessageRpcClientOptions = {}) {
|
||||
this.cliPath = opts.cliPath?.trim() || "imsg";
|
||||
this.dbPath = opts.dbPath?.trim() ? resolveUserPath(opts.dbPath) : undefined;
|
||||
this.runtime = opts.runtime;
|
||||
this.onNotification = opts.onNotification;
|
||||
this.closed = new Promise((resolve) => {
|
||||
this.closedResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.child) {
|
||||
return;
|
||||
}
|
||||
if (isTestEnv()) {
|
||||
throw new Error("Refusing to start imsg rpc in test environment; mock iMessage RPC client");
|
||||
}
|
||||
const args = ["rpc"];
|
||||
if (this.dbPath) {
|
||||
args.push("--db", this.dbPath);
|
||||
}
|
||||
const child = spawn(this.cliPath, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
this.child = child;
|
||||
this.reader = createInterface({ input: child.stdout });
|
||||
|
||||
this.reader.on("line", (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
this.handleLine(trimmed);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const lines = chunk.toString().split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
this.runtime?.error?.(`imsg rpc: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
this.failAll(err instanceof Error ? err : new Error(String(err)));
|
||||
this.closedResolve?.();
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
||||
this.failAll(new Error(`imsg rpc exited (${reason})`));
|
||||
} else {
|
||||
this.failAll(new Error("imsg rpc closed"));
|
||||
}
|
||||
this.closedResolve?.();
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.child) {
|
||||
return;
|
||||
}
|
||||
this.reader?.close();
|
||||
this.reader = null;
|
||||
this.child.stdin?.end();
|
||||
const child = this.child;
|
||||
this.child = null;
|
||||
|
||||
await Promise.race([
|
||||
this.closed,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
resolve();
|
||||
}, 500);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async waitForClose(): Promise<void> {
|
||||
await this.closed;
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
opts?: { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
if (!this.child || !this.child.stdin) {
|
||||
throw new Error("imsg rpc not running");
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params: params ?? {},
|
||||
};
|
||||
const line = `${JSON.stringify(payload)}\n`;
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
||||
|
||||
const response = new Promise<T>((resolve, reject) => {
|
||||
const key = String(id);
|
||||
const timer =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(key);
|
||||
reject(new Error(`imsg rpc timeout (${method})`));
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
this.pending.set(key, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
});
|
||||
|
||||
this.child.stdin.write(line);
|
||||
return await response;
|
||||
}
|
||||
|
||||
private handleLine(line: string) {
|
||||
let parsed: IMessageRpcResponse<unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.id !== undefined && parsed.id !== null) {
|
||||
const key = String(parsed.id);
|
||||
const pending = this.pending.get(key);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
this.pending.delete(key);
|
||||
|
||||
if (parsed.error) {
|
||||
const baseMessage = parsed.error.message ?? "imsg rpc error";
|
||||
const details = parsed.error.data;
|
||||
const code = parsed.error.code;
|
||||
const suffixes = [] as string[];
|
||||
if (typeof code === "number") {
|
||||
suffixes.push(`code=${code}`);
|
||||
}
|
||||
if (details !== undefined) {
|
||||
const detailText =
|
||||
typeof details === "string" ? details : JSON.stringify(details, null, 2);
|
||||
if (detailText) {
|
||||
suffixes.push(detailText);
|
||||
}
|
||||
}
|
||||
const msg = suffixes.length > 0 ? `${baseMessage}: ${suffixes.join(" ")}` : baseMessage;
|
||||
pending.reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
pending.resolve(parsed.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.method) {
|
||||
this.onNotification?.({
|
||||
method: parsed.method,
|
||||
params: parsed.params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(err: Error) {
|
||||
for (const [key, pending] of this.pending.entries()) {
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
pending.reject(err);
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createIMessageRpcClient(
|
||||
opts: IMessageRpcClientOptions = {},
|
||||
): Promise<IMessageRpcClient> {
|
||||
const client = new IMessageRpcClient(opts);
|
||||
await client.start();
|
||||
return client;
|
||||
}
|
||||
2
extensions/imessage/src/constants.ts
Normal file
2
extensions/imessage/src/constants.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/** Default timeout for iMessage probe/RPC operations (10 seconds). */
|
||||
export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
buildIMessageInboundContext,
|
||||
resolveIMessageInboundDecision,
|
||||
2
extensions/imessage/src/monitor.ts
Normal file
2
extensions/imessage/src/monitor.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { monitorIMessageProvider } from "./monitor/monitor-provider.js";
|
||||
export type { MonitorIMessageOpts } from "./monitor/types.js";
|
||||
34
extensions/imessage/src/monitor/abort-handler.ts
Normal file
34
extensions/imessage/src/monitor/abort-handler.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export type IMessageMonitorClient = {
|
||||
request: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function attachIMessageMonitorAbortHandler(params: {
|
||||
abortSignal?: AbortSignal;
|
||||
client: IMessageMonitorClient;
|
||||
getSubscriptionId: () => number | null;
|
||||
}): () => void {
|
||||
const abort = params.abortSignal;
|
||||
if (!abort) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
const subscriptionId = params.getSubscriptionId();
|
||||
if (subscriptionId) {
|
||||
void params.client
|
||||
.request("watch.unsubscribe", {
|
||||
subscription: subscriptionId,
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore disconnect errors during shutdown.
|
||||
});
|
||||
}
|
||||
void params.client.stop().catch(() => {
|
||||
// Ignore disconnect errors during shutdown.
|
||||
});
|
||||
};
|
||||
|
||||
abort.addEventListener("abort", onAbort, { once: true });
|
||||
return () => abort.removeEventListener("abort", onAbort);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
|
||||
const sendMessageIMessageMock = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ messageId: "imsg-1" }),
|
||||
@ -14,20 +14,20 @@ vi.mock("../send.js", () => ({
|
||||
sendMessageIMessageMock(to, message, opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/chunk.js", () => ({
|
||||
vi.mock("../../../../src/auto-reply/chunk.js", () => ({
|
||||
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
|
||||
resolveChunkMode: () => resolveChunkModeMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
vi.mock("../../../../src/config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/markdown-tables.js", () => ({
|
||||
vi.mock("../../../../src/config/markdown-tables.js", () => ({
|
||||
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../markdown/tables.js", () => ({
|
||||
vi.mock("../../../../src/markdown/tables.js", () => ({
|
||||
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
|
||||
}));
|
||||
|
||||
70
extensions/imessage/src/monitor/deliver.ts
Normal file
70
extensions/imessage/src/monitor/deliver.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { chunkTextWithMode, resolveChunkMode } from "../../../../src/auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../../../src/auto-reply/types.js";
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../../../../src/markdown/tables.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import type { createIMessageRpcClient } from "../client.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import type { SentMessageCache } from "./echo-cache.js";
|
||||
import { sanitizeOutboundText } from "./sanitize-outbound.js";
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
sentMessageCache?: Pick<SentMessageCache, "remember">;
|
||||
}) {
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
|
||||
params;
|
||||
const scope = `${accountId ?? ""}:${target}`;
|
||||
const cfg = loadConfig();
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const rawText = sanitizeOutboundText(payload.text ?? "");
|
||||
const text = convertMarkdownTables(rawText, tableMode);
|
||||
if (!text && mediaList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
sentMessageCache?.remember(scope, { text });
|
||||
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||
const sent = await sendMessageIMessage(target, chunk, {
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId });
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
const sent = await sendMessageIMessage(target, caption, {
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
sentMessageCache?.remember(scope, {
|
||||
text: caption || undefined,
|
||||
messageId: sent.messageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`imessage: delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
87
extensions/imessage/src/monitor/echo-cache.ts
Normal file
87
extensions/imessage/src/monitor/echo-cache.ts
Normal file
@ -0,0 +1,87 @@
|
||||
export type SentMessageLookup = {
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export type SentMessageCache = {
|
||||
remember: (scope: string, lookup: SentMessageLookup) => void;
|
||||
has: (scope: string, lookup: SentMessageLookup) => boolean;
|
||||
};
|
||||
|
||||
// Keep the text fallback short so repeated user replies like "ok" are not
|
||||
// suppressed for long; delayed reflections should match the stronger message-id key.
|
||||
const SENT_MESSAGE_TEXT_TTL_MS = 5_000;
|
||||
const SENT_MESSAGE_ID_TTL_MS = 60_000;
|
||||
|
||||
function normalizeEchoTextKey(text: string | undefined): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const normalized = text.replace(/\r\n?/g, "\n").trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeEchoMessageIdKey(messageId: string | undefined): string | null {
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
const normalized = messageId.trim();
|
||||
if (!normalized || normalized === "ok" || normalized === "unknown") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
class DefaultSentMessageCache implements SentMessageCache {
|
||||
private textCache = new Map<string, number>();
|
||||
private messageIdCache = new Map<string, number>();
|
||||
|
||||
remember(scope: string, lookup: SentMessageLookup): void {
|
||||
const textKey = normalizeEchoTextKey(lookup.text);
|
||||
if (textKey) {
|
||||
this.textCache.set(`${scope}:${textKey}`, Date.now());
|
||||
}
|
||||
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
||||
if (messageIdKey) {
|
||||
this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now());
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
has(scope: string, lookup: SentMessageLookup): boolean {
|
||||
this.cleanup();
|
||||
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
||||
if (messageIdKey) {
|
||||
const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
|
||||
if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const textKey = normalizeEchoTextKey(lookup.text);
|
||||
if (textKey) {
|
||||
const textTimestamp = this.textCache.get(`${scope}:${textKey}`);
|
||||
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of this.textCache.entries()) {
|
||||
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
|
||||
this.textCache.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, timestamp] of this.messageIdCache.entries()) {
|
||||
if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) {
|
||||
this.messageIdCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSentMessageCache(): SentMessageCache {
|
||||
return new DefaultSentMessageCache();
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { sanitizeTerminalText } from "../../terminal/safe-text.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js";
|
||||
import {
|
||||
describeIMessageEchoDropLog,
|
||||
resolveIMessageInboundDecision,
|
||||
525
extensions/imessage/src/monitor/inbound-processing.ts
Normal file
525
extensions/imessage/src/monitor/inbound-processing.ts
Normal file
@ -0,0 +1,525 @@
|
||||
import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js";
|
||||
import {
|
||||
formatInboundEnvelope,
|
||||
formatInboundFromLabel,
|
||||
resolveEnvelopeFormatOptions,
|
||||
type EnvelopeFormatOptions,
|
||||
} from "../../../../src/auto-reply/envelope.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "../../../../src/auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
} from "../../../../src/auto-reply/reply/mentions.js";
|
||||
import { resolveDualTextControlCommandGate } from "../../../../src/channels/command-gating.js";
|
||||
import { logInboundDrop } from "../../../../src/channels/logging.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "../../../../src/config/group-policy.js";
|
||||
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../../../../src/security/dm-policy-shared.js";
|
||||
import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js";
|
||||
import { truncateUtf16Safe } from "../../../../src/utils.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
isAllowedIMessageSender,
|
||||
normalizeIMessageHandle,
|
||||
} from "../targets.js";
|
||||
import { detectReflectedContent } from "./reflection-guard.js";
|
||||
import type { SelfChatCache } from "./self-chat-cache.js";
|
||||
import type { MonitorIMessageOpts, IMessagePayload } from "./types.js";
|
||||
|
||||
type IMessageReplyContext = {
|
||||
id?: string;
|
||||
body: string;
|
||||
sender?: string;
|
||||
};
|
||||
|
||||
function normalizeReplyField(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeReplyContext(message: IMessagePayload): IMessageReplyContext | null {
|
||||
const body = normalizeReplyField(message.reply_to_text);
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
const id = normalizeReplyField(message.reply_to_id);
|
||||
const sender = normalizeReplyField(message.reply_to_sender);
|
||||
return { body, id, sender };
|
||||
}
|
||||
|
||||
export type IMessageInboundDispatchDecision = {
|
||||
kind: "dispatch";
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
chatGuid?: string;
|
||||
chatIdentifier?: string;
|
||||
groupId?: string;
|
||||
historyKey?: string;
|
||||
sender: string;
|
||||
senderNormalized: string;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
bodyText: string;
|
||||
createdAt?: number;
|
||||
replyContext: IMessageReplyContext | null;
|
||||
effectiveWasMentioned: boolean;
|
||||
commandAuthorized: boolean;
|
||||
// Used for allowlist checks for control commands.
|
||||
effectiveDmAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
};
|
||||
|
||||
export type IMessageInboundDecision =
|
||||
| { kind: "drop"; reason: string }
|
||||
| { kind: "pairing"; senderId: string }
|
||||
| IMessageInboundDispatchDecision;
|
||||
|
||||
export function resolveIMessageInboundDecision(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
message: IMessagePayload;
|
||||
opts?: Pick<MonitorIMessageOpts, "requireMention">;
|
||||
messageText: string;
|
||||
bodyText: string;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
groupPolicy: string;
|
||||
dmPolicy: string;
|
||||
storeAllowFrom: string[];
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean };
|
||||
selfChatCache?: SelfChatCache;
|
||||
logVerbose?: (msg: string) => void;
|
||||
}): IMessageInboundDecision {
|
||||
const senderRaw = params.message.sender ?? "";
|
||||
const sender = senderRaw.trim();
|
||||
if (!sender) {
|
||||
return { kind: "drop", reason: "missing sender" };
|
||||
}
|
||||
const senderNormalized = normalizeIMessageHandle(sender);
|
||||
const chatId = params.message.chat_id ?? undefined;
|
||||
const chatGuid = params.message.chat_guid ?? undefined;
|
||||
const chatIdentifier = params.message.chat_identifier ?? undefined;
|
||||
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
|
||||
|
||||
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
|
||||
const groupListPolicy = groupIdCandidate
|
||||
? resolveChannelGroupPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
groupId: groupIdCandidate,
|
||||
})
|
||||
: {
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
groupConfig: undefined,
|
||||
defaultConfig: undefined,
|
||||
};
|
||||
|
||||
// If the owner explicitly configures a chat_id under imessage.groups, treat that thread as a
|
||||
// "group" for permission gating + session isolation, even when is_group=false.
|
||||
const treatAsGroupByConfig = Boolean(
|
||||
groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig,
|
||||
);
|
||||
const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig;
|
||||
const selfChatLookup = {
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
sender,
|
||||
text: params.bodyText,
|
||||
createdAt,
|
||||
};
|
||||
if (params.message.is_from_me) {
|
||||
params.selfChatCache?.remember(selfChatLookup);
|
||||
return { kind: "drop", reason: "from me" };
|
||||
}
|
||||
if (isGroup && !chatId) {
|
||||
return { kind: "drop", reason: "group without chat_id" };
|
||||
}
|
||||
|
||||
const groupId = isGroup ? groupIdCandidate : undefined;
|
||||
const accessDecision = resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: params.groupPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
storeAllowFrom: params.storeAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isAllowedIMessageSender({
|
||||
allowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
}),
|
||||
});
|
||||
const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom;
|
||||
const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
|
||||
|
||||
if (accessDecision.decision !== "allow") {
|
||||
if (isGroup) {
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
|
||||
params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)");
|
||||
return { kind: "drop", reason: "groupPolicy disabled" };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
|
||||
params.logVerbose?.(
|
||||
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
|
||||
);
|
||||
return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
|
||||
params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`);
|
||||
return { kind: "drop", reason: "not in groupAllowFrom" };
|
||||
}
|
||||
params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`);
|
||||
return { kind: "drop", reason: accessDecision.reason };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
|
||||
return { kind: "drop", reason: "dmPolicy disabled" };
|
||||
}
|
||||
if (accessDecision.decision === "pairing") {
|
||||
return { kind: "pairing", senderId: senderNormalized };
|
||||
}
|
||||
params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`);
|
||||
return { kind: "drop", reason: "dmPolicy blocked" };
|
||||
}
|
||||
|
||||
if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
|
||||
params.logVerbose?.(
|
||||
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,
|
||||
);
|
||||
return { kind: "drop", reason: "group id not in allowlist" };
|
||||
}
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? String(chatId ?? "unknown") : senderNormalized,
|
||||
},
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
|
||||
const messageText = params.messageText.trim();
|
||||
const bodyText = params.bodyText.trim();
|
||||
if (!bodyText) {
|
||||
return { kind: "drop", reason: "empty body" };
|
||||
}
|
||||
|
||||
if (
|
||||
params.selfChatCache?.has({
|
||||
...selfChatLookup,
|
||||
text: bodyText,
|
||||
})
|
||||
) {
|
||||
const preview = sanitizeTerminalText(truncateUtf16Safe(bodyText, 50));
|
||||
params.logVerbose?.(`imessage: dropping self-chat reflected duplicate: "${preview}"`);
|
||||
return { kind: "drop", reason: "self-chat echo" };
|
||||
}
|
||||
|
||||
// Echo detection: check if the received message matches a recently sent message.
|
||||
// Scope by conversation so same text in different chats is not conflated.
|
||||
const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined;
|
||||
if (params.echoCache && (messageText || inboundMessageId)) {
|
||||
const echoScope = buildIMessageEchoScope({
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
sender,
|
||||
});
|
||||
if (
|
||||
params.echoCache.has(echoScope, {
|
||||
text: messageText || undefined,
|
||||
messageId: inboundMessageId,
|
||||
})
|
||||
) {
|
||||
params.logVerbose?.(
|
||||
describeIMessageEchoDropLog({ messageText, messageId: inboundMessageId }),
|
||||
);
|
||||
return { kind: "drop", reason: "echo" };
|
||||
}
|
||||
}
|
||||
|
||||
// Reflection guard: drop inbound messages that contain assistant-internal
|
||||
// metadata markers. These indicate outbound content was reflected back as
|
||||
// inbound, which causes recursive echo amplification.
|
||||
const reflection = detectReflectedContent(messageText);
|
||||
if (reflection.isReflection) {
|
||||
params.logVerbose?.(
|
||||
`imessage: dropping reflected assistant content (markers: ${reflection.matchedLabels.join(", ")})`,
|
||||
);
|
||||
return { kind: "drop", reason: "reflected assistant content" };
|
||||
}
|
||||
|
||||
const replyContext = describeReplyContext(params.message);
|
||||
const historyKey = isGroup
|
||||
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
|
||||
: undefined;
|
||||
|
||||
const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true;
|
||||
const requireMention = resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
groupId,
|
||||
requireMentionOverride: params.opts?.requireMention,
|
||||
overrideOrder: "before-config",
|
||||
});
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom;
|
||||
const ownerAllowedForCommands =
|
||||
commandDmAllowFrom.length > 0
|
||||
? isAllowedIMessageSender({
|
||||
allowFrom: commandDmAllowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
})
|
||||
: false;
|
||||
const groupAllowedForCommands =
|
||||
effectiveGroupAllowFrom.length > 0
|
||||
? isAllowedIMessageSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
})
|
||||
: false;
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg);
|
||||
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
|
||||
useAccessGroups,
|
||||
primaryConfigured: commandDmAllowFrom.length > 0,
|
||||
primaryAllowed: ownerAllowedForCommands,
|
||||
secondaryConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
secondaryAllowed: groupAllowedForCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
if (isGroup && shouldBlock) {
|
||||
if (params.logVerbose) {
|
||||
logInboundDrop({
|
||||
log: params.logVerbose,
|
||||
channel: "imessage",
|
||||
reason: "control command (unauthorized)",
|
||||
target: sender,
|
||||
});
|
||||
}
|
||||
return { kind: "drop", reason: "control command (unauthorized)" };
|
||||
}
|
||||
|
||||
const shouldBypassMention =
|
||||
isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
|
||||
const effectiveWasMentioned = mentioned || shouldBypassMention;
|
||||
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
|
||||
params.logVerbose?.(`imessage: skipping group message (no mention)`);
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: params.groupHistories,
|
||||
historyKey: historyKey ?? "",
|
||||
limit: params.historyLimit,
|
||||
entry: historyKey
|
||||
? {
|
||||
sender: senderNormalized,
|
||||
body: bodyText,
|
||||
timestamp: createdAt,
|
||||
messageId: params.message.id ? String(params.message.id) : undefined,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return { kind: "drop", reason: "no mention" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "dispatch",
|
||||
isGroup,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
groupId,
|
||||
historyKey,
|
||||
sender,
|
||||
senderNormalized,
|
||||
route,
|
||||
bodyText,
|
||||
createdAt,
|
||||
replyContext,
|
||||
effectiveWasMentioned,
|
||||
commandAuthorized,
|
||||
effectiveDmAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIMessageInboundContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
decision: IMessageInboundDispatchDecision;
|
||||
message: IMessagePayload;
|
||||
envelopeOptions?: EnvelopeFormatOptions;
|
||||
previousTimestamp?: number;
|
||||
remoteHost?: string;
|
||||
media?: {
|
||||
path?: string;
|
||||
type?: string;
|
||||
paths?: string[];
|
||||
types?: Array<string | undefined>;
|
||||
};
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
}): {
|
||||
ctxPayload: ReturnType<typeof finalizeInboundContext>;
|
||||
fromLabel: string;
|
||||
chatTarget?: string;
|
||||
imessageTo: string;
|
||||
inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>;
|
||||
} {
|
||||
const envelopeOptions = params.envelopeOptions ?? resolveEnvelopeFormatOptions(params.cfg);
|
||||
const { decision } = params;
|
||||
const chatId = decision.chatId;
|
||||
const chatTarget =
|
||||
decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined;
|
||||
|
||||
const replySuffix = decision.replyContext
|
||||
? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${
|
||||
decision.replyContext.id ? ` id:${decision.replyContext.id}` : ""
|
||||
}]\n${decision.replyContext.body}\n[/Replying]`
|
||||
: "";
|
||||
|
||||
const fromLabel = formatInboundFromLabel({
|
||||
isGroup: decision.isGroup,
|
||||
groupLabel: params.message.chat_name ?? undefined,
|
||||
groupId: chatId !== undefined ? String(chatId) : "unknown",
|
||||
groupFallback: "Group",
|
||||
directLabel: decision.senderNormalized,
|
||||
directId: decision.sender,
|
||||
});
|
||||
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "iMessage",
|
||||
from: fromLabel,
|
||||
timestamp: decision.createdAt,
|
||||
body: `${decision.bodyText}${replySuffix}`,
|
||||
chatType: decision.isGroup ? "group" : "direct",
|
||||
sender: { name: decision.senderNormalized, id: decision.sender },
|
||||
previousTimestamp: params.previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
|
||||
let combinedBody = body;
|
||||
if (decision.isGroup && decision.historyKey) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: params.groupHistories,
|
||||
historyKey: decision.historyKey,
|
||||
limit: params.historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "iMessage",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
chatType: "group",
|
||||
senderLabel: entry.sender,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`;
|
||||
const inboundHistory =
|
||||
decision.isGroup && decision.historyKey && params.historyLimit > 0
|
||||
? (params.groupHistories.get(decision.historyKey) ?? []).map((entry) => ({
|
||||
sender: entry.sender,
|
||||
body: entry.body,
|
||||
timestamp: entry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: decision.bodyText,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: decision.bodyText,
|
||||
CommandBody: decision.bodyText,
|
||||
From: decision.isGroup
|
||||
? `imessage:group:${chatId ?? "unknown"}`
|
||||
: `imessage:${decision.sender}`,
|
||||
To: imessageTo,
|
||||
SessionKey: decision.route.sessionKey,
|
||||
AccountId: decision.route.accountId,
|
||||
ChatType: decision.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined,
|
||||
GroupMembers: decision.isGroup
|
||||
? (params.message.participants ?? []).filter(Boolean).join(", ")
|
||||
: undefined,
|
||||
SenderName: decision.senderNormalized,
|
||||
SenderId: decision.sender,
|
||||
Provider: "imessage",
|
||||
Surface: "imessage",
|
||||
MessageSid: params.message.id ? String(params.message.id) : undefined,
|
||||
ReplyToId: decision.replyContext?.id,
|
||||
ReplyToBody: decision.replyContext?.body,
|
||||
ReplyToSender: decision.replyContext?.sender,
|
||||
Timestamp: decision.createdAt,
|
||||
MediaPath: params.media?.path,
|
||||
MediaType: params.media?.type,
|
||||
MediaUrl: params.media?.path,
|
||||
MediaPaths:
|
||||
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
|
||||
MediaTypes:
|
||||
params.media?.types && params.media.types.length > 0 ? params.media.types : undefined,
|
||||
MediaUrls:
|
||||
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
|
||||
MediaRemoteHost: params.remoteHost,
|
||||
WasMentioned: decision.effectiveWasMentioned,
|
||||
CommandAuthorized: decision.commandAuthorized,
|
||||
OriginatingChannel: "imessage" as const,
|
||||
OriginatingTo: imessageTo,
|
||||
});
|
||||
|
||||
return { ctxPayload, fromLabel, chatTarget, imessageTo, inboundHistory };
|
||||
}
|
||||
|
||||
export function buildIMessageEchoScope(params: {
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
sender: string;
|
||||
}): string {
|
||||
return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`;
|
||||
}
|
||||
|
||||
export function describeIMessageEchoDropLog(params: {
|
||||
messageText: string;
|
||||
messageId?: string;
|
||||
}): string {
|
||||
const preview = truncateUtf16Safe(params.messageText, 50);
|
||||
const messageIdPart = params.messageId ? ` id=${params.messageId}` : "";
|
||||
return `imessage: skipping echo message${messageIdPart}: "${preview}"`;
|
||||
}
|
||||
69
extensions/imessage/src/monitor/loop-rate-limiter.ts
Normal file
69
extensions/imessage/src/monitor/loop-rate-limiter.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Per-conversation rate limiter that detects rapid-fire identical echo
|
||||
* patterns and suppresses them before they amplify into queue overflow.
|
||||
*/
|
||||
|
||||
const DEFAULT_WINDOW_MS = 60_000;
|
||||
const DEFAULT_MAX_HITS = 5;
|
||||
const CLEANUP_INTERVAL_MS = 120_000;
|
||||
|
||||
type ConversationWindow = {
|
||||
timestamps: number[];
|
||||
};
|
||||
|
||||
export type LoopRateLimiter = {
|
||||
/** Returns true if this conversation has exceeded the rate limit. */
|
||||
isRateLimited: (conversationKey: string) => boolean;
|
||||
/** Record an inbound message for a conversation. */
|
||||
record: (conversationKey: string) => void;
|
||||
};
|
||||
|
||||
export function createLoopRateLimiter(opts?: {
|
||||
windowMs?: number;
|
||||
maxHits?: number;
|
||||
}): LoopRateLimiter {
|
||||
const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
|
||||
const maxHits = opts?.maxHits ?? DEFAULT_MAX_HITS;
|
||||
const conversations = new Map<string, ConversationWindow>();
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastCleanup = now;
|
||||
for (const [key, win] of conversations.entries()) {
|
||||
const recent = win.timestamps.filter((ts) => now - ts <= windowMs);
|
||||
if (recent.length === 0) {
|
||||
conversations.delete(key);
|
||||
} else {
|
||||
win.timestamps = recent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
record(conversationKey: string) {
|
||||
cleanup();
|
||||
let win = conversations.get(conversationKey);
|
||||
if (!win) {
|
||||
win = { timestamps: [] };
|
||||
conversations.set(conversationKey, win);
|
||||
}
|
||||
win.timestamps.push(Date.now());
|
||||
},
|
||||
|
||||
isRateLimited(conversationKey: string): boolean {
|
||||
cleanup();
|
||||
const win = conversations.get(conversationKey);
|
||||
if (!win) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
const recent = win.timestamps.filter((ts) => now - ts <= windowMs);
|
||||
win.timestamps = recent;
|
||||
return recent.length >= maxHits;
|
||||
},
|
||||
};
|
||||
}
|
||||
537
extensions/imessage/src/monitor/monitor-provider.ts
Normal file
537
extensions/imessage/src/monitor/monitor-provider.ts
Normal file
@ -0,0 +1,537 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js";
|
||||
import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
||||
import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js";
|
||||
import {
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} from "../../../../src/auto-reply/reply/history.js";
|
||||
import { createReplyDispatcher } from "../../../../src/auto-reply/reply/reply-dispatcher.js";
|
||||
import {
|
||||
createChannelInboundDebouncer,
|
||||
shouldDebounceTextInbound,
|
||||
} from "../../../../src/channels/inbound-debounce-policy.js";
|
||||
import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../../../src/channels/session.js";
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import {
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../../../../src/config/runtime-group-policy.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js";
|
||||
import { normalizeScpRemoteHost } from "../../../../src/infra/scp-host.js";
|
||||
import { waitForTransportReady } from "../../../../src/infra/transport-ready.js";
|
||||
import {
|
||||
isInboundPathAllowed,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "../../../../src/media/inbound-path-policy.js";
|
||||
import { kindFromMime } from "../../../../src/media/mime.js";
|
||||
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../../../src/pairing/pairing-store.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js";
|
||||
import { truncateUtf16Safe } from "../../../../src/utils.js";
|
||||
import { resolveIMessageAccount } from "../accounts.js";
|
||||
import { createIMessageRpcClient } from "../client.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
||||
import { probeIMessage } from "../probe.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import { normalizeIMessageHandle } from "../targets.js";
|
||||
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
||||
import { deliverReplies } from "./deliver.js";
|
||||
import { createSentMessageCache } from "./echo-cache.js";
|
||||
import {
|
||||
buildIMessageInboundContext,
|
||||
resolveIMessageInboundDecision,
|
||||
} from "./inbound-processing.js";
|
||||
import { createLoopRateLimiter } from "./loop-rate-limiter.js";
|
||||
import { parseIMessageNotification } from "./parse-notification.js";
|
||||
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
|
||||
import { createSelfChatCache } from "./self-chat-cache.js";
|
||||
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
|
||||
|
||||
/**
|
||||
* Try to detect remote host from an SSH wrapper script like:
|
||||
* exec ssh -T openclaw@192.168.64.3 /opt/homebrew/bin/imsg "$@"
|
||||
* exec ssh -T mac-mini imsg "$@"
|
||||
* Returns the user@host or host portion if found, undefined otherwise.
|
||||
*/
|
||||
async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
// Expand ~ to home directory
|
||||
const expanded = cliPath.startsWith("~")
|
||||
? cliPath.replace(/^~/, process.env.HOME ?? "")
|
||||
: cliPath;
|
||||
const content = await fs.readFile(expanded, "utf8");
|
||||
|
||||
// Match user@host pattern first (e.g., openclaw@192.168.64.3)
|
||||
const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/);
|
||||
if (userHostMatch) {
|
||||
return userHostMatch[1];
|
||||
}
|
||||
|
||||
// Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg)
|
||||
const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/);
|
||||
return hostOnlyMatch?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const accountInfo = resolveIMessageAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const imessageCfg = accountInfo.config;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
imessageCfg.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const sentMessageCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const loopRateLimiter = createLoopRateLimiter();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
imessageCfg.groupAllowFrom ??
|
||||
(imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []),
|
||||
);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.imessage !== undefined,
|
||||
groupPolicy: imessageCfg.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
log: (message) => runtime.log?.(warn(message)),
|
||||
});
|
||||
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
|
||||
const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
|
||||
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
|
||||
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
|
||||
const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
||||
const attachmentRoots = resolveIMessageAttachmentRoots({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
|
||||
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script.
|
||||
// Accept only a safe host token to avoid option/argument injection into SCP.
|
||||
const configuredRemoteHost = normalizeScpRemoteHost(imessageCfg.remoteHost);
|
||||
if (imessageCfg.remoteHost && !configuredRemoteHost) {
|
||||
logVerbose("imessage: ignoring unsafe channels.imessage.remoteHost value");
|
||||
}
|
||||
|
||||
let remoteHost = configuredRemoteHost;
|
||||
if (!remoteHost && cliPath && cliPath !== "imsg") {
|
||||
const detected = await detectRemoteHostFromCliPath(cliPath);
|
||||
const normalizedDetected = normalizeScpRemoteHost(detected);
|
||||
if (detected && !normalizedDetected) {
|
||||
logVerbose("imessage: ignoring unsafe auto-detected remoteHost from cliPath");
|
||||
}
|
||||
remoteHost = normalizedDetected;
|
||||
if (remoteHost) {
|
||||
logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`);
|
||||
}
|
||||
}
|
||||
|
||||
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{
|
||||
message: IMessagePayload;
|
||||
}>({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
buildKey: (entry) => {
|
||||
const sender = entry.message.sender?.trim();
|
||||
if (!sender) {
|
||||
return null;
|
||||
}
|
||||
const conversationId =
|
||||
entry.message.chat_id != null
|
||||
? `chat:${entry.message.chat_id}`
|
||||
: (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown");
|
||||
return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
return shouldDebounceTextInbound({
|
||||
text: entry.message.text,
|
||||
cfg,
|
||||
hasMedia: Boolean(entry.message.attachments && entry.message.attachments.length > 0),
|
||||
});
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await handleMessageNow(last.message);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.message.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const syntheticMessage: IMessagePayload = {
|
||||
...last.message,
|
||||
text: combinedText,
|
||||
attachments: null,
|
||||
};
|
||||
await handleMessageNow(syntheticMessage);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(`imessage debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleMessageNow(message: IMessagePayload) {
|
||||
const messageText = (message.text ?? "").trim();
|
||||
|
||||
const attachments = includeAttachments ? (message.attachments ?? []) : [];
|
||||
const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots;
|
||||
const validAttachments = attachments.filter((entry) => {
|
||||
const attachmentPath = entry?.original_path?.trim();
|
||||
if (!attachmentPath || entry?.missing) {
|
||||
return false;
|
||||
}
|
||||
if (isInboundPathAllowed({ filePath: attachmentPath, roots: effectiveAttachmentRoots })) {
|
||||
return true;
|
||||
}
|
||||
logVerbose(`imessage: dropping inbound attachment outside allowed roots: ${attachmentPath}`);
|
||||
return false;
|
||||
});
|
||||
const firstAttachment = validAttachments[0];
|
||||
const mediaPath = firstAttachment?.original_path ?? undefined;
|
||||
const mediaType = firstAttachment?.mime_type ?? undefined;
|
||||
// Build arrays for all attachments (for multi-image support)
|
||||
const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[];
|
||||
const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined);
|
||||
const kind = kindFromMime(mediaType ?? undefined);
|
||||
const placeholder = kind
|
||||
? `<media:${kind}>`
|
||||
: validAttachments.length
|
||||
? "<media:attachment>"
|
||||
: "";
|
||||
const bodyText = messageText || placeholder;
|
||||
|
||||
const storeAllowFrom = await readChannelAllowFromStore(
|
||||
"imessage",
|
||||
process.env,
|
||||
accountInfo.accountId,
|
||||
).catch(() => []);
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
message,
|
||||
opts,
|
||||
messageText,
|
||||
bodyText,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
groupPolicy,
|
||||
dmPolicy,
|
||||
storeAllowFrom,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
echoCache: sentMessageCache,
|
||||
selfChatCache,
|
||||
logVerbose,
|
||||
});
|
||||
|
||||
// Build conversation key for rate limiting (used by both drop and dispatch paths).
|
||||
const chatId = message.chat_id ?? undefined;
|
||||
const senderForKey = (message.sender ?? "").trim();
|
||||
const conversationKey = chatId != null ? `group:${chatId}` : `dm:${senderForKey}`;
|
||||
const rateLimitKey = `${accountInfo.accountId}:${conversationKey}`;
|
||||
|
||||
if (decision.kind === "drop") {
|
||||
// Record echo/reflection drops so the rate limiter can detect sustained loops.
|
||||
// Only loop-related drop reasons feed the counter; policy/mention/empty drops
|
||||
// are normal and should not escalate.
|
||||
const isLoopDrop =
|
||||
decision.reason === "echo" ||
|
||||
decision.reason === "self-chat echo" ||
|
||||
decision.reason === "reflected assistant content" ||
|
||||
decision.reason === "from me";
|
||||
if (isLoopDrop) {
|
||||
loopRateLimiter.record(rateLimitKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// After repeated echo/reflection drops for a conversation, suppress all
|
||||
// remaining messages as a safety net against amplification that slips
|
||||
// through the primary guards.
|
||||
if (decision.kind === "dispatch" && loopRateLimiter.isRateLimited(rateLimitKey)) {
|
||||
logVerbose(`imessage: rate-limited conversation ${conversationKey} (echo loop detected)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.kind === "pairing") {
|
||||
const sender = (message.sender ?? "").trim();
|
||||
if (!sender) {
|
||||
return;
|
||||
}
|
||||
await issuePairingChallenge({
|
||||
channel: "imessage",
|
||||
senderId: decision.senderId,
|
||||
senderIdLine: `Your iMessage sender id: ${decision.senderId}`,
|
||||
meta: {
|
||||
sender: decision.senderId,
|
||||
chatId: chatId ? String(chatId) : undefined,
|
||||
},
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "imessage",
|
||||
id,
|
||||
accountId: accountInfo.accountId,
|
||||
meta,
|
||||
}),
|
||||
onCreated: () => {
|
||||
logVerbose(`imessage pairing request sender=${decision.senderId}`);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageIMessage(sender, text, {
|
||||
client,
|
||||
maxBytes: mediaMaxBytes,
|
||||
accountId: accountInfo.accountId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: decision.route.agentId,
|
||||
});
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: decision.route.sessionKey,
|
||||
});
|
||||
const { ctxPayload, chatTarget } = buildIMessageInboundContext({
|
||||
cfg,
|
||||
decision,
|
||||
message,
|
||||
previousTimestamp,
|
||||
remoteHost,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
media: {
|
||||
path: mediaPath,
|
||||
type: mediaType,
|
||||
paths: mediaPaths,
|
||||
types: mediaTypes,
|
||||
},
|
||||
});
|
||||
|
||||
const updateTarget = chatTarget || decision.sender;
|
||||
const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: cfg.session?.dmScope,
|
||||
allowFrom,
|
||||
normalizeEntry: normalizeIMessageHandle,
|
||||
});
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute:
|
||||
!decision.isGroup && updateTarget
|
||||
? {
|
||||
sessionKey: decision.route.mainSessionKey,
|
||||
channel: "imessage",
|
||||
to: updateTarget,
|
||||
accountId: decision.route.accountId,
|
||||
mainDmOwnerPin:
|
||||
pinnedMainDmOwner && decision.senderNormalized
|
||||
? {
|
||||
ownerRecipient: pinnedMainDmOwner,
|
||||
senderRecipient: decision.senderNormalized,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = truncateUtf16Safe(String(ctxPayload.Body ?? ""), 200).replace(/\n/g, "\\n");
|
||||
logVerbose(
|
||||
`imessage inbound: chatId=${chatId ?? "unknown"} from=${ctxPayload.From} len=${
|
||||
String(ctxPayload.Body ?? "").length
|
||||
} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: decision.route.agentId,
|
||||
channel: "imessage",
|
||||
accountId: decision.route.accountId,
|
||||
});
|
||||
|
||||
const dispatcher = createReplyDispatcher({
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId),
|
||||
deliver: async (payload) => {
|
||||
const target = ctxPayload.To;
|
||||
if (!target) {
|
||||
runtime.error?.(danger("imessage: missing delivery target"));
|
||||
return;
|
||||
}
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target,
|
||||
client,
|
||||
accountId: accountInfo.accountId,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
sentMessageCache,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
});
|
||||
|
||||
const { queuedFinal } = await dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
disableBlockStreaming:
|
||||
typeof accountInfo.config.blockStreaming === "boolean"
|
||||
? !accountInfo.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queuedFinal) {
|
||||
if (decision.isGroup && decision.historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: groupHistories,
|
||||
historyKey: decision.historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (decision.isGroup && decision.historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: groupHistories,
|
||||
historyKey: decision.historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessage = async (raw: unknown) => {
|
||||
const message = parseIMessageNotification(raw);
|
||||
if (!message) {
|
||||
logVerbose("imessage: dropping malformed RPC message payload");
|
||||
return;
|
||||
}
|
||||
await inboundDebouncer.enqueue({ message });
|
||||
};
|
||||
|
||||
await waitForTransportReady({
|
||||
label: "imsg rpc",
|
||||
timeoutMs: 30_000,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
pollIntervalMs: 500,
|
||||
abortSignal: opts.abortSignal,
|
||||
runtime,
|
||||
check: async () => {
|
||||
const probe = await probeIMessage(probeTimeoutMs, { cliPath, dbPath, runtime });
|
||||
if (probe.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (probe.fatal) {
|
||||
throw new Error(probe.error ?? "imsg rpc unavailable");
|
||||
}
|
||||
return { ok: false, error: probe.error ?? "unreachable" };
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await createIMessageRpcClient({
|
||||
cliPath,
|
||||
dbPath,
|
||||
runtime,
|
||||
onNotification: (msg) => {
|
||||
if (msg.method === "message") {
|
||||
void handleMessage(msg.params).catch((err) => {
|
||||
runtime.error?.(`imessage: handler failed: ${String(err)}`);
|
||||
});
|
||||
} else if (msg.method === "error") {
|
||||
runtime.error?.(`imessage: watch error ${JSON.stringify(msg.params)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let subscriptionId: number | null = null;
|
||||
const abort = opts.abortSignal;
|
||||
const detachAbortHandler = attachIMessageMonitorAbortHandler({
|
||||
abortSignal: abort,
|
||||
client,
|
||||
getSubscriptionId: () => subscriptionId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request<{ subscription?: number }>("watch.subscribe", {
|
||||
attachments: includeAttachments,
|
||||
});
|
||||
subscriptionId = result?.subscription ?? null;
|
||||
await client.waitForClose();
|
||||
} catch (err) {
|
||||
if (abort?.aborted) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(danger(`imessage: monitor failed: ${String(err)}`));
|
||||
throw err;
|
||||
} finally {
|
||||
detachAbortHandler();
|
||||
await client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
};
|
||||
83
extensions/imessage/src/monitor/parse-notification.ts
Normal file
83
extensions/imessage/src/monitor/parse-notification.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { IMessagePayload } from "./types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown): value is string | null | undefined {
|
||||
return value === undefined || value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined {
|
||||
return (
|
||||
value === undefined || value === null || typeof value === "string" || typeof value === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalNumber(value: unknown): value is number | null | undefined {
|
||||
return value === undefined || value === null || typeof value === "number";
|
||||
}
|
||||
|
||||
function isOptionalBoolean(value: unknown): value is boolean | null | undefined {
|
||||
return value === undefined || value === null || typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isOptionalStringArray(value: unknown): value is string[] | null | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(Array.isArray(value) && value.every((entry) => typeof entry === "string"))
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalAttachments(value: unknown): value is IMessagePayload["attachments"] {
|
||||
if (value === undefined || value === null) {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return value.every((attachment) => {
|
||||
if (!isRecord(attachment)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isOptionalString(attachment.original_path) &&
|
||||
isOptionalString(attachment.mime_type) &&
|
||||
isOptionalBoolean(attachment.missing)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseIMessageNotification(raw: unknown): IMessagePayload | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const maybeMessage = raw.message;
|
||||
if (!isRecord(maybeMessage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message: IMessagePayload = maybeMessage;
|
||||
if (
|
||||
!isOptionalNumber(message.id) ||
|
||||
!isOptionalNumber(message.chat_id) ||
|
||||
!isOptionalString(message.sender) ||
|
||||
!isOptionalBoolean(message.is_from_me) ||
|
||||
!isOptionalString(message.text) ||
|
||||
!isOptionalStringOrNumber(message.reply_to_id) ||
|
||||
!isOptionalString(message.reply_to_text) ||
|
||||
!isOptionalString(message.reply_to_sender) ||
|
||||
!isOptionalString(message.created_at) ||
|
||||
!isOptionalAttachments(message.attachments) ||
|
||||
!isOptionalString(message.chat_identifier) ||
|
||||
!isOptionalString(message.chat_guid) ||
|
||||
!isOptionalString(message.chat_name) ||
|
||||
!isOptionalStringArray(message.participants) ||
|
||||
!isOptionalBoolean(message.is_group)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./monitor-provider.js";
|
||||
|
||||
describe("resolveIMessageRuntimeGroupPolicy", () => {
|
||||
64
extensions/imessage/src/monitor/reflection-guard.ts
Normal file
64
extensions/imessage/src/monitor/reflection-guard.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Detects inbound messages that are reflections of assistant-originated content.
|
||||
* These patterns indicate internal metadata leaked into a channel and then
|
||||
* bounced back as a new inbound message — creating an echo loop.
|
||||
*/
|
||||
|
||||
import { findCodeRegions, isInsideCode } from "../../../../src/shared/text/code-regions.js";
|
||||
|
||||
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/;
|
||||
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i;
|
||||
// Require closing `>` to avoid false-positives on phrases like "<thought experiment>".
|
||||
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/i;
|
||||
const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i;
|
||||
// Require closing `>` to avoid false-positives on phrases like "<final answer>".
|
||||
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i;
|
||||
|
||||
const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
||||
{ re: INTERNAL_SEPARATOR_RE, label: "internal-separator" },
|
||||
{ re: ASSISTANT_ROLE_MARKER_RE, label: "assistant-role-marker" },
|
||||
{ re: THINKING_TAG_RE, label: "thinking-tag" },
|
||||
{ re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" },
|
||||
{ re: FINAL_TAG_RE, label: "final-tag" },
|
||||
];
|
||||
|
||||
export type ReflectionDetection = {
|
||||
isReflection: boolean;
|
||||
matchedLabels: string[];
|
||||
};
|
||||
|
||||
function hasMatchOutsideCode(text: string, re: RegExp): boolean {
|
||||
const codeRegions = findCodeRegions(text);
|
||||
const globalRe = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
|
||||
|
||||
for (const match of text.matchAll(globalRe)) {
|
||||
const start = match.index ?? -1;
|
||||
if (start >= 0 && !isInsideCode(start, codeRegions)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an inbound message appears to be a reflection of
|
||||
* assistant-originated content. Returns matched pattern labels for telemetry.
|
||||
*/
|
||||
export function detectReflectedContent(text: string): ReflectionDetection {
|
||||
if (!text) {
|
||||
return { isReflection: false, matchedLabels: [] };
|
||||
}
|
||||
|
||||
const matchedLabels: string[] = [];
|
||||
for (const { re, label } of REFLECTION_PATTERNS) {
|
||||
if (hasMatchOutsideCode(text, re)) {
|
||||
matchedLabels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isReflection: matchedLabels.length > 0,
|
||||
matchedLabels,
|
||||
};
|
||||
}
|
||||
11
extensions/imessage/src/monitor/runtime.ts
Normal file
11
extensions/imessage/src/monitor/runtime.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js";
|
||||
import type { MonitorIMessageOpts } from "./types.js";
|
||||
|
||||
export function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
||||
return opts.runtime ?? createNonExitingRuntime();
|
||||
}
|
||||
|
||||
export function normalizeAllowList(list?: Array<string | number>) {
|
||||
return normalizeStringEntries(list);
|
||||
}
|
||||
31
extensions/imessage/src/monitor/sanitize-outbound.ts
Normal file
31
extensions/imessage/src/monitor/sanitize-outbound.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { stripAssistantInternalScaffolding } from "../../../../src/shared/text/assistant-visible-text.js";
|
||||
|
||||
/**
|
||||
* Patterns that indicate assistant-internal metadata leaked into text.
|
||||
* These must never reach a user-facing channel.
|
||||
*/
|
||||
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g;
|
||||
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi;
|
||||
const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm;
|
||||
|
||||
/**
|
||||
* Strip all assistant-internal scaffolding from outbound text before delivery.
|
||||
* Applies reasoning/thinking tag removal, memory tag removal, and
|
||||
* model-specific internal separator stripping.
|
||||
*/
|
||||
export function sanitizeOutboundText(text: string): string {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let cleaned = stripAssistantInternalScaffolding(text);
|
||||
|
||||
cleaned = cleaned.replace(INTERNAL_SEPARATOR_RE, "");
|
||||
cleaned = cleaned.replace(ASSISTANT_ROLE_MARKER_RE, "");
|
||||
cleaned = cleaned.replace(ROLE_TURN_MARKER_RE, "");
|
||||
|
||||
// Collapse excessive blank lines left after stripping.
|
||||
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
103
extensions/imessage/src/monitor/self-chat-cache.ts
Normal file
103
extensions/imessage/src/monitor/self-chat-cache.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { formatIMessageChatTarget } from "../targets.js";
|
||||
|
||||
type SelfChatCacheKeyParts = {
|
||||
accountId: string;
|
||||
sender: string;
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
};
|
||||
|
||||
export type SelfChatLookup = SelfChatCacheKeyParts & {
|
||||
text?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
|
||||
export type SelfChatCache = {
|
||||
remember: (lookup: SelfChatLookup) => void;
|
||||
has: (lookup: SelfChatLookup) => boolean;
|
||||
};
|
||||
|
||||
const SELF_CHAT_TTL_MS = 10_000;
|
||||
const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
|
||||
const CLEANUP_MIN_INTERVAL_MS = 1_000;
|
||||
|
||||
function normalizeText(text: string | undefined): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const normalized = text.replace(/\r\n?/g, "\n").trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function isUsableTimestamp(createdAt: number | undefined): createdAt is number {
|
||||
return typeof createdAt === "number" && Number.isFinite(createdAt);
|
||||
}
|
||||
|
||||
function digestText(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function buildScope(parts: SelfChatCacheKeyParts): string {
|
||||
if (!parts.isGroup) {
|
||||
return `${parts.accountId}:imessage:${parts.sender}`;
|
||||
}
|
||||
const chatTarget = formatIMessageChatTarget(parts.chatId) || "chat_id:unknown";
|
||||
return `${parts.accountId}:${chatTarget}:imessage:${parts.sender}`;
|
||||
}
|
||||
|
||||
class DefaultSelfChatCache implements SelfChatCache {
|
||||
private cache = new Map<string, number>();
|
||||
private lastCleanupAt = 0;
|
||||
|
||||
private buildKey(lookup: SelfChatLookup): string | null {
|
||||
const text = normalizeText(lookup.text);
|
||||
if (!text || !isUsableTimestamp(lookup.createdAt)) {
|
||||
return null;
|
||||
}
|
||||
return `${buildScope(lookup)}:${lookup.createdAt}:${digestText(text)}`;
|
||||
}
|
||||
|
||||
remember(lookup: SelfChatLookup): void {
|
||||
const key = this.buildKey(lookup);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
this.cache.set(key, Date.now());
|
||||
this.maybeCleanup();
|
||||
}
|
||||
|
||||
has(lookup: SelfChatLookup): boolean {
|
||||
this.maybeCleanup();
|
||||
const key = this.buildKey(lookup);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = this.cache.get(key);
|
||||
return typeof timestamp === "number" && Date.now() - timestamp <= SELF_CHAT_TTL_MS;
|
||||
}
|
||||
|
||||
private maybeCleanup(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this.lastCleanupAt = now;
|
||||
for (const [key, timestamp] of this.cache.entries()) {
|
||||
if (now - timestamp > SELF_CHAT_TTL_MS) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
while (this.cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
if (typeof oldestKey !== "string") {
|
||||
break;
|
||||
}
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSelfChatCache(): SelfChatCache {
|
||||
return new DefaultSelfChatCache();
|
||||
}
|
||||
40
extensions/imessage/src/monitor/types.ts
Normal file
40
extensions/imessage/src/monitor/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
|
||||
export type IMessageAttachment = {
|
||||
original_path?: string | null;
|
||||
mime_type?: string | null;
|
||||
missing?: boolean | null;
|
||||
};
|
||||
|
||||
export type IMessagePayload = {
|
||||
id?: number | null;
|
||||
chat_id?: number | null;
|
||||
sender?: string | null;
|
||||
is_from_me?: boolean | null;
|
||||
text?: string | null;
|
||||
reply_to_id?: number | string | null;
|
||||
reply_to_text?: string | null;
|
||||
reply_to_sender?: string | null;
|
||||
created_at?: string | null;
|
||||
attachments?: IMessageAttachment[] | null;
|
||||
chat_identifier?: string | null;
|
||||
chat_guid?: string | null;
|
||||
chat_name?: string | null;
|
||||
participants?: string[] | null;
|
||||
is_group?: boolean | null;
|
||||
};
|
||||
|
||||
export type MonitorIMessageOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
accountId?: string;
|
||||
config?: OpenClawConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
includeAttachments?: boolean;
|
||||
mediaMaxMb?: number;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
@ -5,11 +5,11 @@ const detectBinaryMock = vi.hoisted(() => vi.fn());
|
||||
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
|
||||
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", () => ({
|
||||
vi.mock("../../../src/commands/onboard-helpers.js", () => ({
|
||||
detectBinary: (...args: unknown[]) => detectBinaryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
vi.mock("../../../src/process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
105
extensions/imessage/src/probe.ts
Normal file
105
extensions/imessage/src/probe.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import type { BaseProbeResult } from "../../../src/channels/plugins/types.js";
|
||||
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import { runCommandWithTimeout } from "../../../src/process/exec.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { createIMessageRpcClient } from "./client.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
export type IMessageProbe = BaseProbeResult & {
|
||||
fatal?: boolean;
|
||||
};
|
||||
|
||||
export type IMessageProbeOptions = {
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
type RpcSupportResult = {
|
||||
supported: boolean;
|
||||
error?: string;
|
||||
fatal?: boolean;
|
||||
};
|
||||
|
||||
const rpcSupportCache = new Map<string, RpcSupportResult>();
|
||||
|
||||
async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcSupportResult> {
|
||||
const cached = rpcSupportCache.get(cliPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
|
||||
const combined = `${result.stdout}\n${result.stderr}`.trim();
|
||||
const normalized = combined.toLowerCase();
|
||||
if (normalized.includes("unknown command") && normalized.includes("rpc")) {
|
||||
const fatal = {
|
||||
supported: false,
|
||||
fatal: true,
|
||||
error: 'imsg CLI does not support the "rpc" subcommand (update imsg)',
|
||||
};
|
||||
rpcSupportCache.set(cliPath, fatal);
|
||||
return fatal;
|
||||
}
|
||||
if (result.code === 0) {
|
||||
const supported = { supported: true };
|
||||
rpcSupportCache.set(cliPath, supported);
|
||||
return supported;
|
||||
}
|
||||
return {
|
||||
supported: false,
|
||||
error: combined || `imsg rpc --help failed (code ${String(result.code ?? "unknown")})`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { supported: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe iMessage RPC availability.
|
||||
* @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default.
|
||||
* @param opts - Additional options (cliPath, dbPath, runtime).
|
||||
*/
|
||||
export async function probeIMessage(
|
||||
timeoutMs?: number,
|
||||
opts: IMessageProbeOptions = {},
|
||||
): Promise<IMessageProbe> {
|
||||
const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig();
|
||||
const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg";
|
||||
const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim();
|
||||
// Use explicit timeout if provided, otherwise fall back to config, then default
|
||||
const effectiveTimeout =
|
||||
timeoutMs ?? cfg?.channels?.imessage?.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
||||
|
||||
const detected = await detectBinary(cliPath);
|
||||
if (!detected) {
|
||||
return { ok: false, error: `imsg not found (${cliPath})` };
|
||||
}
|
||||
|
||||
const rpcSupport = await probeRpcSupport(cliPath, effectiveTimeout);
|
||||
if (!rpcSupport.supported) {
|
||||
return {
|
||||
ok: false,
|
||||
error: rpcSupport.error ?? "imsg rpc unavailable",
|
||||
fatal: rpcSupport.fatal,
|
||||
};
|
||||
}
|
||||
|
||||
const client = await createIMessageRpcClient({
|
||||
cliPath,
|
||||
dbPath,
|
||||
runtime: opts.runtime,
|
||||
});
|
||||
try {
|
||||
await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
} finally {
|
||||
await client.stop();
|
||||
}
|
||||
}
|
||||
190
extensions/imessage/src/send.ts
Normal file
190
extensions/imessage/src/send.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../../../src/markdown/tables.js";
|
||||
import { kindFromMime } from "../../../src/media/mime.js";
|
||||
import { resolveOutboundAttachmentFromUrl } from "../../../src/media/outbound-attachment.js";
|
||||
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
|
||||
|
||||
export type IMessageSendOpts = {
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
service?: IMessageService;
|
||||
region?: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
chatId?: number;
|
||||
client?: IMessageRpcClient;
|
||||
config?: ReturnType<typeof loadConfig>;
|
||||
account?: ResolvedIMessageAccount;
|
||||
resolveAttachmentImpl?: (
|
||||
mediaUrl: string,
|
||||
maxBytes: number,
|
||||
options?: { localRoots?: readonly string[] },
|
||||
) => Promise<{ path: string; contentType?: string }>;
|
||||
createClient?: (params: { cliPath: string; dbPath?: string }) => Promise<IMessageRpcClient>;
|
||||
};
|
||||
|
||||
export type IMessageSendResult = {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const LEADING_REPLY_TAG_RE = /^\s*\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]\s*/i;
|
||||
const MAX_REPLY_TO_ID_LENGTH = 256;
|
||||
|
||||
function stripUnsafeReplyTagChars(value: string): string {
|
||||
let next = "";
|
||||
for (const ch of value) {
|
||||
const code = ch.charCodeAt(0);
|
||||
if ((code >= 0 && code <= 31) || code === 127 || ch === "[" || ch === "]") {
|
||||
continue;
|
||||
}
|
||||
next += ch;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function sanitizeReplyToId(rawReplyToId?: string): string | undefined {
|
||||
const trimmed = rawReplyToId?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const sanitized = stripUnsafeReplyTagChars(trimmed).trim();
|
||||
if (!sanitized) {
|
||||
return undefined;
|
||||
}
|
||||
if (sanitized.length > MAX_REPLY_TO_ID_LENGTH) {
|
||||
return sanitized.slice(0, MAX_REPLY_TO_ID_LENGTH);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function prependReplyTagIfNeeded(message: string, replyToId?: string): string {
|
||||
const resolvedReplyToId = sanitizeReplyToId(replyToId);
|
||||
if (!resolvedReplyToId) {
|
||||
return message;
|
||||
}
|
||||
const replyTag = `[[reply_to:${resolvedReplyToId}]]`;
|
||||
const existingLeadingTag = message.match(LEADING_REPLY_TAG_RE);
|
||||
if (existingLeadingTag) {
|
||||
const remainder = message.slice(existingLeadingTag[0].length).trimStart();
|
||||
return remainder ? `${replyTag} ${remainder}` : replyTag;
|
||||
}
|
||||
const trimmedMessage = message.trimStart();
|
||||
return trimmedMessage ? `${replyTag} ${trimmedMessage}` : replyTag;
|
||||
}
|
||||
|
||||
function resolveMessageId(result: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
const raw =
|
||||
(typeof result.messageId === "string" && result.messageId.trim()) ||
|
||||
(typeof result.message_id === "string" && result.message_id.trim()) ||
|
||||
(typeof result.id === "string" && result.id.trim()) ||
|
||||
(typeof result.guid === "string" && result.guid.trim()) ||
|
||||
(typeof result.message_id === "number" ? String(result.message_id) : null) ||
|
||||
(typeof result.id === "number" ? String(result.id) : null);
|
||||
return raw ? String(raw).trim() : null;
|
||||
}
|
||||
|
||||
export async function sendMessageIMessage(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: IMessageSendOpts = {},
|
||||
): Promise<IMessageSendResult> {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account =
|
||||
opts.account ??
|
||||
resolveIMessageAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
|
||||
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
|
||||
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
|
||||
const service =
|
||||
opts.service ??
|
||||
(target.kind === "handle" ? target.service : undefined) ??
|
||||
(account.config.service as IMessageService | undefined);
|
||||
const region = opts.region?.trim() || account.config.region?.trim() || "US";
|
||||
const maxBytes =
|
||||
typeof opts.maxBytes === "number"
|
||||
? opts.maxBytes
|
||||
: typeof account.config.mediaMaxMb === "number"
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
: 16 * 1024 * 1024;
|
||||
let message = text ?? "";
|
||||
let filePath: string | undefined;
|
||||
|
||||
if (opts.mediaUrl?.trim()) {
|
||||
const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl;
|
||||
const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, {
|
||||
localRoots: opts.mediaLocalRoots,
|
||||
});
|
||||
filePath = resolved.path;
|
||||
if (!message.trim()) {
|
||||
const kind = kindFromMime(resolved.contentType ?? undefined);
|
||||
if (kind) {
|
||||
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.trim() && !filePath) {
|
||||
throw new Error("iMessage send requires text or media");
|
||||
}
|
||||
if (message.trim()) {
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
message = convertMarkdownTables(message, tableMode);
|
||||
}
|
||||
message = prependReplyTagIfNeeded(message, opts.replyToId);
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
text: message,
|
||||
service: service || "auto",
|
||||
region,
|
||||
};
|
||||
if (filePath) {
|
||||
params.file = filePath;
|
||||
}
|
||||
|
||||
if (target.kind === "chat_id") {
|
||||
params.chat_id = target.chatId;
|
||||
} else if (target.kind === "chat_guid") {
|
||||
params.chat_guid = target.chatGuid;
|
||||
} else if (target.kind === "chat_identifier") {
|
||||
params.chat_identifier = target.chatIdentifier;
|
||||
} else {
|
||||
params.to = target.to;
|
||||
}
|
||||
|
||||
const client =
|
||||
opts.client ??
|
||||
(opts.createClient
|
||||
? await opts.createClient({ cliPath, dbPath })
|
||||
: await createIMessageRpcClient({ cliPath, dbPath }));
|
||||
const shouldClose = !opts.client;
|
||||
try {
|
||||
const result = await client.request<{ ok?: string }>("send", params, {
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
const resolvedId = resolveMessageId(result);
|
||||
return {
|
||||
messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"),
|
||||
};
|
||||
} finally {
|
||||
if (shouldClose) {
|
||||
await client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
223
extensions/imessage/src/target-parsing-helpers.ts
Normal file
223
extensions/imessage/src/target-parsing-helpers.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js";
|
||||
|
||||
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
|
||||
|
||||
export type ChatTargetPrefixesParams = {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
chatIdPrefixes: string[];
|
||||
chatGuidPrefixes: string[];
|
||||
chatIdentifierPrefixes: string[];
|
||||
};
|
||||
|
||||
export type ParsedChatTarget =
|
||||
| { kind: "chat_id"; chatId: number }
|
||||
| { kind: "chat_guid"; chatGuid: string }
|
||||
| { kind: "chat_identifier"; chatIdentifier: string };
|
||||
|
||||
export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
|
||||
|
||||
export type ChatSenderAllowParams = {
|
||||
allowFrom: Array<string | number>;
|
||||
sender: string;
|
||||
chatId?: number | null;
|
||||
chatGuid?: string | null;
|
||||
chatIdentifier?: string | null;
|
||||
};
|
||||
|
||||
function stripPrefix(value: string, prefix: string): string {
|
||||
return value.slice(prefix.length).trim();
|
||||
}
|
||||
|
||||
function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean {
|
||||
return prefixes.some((prefix) => value.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedTarget<TService extends string, TTarget>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<ServicePrefix<TService>>;
|
||||
isChatTarget: (remainderLower: string) => boolean;
|
||||
parseTarget: (remainder: string) => TTarget;
|
||||
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
|
||||
for (const { prefix, service } of params.servicePrefixes) {
|
||||
if (!params.lower.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const remainder = stripPrefix(params.trimmed, prefix);
|
||||
if (!remainder) {
|
||||
throw new Error(`${prefix} target is required`);
|
||||
}
|
||||
const remainderLower = remainder.toLowerCase();
|
||||
if (params.isChatTarget(remainderLower)) {
|
||||
return params.parseTarget(remainder);
|
||||
}
|
||||
return { kind: "handle", to: remainder, service };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedChatTarget<TService extends string, TTarget>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<ServicePrefix<TService>>;
|
||||
chatIdPrefixes: string[];
|
||||
chatGuidPrefixes: string[];
|
||||
chatIdentifierPrefixes: string[];
|
||||
extraChatPrefixes?: string[];
|
||||
parseTarget: (remainder: string) => TTarget;
|
||||
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
|
||||
const chatPrefixes = [
|
||||
...params.chatIdPrefixes,
|
||||
...params.chatGuidPrefixes,
|
||||
...params.chatIdentifierPrefixes,
|
||||
...(params.extraChatPrefixes ?? []),
|
||||
];
|
||||
return resolveServicePrefixedTarget({
|
||||
trimmed: params.trimmed,
|
||||
lower: params.lower,
|
||||
servicePrefixes: params.servicePrefixes,
|
||||
isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes),
|
||||
parseTarget: params.parseTarget,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChatTargetPrefixesOrThrow(
|
||||
params: ChatTargetPrefixesParams,
|
||||
): ParsedChatTarget | null {
|
||||
for (const prefix of params.chatIdPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
const chatId = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(chatId)) {
|
||||
throw new Error(`Invalid chat_id: ${value}`);
|
||||
}
|
||||
return { kind: "chat_id", chatId };
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatGuidPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (!value) {
|
||||
throw new Error("chat_guid is required");
|
||||
}
|
||||
return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatIdentifierPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (!value) {
|
||||
throw new Error("chat_identifier is required");
|
||||
}
|
||||
return { kind: "chat_identifier", chatIdentifier: value };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<{ prefix: string }>;
|
||||
parseAllowTarget: (remainder: string) => TAllowTarget;
|
||||
}): (TAllowTarget | { kind: "handle"; handle: string }) | null {
|
||||
for (const { prefix } of params.servicePrefixes) {
|
||||
if (!params.lower.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const remainder = stripPrefix(params.trimmed, prefix);
|
||||
if (!remainder) {
|
||||
return { kind: "handle", handle: "" };
|
||||
}
|
||||
return params.parseAllowTarget(remainder);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedOrChatAllowTarget<
|
||||
TAllowTarget extends ParsedChatAllowTarget,
|
||||
>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<{ prefix: string }>;
|
||||
parseAllowTarget: (remainder: string) => TAllowTarget;
|
||||
chatIdPrefixes: string[];
|
||||
chatGuidPrefixes: string[];
|
||||
chatIdentifierPrefixes: string[];
|
||||
}): TAllowTarget | null {
|
||||
const servicePrefixed = resolveServicePrefixedAllowTarget({
|
||||
trimmed: params.trimmed,
|
||||
lower: params.lower,
|
||||
servicePrefixes: params.servicePrefixes,
|
||||
parseAllowTarget: params.parseAllowTarget,
|
||||
});
|
||||
if (servicePrefixed) {
|
||||
return servicePrefixed as TAllowTarget;
|
||||
}
|
||||
|
||||
const chatTarget = parseChatAllowTargetPrefixes({
|
||||
trimmed: params.trimmed,
|
||||
lower: params.lower,
|
||||
chatIdPrefixes: params.chatIdPrefixes,
|
||||
chatGuidPrefixes: params.chatGuidPrefixes,
|
||||
chatIdentifierPrefixes: params.chatIdentifierPrefixes,
|
||||
});
|
||||
if (chatTarget) {
|
||||
return chatTarget as TAllowTarget;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAllowedChatSenderMatcher<TParsed extends ParsedChatAllowTarget>(params: {
|
||||
normalizeSender: (sender: string) => string;
|
||||
parseAllowTarget: (entry: string) => TParsed;
|
||||
}): (input: ChatSenderAllowParams) => boolean {
|
||||
return (input) =>
|
||||
isAllowedParsedChatSender({
|
||||
allowFrom: input.allowFrom,
|
||||
sender: input.sender,
|
||||
chatId: input.chatId,
|
||||
chatGuid: input.chatGuid,
|
||||
chatIdentifier: input.chatIdentifier,
|
||||
normalizeSender: params.normalizeSender,
|
||||
parseAllowTarget: params.parseAllowTarget,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChatAllowTargetPrefixes(
|
||||
params: ChatTargetPrefixesParams,
|
||||
): ParsedChatTarget | null {
|
||||
for (const prefix of params.chatIdPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
const chatId = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(chatId)) {
|
||||
return { kind: "chat_id", chatId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatGuidPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (value) {
|
||||
return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatIdentifierPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (value) {
|
||||
return { kind: "chat_identifier", chatIdentifier: value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
147
extensions/imessage/src/targets.ts
Normal file
147
extensions/imessage/src/targets.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { normalizeE164 } from "../../../src/utils.js";
|
||||
import {
|
||||
createAllowedChatSenderMatcher,
|
||||
type ChatSenderAllowParams,
|
||||
type ParsedChatTarget,
|
||||
parseChatTargetPrefixesOrThrow,
|
||||
resolveServicePrefixedChatTarget,
|
||||
resolveServicePrefixedOrChatAllowTarget,
|
||||
} from "./target-parsing-helpers.js";
|
||||
|
||||
export type IMessageService = "imessage" | "sms" | "auto";
|
||||
|
||||
export type IMessageTarget =
|
||||
| { kind: "chat_id"; chatId: number }
|
||||
| { kind: "chat_guid"; chatGuid: string }
|
||||
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||
| { kind: "handle"; to: string; service: IMessageService };
|
||||
|
||||
export type IMessageAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
|
||||
|
||||
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
|
||||
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
|
||||
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
|
||||
const SERVICE_PREFIXES: Array<{ prefix: string; service: IMessageService }> = [
|
||||
{ prefix: "imessage:", service: "imessage" },
|
||||
{ prefix: "sms:", service: "sms" },
|
||||
{ prefix: "auto:", service: "auto" },
|
||||
];
|
||||
|
||||
export function normalizeIMessageHandle(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("imessage:")) {
|
||||
return normalizeIMessageHandle(trimmed.slice(9));
|
||||
}
|
||||
if (lowered.startsWith("sms:")) {
|
||||
return normalizeIMessageHandle(trimmed.slice(4));
|
||||
}
|
||||
if (lowered.startsWith("auto:")) {
|
||||
return normalizeIMessageHandle(trimmed.slice(5));
|
||||
}
|
||||
|
||||
// Normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively
|
||||
for (const prefix of CHAT_ID_PREFIXES) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
const value = trimmed.slice(prefix.length).trim();
|
||||
return `chat_id:${value}`;
|
||||
}
|
||||
}
|
||||
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
const value = trimmed.slice(prefix.length).trim();
|
||||
return `chat_guid:${value}`;
|
||||
}
|
||||
}
|
||||
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
const value = trimmed.slice(prefix.length).trim();
|
||||
return `chat_identifier:${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes("@")) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
const normalized = normalizeE164(trimmed);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
return trimmed.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
export function parseIMessageTarget(raw: string): IMessageTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("iMessage target is required");
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
const servicePrefixed = resolveServicePrefixedChatTarget({
|
||||
trimmed,
|
||||
lower,
|
||||
servicePrefixes: SERVICE_PREFIXES,
|
||||
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||
parseTarget: parseIMessageTarget,
|
||||
});
|
||||
if (servicePrefixed) {
|
||||
return servicePrefixed;
|
||||
}
|
||||
|
||||
const chatTarget = parseChatTargetPrefixesOrThrow({
|
||||
trimmed,
|
||||
lower,
|
||||
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||
});
|
||||
if (chatTarget) {
|
||||
return chatTarget;
|
||||
}
|
||||
|
||||
return { kind: "handle", to: trimmed, service: "auto" };
|
||||
}
|
||||
|
||||
export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { kind: "handle", handle: "" };
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({
|
||||
trimmed,
|
||||
lower,
|
||||
servicePrefixes: SERVICE_PREFIXES,
|
||||
parseAllowTarget: parseIMessageAllowTarget,
|
||||
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||
});
|
||||
if (servicePrefixed) {
|
||||
return servicePrefixed;
|
||||
}
|
||||
|
||||
return { kind: "handle", handle: normalizeIMessageHandle(trimmed) };
|
||||
}
|
||||
|
||||
const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({
|
||||
normalizeSender: normalizeIMessageHandle,
|
||||
parseAllowTarget: parseIMessageAllowTarget,
|
||||
});
|
||||
|
||||
export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean {
|
||||
return isAllowedIMessageSenderMatcher(params);
|
||||
}
|
||||
|
||||
export function formatIMessageChatTarget(chatId?: number | null): string {
|
||||
if (!chatId || !Number.isFinite(chatId)) {
|
||||
return "";
|
||||
}
|
||||
return `chat_id:${chatId}`;
|
||||
}
|
||||
@ -1,70 +1,2 @@
|
||||
import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { IMessageAccountConfig } from "../config/types.js";
|
||||
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
|
||||
export type ResolvedIMessageAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
config: IMessageAccountConfig;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage");
|
||||
export const listIMessageAccountIds = listAccountIds;
|
||||
export const resolveDefaultIMessageAccountId = resolveDefaultAccountId;
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): IMessageAccountConfig | undefined {
|
||||
return resolveAccountEntry(cfg.channels?.imessage?.accounts, accountId);
|
||||
}
|
||||
|
||||
function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.imessage ??
|
||||
{}) as IMessageAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveIMessageAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedIMessageAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
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.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: merged.name?.trim() || undefined,
|
||||
config: merged,
|
||||
configured,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledIMessageAccounts(cfg: OpenClawConfig): ResolvedIMessageAccount[] {
|
||||
return listIMessageAccountIds(cfg)
|
||||
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/accounts
|
||||
export * from "../../extensions/imessage/src/accounts.js";
|
||||
|
||||
@ -1,255 +1,2 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { createInterface, type Interface } from "node:readline";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
export type IMessageRpcError = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type IMessageRpcResponse<T> = {
|
||||
jsonrpc?: string;
|
||||
id?: string | number | null;
|
||||
result?: T;
|
||||
error?: IMessageRpcError;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
export type IMessageRpcNotification = {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
export type IMessageRpcClientOptions = {
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
onNotification?: (msg: IMessageRpcNotification) => void;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer?: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
function isTestEnv(): boolean {
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return true;
|
||||
}
|
||||
const vitest = process.env.VITEST?.trim().toLowerCase();
|
||||
return Boolean(vitest);
|
||||
}
|
||||
|
||||
export class IMessageRpcClient {
|
||||
private readonly cliPath: string;
|
||||
private readonly dbPath?: string;
|
||||
private readonly runtime?: RuntimeEnv;
|
||||
private readonly onNotification?: (msg: IMessageRpcNotification) => void;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly closed: Promise<void>;
|
||||
private closedResolve: (() => void) | null = null;
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
private reader: Interface | null = null;
|
||||
private nextId = 1;
|
||||
|
||||
constructor(opts: IMessageRpcClientOptions = {}) {
|
||||
this.cliPath = opts.cliPath?.trim() || "imsg";
|
||||
this.dbPath = opts.dbPath?.trim() ? resolveUserPath(opts.dbPath) : undefined;
|
||||
this.runtime = opts.runtime;
|
||||
this.onNotification = opts.onNotification;
|
||||
this.closed = new Promise((resolve) => {
|
||||
this.closedResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.child) {
|
||||
return;
|
||||
}
|
||||
if (isTestEnv()) {
|
||||
throw new Error("Refusing to start imsg rpc in test environment; mock iMessage RPC client");
|
||||
}
|
||||
const args = ["rpc"];
|
||||
if (this.dbPath) {
|
||||
args.push("--db", this.dbPath);
|
||||
}
|
||||
const child = spawn(this.cliPath, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
this.child = child;
|
||||
this.reader = createInterface({ input: child.stdout });
|
||||
|
||||
this.reader.on("line", (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
this.handleLine(trimmed);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const lines = chunk.toString().split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
this.runtime?.error?.(`imsg rpc: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
this.failAll(err instanceof Error ? err : new Error(String(err)));
|
||||
this.closedResolve?.();
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
||||
this.failAll(new Error(`imsg rpc exited (${reason})`));
|
||||
} else {
|
||||
this.failAll(new Error("imsg rpc closed"));
|
||||
}
|
||||
this.closedResolve?.();
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.child) {
|
||||
return;
|
||||
}
|
||||
this.reader?.close();
|
||||
this.reader = null;
|
||||
this.child.stdin?.end();
|
||||
const child = this.child;
|
||||
this.child = null;
|
||||
|
||||
await Promise.race([
|
||||
this.closed,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
resolve();
|
||||
}, 500);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async waitForClose(): Promise<void> {
|
||||
await this.closed;
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
opts?: { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
if (!this.child || !this.child.stdin) {
|
||||
throw new Error("imsg rpc not running");
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params: params ?? {},
|
||||
};
|
||||
const line = `${JSON.stringify(payload)}\n`;
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
||||
|
||||
const response = new Promise<T>((resolve, reject) => {
|
||||
const key = String(id);
|
||||
const timer =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(key);
|
||||
reject(new Error(`imsg rpc timeout (${method})`));
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
this.pending.set(key, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
});
|
||||
|
||||
this.child.stdin.write(line);
|
||||
return await response;
|
||||
}
|
||||
|
||||
private handleLine(line: string) {
|
||||
let parsed: IMessageRpcResponse<unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.id !== undefined && parsed.id !== null) {
|
||||
const key = String(parsed.id);
|
||||
const pending = this.pending.get(key);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
this.pending.delete(key);
|
||||
|
||||
if (parsed.error) {
|
||||
const baseMessage = parsed.error.message ?? "imsg rpc error";
|
||||
const details = parsed.error.data;
|
||||
const code = parsed.error.code;
|
||||
const suffixes = [] as string[];
|
||||
if (typeof code === "number") {
|
||||
suffixes.push(`code=${code}`);
|
||||
}
|
||||
if (details !== undefined) {
|
||||
const detailText =
|
||||
typeof details === "string" ? details : JSON.stringify(details, null, 2);
|
||||
if (detailText) {
|
||||
suffixes.push(detailText);
|
||||
}
|
||||
}
|
||||
const msg = suffixes.length > 0 ? `${baseMessage}: ${suffixes.join(" ")}` : baseMessage;
|
||||
pending.reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
pending.resolve(parsed.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.method) {
|
||||
this.onNotification?.({
|
||||
method: parsed.method,
|
||||
params: parsed.params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(err: Error) {
|
||||
for (const [key, pending] of this.pending.entries()) {
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
pending.reject(err);
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createIMessageRpcClient(
|
||||
opts: IMessageRpcClientOptions = {},
|
||||
): Promise<IMessageRpcClient> {
|
||||
const client = new IMessageRpcClient(opts);
|
||||
await client.start();
|
||||
return client;
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/client
|
||||
export * from "../../extensions/imessage/src/client.js";
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
/** Default timeout for iMessage probe/RPC operations (10 seconds). */
|
||||
export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;
|
||||
// Shim: re-exports from extensions/imessage/src/constants
|
||||
export * from "../../extensions/imessage/src/constants.js";
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export { monitorIMessageProvider } from "./monitor/monitor-provider.js";
|
||||
export type { MonitorIMessageOpts } from "./monitor/types.js";
|
||||
// Shim: re-exports from extensions/imessage/src/monitor
|
||||
export * from "../../extensions/imessage/src/monitor.js";
|
||||
|
||||
@ -1,34 +1,2 @@
|
||||
export type IMessageMonitorClient = {
|
||||
request: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function attachIMessageMonitorAbortHandler(params: {
|
||||
abortSignal?: AbortSignal;
|
||||
client: IMessageMonitorClient;
|
||||
getSubscriptionId: () => number | null;
|
||||
}): () => void {
|
||||
const abort = params.abortSignal;
|
||||
if (!abort) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
const subscriptionId = params.getSubscriptionId();
|
||||
if (subscriptionId) {
|
||||
void params.client
|
||||
.request("watch.unsubscribe", {
|
||||
subscription: subscriptionId,
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore disconnect errors during shutdown.
|
||||
});
|
||||
}
|
||||
void params.client.stop().catch(() => {
|
||||
// Ignore disconnect errors during shutdown.
|
||||
});
|
||||
};
|
||||
|
||||
abort.addEventListener("abort", onAbort, { once: true });
|
||||
return () => abort.removeEventListener("abort", onAbort);
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/abort-handler
|
||||
export * from "../../../extensions/imessage/src/monitor/abort-handler.js";
|
||||
|
||||
@ -1,70 +1,2 @@
|
||||
import { chunkTextWithMode, resolveChunkMode } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { createIMessageRpcClient } from "../client.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import type { SentMessageCache } from "./echo-cache.js";
|
||||
import { sanitizeOutboundText } from "./sanitize-outbound.js";
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
sentMessageCache?: Pick<SentMessageCache, "remember">;
|
||||
}) {
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
|
||||
params;
|
||||
const scope = `${accountId ?? ""}:${target}`;
|
||||
const cfg = loadConfig();
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const rawText = sanitizeOutboundText(payload.text ?? "");
|
||||
const text = convertMarkdownTables(rawText, tableMode);
|
||||
if (!text && mediaList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
sentMessageCache?.remember(scope, { text });
|
||||
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||
const sent = await sendMessageIMessage(target, chunk, {
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId });
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
const sent = await sendMessageIMessage(target, caption, {
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
sentMessageCache?.remember(scope, {
|
||||
text: caption || undefined,
|
||||
messageId: sent.messageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`imessage: delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/deliver
|
||||
export * from "../../../extensions/imessage/src/monitor/deliver.js";
|
||||
|
||||
@ -1,87 +1,2 @@
|
||||
export type SentMessageLookup = {
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export type SentMessageCache = {
|
||||
remember: (scope: string, lookup: SentMessageLookup) => void;
|
||||
has: (scope: string, lookup: SentMessageLookup) => boolean;
|
||||
};
|
||||
|
||||
// Keep the text fallback short so repeated user replies like "ok" are not
|
||||
// suppressed for long; delayed reflections should match the stronger message-id key.
|
||||
const SENT_MESSAGE_TEXT_TTL_MS = 5_000;
|
||||
const SENT_MESSAGE_ID_TTL_MS = 60_000;
|
||||
|
||||
function normalizeEchoTextKey(text: string | undefined): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const normalized = text.replace(/\r\n?/g, "\n").trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeEchoMessageIdKey(messageId: string | undefined): string | null {
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
const normalized = messageId.trim();
|
||||
if (!normalized || normalized === "ok" || normalized === "unknown") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
class DefaultSentMessageCache implements SentMessageCache {
|
||||
private textCache = new Map<string, number>();
|
||||
private messageIdCache = new Map<string, number>();
|
||||
|
||||
remember(scope: string, lookup: SentMessageLookup): void {
|
||||
const textKey = normalizeEchoTextKey(lookup.text);
|
||||
if (textKey) {
|
||||
this.textCache.set(`${scope}:${textKey}`, Date.now());
|
||||
}
|
||||
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
||||
if (messageIdKey) {
|
||||
this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now());
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
has(scope: string, lookup: SentMessageLookup): boolean {
|
||||
this.cleanup();
|
||||
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
||||
if (messageIdKey) {
|
||||
const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
|
||||
if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const textKey = normalizeEchoTextKey(lookup.text);
|
||||
if (textKey) {
|
||||
const textTimestamp = this.textCache.get(`${scope}:${textKey}`);
|
||||
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of this.textCache.entries()) {
|
||||
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
|
||||
this.textCache.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, timestamp] of this.messageIdCache.entries()) {
|
||||
if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) {
|
||||
this.messageIdCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSentMessageCache(): SentMessageCache {
|
||||
return new DefaultSentMessageCache();
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/echo-cache
|
||||
export * from "../../../extensions/imessage/src/monitor/echo-cache.js";
|
||||
|
||||
@ -1,522 +1,2 @@
|
||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||
import {
|
||||
formatInboundEnvelope,
|
||||
formatInboundFromLabel,
|
||||
resolveEnvelopeFormatOptions,
|
||||
type EnvelopeFormatOptions,
|
||||
} from "../../auto-reply/envelope.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "../../auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
||||
import { resolveDualTextControlCommandGate } from "../../channels/command-gating.js";
|
||||
import { logInboundDrop } from "../../channels/logging.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "../../config/group-policy.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../../security/dm-policy-shared.js";
|
||||
import { sanitizeTerminalText } from "../../terminal/safe-text.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
isAllowedIMessageSender,
|
||||
normalizeIMessageHandle,
|
||||
} from "../targets.js";
|
||||
import { detectReflectedContent } from "./reflection-guard.js";
|
||||
import type { SelfChatCache } from "./self-chat-cache.js";
|
||||
import type { MonitorIMessageOpts, IMessagePayload } from "./types.js";
|
||||
|
||||
type IMessageReplyContext = {
|
||||
id?: string;
|
||||
body: string;
|
||||
sender?: string;
|
||||
};
|
||||
|
||||
function normalizeReplyField(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeReplyContext(message: IMessagePayload): IMessageReplyContext | null {
|
||||
const body = normalizeReplyField(message.reply_to_text);
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
const id = normalizeReplyField(message.reply_to_id);
|
||||
const sender = normalizeReplyField(message.reply_to_sender);
|
||||
return { body, id, sender };
|
||||
}
|
||||
|
||||
export type IMessageInboundDispatchDecision = {
|
||||
kind: "dispatch";
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
chatGuid?: string;
|
||||
chatIdentifier?: string;
|
||||
groupId?: string;
|
||||
historyKey?: string;
|
||||
sender: string;
|
||||
senderNormalized: string;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
bodyText: string;
|
||||
createdAt?: number;
|
||||
replyContext: IMessageReplyContext | null;
|
||||
effectiveWasMentioned: boolean;
|
||||
commandAuthorized: boolean;
|
||||
// Used for allowlist checks for control commands.
|
||||
effectiveDmAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
};
|
||||
|
||||
export type IMessageInboundDecision =
|
||||
| { kind: "drop"; reason: string }
|
||||
| { kind: "pairing"; senderId: string }
|
||||
| IMessageInboundDispatchDecision;
|
||||
|
||||
export function resolveIMessageInboundDecision(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
message: IMessagePayload;
|
||||
opts?: Pick<MonitorIMessageOpts, "requireMention">;
|
||||
messageText: string;
|
||||
bodyText: string;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
groupPolicy: string;
|
||||
dmPolicy: string;
|
||||
storeAllowFrom: string[];
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean };
|
||||
selfChatCache?: SelfChatCache;
|
||||
logVerbose?: (msg: string) => void;
|
||||
}): IMessageInboundDecision {
|
||||
const senderRaw = params.message.sender ?? "";
|
||||
const sender = senderRaw.trim();
|
||||
if (!sender) {
|
||||
return { kind: "drop", reason: "missing sender" };
|
||||
}
|
||||
const senderNormalized = normalizeIMessageHandle(sender);
|
||||
const chatId = params.message.chat_id ?? undefined;
|
||||
const chatGuid = params.message.chat_guid ?? undefined;
|
||||
const chatIdentifier = params.message.chat_identifier ?? undefined;
|
||||
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
|
||||
|
||||
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
|
||||
const groupListPolicy = groupIdCandidate
|
||||
? resolveChannelGroupPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
groupId: groupIdCandidate,
|
||||
})
|
||||
: {
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
groupConfig: undefined,
|
||||
defaultConfig: undefined,
|
||||
};
|
||||
|
||||
// If the owner explicitly configures a chat_id under imessage.groups, treat that thread as a
|
||||
// "group" for permission gating + session isolation, even when is_group=false.
|
||||
const treatAsGroupByConfig = Boolean(
|
||||
groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig,
|
||||
);
|
||||
const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig;
|
||||
const selfChatLookup = {
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
sender,
|
||||
text: params.bodyText,
|
||||
createdAt,
|
||||
};
|
||||
if (params.message.is_from_me) {
|
||||
params.selfChatCache?.remember(selfChatLookup);
|
||||
return { kind: "drop", reason: "from me" };
|
||||
}
|
||||
if (isGroup && !chatId) {
|
||||
return { kind: "drop", reason: "group without chat_id" };
|
||||
}
|
||||
|
||||
const groupId = isGroup ? groupIdCandidate : undefined;
|
||||
const accessDecision = resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: params.groupPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
storeAllowFrom: params.storeAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isAllowedIMessageSender({
|
||||
allowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
}),
|
||||
});
|
||||
const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom;
|
||||
const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
|
||||
|
||||
if (accessDecision.decision !== "allow") {
|
||||
if (isGroup) {
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
|
||||
params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)");
|
||||
return { kind: "drop", reason: "groupPolicy disabled" };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
|
||||
params.logVerbose?.(
|
||||
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
|
||||
);
|
||||
return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
|
||||
params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`);
|
||||
return { kind: "drop", reason: "not in groupAllowFrom" };
|
||||
}
|
||||
params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`);
|
||||
return { kind: "drop", reason: accessDecision.reason };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
|
||||
return { kind: "drop", reason: "dmPolicy disabled" };
|
||||
}
|
||||
if (accessDecision.decision === "pairing") {
|
||||
return { kind: "pairing", senderId: senderNormalized };
|
||||
}
|
||||
params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`);
|
||||
return { kind: "drop", reason: "dmPolicy blocked" };
|
||||
}
|
||||
|
||||
if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
|
||||
params.logVerbose?.(
|
||||
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,
|
||||
);
|
||||
return { kind: "drop", reason: "group id not in allowlist" };
|
||||
}
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? String(chatId ?? "unknown") : senderNormalized,
|
||||
},
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
|
||||
const messageText = params.messageText.trim();
|
||||
const bodyText = params.bodyText.trim();
|
||||
if (!bodyText) {
|
||||
return { kind: "drop", reason: "empty body" };
|
||||
}
|
||||
|
||||
if (
|
||||
params.selfChatCache?.has({
|
||||
...selfChatLookup,
|
||||
text: bodyText,
|
||||
})
|
||||
) {
|
||||
const preview = sanitizeTerminalText(truncateUtf16Safe(bodyText, 50));
|
||||
params.logVerbose?.(`imessage: dropping self-chat reflected duplicate: "${preview}"`);
|
||||
return { kind: "drop", reason: "self-chat echo" };
|
||||
}
|
||||
|
||||
// Echo detection: check if the received message matches a recently sent message.
|
||||
// Scope by conversation so same text in different chats is not conflated.
|
||||
const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined;
|
||||
if (params.echoCache && (messageText || inboundMessageId)) {
|
||||
const echoScope = buildIMessageEchoScope({
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
sender,
|
||||
});
|
||||
if (
|
||||
params.echoCache.has(echoScope, {
|
||||
text: messageText || undefined,
|
||||
messageId: inboundMessageId,
|
||||
})
|
||||
) {
|
||||
params.logVerbose?.(
|
||||
describeIMessageEchoDropLog({ messageText, messageId: inboundMessageId }),
|
||||
);
|
||||
return { kind: "drop", reason: "echo" };
|
||||
}
|
||||
}
|
||||
|
||||
// Reflection guard: drop inbound messages that contain assistant-internal
|
||||
// metadata markers. These indicate outbound content was reflected back as
|
||||
// inbound, which causes recursive echo amplification.
|
||||
const reflection = detectReflectedContent(messageText);
|
||||
if (reflection.isReflection) {
|
||||
params.logVerbose?.(
|
||||
`imessage: dropping reflected assistant content (markers: ${reflection.matchedLabels.join(", ")})`,
|
||||
);
|
||||
return { kind: "drop", reason: "reflected assistant content" };
|
||||
}
|
||||
|
||||
const replyContext = describeReplyContext(params.message);
|
||||
const historyKey = isGroup
|
||||
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
|
||||
: undefined;
|
||||
|
||||
const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true;
|
||||
const requireMention = resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
groupId,
|
||||
requireMentionOverride: params.opts?.requireMention,
|
||||
overrideOrder: "before-config",
|
||||
});
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom;
|
||||
const ownerAllowedForCommands =
|
||||
commandDmAllowFrom.length > 0
|
||||
? isAllowedIMessageSender({
|
||||
allowFrom: commandDmAllowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
})
|
||||
: false;
|
||||
const groupAllowedForCommands =
|
||||
effectiveGroupAllowFrom.length > 0
|
||||
? isAllowedIMessageSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
})
|
||||
: false;
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg);
|
||||
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
|
||||
useAccessGroups,
|
||||
primaryConfigured: commandDmAllowFrom.length > 0,
|
||||
primaryAllowed: ownerAllowedForCommands,
|
||||
secondaryConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
secondaryAllowed: groupAllowedForCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
if (isGroup && shouldBlock) {
|
||||
if (params.logVerbose) {
|
||||
logInboundDrop({
|
||||
log: params.logVerbose,
|
||||
channel: "imessage",
|
||||
reason: "control command (unauthorized)",
|
||||
target: sender,
|
||||
});
|
||||
}
|
||||
return { kind: "drop", reason: "control command (unauthorized)" };
|
||||
}
|
||||
|
||||
const shouldBypassMention =
|
||||
isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
|
||||
const effectiveWasMentioned = mentioned || shouldBypassMention;
|
||||
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
|
||||
params.logVerbose?.(`imessage: skipping group message (no mention)`);
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: params.groupHistories,
|
||||
historyKey: historyKey ?? "",
|
||||
limit: params.historyLimit,
|
||||
entry: historyKey
|
||||
? {
|
||||
sender: senderNormalized,
|
||||
body: bodyText,
|
||||
timestamp: createdAt,
|
||||
messageId: params.message.id ? String(params.message.id) : undefined,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return { kind: "drop", reason: "no mention" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "dispatch",
|
||||
isGroup,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
groupId,
|
||||
historyKey,
|
||||
sender,
|
||||
senderNormalized,
|
||||
route,
|
||||
bodyText,
|
||||
createdAt,
|
||||
replyContext,
|
||||
effectiveWasMentioned,
|
||||
commandAuthorized,
|
||||
effectiveDmAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIMessageInboundContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
decision: IMessageInboundDispatchDecision;
|
||||
message: IMessagePayload;
|
||||
envelopeOptions?: EnvelopeFormatOptions;
|
||||
previousTimestamp?: number;
|
||||
remoteHost?: string;
|
||||
media?: {
|
||||
path?: string;
|
||||
type?: string;
|
||||
paths?: string[];
|
||||
types?: Array<string | undefined>;
|
||||
};
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
}): {
|
||||
ctxPayload: ReturnType<typeof finalizeInboundContext>;
|
||||
fromLabel: string;
|
||||
chatTarget?: string;
|
||||
imessageTo: string;
|
||||
inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>;
|
||||
} {
|
||||
const envelopeOptions = params.envelopeOptions ?? resolveEnvelopeFormatOptions(params.cfg);
|
||||
const { decision } = params;
|
||||
const chatId = decision.chatId;
|
||||
const chatTarget =
|
||||
decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined;
|
||||
|
||||
const replySuffix = decision.replyContext
|
||||
? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${
|
||||
decision.replyContext.id ? ` id:${decision.replyContext.id}` : ""
|
||||
}]\n${decision.replyContext.body}\n[/Replying]`
|
||||
: "";
|
||||
|
||||
const fromLabel = formatInboundFromLabel({
|
||||
isGroup: decision.isGroup,
|
||||
groupLabel: params.message.chat_name ?? undefined,
|
||||
groupId: chatId !== undefined ? String(chatId) : "unknown",
|
||||
groupFallback: "Group",
|
||||
directLabel: decision.senderNormalized,
|
||||
directId: decision.sender,
|
||||
});
|
||||
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "iMessage",
|
||||
from: fromLabel,
|
||||
timestamp: decision.createdAt,
|
||||
body: `${decision.bodyText}${replySuffix}`,
|
||||
chatType: decision.isGroup ? "group" : "direct",
|
||||
sender: { name: decision.senderNormalized, id: decision.sender },
|
||||
previousTimestamp: params.previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
|
||||
let combinedBody = body;
|
||||
if (decision.isGroup && decision.historyKey) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: params.groupHistories,
|
||||
historyKey: decision.historyKey,
|
||||
limit: params.historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "iMessage",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
chatType: "group",
|
||||
senderLabel: entry.sender,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`;
|
||||
const inboundHistory =
|
||||
decision.isGroup && decision.historyKey && params.historyLimit > 0
|
||||
? (params.groupHistories.get(decision.historyKey) ?? []).map((entry) => ({
|
||||
sender: entry.sender,
|
||||
body: entry.body,
|
||||
timestamp: entry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: decision.bodyText,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: decision.bodyText,
|
||||
CommandBody: decision.bodyText,
|
||||
From: decision.isGroup
|
||||
? `imessage:group:${chatId ?? "unknown"}`
|
||||
: `imessage:${decision.sender}`,
|
||||
To: imessageTo,
|
||||
SessionKey: decision.route.sessionKey,
|
||||
AccountId: decision.route.accountId,
|
||||
ChatType: decision.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined,
|
||||
GroupMembers: decision.isGroup
|
||||
? (params.message.participants ?? []).filter(Boolean).join(", ")
|
||||
: undefined,
|
||||
SenderName: decision.senderNormalized,
|
||||
SenderId: decision.sender,
|
||||
Provider: "imessage",
|
||||
Surface: "imessage",
|
||||
MessageSid: params.message.id ? String(params.message.id) : undefined,
|
||||
ReplyToId: decision.replyContext?.id,
|
||||
ReplyToBody: decision.replyContext?.body,
|
||||
ReplyToSender: decision.replyContext?.sender,
|
||||
Timestamp: decision.createdAt,
|
||||
MediaPath: params.media?.path,
|
||||
MediaType: params.media?.type,
|
||||
MediaUrl: params.media?.path,
|
||||
MediaPaths:
|
||||
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
|
||||
MediaTypes:
|
||||
params.media?.types && params.media.types.length > 0 ? params.media.types : undefined,
|
||||
MediaUrls:
|
||||
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
|
||||
MediaRemoteHost: params.remoteHost,
|
||||
WasMentioned: decision.effectiveWasMentioned,
|
||||
CommandAuthorized: decision.commandAuthorized,
|
||||
OriginatingChannel: "imessage" as const,
|
||||
OriginatingTo: imessageTo,
|
||||
});
|
||||
|
||||
return { ctxPayload, fromLabel, chatTarget, imessageTo, inboundHistory };
|
||||
}
|
||||
|
||||
export function buildIMessageEchoScope(params: {
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
sender: string;
|
||||
}): string {
|
||||
return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`;
|
||||
}
|
||||
|
||||
export function describeIMessageEchoDropLog(params: {
|
||||
messageText: string;
|
||||
messageId?: string;
|
||||
}): string {
|
||||
const preview = truncateUtf16Safe(params.messageText, 50);
|
||||
const messageIdPart = params.messageId ? ` id=${params.messageId}` : "";
|
||||
return `imessage: skipping echo message${messageIdPart}: "${preview}"`;
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/inbound-processing
|
||||
export * from "../../../extensions/imessage/src/monitor/inbound-processing.js";
|
||||
|
||||
@ -1,69 +1,2 @@
|
||||
/**
|
||||
* Per-conversation rate limiter that detects rapid-fire identical echo
|
||||
* patterns and suppresses them before they amplify into queue overflow.
|
||||
*/
|
||||
|
||||
const DEFAULT_WINDOW_MS = 60_000;
|
||||
const DEFAULT_MAX_HITS = 5;
|
||||
const CLEANUP_INTERVAL_MS = 120_000;
|
||||
|
||||
type ConversationWindow = {
|
||||
timestamps: number[];
|
||||
};
|
||||
|
||||
export type LoopRateLimiter = {
|
||||
/** Returns true if this conversation has exceeded the rate limit. */
|
||||
isRateLimited: (conversationKey: string) => boolean;
|
||||
/** Record an inbound message for a conversation. */
|
||||
record: (conversationKey: string) => void;
|
||||
};
|
||||
|
||||
export function createLoopRateLimiter(opts?: {
|
||||
windowMs?: number;
|
||||
maxHits?: number;
|
||||
}): LoopRateLimiter {
|
||||
const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
|
||||
const maxHits = opts?.maxHits ?? DEFAULT_MAX_HITS;
|
||||
const conversations = new Map<string, ConversationWindow>();
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastCleanup = now;
|
||||
for (const [key, win] of conversations.entries()) {
|
||||
const recent = win.timestamps.filter((ts) => now - ts <= windowMs);
|
||||
if (recent.length === 0) {
|
||||
conversations.delete(key);
|
||||
} else {
|
||||
win.timestamps = recent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
record(conversationKey: string) {
|
||||
cleanup();
|
||||
let win = conversations.get(conversationKey);
|
||||
if (!win) {
|
||||
win = { timestamps: [] };
|
||||
conversations.set(conversationKey, win);
|
||||
}
|
||||
win.timestamps.push(Date.now());
|
||||
},
|
||||
|
||||
isRateLimited(conversationKey: string): boolean {
|
||||
cleanup();
|
||||
const win = conversations.get(conversationKey);
|
||||
if (!win) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
const recent = win.timestamps.filter((ts) => now - ts <= windowMs);
|
||||
win.timestamps = recent;
|
||||
return recent.length >= maxHits;
|
||||
},
|
||||
};
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/loop-rate-limiter
|
||||
export * from "../../../extensions/imessage/src/monitor/loop-rate-limiter.js";
|
||||
|
||||
@ -1,537 +1,2 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||
import {
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} from "../../auto-reply/reply/history.js";
|
||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import {
|
||||
createChannelInboundDebouncer,
|
||||
shouldDebounceTextInbound,
|
||||
} from "../../channels/inbound-debounce-policy.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../../config/runtime-group-policy.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
|
||||
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
|
||||
import { waitForTransportReady } from "../../infra/transport-ready.js";
|
||||
import {
|
||||
isInboundPathAllowed,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "../../media/inbound-path-policy.js";
|
||||
import { kindFromMime } from "../../media/mime.js";
|
||||
import { issuePairingChallenge } from "../../pairing/pairing-challenge.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../security/dm-policy-shared.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { resolveIMessageAccount } from "../accounts.js";
|
||||
import { createIMessageRpcClient } from "../client.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
||||
import { probeIMessage } from "../probe.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import { normalizeIMessageHandle } from "../targets.js";
|
||||
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
||||
import { deliverReplies } from "./deliver.js";
|
||||
import { createSentMessageCache } from "./echo-cache.js";
|
||||
import {
|
||||
buildIMessageInboundContext,
|
||||
resolveIMessageInboundDecision,
|
||||
} from "./inbound-processing.js";
|
||||
import { createLoopRateLimiter } from "./loop-rate-limiter.js";
|
||||
import { parseIMessageNotification } from "./parse-notification.js";
|
||||
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
|
||||
import { createSelfChatCache } from "./self-chat-cache.js";
|
||||
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
|
||||
|
||||
/**
|
||||
* Try to detect remote host from an SSH wrapper script like:
|
||||
* exec ssh -T openclaw@192.168.64.3 /opt/homebrew/bin/imsg "$@"
|
||||
* exec ssh -T mac-mini imsg "$@"
|
||||
* Returns the user@host or host portion if found, undefined otherwise.
|
||||
*/
|
||||
async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
// Expand ~ to home directory
|
||||
const expanded = cliPath.startsWith("~")
|
||||
? cliPath.replace(/^~/, process.env.HOME ?? "")
|
||||
: cliPath;
|
||||
const content = await fs.readFile(expanded, "utf8");
|
||||
|
||||
// Match user@host pattern first (e.g., openclaw@192.168.64.3)
|
||||
const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/);
|
||||
if (userHostMatch) {
|
||||
return userHostMatch[1];
|
||||
}
|
||||
|
||||
// Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg)
|
||||
const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/);
|
||||
return hostOnlyMatch?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const accountInfo = resolveIMessageAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const imessageCfg = accountInfo.config;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
imessageCfg.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const sentMessageCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const loopRateLimiter = createLoopRateLimiter();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
imessageCfg.groupAllowFrom ??
|
||||
(imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []),
|
||||
);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.imessage !== undefined,
|
||||
groupPolicy: imessageCfg.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
log: (message) => runtime.log?.(warn(message)),
|
||||
});
|
||||
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
|
||||
const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
|
||||
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
|
||||
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
|
||||
const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
||||
const attachmentRoots = resolveIMessageAttachmentRoots({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
|
||||
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script.
|
||||
// Accept only a safe host token to avoid option/argument injection into SCP.
|
||||
const configuredRemoteHost = normalizeScpRemoteHost(imessageCfg.remoteHost);
|
||||
if (imessageCfg.remoteHost && !configuredRemoteHost) {
|
||||
logVerbose("imessage: ignoring unsafe channels.imessage.remoteHost value");
|
||||
}
|
||||
|
||||
let remoteHost = configuredRemoteHost;
|
||||
if (!remoteHost && cliPath && cliPath !== "imsg") {
|
||||
const detected = await detectRemoteHostFromCliPath(cliPath);
|
||||
const normalizedDetected = normalizeScpRemoteHost(detected);
|
||||
if (detected && !normalizedDetected) {
|
||||
logVerbose("imessage: ignoring unsafe auto-detected remoteHost from cliPath");
|
||||
}
|
||||
remoteHost = normalizedDetected;
|
||||
if (remoteHost) {
|
||||
logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`);
|
||||
}
|
||||
}
|
||||
|
||||
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{
|
||||
message: IMessagePayload;
|
||||
}>({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
buildKey: (entry) => {
|
||||
const sender = entry.message.sender?.trim();
|
||||
if (!sender) {
|
||||
return null;
|
||||
}
|
||||
const conversationId =
|
||||
entry.message.chat_id != null
|
||||
? `chat:${entry.message.chat_id}`
|
||||
: (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown");
|
||||
return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
return shouldDebounceTextInbound({
|
||||
text: entry.message.text,
|
||||
cfg,
|
||||
hasMedia: Boolean(entry.message.attachments && entry.message.attachments.length > 0),
|
||||
});
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await handleMessageNow(last.message);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.message.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const syntheticMessage: IMessagePayload = {
|
||||
...last.message,
|
||||
text: combinedText,
|
||||
attachments: null,
|
||||
};
|
||||
await handleMessageNow(syntheticMessage);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(`imessage debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleMessageNow(message: IMessagePayload) {
|
||||
const messageText = (message.text ?? "").trim();
|
||||
|
||||
const attachments = includeAttachments ? (message.attachments ?? []) : [];
|
||||
const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots;
|
||||
const validAttachments = attachments.filter((entry) => {
|
||||
const attachmentPath = entry?.original_path?.trim();
|
||||
if (!attachmentPath || entry?.missing) {
|
||||
return false;
|
||||
}
|
||||
if (isInboundPathAllowed({ filePath: attachmentPath, roots: effectiveAttachmentRoots })) {
|
||||
return true;
|
||||
}
|
||||
logVerbose(`imessage: dropping inbound attachment outside allowed roots: ${attachmentPath}`);
|
||||
return false;
|
||||
});
|
||||
const firstAttachment = validAttachments[0];
|
||||
const mediaPath = firstAttachment?.original_path ?? undefined;
|
||||
const mediaType = firstAttachment?.mime_type ?? undefined;
|
||||
// Build arrays for all attachments (for multi-image support)
|
||||
const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[];
|
||||
const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined);
|
||||
const kind = kindFromMime(mediaType ?? undefined);
|
||||
const placeholder = kind
|
||||
? `<media:${kind}>`
|
||||
: validAttachments.length
|
||||
? "<media:attachment>"
|
||||
: "";
|
||||
const bodyText = messageText || placeholder;
|
||||
|
||||
const storeAllowFrom = await readChannelAllowFromStore(
|
||||
"imessage",
|
||||
process.env,
|
||||
accountInfo.accountId,
|
||||
).catch(() => []);
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
message,
|
||||
opts,
|
||||
messageText,
|
||||
bodyText,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
groupPolicy,
|
||||
dmPolicy,
|
||||
storeAllowFrom,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
echoCache: sentMessageCache,
|
||||
selfChatCache,
|
||||
logVerbose,
|
||||
});
|
||||
|
||||
// Build conversation key for rate limiting (used by both drop and dispatch paths).
|
||||
const chatId = message.chat_id ?? undefined;
|
||||
const senderForKey = (message.sender ?? "").trim();
|
||||
const conversationKey = chatId != null ? `group:${chatId}` : `dm:${senderForKey}`;
|
||||
const rateLimitKey = `${accountInfo.accountId}:${conversationKey}`;
|
||||
|
||||
if (decision.kind === "drop") {
|
||||
// Record echo/reflection drops so the rate limiter can detect sustained loops.
|
||||
// Only loop-related drop reasons feed the counter; policy/mention/empty drops
|
||||
// are normal and should not escalate.
|
||||
const isLoopDrop =
|
||||
decision.reason === "echo" ||
|
||||
decision.reason === "self-chat echo" ||
|
||||
decision.reason === "reflected assistant content" ||
|
||||
decision.reason === "from me";
|
||||
if (isLoopDrop) {
|
||||
loopRateLimiter.record(rateLimitKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// After repeated echo/reflection drops for a conversation, suppress all
|
||||
// remaining messages as a safety net against amplification that slips
|
||||
// through the primary guards.
|
||||
if (decision.kind === "dispatch" && loopRateLimiter.isRateLimited(rateLimitKey)) {
|
||||
logVerbose(`imessage: rate-limited conversation ${conversationKey} (echo loop detected)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.kind === "pairing") {
|
||||
const sender = (message.sender ?? "").trim();
|
||||
if (!sender) {
|
||||
return;
|
||||
}
|
||||
await issuePairingChallenge({
|
||||
channel: "imessage",
|
||||
senderId: decision.senderId,
|
||||
senderIdLine: `Your iMessage sender id: ${decision.senderId}`,
|
||||
meta: {
|
||||
sender: decision.senderId,
|
||||
chatId: chatId ? String(chatId) : undefined,
|
||||
},
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "imessage",
|
||||
id,
|
||||
accountId: accountInfo.accountId,
|
||||
meta,
|
||||
}),
|
||||
onCreated: () => {
|
||||
logVerbose(`imessage pairing request sender=${decision.senderId}`);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageIMessage(sender, text, {
|
||||
client,
|
||||
maxBytes: mediaMaxBytes,
|
||||
accountId: accountInfo.accountId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: decision.route.agentId,
|
||||
});
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: decision.route.sessionKey,
|
||||
});
|
||||
const { ctxPayload, chatTarget } = buildIMessageInboundContext({
|
||||
cfg,
|
||||
decision,
|
||||
message,
|
||||
previousTimestamp,
|
||||
remoteHost,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
media: {
|
||||
path: mediaPath,
|
||||
type: mediaType,
|
||||
paths: mediaPaths,
|
||||
types: mediaTypes,
|
||||
},
|
||||
});
|
||||
|
||||
const updateTarget = chatTarget || decision.sender;
|
||||
const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: cfg.session?.dmScope,
|
||||
allowFrom,
|
||||
normalizeEntry: normalizeIMessageHandle,
|
||||
});
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute:
|
||||
!decision.isGroup && updateTarget
|
||||
? {
|
||||
sessionKey: decision.route.mainSessionKey,
|
||||
channel: "imessage",
|
||||
to: updateTarget,
|
||||
accountId: decision.route.accountId,
|
||||
mainDmOwnerPin:
|
||||
pinnedMainDmOwner && decision.senderNormalized
|
||||
? {
|
||||
ownerRecipient: pinnedMainDmOwner,
|
||||
senderRecipient: decision.senderNormalized,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = truncateUtf16Safe(String(ctxPayload.Body ?? ""), 200).replace(/\n/g, "\\n");
|
||||
logVerbose(
|
||||
`imessage inbound: chatId=${chatId ?? "unknown"} from=${ctxPayload.From} len=${
|
||||
String(ctxPayload.Body ?? "").length
|
||||
} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: decision.route.agentId,
|
||||
channel: "imessage",
|
||||
accountId: decision.route.accountId,
|
||||
});
|
||||
|
||||
const dispatcher = createReplyDispatcher({
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId),
|
||||
deliver: async (payload) => {
|
||||
const target = ctxPayload.To;
|
||||
if (!target) {
|
||||
runtime.error?.(danger("imessage: missing delivery target"));
|
||||
return;
|
||||
}
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target,
|
||||
client,
|
||||
accountId: accountInfo.accountId,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
sentMessageCache,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
});
|
||||
|
||||
const { queuedFinal } = await dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
disableBlockStreaming:
|
||||
typeof accountInfo.config.blockStreaming === "boolean"
|
||||
? !accountInfo.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queuedFinal) {
|
||||
if (decision.isGroup && decision.historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: groupHistories,
|
||||
historyKey: decision.historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (decision.isGroup && decision.historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: groupHistories,
|
||||
historyKey: decision.historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessage = async (raw: unknown) => {
|
||||
const message = parseIMessageNotification(raw);
|
||||
if (!message) {
|
||||
logVerbose("imessage: dropping malformed RPC message payload");
|
||||
return;
|
||||
}
|
||||
await inboundDebouncer.enqueue({ message });
|
||||
};
|
||||
|
||||
await waitForTransportReady({
|
||||
label: "imsg rpc",
|
||||
timeoutMs: 30_000,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
pollIntervalMs: 500,
|
||||
abortSignal: opts.abortSignal,
|
||||
runtime,
|
||||
check: async () => {
|
||||
const probe = await probeIMessage(probeTimeoutMs, { cliPath, dbPath, runtime });
|
||||
if (probe.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (probe.fatal) {
|
||||
throw new Error(probe.error ?? "imsg rpc unavailable");
|
||||
}
|
||||
return { ok: false, error: probe.error ?? "unreachable" };
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await createIMessageRpcClient({
|
||||
cliPath,
|
||||
dbPath,
|
||||
runtime,
|
||||
onNotification: (msg) => {
|
||||
if (msg.method === "message") {
|
||||
void handleMessage(msg.params).catch((err) => {
|
||||
runtime.error?.(`imessage: handler failed: ${String(err)}`);
|
||||
});
|
||||
} else if (msg.method === "error") {
|
||||
runtime.error?.(`imessage: watch error ${JSON.stringify(msg.params)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let subscriptionId: number | null = null;
|
||||
const abort = opts.abortSignal;
|
||||
const detachAbortHandler = attachIMessageMonitorAbortHandler({
|
||||
abortSignal: abort,
|
||||
client,
|
||||
getSubscriptionId: () => subscriptionId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request<{ subscription?: number }>("watch.subscribe", {
|
||||
attachments: includeAttachments,
|
||||
});
|
||||
subscriptionId = result?.subscription ?? null;
|
||||
await client.waitForClose();
|
||||
} catch (err) {
|
||||
if (abort?.aborted) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(danger(`imessage: monitor failed: ${String(err)}`));
|
||||
throw err;
|
||||
} finally {
|
||||
detachAbortHandler();
|
||||
await client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
};
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/monitor-provider
|
||||
export * from "../../../extensions/imessage/src/monitor/monitor-provider.js";
|
||||
|
||||
@ -1,83 +1,2 @@
|
||||
import type { IMessagePayload } from "./types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown): value is string | null | undefined {
|
||||
return value === undefined || value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined {
|
||||
return (
|
||||
value === undefined || value === null || typeof value === "string" || typeof value === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalNumber(value: unknown): value is number | null | undefined {
|
||||
return value === undefined || value === null || typeof value === "number";
|
||||
}
|
||||
|
||||
function isOptionalBoolean(value: unknown): value is boolean | null | undefined {
|
||||
return value === undefined || value === null || typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isOptionalStringArray(value: unknown): value is string[] | null | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(Array.isArray(value) && value.every((entry) => typeof entry === "string"))
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalAttachments(value: unknown): value is IMessagePayload["attachments"] {
|
||||
if (value === undefined || value === null) {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return value.every((attachment) => {
|
||||
if (!isRecord(attachment)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isOptionalString(attachment.original_path) &&
|
||||
isOptionalString(attachment.mime_type) &&
|
||||
isOptionalBoolean(attachment.missing)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseIMessageNotification(raw: unknown): IMessagePayload | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const maybeMessage = raw.message;
|
||||
if (!isRecord(maybeMessage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message: IMessagePayload = maybeMessage;
|
||||
if (
|
||||
!isOptionalNumber(message.id) ||
|
||||
!isOptionalNumber(message.chat_id) ||
|
||||
!isOptionalString(message.sender) ||
|
||||
!isOptionalBoolean(message.is_from_me) ||
|
||||
!isOptionalString(message.text) ||
|
||||
!isOptionalStringOrNumber(message.reply_to_id) ||
|
||||
!isOptionalString(message.reply_to_text) ||
|
||||
!isOptionalString(message.reply_to_sender) ||
|
||||
!isOptionalString(message.created_at) ||
|
||||
!isOptionalAttachments(message.attachments) ||
|
||||
!isOptionalString(message.chat_identifier) ||
|
||||
!isOptionalString(message.chat_guid) ||
|
||||
!isOptionalString(message.chat_name) ||
|
||||
!isOptionalStringArray(message.participants) ||
|
||||
!isOptionalBoolean(message.is_group)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/parse-notification
|
||||
export * from "../../../extensions/imessage/src/monitor/parse-notification.js";
|
||||
|
||||
@ -1,64 +1,2 @@
|
||||
/**
|
||||
* Detects inbound messages that are reflections of assistant-originated content.
|
||||
* These patterns indicate internal metadata leaked into a channel and then
|
||||
* bounced back as a new inbound message — creating an echo loop.
|
||||
*/
|
||||
|
||||
import { findCodeRegions, isInsideCode } from "../../shared/text/code-regions.js";
|
||||
|
||||
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/;
|
||||
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i;
|
||||
// Require closing `>` to avoid false-positives on phrases like "<thought experiment>".
|
||||
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/i;
|
||||
const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i;
|
||||
// Require closing `>` to avoid false-positives on phrases like "<final answer>".
|
||||
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i;
|
||||
|
||||
const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
||||
{ re: INTERNAL_SEPARATOR_RE, label: "internal-separator" },
|
||||
{ re: ASSISTANT_ROLE_MARKER_RE, label: "assistant-role-marker" },
|
||||
{ re: THINKING_TAG_RE, label: "thinking-tag" },
|
||||
{ re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" },
|
||||
{ re: FINAL_TAG_RE, label: "final-tag" },
|
||||
];
|
||||
|
||||
export type ReflectionDetection = {
|
||||
isReflection: boolean;
|
||||
matchedLabels: string[];
|
||||
};
|
||||
|
||||
function hasMatchOutsideCode(text: string, re: RegExp): boolean {
|
||||
const codeRegions = findCodeRegions(text);
|
||||
const globalRe = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
|
||||
|
||||
for (const match of text.matchAll(globalRe)) {
|
||||
const start = match.index ?? -1;
|
||||
if (start >= 0 && !isInsideCode(start, codeRegions)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an inbound message appears to be a reflection of
|
||||
* assistant-originated content. Returns matched pattern labels for telemetry.
|
||||
*/
|
||||
export function detectReflectedContent(text: string): ReflectionDetection {
|
||||
if (!text) {
|
||||
return { isReflection: false, matchedLabels: [] };
|
||||
}
|
||||
|
||||
const matchedLabels: string[] = [];
|
||||
for (const { re, label } of REFLECTION_PATTERNS) {
|
||||
if (hasMatchOutsideCode(text, re)) {
|
||||
matchedLabels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isReflection: matchedLabels.length > 0,
|
||||
matchedLabels,
|
||||
};
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/reflection-guard
|
||||
export * from "../../../extensions/imessage/src/monitor/reflection-guard.js";
|
||||
|
||||
@ -1,11 +1,2 @@
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { normalizeStringEntries } from "../../shared/string-normalization.js";
|
||||
import type { MonitorIMessageOpts } from "./types.js";
|
||||
|
||||
export function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
||||
return opts.runtime ?? createNonExitingRuntime();
|
||||
}
|
||||
|
||||
export function normalizeAllowList(list?: Array<string | number>) {
|
||||
return normalizeStringEntries(list);
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/runtime
|
||||
export * from "../../../extensions/imessage/src/monitor/runtime.js";
|
||||
|
||||
@ -1,31 +1,2 @@
|
||||
import { stripAssistantInternalScaffolding } from "../../shared/text/assistant-visible-text.js";
|
||||
|
||||
/**
|
||||
* Patterns that indicate assistant-internal metadata leaked into text.
|
||||
* These must never reach a user-facing channel.
|
||||
*/
|
||||
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g;
|
||||
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi;
|
||||
const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm;
|
||||
|
||||
/**
|
||||
* Strip all assistant-internal scaffolding from outbound text before delivery.
|
||||
* Applies reasoning/thinking tag removal, memory tag removal, and
|
||||
* model-specific internal separator stripping.
|
||||
*/
|
||||
export function sanitizeOutboundText(text: string): string {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let cleaned = stripAssistantInternalScaffolding(text);
|
||||
|
||||
cleaned = cleaned.replace(INTERNAL_SEPARATOR_RE, "");
|
||||
cleaned = cleaned.replace(ASSISTANT_ROLE_MARKER_RE, "");
|
||||
cleaned = cleaned.replace(ROLE_TURN_MARKER_RE, "");
|
||||
|
||||
// Collapse excessive blank lines left after stripping.
|
||||
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/sanitize-outbound
|
||||
export * from "../../../extensions/imessage/src/monitor/sanitize-outbound.js";
|
||||
|
||||
@ -1,103 +1,2 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { formatIMessageChatTarget } from "../targets.js";
|
||||
|
||||
type SelfChatCacheKeyParts = {
|
||||
accountId: string;
|
||||
sender: string;
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
};
|
||||
|
||||
export type SelfChatLookup = SelfChatCacheKeyParts & {
|
||||
text?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
|
||||
export type SelfChatCache = {
|
||||
remember: (lookup: SelfChatLookup) => void;
|
||||
has: (lookup: SelfChatLookup) => boolean;
|
||||
};
|
||||
|
||||
const SELF_CHAT_TTL_MS = 10_000;
|
||||
const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
|
||||
const CLEANUP_MIN_INTERVAL_MS = 1_000;
|
||||
|
||||
function normalizeText(text: string | undefined): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const normalized = text.replace(/\r\n?/g, "\n").trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function isUsableTimestamp(createdAt: number | undefined): createdAt is number {
|
||||
return typeof createdAt === "number" && Number.isFinite(createdAt);
|
||||
}
|
||||
|
||||
function digestText(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function buildScope(parts: SelfChatCacheKeyParts): string {
|
||||
if (!parts.isGroup) {
|
||||
return `${parts.accountId}:imessage:${parts.sender}`;
|
||||
}
|
||||
const chatTarget = formatIMessageChatTarget(parts.chatId) || "chat_id:unknown";
|
||||
return `${parts.accountId}:${chatTarget}:imessage:${parts.sender}`;
|
||||
}
|
||||
|
||||
class DefaultSelfChatCache implements SelfChatCache {
|
||||
private cache = new Map<string, number>();
|
||||
private lastCleanupAt = 0;
|
||||
|
||||
private buildKey(lookup: SelfChatLookup): string | null {
|
||||
const text = normalizeText(lookup.text);
|
||||
if (!text || !isUsableTimestamp(lookup.createdAt)) {
|
||||
return null;
|
||||
}
|
||||
return `${buildScope(lookup)}:${lookup.createdAt}:${digestText(text)}`;
|
||||
}
|
||||
|
||||
remember(lookup: SelfChatLookup): void {
|
||||
const key = this.buildKey(lookup);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
this.cache.set(key, Date.now());
|
||||
this.maybeCleanup();
|
||||
}
|
||||
|
||||
has(lookup: SelfChatLookup): boolean {
|
||||
this.maybeCleanup();
|
||||
const key = this.buildKey(lookup);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = this.cache.get(key);
|
||||
return typeof timestamp === "number" && Date.now() - timestamp <= SELF_CHAT_TTL_MS;
|
||||
}
|
||||
|
||||
private maybeCleanup(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this.lastCleanupAt = now;
|
||||
for (const [key, timestamp] of this.cache.entries()) {
|
||||
if (now - timestamp > SELF_CHAT_TTL_MS) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
while (this.cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
if (typeof oldestKey !== "string") {
|
||||
break;
|
||||
}
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSelfChatCache(): SelfChatCache {
|
||||
return new DefaultSelfChatCache();
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/self-chat-cache
|
||||
export * from "../../../extensions/imessage/src/monitor/self-chat-cache.js";
|
||||
|
||||
@ -1,40 +1,2 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
export type IMessageAttachment = {
|
||||
original_path?: string | null;
|
||||
mime_type?: string | null;
|
||||
missing?: boolean | null;
|
||||
};
|
||||
|
||||
export type IMessagePayload = {
|
||||
id?: number | null;
|
||||
chat_id?: number | null;
|
||||
sender?: string | null;
|
||||
is_from_me?: boolean | null;
|
||||
text?: string | null;
|
||||
reply_to_id?: number | string | null;
|
||||
reply_to_text?: string | null;
|
||||
reply_to_sender?: string | null;
|
||||
created_at?: string | null;
|
||||
attachments?: IMessageAttachment[] | null;
|
||||
chat_identifier?: string | null;
|
||||
chat_guid?: string | null;
|
||||
chat_name?: string | null;
|
||||
participants?: string[] | null;
|
||||
is_group?: boolean | null;
|
||||
};
|
||||
|
||||
export type MonitorIMessageOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
accountId?: string;
|
||||
config?: OpenClawConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
includeAttachments?: boolean;
|
||||
mediaMaxMb?: number;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
// Shim: re-exports from extensions/imessage/src/monitor/types
|
||||
export * from "../../../extensions/imessage/src/monitor/types.js";
|
||||
|
||||
@ -1,105 +1,2 @@
|
||||
import type { BaseProbeResult } from "../channels/plugins/types.js";
|
||||
import { detectBinary } from "../commands/onboard-helpers.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { createIMessageRpcClient } from "./client.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
export type IMessageProbe = BaseProbeResult & {
|
||||
fatal?: boolean;
|
||||
};
|
||||
|
||||
export type IMessageProbeOptions = {
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
type RpcSupportResult = {
|
||||
supported: boolean;
|
||||
error?: string;
|
||||
fatal?: boolean;
|
||||
};
|
||||
|
||||
const rpcSupportCache = new Map<string, RpcSupportResult>();
|
||||
|
||||
async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcSupportResult> {
|
||||
const cached = rpcSupportCache.get(cliPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
|
||||
const combined = `${result.stdout}\n${result.stderr}`.trim();
|
||||
const normalized = combined.toLowerCase();
|
||||
if (normalized.includes("unknown command") && normalized.includes("rpc")) {
|
||||
const fatal = {
|
||||
supported: false,
|
||||
fatal: true,
|
||||
error: 'imsg CLI does not support the "rpc" subcommand (update imsg)',
|
||||
};
|
||||
rpcSupportCache.set(cliPath, fatal);
|
||||
return fatal;
|
||||
}
|
||||
if (result.code === 0) {
|
||||
const supported = { supported: true };
|
||||
rpcSupportCache.set(cliPath, supported);
|
||||
return supported;
|
||||
}
|
||||
return {
|
||||
supported: false,
|
||||
error: combined || `imsg rpc --help failed (code ${String(result.code ?? "unknown")})`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { supported: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe iMessage RPC availability.
|
||||
* @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default.
|
||||
* @param opts - Additional options (cliPath, dbPath, runtime).
|
||||
*/
|
||||
export async function probeIMessage(
|
||||
timeoutMs?: number,
|
||||
opts: IMessageProbeOptions = {},
|
||||
): Promise<IMessageProbe> {
|
||||
const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig();
|
||||
const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg";
|
||||
const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim();
|
||||
// Use explicit timeout if provided, otherwise fall back to config, then default
|
||||
const effectiveTimeout =
|
||||
timeoutMs ?? cfg?.channels?.imessage?.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
||||
|
||||
const detected = await detectBinary(cliPath);
|
||||
if (!detected) {
|
||||
return { ok: false, error: `imsg not found (${cliPath})` };
|
||||
}
|
||||
|
||||
const rpcSupport = await probeRpcSupport(cliPath, effectiveTimeout);
|
||||
if (!rpcSupport.supported) {
|
||||
return {
|
||||
ok: false,
|
||||
error: rpcSupport.error ?? "imsg rpc unavailable",
|
||||
fatal: rpcSupport.fatal,
|
||||
};
|
||||
}
|
||||
|
||||
const client = await createIMessageRpcClient({
|
||||
cliPath,
|
||||
dbPath,
|
||||
runtime: opts.runtime,
|
||||
});
|
||||
try {
|
||||
await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
} finally {
|
||||
await client.stop();
|
||||
}
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/probe
|
||||
export * from "../../extensions/imessage/src/probe.js";
|
||||
|
||||
@ -1,190 +1,2 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../markdown/tables.js";
|
||||
import { kindFromMime } from "../media/mime.js";
|
||||
import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js";
|
||||
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
|
||||
|
||||
export type IMessageSendOpts = {
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
service?: IMessageService;
|
||||
region?: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
chatId?: number;
|
||||
client?: IMessageRpcClient;
|
||||
config?: ReturnType<typeof loadConfig>;
|
||||
account?: ResolvedIMessageAccount;
|
||||
resolveAttachmentImpl?: (
|
||||
mediaUrl: string,
|
||||
maxBytes: number,
|
||||
options?: { localRoots?: readonly string[] },
|
||||
) => Promise<{ path: string; contentType?: string }>;
|
||||
createClient?: (params: { cliPath: string; dbPath?: string }) => Promise<IMessageRpcClient>;
|
||||
};
|
||||
|
||||
export type IMessageSendResult = {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const LEADING_REPLY_TAG_RE = /^\s*\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]\s*/i;
|
||||
const MAX_REPLY_TO_ID_LENGTH = 256;
|
||||
|
||||
function stripUnsafeReplyTagChars(value: string): string {
|
||||
let next = "";
|
||||
for (const ch of value) {
|
||||
const code = ch.charCodeAt(0);
|
||||
if ((code >= 0 && code <= 31) || code === 127 || ch === "[" || ch === "]") {
|
||||
continue;
|
||||
}
|
||||
next += ch;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function sanitizeReplyToId(rawReplyToId?: string): string | undefined {
|
||||
const trimmed = rawReplyToId?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const sanitized = stripUnsafeReplyTagChars(trimmed).trim();
|
||||
if (!sanitized) {
|
||||
return undefined;
|
||||
}
|
||||
if (sanitized.length > MAX_REPLY_TO_ID_LENGTH) {
|
||||
return sanitized.slice(0, MAX_REPLY_TO_ID_LENGTH);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function prependReplyTagIfNeeded(message: string, replyToId?: string): string {
|
||||
const resolvedReplyToId = sanitizeReplyToId(replyToId);
|
||||
if (!resolvedReplyToId) {
|
||||
return message;
|
||||
}
|
||||
const replyTag = `[[reply_to:${resolvedReplyToId}]]`;
|
||||
const existingLeadingTag = message.match(LEADING_REPLY_TAG_RE);
|
||||
if (existingLeadingTag) {
|
||||
const remainder = message.slice(existingLeadingTag[0].length).trimStart();
|
||||
return remainder ? `${replyTag} ${remainder}` : replyTag;
|
||||
}
|
||||
const trimmedMessage = message.trimStart();
|
||||
return trimmedMessage ? `${replyTag} ${trimmedMessage}` : replyTag;
|
||||
}
|
||||
|
||||
function resolveMessageId(result: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
const raw =
|
||||
(typeof result.messageId === "string" && result.messageId.trim()) ||
|
||||
(typeof result.message_id === "string" && result.message_id.trim()) ||
|
||||
(typeof result.id === "string" && result.id.trim()) ||
|
||||
(typeof result.guid === "string" && result.guid.trim()) ||
|
||||
(typeof result.message_id === "number" ? String(result.message_id) : null) ||
|
||||
(typeof result.id === "number" ? String(result.id) : null);
|
||||
return raw ? String(raw).trim() : null;
|
||||
}
|
||||
|
||||
export async function sendMessageIMessage(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: IMessageSendOpts = {},
|
||||
): Promise<IMessageSendResult> {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account =
|
||||
opts.account ??
|
||||
resolveIMessageAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
|
||||
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
|
||||
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
|
||||
const service =
|
||||
opts.service ??
|
||||
(target.kind === "handle" ? target.service : undefined) ??
|
||||
(account.config.service as IMessageService | undefined);
|
||||
const region = opts.region?.trim() || account.config.region?.trim() || "US";
|
||||
const maxBytes =
|
||||
typeof opts.maxBytes === "number"
|
||||
? opts.maxBytes
|
||||
: typeof account.config.mediaMaxMb === "number"
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
: 16 * 1024 * 1024;
|
||||
let message = text ?? "";
|
||||
let filePath: string | undefined;
|
||||
|
||||
if (opts.mediaUrl?.trim()) {
|
||||
const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl;
|
||||
const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, {
|
||||
localRoots: opts.mediaLocalRoots,
|
||||
});
|
||||
filePath = resolved.path;
|
||||
if (!message.trim()) {
|
||||
const kind = kindFromMime(resolved.contentType ?? undefined);
|
||||
if (kind) {
|
||||
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.trim() && !filePath) {
|
||||
throw new Error("iMessage send requires text or media");
|
||||
}
|
||||
if (message.trim()) {
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
message = convertMarkdownTables(message, tableMode);
|
||||
}
|
||||
message = prependReplyTagIfNeeded(message, opts.replyToId);
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
text: message,
|
||||
service: service || "auto",
|
||||
region,
|
||||
};
|
||||
if (filePath) {
|
||||
params.file = filePath;
|
||||
}
|
||||
|
||||
if (target.kind === "chat_id") {
|
||||
params.chat_id = target.chatId;
|
||||
} else if (target.kind === "chat_guid") {
|
||||
params.chat_guid = target.chatGuid;
|
||||
} else if (target.kind === "chat_identifier") {
|
||||
params.chat_identifier = target.chatIdentifier;
|
||||
} else {
|
||||
params.to = target.to;
|
||||
}
|
||||
|
||||
const client =
|
||||
opts.client ??
|
||||
(opts.createClient
|
||||
? await opts.createClient({ cliPath, dbPath })
|
||||
: await createIMessageRpcClient({ cliPath, dbPath }));
|
||||
const shouldClose = !opts.client;
|
||||
try {
|
||||
const result = await client.request<{ ok?: string }>("send", params, {
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
const resolvedId = resolveMessageId(result);
|
||||
return {
|
||||
messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"),
|
||||
};
|
||||
} finally {
|
||||
if (shouldClose) {
|
||||
await client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/send
|
||||
export * from "../../extensions/imessage/src/send.js";
|
||||
|
||||
@ -1,223 +1,2 @@
|
||||
import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js";
|
||||
|
||||
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
|
||||
|
||||
export type ChatTargetPrefixesParams = {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
chatIdPrefixes: string[];
|
||||
chatGuidPrefixes: string[];
|
||||
chatIdentifierPrefixes: string[];
|
||||
};
|
||||
|
||||
export type ParsedChatTarget =
|
||||
| { kind: "chat_id"; chatId: number }
|
||||
| { kind: "chat_guid"; chatGuid: string }
|
||||
| { kind: "chat_identifier"; chatIdentifier: string };
|
||||
|
||||
export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
|
||||
|
||||
export type ChatSenderAllowParams = {
|
||||
allowFrom: Array<string | number>;
|
||||
sender: string;
|
||||
chatId?: number | null;
|
||||
chatGuid?: string | null;
|
||||
chatIdentifier?: string | null;
|
||||
};
|
||||
|
||||
function stripPrefix(value: string, prefix: string): string {
|
||||
return value.slice(prefix.length).trim();
|
||||
}
|
||||
|
||||
function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean {
|
||||
return prefixes.some((prefix) => value.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedTarget<TService extends string, TTarget>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<ServicePrefix<TService>>;
|
||||
isChatTarget: (remainderLower: string) => boolean;
|
||||
parseTarget: (remainder: string) => TTarget;
|
||||
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
|
||||
for (const { prefix, service } of params.servicePrefixes) {
|
||||
if (!params.lower.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const remainder = stripPrefix(params.trimmed, prefix);
|
||||
if (!remainder) {
|
||||
throw new Error(`${prefix} target is required`);
|
||||
}
|
||||
const remainderLower = remainder.toLowerCase();
|
||||
if (params.isChatTarget(remainderLower)) {
|
||||
return params.parseTarget(remainder);
|
||||
}
|
||||
return { kind: "handle", to: remainder, service };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedChatTarget<TService extends string, TTarget>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<ServicePrefix<TService>>;
|
||||
chatIdPrefixes: string[];
|
||||
chatGuidPrefixes: string[];
|
||||
chatIdentifierPrefixes: string[];
|
||||
extraChatPrefixes?: string[];
|
||||
parseTarget: (remainder: string) => TTarget;
|
||||
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
|
||||
const chatPrefixes = [
|
||||
...params.chatIdPrefixes,
|
||||
...params.chatGuidPrefixes,
|
||||
...params.chatIdentifierPrefixes,
|
||||
...(params.extraChatPrefixes ?? []),
|
||||
];
|
||||
return resolveServicePrefixedTarget({
|
||||
trimmed: params.trimmed,
|
||||
lower: params.lower,
|
||||
servicePrefixes: params.servicePrefixes,
|
||||
isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes),
|
||||
parseTarget: params.parseTarget,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChatTargetPrefixesOrThrow(
|
||||
params: ChatTargetPrefixesParams,
|
||||
): ParsedChatTarget | null {
|
||||
for (const prefix of params.chatIdPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
const chatId = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(chatId)) {
|
||||
throw new Error(`Invalid chat_id: ${value}`);
|
||||
}
|
||||
return { kind: "chat_id", chatId };
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatGuidPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (!value) {
|
||||
throw new Error("chat_guid is required");
|
||||
}
|
||||
return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatIdentifierPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (!value) {
|
||||
throw new Error("chat_identifier is required");
|
||||
}
|
||||
return { kind: "chat_identifier", chatIdentifier: value };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<{ prefix: string }>;
|
||||
parseAllowTarget: (remainder: string) => TAllowTarget;
|
||||
}): (TAllowTarget | { kind: "handle"; handle: string }) | null {
|
||||
for (const { prefix } of params.servicePrefixes) {
|
||||
if (!params.lower.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const remainder = stripPrefix(params.trimmed, prefix);
|
||||
if (!remainder) {
|
||||
return { kind: "handle", handle: "" };
|
||||
}
|
||||
return params.parseAllowTarget(remainder);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveServicePrefixedOrChatAllowTarget<
|
||||
TAllowTarget extends ParsedChatAllowTarget,
|
||||
>(params: {
|
||||
trimmed: string;
|
||||
lower: string;
|
||||
servicePrefixes: Array<{ prefix: string }>;
|
||||
parseAllowTarget: (remainder: string) => TAllowTarget;
|
||||
chatIdPrefixes: string[];
|
||||
chatGuidPrefixes: string[];
|
||||
chatIdentifierPrefixes: string[];
|
||||
}): TAllowTarget | null {
|
||||
const servicePrefixed = resolveServicePrefixedAllowTarget({
|
||||
trimmed: params.trimmed,
|
||||
lower: params.lower,
|
||||
servicePrefixes: params.servicePrefixes,
|
||||
parseAllowTarget: params.parseAllowTarget,
|
||||
});
|
||||
if (servicePrefixed) {
|
||||
return servicePrefixed as TAllowTarget;
|
||||
}
|
||||
|
||||
const chatTarget = parseChatAllowTargetPrefixes({
|
||||
trimmed: params.trimmed,
|
||||
lower: params.lower,
|
||||
chatIdPrefixes: params.chatIdPrefixes,
|
||||
chatGuidPrefixes: params.chatGuidPrefixes,
|
||||
chatIdentifierPrefixes: params.chatIdentifierPrefixes,
|
||||
});
|
||||
if (chatTarget) {
|
||||
return chatTarget as TAllowTarget;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAllowedChatSenderMatcher<TParsed extends ParsedChatAllowTarget>(params: {
|
||||
normalizeSender: (sender: string) => string;
|
||||
parseAllowTarget: (entry: string) => TParsed;
|
||||
}): (input: ChatSenderAllowParams) => boolean {
|
||||
return (input) =>
|
||||
isAllowedParsedChatSender({
|
||||
allowFrom: input.allowFrom,
|
||||
sender: input.sender,
|
||||
chatId: input.chatId,
|
||||
chatGuid: input.chatGuid,
|
||||
chatIdentifier: input.chatIdentifier,
|
||||
normalizeSender: params.normalizeSender,
|
||||
parseAllowTarget: params.parseAllowTarget,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChatAllowTargetPrefixes(
|
||||
params: ChatTargetPrefixesParams,
|
||||
): ParsedChatTarget | null {
|
||||
for (const prefix of params.chatIdPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
const chatId = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(chatId)) {
|
||||
return { kind: "chat_id", chatId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatGuidPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (value) {
|
||||
return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of params.chatIdentifierPrefixes) {
|
||||
if (params.lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(params.trimmed, prefix);
|
||||
if (value) {
|
||||
return { kind: "chat_identifier", chatIdentifier: value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/target-parsing-helpers
|
||||
export * from "../../extensions/imessage/src/target-parsing-helpers.js";
|
||||
|
||||
@ -1,147 +1,2 @@
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import {
|
||||
createAllowedChatSenderMatcher,
|
||||
type ChatSenderAllowParams,
|
||||
type ParsedChatTarget,
|
||||
parseChatTargetPrefixesOrThrow,
|
||||
resolveServicePrefixedChatTarget,
|
||||
resolveServicePrefixedOrChatAllowTarget,
|
||||
} from "./target-parsing-helpers.js";
|
||||
|
||||
export type IMessageService = "imessage" | "sms" | "auto";
|
||||
|
||||
export type IMessageTarget =
|
||||
| { kind: "chat_id"; chatId: number }
|
||||
| { kind: "chat_guid"; chatGuid: string }
|
||||
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||
| { kind: "handle"; to: string; service: IMessageService };
|
||||
|
||||
export type IMessageAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
|
||||
|
||||
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
|
||||
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
|
||||
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
|
||||
const SERVICE_PREFIXES: Array<{ prefix: string; service: IMessageService }> = [
|
||||
{ prefix: "imessage:", service: "imessage" },
|
||||
{ prefix: "sms:", service: "sms" },
|
||||
{ prefix: "auto:", service: "auto" },
|
||||
];
|
||||
|
||||
export function normalizeIMessageHandle(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("imessage:")) {
|
||||
return normalizeIMessageHandle(trimmed.slice(9));
|
||||
}
|
||||
if (lowered.startsWith("sms:")) {
|
||||
return normalizeIMessageHandle(trimmed.slice(4));
|
||||
}
|
||||
if (lowered.startsWith("auto:")) {
|
||||
return normalizeIMessageHandle(trimmed.slice(5));
|
||||
}
|
||||
|
||||
// Normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively
|
||||
for (const prefix of CHAT_ID_PREFIXES) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
const value = trimmed.slice(prefix.length).trim();
|
||||
return `chat_id:${value}`;
|
||||
}
|
||||
}
|
||||
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
const value = trimmed.slice(prefix.length).trim();
|
||||
return `chat_guid:${value}`;
|
||||
}
|
||||
}
|
||||
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
const value = trimmed.slice(prefix.length).trim();
|
||||
return `chat_identifier:${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes("@")) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
const normalized = normalizeE164(trimmed);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
return trimmed.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
export function parseIMessageTarget(raw: string): IMessageTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("iMessage target is required");
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
const servicePrefixed = resolveServicePrefixedChatTarget({
|
||||
trimmed,
|
||||
lower,
|
||||
servicePrefixes: SERVICE_PREFIXES,
|
||||
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||
parseTarget: parseIMessageTarget,
|
||||
});
|
||||
if (servicePrefixed) {
|
||||
return servicePrefixed;
|
||||
}
|
||||
|
||||
const chatTarget = parseChatTargetPrefixesOrThrow({
|
||||
trimmed,
|
||||
lower,
|
||||
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||
});
|
||||
if (chatTarget) {
|
||||
return chatTarget;
|
||||
}
|
||||
|
||||
return { kind: "handle", to: trimmed, service: "auto" };
|
||||
}
|
||||
|
||||
export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { kind: "handle", handle: "" };
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({
|
||||
trimmed,
|
||||
lower,
|
||||
servicePrefixes: SERVICE_PREFIXES,
|
||||
parseAllowTarget: parseIMessageAllowTarget,
|
||||
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||
});
|
||||
if (servicePrefixed) {
|
||||
return servicePrefixed;
|
||||
}
|
||||
|
||||
return { kind: "handle", handle: normalizeIMessageHandle(trimmed) };
|
||||
}
|
||||
|
||||
const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({
|
||||
normalizeSender: normalizeIMessageHandle,
|
||||
parseAllowTarget: parseIMessageAllowTarget,
|
||||
});
|
||||
|
||||
export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean {
|
||||
return isAllowedIMessageSenderMatcher(params);
|
||||
}
|
||||
|
||||
export function formatIMessageChatTarget(chatId?: number | null): string {
|
||||
if (!chatId || !Number.isFinite(chatId)) {
|
||||
return "";
|
||||
}
|
||||
return `chat_id:${chatId}`;
|
||||
}
|
||||
// Shim: re-exports from extensions/imessage/src/targets
|
||||
export * from "../../extensions/imessage/src/targets.js";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user