elizaos--eliza
426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
489 行
17 KiB
TypeScript
489 行
17 KiB
TypeScript
/**
|
|
* macOS Contacts reader for @elizaos/plugin-imessage.
|
|
*
|
|
* Incoming iMessages arrive tagged with a raw handle — a phone number in
|
|
* E.164 form (`+15551234567`) or an email address. Raw handles are ugly
|
|
* to read and make the agent's replies feel impersonal. This module
|
|
* resolves each handle to the real display name from the user's Apple
|
|
* Contacts so the agent sees "Mom" or "Alex Chen" instead of a string
|
|
* of digits.
|
|
*
|
|
* ---
|
|
*
|
|
* Backend: **CNContactStore** through the shared native macOS dylib. This
|
|
* keeps the feature aligned with the macOS Contacts privacy grant and avoids
|
|
* asking for Automation access to the Contacts app.
|
|
*
|
|
* The service calls this lazily when it actually needs name resolution or
|
|
* contact CRUD. Contacts rarely change mid-session, so the iMessage service
|
|
* caches the returned map for v1.
|
|
*
|
|
* Graceful degradation: if Contacts is not authorized, or returns no
|
|
* rows, or the native bridge fails for any other reason, the reader returns
|
|
* an empty map. The service treats that as "handles remain anonymous"
|
|
* and proceeds normally — no crash, no hard failure.
|
|
*/
|
|
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { logger } from "@elizaos/core";
|
|
|
|
const NATIVE_DYLIB_CANDIDATES = [
|
|
process.env.ELIZA_NATIVE_PERMISSIONS_DYLIB ?? "",
|
|
"../../../packages/app-core/platforms/electrobun/src/libMacWindowEffects.dylib",
|
|
].filter(Boolean);
|
|
|
|
/**
|
|
* A single resolved contact: the display name and one of the handles
|
|
* (phone or email) through which that contact reaches the agent. The
|
|
* same name can appear under multiple handles.
|
|
*/
|
|
export interface ResolvedContact {
|
|
/** The contact's display name as stored in Apple Contacts. */
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* Handle → contact map. Keys are normalized handles (phone numbers in
|
|
* digits-only form with a leading `+` if international, emails in
|
|
* lowercase). Callers should normalize their lookup keys with
|
|
* {@link normalizeContactHandle} before querying.
|
|
*/
|
|
export type ContactsMap = Map<string, ResolvedContact>;
|
|
|
|
type NativeContactsResponse = {
|
|
contacts?: unknown[];
|
|
error?: string;
|
|
id?: string;
|
|
message?: string;
|
|
ok: boolean;
|
|
};
|
|
|
|
type ContactsFailure = "bridge_unavailable" | "native_error" | "permission" | null;
|
|
|
|
type NativeContactsBridge = {
|
|
addContact(payloadJson: string): string | null;
|
|
deleteContact(personId: string): string | null;
|
|
listAllContacts(): string | null;
|
|
loadContacts(): string | null;
|
|
updateContact(personId: string, payloadJson: string): string | null;
|
|
};
|
|
|
|
let nativeContactsBridge: NativeContactsBridge | null | undefined;
|
|
let lastContactsFailure: ContactsFailure = null;
|
|
|
|
export function getLastContactsFailure(): ContactsFailure {
|
|
return lastContactsFailure;
|
|
}
|
|
|
|
function cStringBuffer(value: string): Buffer {
|
|
const bytes = Buffer.from(value, "utf8");
|
|
const buffer = Buffer.alloc(bytes.byteLength + 1);
|
|
bytes.copy(buffer);
|
|
return buffer;
|
|
}
|
|
|
|
async function loadNativeContactsBridge(): Promise<NativeContactsBridge | null> {
|
|
if (nativeContactsBridge !== undefined) return nativeContactsBridge;
|
|
nativeContactsBridge = null;
|
|
if (process.platform !== "darwin") return null;
|
|
|
|
for (const candidate of NATIVE_DYLIB_CANDIDATES) {
|
|
const dylibPath = path.isAbsolute(candidate)
|
|
? candidate
|
|
: path.resolve(import.meta.dir, candidate);
|
|
if (!existsSync(dylibPath)) continue;
|
|
try {
|
|
const { CString, FFIType, dlopen, ptr } = await import("bun:ffi");
|
|
const lib = dlopen(dylibPath, {
|
|
loadContactsJson: { args: [], returns: FFIType.ptr },
|
|
listAllContactsJson: { args: [], returns: FFIType.ptr },
|
|
addContactJson: { args: [FFIType.ptr], returns: FFIType.ptr },
|
|
updateContactJson: {
|
|
args: [FFIType.ptr, FFIType.ptr],
|
|
returns: FFIType.ptr,
|
|
},
|
|
deleteContactJson: { args: [FFIType.ptr], returns: FFIType.ptr },
|
|
freeNativeCString: { args: [FFIType.ptr], returns: FFIType.void },
|
|
});
|
|
|
|
const takeNativeString = (value: unknown): string | null => {
|
|
if (!value) return null;
|
|
try {
|
|
return new CString(value as never).toString();
|
|
} finally {
|
|
lib.symbols.freeNativeCString(value as never);
|
|
}
|
|
};
|
|
|
|
nativeContactsBridge = {
|
|
loadContacts() {
|
|
return takeNativeString(lib.symbols.loadContactsJson());
|
|
},
|
|
listAllContacts() {
|
|
return takeNativeString(lib.symbols.listAllContactsJson());
|
|
},
|
|
addContact(payloadJson) {
|
|
const payload = cStringBuffer(payloadJson);
|
|
return takeNativeString(lib.symbols.addContactJson(ptr(payload)));
|
|
},
|
|
updateContact(personId, payloadJson) {
|
|
const id = cStringBuffer(personId);
|
|
const payload = cStringBuffer(payloadJson);
|
|
return takeNativeString(lib.symbols.updateContactJson(ptr(id), ptr(payload)));
|
|
},
|
|
deleteContact(personId) {
|
|
const id = cStringBuffer(personId);
|
|
return takeNativeString(lib.symbols.deleteContactJson(ptr(id)));
|
|
},
|
|
};
|
|
return nativeContactsBridge;
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[imessage] Failed to load native Contacts bridge from ${dylibPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseNativeContactsResponse(raw: string | null): NativeContactsResponse {
|
|
if (!raw) {
|
|
return {
|
|
ok: false,
|
|
error: "native_error",
|
|
message: "Native Contacts bridge returned no response.",
|
|
};
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<NativeContactsResponse>;
|
|
return {
|
|
ok: parsed.ok === true,
|
|
error: typeof parsed.error === "string" ? parsed.error : undefined,
|
|
id: typeof parsed.id === "string" ? parsed.id : undefined,
|
|
message: typeof parsed.message === "string" ? parsed.message : undefined,
|
|
contacts: Array.isArray(parsed.contacts) ? parsed.contacts : undefined,
|
|
};
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
error: "native_error",
|
|
message: "Native Contacts bridge returned invalid JSON.",
|
|
};
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
function stringField(record: Record<string, unknown>, key: string): string {
|
|
const value = record[key];
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
/**
|
|
* Normalize a handle to the canonical form used as a key in the
|
|
* ContactsMap. Strips whitespace, parentheses, hyphens, and dots from
|
|
* phone numbers and lowercases emails. Leaves a leading `+` in place.
|
|
*/
|
|
export function normalizeContactHandle(raw: string): string {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return "";
|
|
|
|
// Email: lowercase
|
|
if (trimmed.includes("@")) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
// Phone: strip formatting characters, preserve leading +
|
|
const hasPlus = trimmed.startsWith("+");
|
|
const digitsOnly = trimmed.replace(/[^\d]/g, "");
|
|
return hasPlus ? `+${digitsOnly}` : digitsOnly;
|
|
}
|
|
|
|
/**
|
|
* Parse legacy tab-delimited contact fixture output into a ContactsMap.
|
|
* Exported so tests can exercise normalization without a live address book.
|
|
*
|
|
* Input format per line: `kind\thandle\tname`.
|
|
* Empty lines are skipped. Lines with fewer than 3 fields are skipped.
|
|
* Empty handles are skipped. Duplicate handles keep the first entry.
|
|
*/
|
|
export function parseContactsOutput(raw: string): ContactsMap {
|
|
const map: ContactsMap = new Map();
|
|
if (!raw.trim()) return map;
|
|
|
|
for (const line of raw.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const fields = trimmed.split("\t");
|
|
if (fields.length < 3) continue;
|
|
|
|
const [_kind, handle, name] = fields;
|
|
if (!handle || !name) continue;
|
|
|
|
const normalized = normalizeContactHandle(handle);
|
|
if (!normalized) continue;
|
|
if (map.has(normalized)) continue;
|
|
|
|
map.set(normalized, { name: name.trim() });
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function contactsMapFromNativeRows(rows: unknown[] | undefined): ContactsMap {
|
|
const map: ContactsMap = new Map();
|
|
for (const row of rows ?? []) {
|
|
if (!isRecord(row)) continue;
|
|
const handle = stringField(row, "handle");
|
|
const name = stringField(row, "name");
|
|
if (!handle || !name) continue;
|
|
|
|
const normalized = normalizeContactHandle(handle);
|
|
if (!normalized) continue;
|
|
if (map.has(normalized)) continue;
|
|
|
|
map.set(normalized, { name: name.trim() });
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Read Apple Contacts through CNContactStore and return a ContactsMap. Returns
|
|
* an empty map (with a warning log) on any failure — most commonly, the
|
|
* user hasn't authorized Contacts access yet.
|
|
*/
|
|
export async function loadContacts(): Promise<ContactsMap> {
|
|
const bridge = await loadNativeContactsBridge();
|
|
if (!bridge) {
|
|
lastContactsFailure = "bridge_unavailable";
|
|
logger.warn(
|
|
"[imessage] Native Contacts bridge unavailable. Inbound messages will use raw handles."
|
|
);
|
|
return new Map();
|
|
}
|
|
const response = parseNativeContactsResponse(bridge.loadContacts());
|
|
if (response.ok) {
|
|
lastContactsFailure = null;
|
|
const map = contactsMapFromNativeRows(response.contacts);
|
|
logger.info(`[imessage] Contacts loaded: ${map.size} handle(s) resolved from Apple Contacts`);
|
|
return map;
|
|
}
|
|
if (response.error === "permission") {
|
|
lastContactsFailure = "permission";
|
|
logger.warn(
|
|
"[imessage] Contacts access not authorized. Inbound messages will use raw handles until Contacts access is granted."
|
|
);
|
|
} else {
|
|
lastContactsFailure = "native_error";
|
|
logger.warn(
|
|
`[imessage] Failed to load Apple Contacts data: ${
|
|
response.message ?? response.error ?? "unknown error"
|
|
}. Inbound messages will use raw handles instead of names.`
|
|
);
|
|
}
|
|
return new Map();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Full-contact read + CRUD
|
|
// ============================================================================
|
|
//
|
|
// `loadContacts` above returns a narrow handle→name map used for inline
|
|
// name resolution on inbound messages. The UI layer needs something
|
|
// richer: full contact records (id, name, every phone/email with label)
|
|
// for list views, and write methods (create/update/delete) so the agent
|
|
// and the dashboard can edit the user's address book.
|
|
//
|
|
// Everything below uses the same CNContactStore native bridge as loadContacts.
|
|
// Reads and writes share the macOS Contacts privacy grant and degrade to
|
|
// empty/null/false when the grant or native bridge is unavailable.
|
|
|
|
/**
|
|
* A full contact record, richer than ContactsMap's handle-keyed entries.
|
|
* Returned by `listAllContacts` and the single-contact CRUD helpers.
|
|
* Each phone/email carries its Apple Contacts label when available
|
|
* (`home`, `work`, `mobile`, etc.) so the UI can surface context.
|
|
*/
|
|
export interface FullContact {
|
|
/** Apple Contacts stable person id. Used for update/delete. */
|
|
id: string;
|
|
/** Display name as stored in Apple Contacts. */
|
|
name: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
phones: Array<{ label: string | null; value: string }>;
|
|
emails: Array<{ label: string | null; value: string }>;
|
|
}
|
|
|
|
/** Input shape for creating a contact via `addContact`. */
|
|
export interface NewContactInput {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
phones?: Array<{ label?: string; value: string }>;
|
|
emails?: Array<{ label?: string; value: string }>;
|
|
}
|
|
|
|
function labeledValuesFromNativeRows(
|
|
rows: unknown
|
|
): Array<{ label: string | null; value: string }> {
|
|
if (!Array.isArray(rows)) return [];
|
|
return rows.flatMap((entry) => {
|
|
if (!isRecord(entry)) return [];
|
|
const value = stringField(entry, "value");
|
|
if (!value) return [];
|
|
const label = stringField(entry, "label");
|
|
return [{ label: label || null, value }];
|
|
});
|
|
}
|
|
|
|
function fullContactsFromNativeRows(rows: unknown[] | undefined): FullContact[] {
|
|
return (rows ?? []).flatMap((entry) => {
|
|
if (!isRecord(entry)) return [];
|
|
const id = stringField(entry, "id");
|
|
if (!id) return [];
|
|
return [
|
|
{
|
|
id,
|
|
name: stringField(entry, "name"),
|
|
firstName: stringField(entry, "firstName") || null,
|
|
lastName: stringField(entry, "lastName") || null,
|
|
phones: labeledValuesFromNativeRows(entry.phones),
|
|
emails: labeledValuesFromNativeRows(entry.emails),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List every contact in the user's address book as a full `FullContact`
|
|
* record. Returns an empty array on any failure (permission denied,
|
|
* native bridge error, etc.) with a warning log.
|
|
*/
|
|
export async function listAllContacts(): Promise<FullContact[]> {
|
|
const bridge = await loadNativeContactsBridge();
|
|
if (!bridge) {
|
|
lastContactsFailure = "bridge_unavailable";
|
|
logger.warn("[imessage] listAllContacts failed: native bridge unavailable");
|
|
return [];
|
|
}
|
|
const response = parseNativeContactsResponse(bridge.listAllContacts());
|
|
if (response.ok) {
|
|
lastContactsFailure = null;
|
|
return fullContactsFromNativeRows(response.contacts);
|
|
}
|
|
lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
|
|
logger.warn(
|
|
`[imessage] listAllContacts failed: ${response.message ?? response.error ?? "unknown error"}`
|
|
);
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Create a new Apple Contacts record. Returns the new person's id on
|
|
* success, or null on failure (permission denied, validation, etc.).
|
|
*
|
|
* Requires the Contacts privacy grant.
|
|
*/
|
|
export async function addContact(input: NewContactInput): Promise<string | null> {
|
|
const bridge = await loadNativeContactsBridge();
|
|
if (!bridge) {
|
|
lastContactsFailure = "bridge_unavailable";
|
|
logger.warn("[imessage] addContact failed: native bridge unavailable");
|
|
return null;
|
|
}
|
|
const response = parseNativeContactsResponse(bridge.addContact(JSON.stringify(input)));
|
|
if (response.ok && response.id) {
|
|
lastContactsFailure = null;
|
|
logger.info(`[imessage] Contact created: ${response.id}`);
|
|
return response.id;
|
|
}
|
|
lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
|
|
logger.warn(
|
|
`[imessage] addContact failed: ${response.message ?? response.error ?? "unknown error"}`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Patch an existing contact. `firstName` and `lastName` are set when
|
|
* provided. Phones and emails can be added (`addPhones` / `addEmails`)
|
|
* or removed (`removePhones` / `removeEmails`, matched by value). For
|
|
* simplicity we don't support editing an existing phone in place —
|
|
* callers should remove-then-add to achieve that.
|
|
*/
|
|
export interface ContactPatch {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
addPhones?: Array<{ label?: string; value: string }>;
|
|
removePhones?: string[];
|
|
addEmails?: Array<{ label?: string; value: string }>;
|
|
removeEmails?: string[];
|
|
}
|
|
|
|
export async function updateContact(personId: string, patch: ContactPatch): Promise<boolean> {
|
|
if (
|
|
patch.firstName === undefined &&
|
|
patch.lastName === undefined &&
|
|
(patch.addPhones?.length ?? 0) === 0 &&
|
|
(patch.removePhones?.length ?? 0) === 0 &&
|
|
(patch.addEmails?.length ?? 0) === 0 &&
|
|
(patch.removeEmails?.length ?? 0) === 0
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const bridge = await loadNativeContactsBridge();
|
|
if (!bridge) {
|
|
lastContactsFailure = "bridge_unavailable";
|
|
logger.warn(`[imessage] updateContact failed for ${personId}: native bridge unavailable`);
|
|
return false;
|
|
}
|
|
const response = parseNativeContactsResponse(
|
|
bridge.updateContact(personId, JSON.stringify(patch))
|
|
);
|
|
if (response.ok) {
|
|
lastContactsFailure = null;
|
|
logger.info(`[imessage] Contact updated: ${personId}`);
|
|
return true;
|
|
}
|
|
lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
|
|
logger.warn(
|
|
`[imessage] updateContact failed for ${personId}: ${
|
|
response.message ?? response.error ?? "unknown error"
|
|
}`
|
|
);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Delete a contact by Apple Contacts id. Requires the Contacts privacy grant.
|
|
* Returns false on any failure (not found, permission denied, etc.).
|
|
*/
|
|
export async function deleteContact(personId: string): Promise<boolean> {
|
|
const bridge = await loadNativeContactsBridge();
|
|
if (!bridge) {
|
|
lastContactsFailure = "bridge_unavailable";
|
|
logger.warn(`[imessage] deleteContact failed for ${personId}: native bridge unavailable`);
|
|
return false;
|
|
}
|
|
const response = parseNativeContactsResponse(bridge.deleteContact(personId));
|
|
if (response.ok) {
|
|
lastContactsFailure = null;
|
|
logger.info(`[imessage] Contact deleted: ${personId}`);
|
|
return true;
|
|
}
|
|
lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
|
|
logger.warn(
|
|
`[imessage] deleteContact failed for ${personId}: ${
|
|
response.message ?? response.error ?? "unknown error"
|
|
}`
|
|
);
|
|
return false;
|
|
}
|