项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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