Files
openclaw/extensions/imessage/src/targets.ts
Matthew Delprado 235a5c4d74 fix(imessage): recognize bare hex group chat identifiers as chat targets (#99525)
* fix(imessage): recognize bare hex group chat identifiers as chat targets

A 32-char hex iMessage group chat identifier (as returned by `imsg` for
group chats) with no explicit `chat_identifier:` prefix fell through to
E.164 phone-number normalization. normalizeE164 strips non-digit
characters and prepends `+`, turning
`7d5297154d5f436d83dbbdf03fcc8fdd` into `+75297154543683038` -- a
bogus phone number. Cron/announce delivery then reported
`delivered: true` while silently sending to a nonexistent recipient.

Detect bare 32-char hex strings in both the send-path target parser
(extensions/imessage/src/targets.ts) and the outbound delivery-target
normalizer (extensions/imessage/src/normalize.ts) and route them as
chat_identifier targets, consistent with how an explicit
`chat_identifier:<id>` prefix already resolves.

Fixes #89235.

* refactor(imessage): centralize bare chat identifiers

* refactor(imessage): centralize bare chat identifiers

---------

Co-authored-by: Peter Steinberger <peter@steipete.me>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-03 08:19:02 -07:00

227 lines
7.0 KiB
TypeScript

// Imessage plugin module implements targets behavior.
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeBareIMessageChatIdentifier } from "./target-identifiers.js";
import {
type ChatSenderAllowParams,
createAllowedChatSenderMatcher,
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; serviceExplicit?: boolean };
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" },
];
function parseServicePrefixedBareChatIdentifier(params: {
trimmed: string;
lower: string;
}): IMessageTarget | undefined {
for (const { prefix } of SERVICE_PREFIXES) {
if (!params.lower.startsWith(prefix)) {
continue;
}
const chatIdentifier = normalizeBareIMessageChatIdentifier(params.trimmed.slice(prefix.length));
if (chatIdentifier) {
return { kind: "chat_identifier", chatIdentifier };
}
}
return undefined;
}
export function normalizeIMessageHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = normalizeLowercaseStringOrEmpty(trimmed);
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 normalizeLowercaseStringOrEmpty(trimmed);
}
const bareChatIdentifier = normalizeBareIMessageChatIdentifier(trimmed);
if (bareChatIdentifier) {
return `chat_identifier:${bareChatIdentifier}`;
}
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 = normalizeLowercaseStringOrEmpty(trimmed);
const servicePrefixedBareChatIdentifier = parseServicePrefixedBareChatIdentifier({
trimmed,
lower,
});
if (servicePrefixedBareChatIdentifier) {
return servicePrefixedBareChatIdentifier;
}
const servicePrefixed = resolveServicePrefixedChatTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
parseTarget: parseIMessageTarget,
});
if (servicePrefixed) {
if (servicePrefixed.kind === "handle") {
return { ...servicePrefixed, serviceExplicit: true };
}
return servicePrefixed;
}
const chatTarget = parseChatTargetPrefixesOrThrow({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
const bareChatIdentifier = normalizeBareIMessageChatIdentifier(trimmed);
if (bareChatIdentifier) {
return { kind: "chat_identifier", chatIdentifier: bareChatIdentifier };
}
return { kind: "handle", to: trimmed, service: "auto" };
}
export function looksLikeIMessageExplicitTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
if (/^(imessage:|sms:|auto:)/.test(lower)) {
return true;
}
return (
CHAT_ID_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
CHAT_GUID_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
CHAT_IDENTIFIER_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
Boolean(normalizeBareIMessageChatIdentifier(trimmed))
);
}
export function inferIMessageTargetChatType(raw: string): "direct" | "group" | undefined {
try {
const parsed = parseIMessageTarget(raw);
if (parsed.kind === "handle") {
return "direct";
}
return "group";
} catch {
return undefined;
}
}
export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
const trimmed = raw.trim();
if (!trimmed) {
return { kind: "handle", handle: "" };
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
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,
allowConversationTargets: false,
});
export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean {
return isAllowedIMessageSenderMatcher({ ...params, allowConversationTargets: false });
}
const isAllowedIMessageReplyContextSenderMatcher = createAllowedChatSenderMatcher({
normalizeSender: normalizeIMessageHandle,
parseAllowTarget: parseIMessageAllowTarget,
allowConversationTargets: true,
});
export function isAllowedIMessageReplyContextSender(params: ChatSenderAllowParams): boolean {
return isAllowedIMessageReplyContextSenderMatcher(params);
}
export function formatIMessageChatTarget(chatId?: number | null): string {
if (!chatId || !Number.isFinite(chatId)) {
return "";
}
return `chat_id:${chatId}`;
}