feat: add unified contact graph and cross-platform message search
been working on this for a while now. basically it lets you search messages across all your connected platforms (whatsapp, telegram, discord etc) from one place. the main idea is that same person on different platforms gets linked together so when you search for "what did sarah say about the deadline" you get results from everywhere she messaged you. whats included: - new contacts module with sqlite storage for the unified graph - auto-linking by phone number (high confidence) and name similarity - /search command in chat + clawdbot search CLI - clawdbot contacts CLI for managing the graph manually all tests passing (77 tests)
This commit is contained in:
parent
09a72f1ede
commit
0845b24e9d
@ -558,6 +558,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
},
|
||||
],
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "search",
|
||||
description: "Search messages across all platforms.",
|
||||
textAlias: "/search",
|
||||
scope: "text",
|
||||
args: [
|
||||
{
|
||||
name: "query",
|
||||
description: "Search query",
|
||||
type: "string",
|
||||
required: true,
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
...listChannelDocks()
|
||||
.filter((dock) => dock.capabilities.nativeCommands)
|
||||
.map((dock) => defineDockCommand(dock)),
|
||||
|
||||
208
src/auto-reply/reply/commands-search.ts
Normal file
208
src/auto-reply/reply/commands-search.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { getContactStore } from "../../contacts/index.js";
|
||||
import type { Platform } from "../../contacts/types.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
const VALID_PLATFORMS: Platform[] = [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"signal",
|
||||
"imessage",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse relative time strings like "1h", "2d", "1w"
|
||||
*/
|
||||
function parseRelativeTime(value: string): number | null {
|
||||
const match = value.match(/^(\d+)([hdwm])$/i);
|
||||
if (!match) return null;
|
||||
|
||||
const amount = parseInt(match[1]!, 10);
|
||||
const unit = match[2]!.toLowerCase();
|
||||
const now = Date.now();
|
||||
|
||||
switch (unit) {
|
||||
case "h":
|
||||
return now - amount * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return now - amount * 24 * 60 * 60 * 1000;
|
||||
case "w":
|
||||
return now - amount * 7 * 24 * 60 * 60 * 1000;
|
||||
case "m":
|
||||
return now - amount * 30 * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display
|
||||
*/
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: "short" });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search command arguments.
|
||||
* Format: /search <query> [--from <contact>] [--platform <name>] [--since <time>]
|
||||
*/
|
||||
function parseSearchArgs(commandBody: string): {
|
||||
query: string;
|
||||
from?: string;
|
||||
platform?: Platform;
|
||||
since?: number;
|
||||
error?: string;
|
||||
} {
|
||||
// Remove the /search prefix
|
||||
const argsStr = commandBody.replace(/^\/search\s*/i, "").trim();
|
||||
if (!argsStr) {
|
||||
return {
|
||||
query: "",
|
||||
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
|
||||
};
|
||||
}
|
||||
|
||||
let query = "";
|
||||
let from: string | undefined;
|
||||
let platform: Platform | undefined;
|
||||
let since: number | undefined;
|
||||
|
||||
// Parse options
|
||||
const parts = argsStr.split(/\s+/);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]!;
|
||||
|
||||
if (part === "--from" && i + 1 < parts.length) {
|
||||
from = parts[++i];
|
||||
} else if (part === "--platform" && i + 1 < parts.length) {
|
||||
const p = parts[++i]!.toLowerCase() as Platform;
|
||||
if (!VALID_PLATFORMS.includes(p)) {
|
||||
return { query: "", error: `Invalid platform: ${p}. Valid: ${VALID_PLATFORMS.join(", ")}` };
|
||||
}
|
||||
platform = p;
|
||||
} else if (part === "--since" && i + 1 < parts.length) {
|
||||
const timeStr = parts[++i]!;
|
||||
const parsed = parseRelativeTime(timeStr);
|
||||
if (parsed === null) {
|
||||
return {
|
||||
query: "",
|
||||
error: `Invalid --since value: ${timeStr}. Use format like 1h, 2d, 1w, 1m`,
|
||||
};
|
||||
}
|
||||
since = parsed;
|
||||
} else if (part.startsWith("--")) {
|
||||
return { query: "", error: `Unknown option: ${part}` };
|
||||
} else {
|
||||
queryParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
query = queryParts.join(" ");
|
||||
if (!query) {
|
||||
return {
|
||||
query: "",
|
||||
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
|
||||
};
|
||||
}
|
||||
|
||||
return { query, from, platform, since };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the /search command for cross-platform message search.
|
||||
*/
|
||||
export const handleSearchCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (normalized !== "/search" && !normalized.startsWith("/search ")) return null;
|
||||
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /search from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
// Parse arguments - use rawBodyNormalized which preserves the original text
|
||||
const parsed = parseSearchArgs(params.command.rawBodyNormalized);
|
||||
if (parsed.error) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ ${parsed.error}` },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
// Search messages
|
||||
const results = store.searchMessages({
|
||||
query: parsed.query,
|
||||
from: parsed.from,
|
||||
platforms: parsed.platform ? [parsed.platform] : undefined,
|
||||
since: parsed.since,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
let msg = `🔍 No messages found matching "${parsed.query}"`;
|
||||
if (parsed.from) {
|
||||
const contactMatches = store.searchContacts(parsed.from, 5);
|
||||
if (contactMatches.length === 0) {
|
||||
msg += `\n\n⚠️ Note: No contacts found matching "${parsed.from}"`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: msg },
|
||||
};
|
||||
}
|
||||
|
||||
// Format results
|
||||
const lines = [`🔍 Search Results (${results.length})\n`];
|
||||
|
||||
for (const result of results) {
|
||||
const { message, contact, snippet } = result;
|
||||
const senderName = contact?.displayName ?? message.senderId;
|
||||
const time = formatTimestamp(message.timestamp);
|
||||
const platformLabel = message.platform.toUpperCase();
|
||||
|
||||
lines.push(`[${platformLabel}] ${senderName} - ${time}`);
|
||||
lines.push(` ${snippet}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (results.length === 10) {
|
||||
lines.push('Use the CLI for more results: clawdbot search "' + parsed.query + '" --limit 50');
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: lines.join("\n").trim() },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Search error: ${err instanceof Error ? err.message : String(err)}` },
|
||||
};
|
||||
}
|
||||
};
|
||||
475
src/cli/contacts-cli.ts
Normal file
475
src/cli/contacts-cli.ts
Normal file
@ -0,0 +1,475 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import {
|
||||
autoLinkHighConfidence,
|
||||
ContactStore,
|
||||
findLinkSuggestions,
|
||||
getContactStore,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
} from "../contacts/index.js";
|
||||
import { danger, success } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
function formatPlatformList(platforms: string[]): string {
|
||||
return platforms.join(", ");
|
||||
}
|
||||
|
||||
function formatContactRow(contact: {
|
||||
canonicalId: string;
|
||||
displayName: string;
|
||||
aliases: string[];
|
||||
identities: Array<{ platform: string; platformId: string; username?: string | null }>;
|
||||
}) {
|
||||
const platforms = [...new Set(contact.identities.map((i) => i.platform))];
|
||||
return {
|
||||
ID: contact.canonicalId,
|
||||
Name: contact.displayName,
|
||||
Platforms: formatPlatformList(platforms),
|
||||
Identities: String(contact.identities.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerContactsCli(program: Command) {
|
||||
const contacts = program
|
||||
.command("contacts")
|
||||
.description("Unified contact graph - cross-platform identity management")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/cli/contacts",
|
||||
"docs.clawd.bot/cli/contacts",
|
||||
)}\n`,
|
||||
)
|
||||
.action(() => {
|
||||
contacts.help({ error: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts list
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("list")
|
||||
.description("List all contacts in the unified graph")
|
||||
.option("--query <text>", "Search by name or alias")
|
||||
.option("--platform <name>", "Filter by platform (whatsapp, telegram, discord, slack, signal)")
|
||||
.option("--limit <n>", "Limit results", "50")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 50;
|
||||
|
||||
const contactsList = store.listContacts({
|
||||
query: opts.query as string | undefined,
|
||||
platform: opts.platform as
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
const contactsWithIdentities = contactsList
|
||||
.map((c) => store.getContactWithIdentities(c.canonicalId))
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(contactsWithIdentities, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (contactsWithIdentities.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No contacts found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Contacts")} ${theme.muted(`(${contactsWithIdentities.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 20, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 16, flex: true },
|
||||
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
|
||||
{ key: "Identities", header: "#", minWidth: 4 },
|
||||
],
|
||||
rows: contactsWithIdentities.map(formatContactRow),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts show
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("show")
|
||||
.description("Show details for a specific contact")
|
||||
.argument("<id>", "Contact canonical ID or search query")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
// Try exact match first
|
||||
let contact = store.getContactWithIdentities(id);
|
||||
|
||||
// If not found, search
|
||||
if (!contact) {
|
||||
const matches = store.searchContacts(id, 1);
|
||||
contact = matches[0] ?? null;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
defaultRuntime.error(danger(`Contact not found: ${id}`));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(contact, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(`${theme.heading("Contact")}`);
|
||||
defaultRuntime.log(` ID: ${contact.canonicalId}`);
|
||||
defaultRuntime.log(` Name: ${contact.displayName}`);
|
||||
if (contact.aliases.length > 0) {
|
||||
defaultRuntime.log(` Aliases: ${contact.aliases.join(", ")}`);
|
||||
}
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Platform Identities")} (${contact.identities.length})`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Platform", header: "Platform", minWidth: 10 },
|
||||
{ key: "ID", header: "Platform ID", minWidth: 20, flex: true },
|
||||
{ key: "Username", header: "Username", minWidth: 12, flex: true },
|
||||
{ key: "Phone", header: "Phone", minWidth: 14 },
|
||||
],
|
||||
rows: contact.identities.map((i) => ({
|
||||
Platform: i.platform,
|
||||
ID: i.platformId,
|
||||
Username: i.username ?? "",
|
||||
Phone: i.phone ?? "",
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts search
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("search")
|
||||
.description("Search contacts by name, alias, or username")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--limit <n>", "Limit results", "10")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 10;
|
||||
const results = store.searchContacts(query, limit);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
defaultRuntime.log(theme.muted(`No contacts found matching "${query}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 20, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 16, flex: true },
|
||||
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
|
||||
{ key: "Identities", header: "#", minWidth: 4 },
|
||||
],
|
||||
rows: results.map(formatContactRow),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts link
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("link")
|
||||
.description("Link two contacts (merge into one)")
|
||||
.argument("<primary>", "Primary contact ID (will keep this one)")
|
||||
.argument("<secondary>", "Secondary contact ID (will be merged and deleted)")
|
||||
.action(async (primary: string, secondary: string) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const result = linkContacts(store, primary, secondary);
|
||||
|
||||
if (!result.success) {
|
||||
defaultRuntime.error(danger(result.error ?? "Failed to link contacts"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(success(`Linked: ${secondary} merged into ${primary}`));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts unlink
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("unlink")
|
||||
.description("Unlink a platform identity from its contact (creates a new contact)")
|
||||
.argument("<platform>", "Platform (whatsapp, telegram, discord, slack, signal)")
|
||||
.argument("<platformId>", "Platform-specific user ID")
|
||||
.action(async (platform: string, platformId: string) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const result = unlinkIdentity(store, platform, platformId);
|
||||
|
||||
if (!result.success) {
|
||||
defaultRuntime.error(danger(result.error ?? "Failed to unlink identity"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
success(`Unlinked: ${platform}:${platformId} → new contact ${result.newContactId}`),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts suggestions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("suggestions")
|
||||
.description("Show link suggestions (contacts that may be the same person)")
|
||||
.option("--min-score <n>", "Minimum name similarity score (0-1)", "0.85")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const minScore = parseFloat(opts.minScore as string) || 0.85;
|
||||
const suggestions = findLinkSuggestions(store, { minNameScore: minScore });
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(suggestions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No link suggestions found."));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Link Suggestions")} ${theme.muted(`(${suggestions.length})`)}`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(100, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Source", header: "Source", minWidth: 20, flex: true },
|
||||
{ key: "Target", header: "Target", minWidth: 20, flex: true },
|
||||
{ key: "Reason", header: "Reason", minWidth: 14 },
|
||||
{ key: "Confidence", header: "Confidence", minWidth: 10 },
|
||||
{ key: "Score", header: "Score", minWidth: 6 },
|
||||
],
|
||||
rows: suggestions.map((s) => ({
|
||||
Source: `${s.sourceIdentity.platform}:${s.sourceIdentity.displayName || s.sourceIdentity.platformId}`,
|
||||
Target: `${s.targetIdentity.platform}:${s.targetIdentity.displayName || s.targetIdentity.platformId}`,
|
||||
Reason: s.reason,
|
||||
Confidence: s.confidence,
|
||||
Score: s.score.toFixed(2),
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(
|
||||
theme.muted("To link: clawdbot contacts link <source-contact-id> <target-contact-id>"),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts auto-link
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("auto-link")
|
||||
.description("Automatically link high-confidence matches (e.g., same phone number)")
|
||||
.option("--dry-run", "Show what would be linked without making changes", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
if (opts.dryRun) {
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
const highConfidence = suggestions.filter((s) => s.confidence === "high");
|
||||
|
||||
if (highConfidence.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No high-confidence matches found."));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Would auto-link")} ${theme.muted(`(${highConfidence.length})`)}`,
|
||||
);
|
||||
for (const s of highConfidence) {
|
||||
defaultRuntime.log(
|
||||
` ${s.sourceIdentity.contactId} + ${s.targetIdentity.contactId} (${s.reason})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
|
||||
if (result.linked === 0) {
|
||||
defaultRuntime.log(theme.muted("No high-confidence matches found to auto-link."));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(success(`Auto-linked ${result.linked} contact(s)`));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts stats
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("stats")
|
||||
.description("Show contact store statistics")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const stats = store.getStats();
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(stats, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(`${theme.heading("Contact Store Statistics")}`);
|
||||
defaultRuntime.log(` Contacts: ${stats.contacts}`);
|
||||
defaultRuntime.log(` Identities: ${stats.identities}`);
|
||||
defaultRuntime.log(` Indexed Messages: ${stats.messages}`);
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(`${theme.heading("Identities by Platform")}`);
|
||||
for (const [platform, count] of Object.entries(stats.platforms)) {
|
||||
defaultRuntime.log(` ${platform}: ${count}`);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts alias
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("alias")
|
||||
.description("Add or remove an alias for a contact")
|
||||
.argument("<contactId>", "Contact ID")
|
||||
.argument("<alias>", "Alias to add")
|
||||
.option("--remove", "Remove the alias instead of adding", false)
|
||||
.action(async (contactId: string, alias: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const contact = store.getContact(contactId);
|
||||
|
||||
if (!contact) {
|
||||
defaultRuntime.error(danger(`Contact not found: ${contactId}`));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAliases = contact.aliases;
|
||||
let newAliases: string[];
|
||||
|
||||
if (opts.remove) {
|
||||
newAliases = currentAliases.filter((a) => a !== alias);
|
||||
if (newAliases.length === currentAliases.length) {
|
||||
defaultRuntime.log(theme.muted(`Alias "${alias}" not found on this contact.`));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (currentAliases.includes(alias)) {
|
||||
defaultRuntime.log(theme.muted(`Alias "${alias}" already exists on this contact.`));
|
||||
return;
|
||||
}
|
||||
newAliases = [...currentAliases, alias];
|
||||
}
|
||||
|
||||
store.updateContact(contactId, { aliases: newAliases });
|
||||
defaultRuntime.log(
|
||||
success(opts.remove ? `Removed alias "${alias}"` : `Added alias "${alias}"`),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -222,6 +222,22 @@ const entries: SubCliEntry[] = [
|
||||
mod.registerUpdateCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contacts",
|
||||
description: "Unified contact graph",
|
||||
register: async (program) => {
|
||||
const mod = await import("../contacts-cli.js");
|
||||
mod.registerContactsCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
description: "Cross-platform message search",
|
||||
register: async (program) => {
|
||||
const mod = await import("../search-cli.js");
|
||||
mod.registerSearchCli(program);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function removeCommand(program: Command, command: Command) {
|
||||
|
||||
179
src/cli/search-cli.ts
Normal file
179
src/cli/search-cli.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { getContactStore } from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: "short" });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
// Handle relative times like "1h", "2d", "1w"
|
||||
const relativeMatch = value.match(/^(\d+)([hdwm])$/i);
|
||||
if (relativeMatch) {
|
||||
const amount = parseInt(relativeMatch[1]!, 10);
|
||||
const unit = relativeMatch[2]!.toLowerCase();
|
||||
const now = Date.now();
|
||||
switch (unit) {
|
||||
case "h":
|
||||
return now - amount * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return now - amount * 24 * 60 * 60 * 1000;
|
||||
case "w":
|
||||
return now - amount * 7 * 24 * 60 * 60 * 1000;
|
||||
case "m":
|
||||
return now - amount * 30 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ISO date strings
|
||||
const parsed = Date.parse(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const VALID_PLATFORMS: Platform[] = [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"signal",
|
||||
"imessage",
|
||||
];
|
||||
|
||||
export function registerSearchCli(program: Command) {
|
||||
program
|
||||
.command("search")
|
||||
.description("Search messages across all messaging platforms")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--from <contact>", "Filter by sender (contact name, username, or ID)")
|
||||
.option(
|
||||
"--platform <name>",
|
||||
"Filter by platform (whatsapp, telegram, discord, slack, signal, imessage)",
|
||||
)
|
||||
.option("--since <time>", "Filter messages after this time (e.g., 1h, 2d, 1w, or ISO date)")
|
||||
.option("--until <time>", "Filter messages before this time")
|
||||
.option("--limit <n>", "Limit results", "20")
|
||||
.option("--json", "Output JSON", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Examples:")}\n` +
|
||||
` clawdbot search "meeting tomorrow"\n` +
|
||||
` clawdbot search "deadline" --from alice\n` +
|
||||
` clawdbot search "project" --platform slack --since 1w\n` +
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/search", "docs.clawd.bot/cli/search")}\n`,
|
||||
)
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 20;
|
||||
|
||||
// Parse platforms
|
||||
let platforms: Platform[] | undefined;
|
||||
if (opts.platform) {
|
||||
const platform = (opts.platform as string).toLowerCase() as Platform;
|
||||
if (!VALID_PLATFORMS.includes(platform)) {
|
||||
defaultRuntime.error(
|
||||
danger(`Invalid platform: ${opts.platform}. Valid: ${VALID_PLATFORMS.join(", ")}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
platforms = [platform];
|
||||
}
|
||||
|
||||
// Parse timestamps
|
||||
const since = opts.since ? parseTimestamp(opts.since as string) : undefined;
|
||||
const until = opts.until ? parseTimestamp(opts.until as string) : undefined;
|
||||
|
||||
if (opts.since && since === null) {
|
||||
defaultRuntime.error(danger(`Invalid --since value: ${opts.since}`));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.until && until === null) {
|
||||
defaultRuntime.error(danger(`Invalid --until value: ${opts.until}`));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = store.searchMessages({
|
||||
query,
|
||||
from: opts.from as string | undefined,
|
||||
platforms,
|
||||
since: since ?? undefined,
|
||||
until: until ?? undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
defaultRuntime.log(theme.muted(`No messages found matching "${query}".`));
|
||||
|
||||
// Helpful hints
|
||||
if (opts.from) {
|
||||
const contactMatches = store.searchContacts(opts.from as string, 5);
|
||||
if (contactMatches.length === 0) {
|
||||
defaultRuntime.log(theme.muted(`Note: No contacts found matching "${opts.from}".`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log("");
|
||||
|
||||
for (const result of results) {
|
||||
const { message, contact, snippet } = result;
|
||||
const senderName = contact?.displayName ?? message.senderId;
|
||||
const time = formatTimestamp(message.timestamp);
|
||||
|
||||
defaultRuntime.log(
|
||||
`${theme.accent(`[${message.platform}]`)} ${theme.accentBright(senderName)} ${theme.muted(`- ${time}`)}`,
|
||||
);
|
||||
defaultRuntime.log(` ${snippet}`);
|
||||
defaultRuntime.log("");
|
||||
}
|
||||
|
||||
if (results.length === limit) {
|
||||
defaultRuntime.log(
|
||||
theme.muted(`Showing first ${limit} results. Use --limit to see more.`),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
384
src/contacts/importer.test.ts
Normal file
384
src/contacts/importer.test.ts
Normal file
@ -0,0 +1,384 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import {
|
||||
extractDiscordContact,
|
||||
extractIMessageContact,
|
||||
extractSignalContact,
|
||||
extractSlackContact,
|
||||
extractTelegramContact,
|
||||
extractWhatsAppContact,
|
||||
importContactFromMessage,
|
||||
importDiscordGuildMembers,
|
||||
importSlackUsers,
|
||||
importWhatsAppGroupParticipants,
|
||||
isJidGroup,
|
||||
jidToE164,
|
||||
} from "./importer.js";
|
||||
|
||||
describe("jidToE164", () => {
|
||||
it("extracts phone from standard JID", () => {
|
||||
expect(jidToE164("14155551234@s.whatsapp.net")).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("extracts phone from JID with device suffix", () => {
|
||||
expect(jidToE164("14155551234:0@s.whatsapp.net")).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null for invalid JID", () => {
|
||||
expect(jidToE164("")).toBeNull();
|
||||
expect(jidToE164("invalid")).toBeNull();
|
||||
expect(jidToE164("abc@s.whatsapp.net")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for group JID", () => {
|
||||
// Group JIDs don't have phone numbers
|
||||
expect(jidToE164("123456789-1234567890@g.us")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isJidGroup", () => {
|
||||
it("returns true for group JIDs", () => {
|
||||
expect(isJidGroup("123456789-1234567890@g.us")).toBe(true);
|
||||
expect(isJidGroup("status@broadcast")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for user JIDs", () => {
|
||||
expect(isJidGroup("14155551234@s.whatsapp.net")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractWhatsAppContact", () => {
|
||||
it("extracts contact data from sender JID", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "14155551234@s.whatsapp.net",
|
||||
pushName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for group JID", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "123-456@g.us",
|
||||
pushName: "Group Name",
|
||||
});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing push name", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "14155551234@s.whatsapp.net",
|
||||
});
|
||||
expect(data?.displayName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTelegramContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
username: "johndoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles missing fields", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
});
|
||||
expect(data?.username).toBeNull();
|
||||
expect(data?.displayName).toBeNull();
|
||||
});
|
||||
|
||||
it("handles first name only", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
firstName: "John",
|
||||
});
|
||||
expect(data?.displayName).toBe("John");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDiscordContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
globalName: "John Doe",
|
||||
nick: "Johnny",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "discord",
|
||||
platformId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "Johnny", // Nick takes precedence
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to globalName when no nick", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
globalName: "John Doe",
|
||||
});
|
||||
expect(data?.displayName).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("falls back to username when no globalName", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
});
|
||||
expect(data?.displayName).toBe("johndoe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSlackContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractSlackContact({
|
||||
userId: "U12345678",
|
||||
username: "john.doe",
|
||||
displayName: "John Doe",
|
||||
realName: "John Michael Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "slack",
|
||||
platformId: "U12345678",
|
||||
username: "john.doe",
|
||||
phone: null,
|
||||
displayName: "John Doe", // displayName takes precedence
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to realName when no displayName", () => {
|
||||
const data = extractSlackContact({
|
||||
userId: "U12345678",
|
||||
username: "john.doe",
|
||||
realName: "John Doe",
|
||||
});
|
||||
expect(data?.displayName).toBe("John Doe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSignalContact", () => {
|
||||
it("extracts contact data from signal envelope", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceNumber: "+14155551234",
|
||||
sourceUuid: "uuid-123-456",
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "signal",
|
||||
platformId: "uuid-123-456", // UUID preferred
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses phone as platformId when no UUID", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceNumber: "+14155551234",
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data?.platformId).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null when no identifier", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractIMessageContact", () => {
|
||||
it("extracts contact from phone number", () => {
|
||||
const data = extractIMessageContact({
|
||||
senderId: "+14155551234",
|
||||
senderName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "imessage",
|
||||
platformId: "+14155551234",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts contact from email", () => {
|
||||
const data = extractIMessageContact({
|
||||
senderId: "john@example.com",
|
||||
senderName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "imessage",
|
||||
platformId: "john@example.com",
|
||||
username: null,
|
||||
phone: null, // Email is not a phone
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("importContactFromMessage", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("creates new contact for unknown sender", () => {
|
||||
const { contactId, isNew } = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
|
||||
expect(isNew).toBe(true);
|
||||
expect(contactId).toMatch(/^john-doe-[a-f0-9]{8}$/);
|
||||
|
||||
const contact = store.getContactWithIdentities(contactId);
|
||||
expect(contact?.displayName).toBe("John Doe");
|
||||
expect(contact?.identities.length).toBe(1);
|
||||
expect(contact?.identities[0]?.platform).toBe("telegram");
|
||||
});
|
||||
|
||||
it("returns existing contact for known sender", () => {
|
||||
// First import
|
||||
const first = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
expect(first.isNew).toBe(true);
|
||||
|
||||
// Second import of same sender
|
||||
const second = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
expect(second.isNew).toBe(false);
|
||||
expect(second.contactId).toBe(first.contactId);
|
||||
});
|
||||
|
||||
it("uses platformId as displayName fallback", () => {
|
||||
const { contactId } = importContactFromMessage(store, {
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
displayName: null,
|
||||
phone: "+14155551234",
|
||||
});
|
||||
|
||||
const contact = store.getContact(contactId);
|
||||
expect(contact?.displayName).toBe("14155551234@s.whatsapp.net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk importers", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("importSlackUsers", () => {
|
||||
it("imports users from Slack API response", async () => {
|
||||
const mockUsers = [
|
||||
{ id: "U1", name: "alice", displayName: "Alice", isBot: false },
|
||||
{ id: "U2", name: "bob", realName: "Bob Smith", isBot: false },
|
||||
{ id: "U3", name: "bot", displayName: "Bot", isBot: true }, // Should be skipped
|
||||
{ id: "U4", name: "deleted", displayName: "Deleted", deleted: true }, // Should be skipped
|
||||
];
|
||||
|
||||
const result = await importSlackUsers(store, async () => mockUsers);
|
||||
|
||||
expect(result.platform).toBe("slack");
|
||||
expect(result.imported).toBe(2); // Only Alice and Bob
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
const result = await importSlackUsers(store, async () => {
|
||||
throw new Error("API error");
|
||||
});
|
||||
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.errors.length).toBe(1);
|
||||
expect(result.errors[0]).toContain("Failed to list Slack users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importDiscordGuildMembers", () => {
|
||||
it("imports members from Discord API response", async () => {
|
||||
const mockMembers = [
|
||||
{ user: { id: "1", username: "alice", global_name: "Alice" }, nick: null },
|
||||
{ user: { id: "2", username: "bob", global_name: "Bob" }, nick: "Bobby" },
|
||||
{ user: { id: "3", username: "botuser", bot: true } }, // Should be skipped
|
||||
];
|
||||
|
||||
const result = await importDiscordGuildMembers(store, async () => mockMembers);
|
||||
|
||||
expect(result.platform).toBe("discord");
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importWhatsAppGroupParticipants", () => {
|
||||
it("imports participants from group metadata", async () => {
|
||||
const mockGetMetadata = async (_jid: string) => ({
|
||||
subject: "Test Group",
|
||||
participants: [
|
||||
{ id: "14155551111@s.whatsapp.net" },
|
||||
{ id: "14155552222@s.whatsapp.net" },
|
||||
{ id: "123-456@g.us" }, // Group JID should be skipped
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importWhatsAppGroupParticipants(store, mockGetMetadata, "123-456@g.us");
|
||||
|
||||
expect(result.platform).toBe("whatsapp");
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
385
src/contacts/importer.ts
Normal file
385
src/contacts/importer.ts
Normal file
@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Contact importers for each messaging platform.
|
||||
*
|
||||
* Since many platforms don't have bulk contact APIs, importers use a combination of:
|
||||
* 1. Direct API calls where available (Slack users.list, Discord members)
|
||||
* 2. Message-based discovery (observing incoming messages)
|
||||
* 3. Group metadata extraction
|
||||
*/
|
||||
|
||||
import type { ContactStore } from "./store.js";
|
||||
import type { ImportResult, Platform, PlatformIdentityInput } from "./types.js";
|
||||
|
||||
/**
|
||||
* Base interface for platform-specific contact importers.
|
||||
*/
|
||||
export type ContactImporter = {
|
||||
/** Platform this importer handles */
|
||||
platform: Platform;
|
||||
|
||||
/** Import contacts from this platform */
|
||||
import(store: ContactStore): Promise<ImportResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract E.164 phone number from WhatsApp JID.
|
||||
* JID format: "1234567890@s.whatsapp.net" or "1234567890:0@s.whatsapp.net"
|
||||
*/
|
||||
export function jidToE164(jid: string): string | null {
|
||||
if (!jid) return null;
|
||||
// Remove suffix
|
||||
const numberPart = jid.split("@")[0];
|
||||
if (!numberPart) return null;
|
||||
// Handle device suffix (e.g., "1234567890:0")
|
||||
const phone = numberPart.split(":")[0];
|
||||
if (!phone || !/^\d{7,15}$/.test(phone)) return null;
|
||||
return `+${phone}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a JID is a group.
|
||||
*/
|
||||
export function isJidGroup(jid: string): boolean {
|
||||
return jid.includes("@g.us") || jid.includes("@broadcast");
|
||||
}
|
||||
|
||||
/**
|
||||
* Data extracted from an incoming message for contact discovery.
|
||||
*/
|
||||
export type MessageContactData = {
|
||||
platform: Platform;
|
||||
platformId: string;
|
||||
username?: string | null;
|
||||
phone?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Import a contact from message data.
|
||||
* Creates a new contact if the platform identity doesn't exist,
|
||||
* or updates the existing contact's metadata.
|
||||
*/
|
||||
export function importContactFromMessage(
|
||||
store: ContactStore,
|
||||
data: MessageContactData,
|
||||
): { contactId: string; isNew: boolean } {
|
||||
// Check if identity already exists
|
||||
const existing = store.getIdentityByPlatformId(data.platform, data.platformId);
|
||||
if (existing) {
|
||||
// Update last seen
|
||||
store.updateIdentityLastSeen(data.platform, data.platformId);
|
||||
return { contactId: existing.contactId, isNew: false };
|
||||
}
|
||||
|
||||
// Create new contact and identity
|
||||
const displayName = data.displayName || data.username || data.platformId;
|
||||
const contact = store.createContact(displayName);
|
||||
|
||||
const input: PlatformIdentityInput = {
|
||||
contactId: contact.canonicalId,
|
||||
platform: data.platform,
|
||||
platformId: data.platformId,
|
||||
username: data.username ?? null,
|
||||
phone: data.phone ?? null,
|
||||
displayName: data.displayName ?? null,
|
||||
lastSeenAt: Date.now(),
|
||||
};
|
||||
store.addIdentity(input);
|
||||
|
||||
return { contactId: contact.canonicalId, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp contact data extraction from message.
|
||||
*/
|
||||
export function extractWhatsAppContact(params: {
|
||||
senderJid: string;
|
||||
pushName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { senderJid, pushName } = params;
|
||||
if (!senderJid || isJidGroup(senderJid)) return null;
|
||||
|
||||
const phone = jidToE164(senderJid);
|
||||
|
||||
return {
|
||||
platform: "whatsapp",
|
||||
platformId: senderJid,
|
||||
username: null,
|
||||
phone,
|
||||
displayName: pushName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram contact data extraction from message.
|
||||
*/
|
||||
export function extractTelegramContact(params: {
|
||||
userId: number | string;
|
||||
username?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, firstName, lastName } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
const displayName = [firstName, lastName].filter(Boolean).join(" ") || null;
|
||||
|
||||
return {
|
||||
platform: "telegram",
|
||||
platformId: String(userId),
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord contact data extraction from message.
|
||||
*/
|
||||
export function extractDiscordContact(params: {
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
globalName?: string | null;
|
||||
nick?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, globalName, nick } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
// Prefer display names: nick > globalName > username
|
||||
const displayName = nick || globalName || username || null;
|
||||
|
||||
return {
|
||||
platform: "discord",
|
||||
platformId: userId,
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack contact data extraction from message.
|
||||
*/
|
||||
export function extractSlackContact(params: {
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
realName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, displayName, realName } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
return {
|
||||
platform: "slack",
|
||||
platformId: userId,
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName: displayName || realName || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal contact data extraction from message.
|
||||
*/
|
||||
export function extractSignalContact(params: {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
sourceName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { sourceNumber, sourceUuid, sourceName } = params;
|
||||
|
||||
// Prefer UUID as platformId, fall back to phone
|
||||
const platformId = sourceUuid || sourceNumber;
|
||||
if (!platformId) return null;
|
||||
|
||||
return {
|
||||
platform: "signal",
|
||||
platformId,
|
||||
username: null,
|
||||
phone: sourceNumber ?? null,
|
||||
displayName: sourceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* iMessage contact data extraction from message.
|
||||
*/
|
||||
export function extractIMessageContact(params: {
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { senderId, senderName } = params;
|
||||
if (!senderId) return null;
|
||||
|
||||
// iMessage senderId can be phone or email
|
||||
const isPhone = /^\+?\d{10,}$/.test(senderId.replace(/\D/g, ""));
|
||||
|
||||
return {
|
||||
platform: "imessage",
|
||||
platformId: senderId,
|
||||
username: null,
|
||||
phone: isPhone ? senderId : null,
|
||||
displayName: senderName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BULK IMPORTERS (for platforms with bulk APIs)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Slack bulk importer using users.list API.
|
||||
*/
|
||||
export async function importSlackUsers(
|
||||
store: ContactStore,
|
||||
listUsers: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
realName?: string;
|
||||
email?: string;
|
||||
isBot?: boolean;
|
||||
deleted?: boolean;
|
||||
}>
|
||||
>,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "slack",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const users = await listUsers();
|
||||
|
||||
for (const user of users) {
|
||||
// Skip bots and deleted users
|
||||
if (user.isBot || user.deleted) continue;
|
||||
if (!user.id) continue;
|
||||
|
||||
try {
|
||||
const data = extractSlackContact({
|
||||
userId: user.id,
|
||||
username: user.name,
|
||||
displayName: user.displayName,
|
||||
realName: user.realName,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import user ${user.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to list Slack users: ${err}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord bulk importer using guild member search.
|
||||
*/
|
||||
export async function importDiscordGuildMembers(
|
||||
store: ContactStore,
|
||||
listMembers: () => Promise<
|
||||
Array<{
|
||||
user: {
|
||||
id: string;
|
||||
username?: string;
|
||||
global_name?: string;
|
||||
bot?: boolean;
|
||||
};
|
||||
nick?: string | null;
|
||||
}>
|
||||
>,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "discord",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const members = await listMembers();
|
||||
|
||||
for (const member of members) {
|
||||
// Skip bots
|
||||
if (member.user.bot) continue;
|
||||
if (!member.user.id) continue;
|
||||
|
||||
try {
|
||||
const data = extractDiscordContact({
|
||||
userId: member.user.id,
|
||||
username: member.user.username,
|
||||
globalName: member.user.global_name,
|
||||
nick: member.nick,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import member ${member.user.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to list Discord members: ${err}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp group participants importer.
|
||||
*/
|
||||
export async function importWhatsAppGroupParticipants(
|
||||
store: ContactStore,
|
||||
getGroupMetadata: (groupJid: string) => Promise<{
|
||||
subject?: string;
|
||||
participants?: Array<{ id: string }>;
|
||||
}>,
|
||||
groupJid: string,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "whatsapp",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const meta = await getGroupMetadata(groupJid);
|
||||
const participants = meta.participants ?? [];
|
||||
|
||||
for (const participant of participants) {
|
||||
if (!participant.id) continue;
|
||||
if (isJidGroup(participant.id)) continue;
|
||||
|
||||
try {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: participant.id,
|
||||
pushName: null, // Group metadata doesn't include push names
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import participant ${participant.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to get group metadata for ${groupJid}: ${err}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
49
src/contacts/index.ts
Normal file
49
src/contacts/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Contacts module - Unified contact graph for cross-platform identity resolution.
|
||||
*
|
||||
* This module provides:
|
||||
* - Canonical contact management (create, link, search)
|
||||
* - Platform identity linking (WhatsApp, Telegram, Discord, Slack, Signal, etc.)
|
||||
* - Message indexing for cross-platform search
|
||||
* - Auto-linking heuristics based on phone/email/name matching
|
||||
*/
|
||||
|
||||
export { ContactStore, getContactStore, closeContactStore } from "./store.js";
|
||||
export { ensureContactStoreSchema, dropContactStoreTables } from "./schema.js";
|
||||
export {
|
||||
importContactFromMessage,
|
||||
extractWhatsAppContact,
|
||||
extractTelegramContact,
|
||||
extractDiscordContact,
|
||||
extractSlackContact,
|
||||
extractSignalContact,
|
||||
extractIMessageContact,
|
||||
importSlackUsers,
|
||||
importDiscordGuildMembers,
|
||||
importWhatsAppGroupParticipants,
|
||||
jidToE164,
|
||||
isJidGroup,
|
||||
} from "./importer.js";
|
||||
export type {
|
||||
Contact,
|
||||
ContactSearchOptions,
|
||||
ContactWithIdentities,
|
||||
ImportResult,
|
||||
IndexedMessage,
|
||||
LinkConfidence,
|
||||
LinkSuggestion,
|
||||
MessageSearchOptions,
|
||||
MessageSearchResult,
|
||||
Platform,
|
||||
PlatformIdentity,
|
||||
PlatformIdentityInput,
|
||||
} from "./types.js";
|
||||
export type { ContactImporter, MessageContactData } from "./importer.js";
|
||||
export {
|
||||
findPhoneMatches,
|
||||
findNameMatches,
|
||||
findLinkSuggestions,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
autoLinkHighConfidence,
|
||||
} from "./linker.js";
|
||||
504
src/contacts/linker.test.ts
Normal file
504
src/contacts/linker.test.ts
Normal file
@ -0,0 +1,504 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import {
|
||||
autoLinkHighConfidence,
|
||||
findLinkSuggestions,
|
||||
findNameMatches,
|
||||
findPhoneMatches,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
} from "./linker.js";
|
||||
|
||||
describe("linker", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("findPhoneMatches", () => {
|
||||
it("finds contacts with same phone number", () => {
|
||||
// Create two contacts with same phone on different platforms
|
||||
const contact1 = store.createContact("John on WhatsApp");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John on Signal");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "uuid-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John D",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(1);
|
||||
expect(suggestions[0]?.reason).toBe("phone_match");
|
||||
expect(suggestions[0]?.confidence).toBe("high");
|
||||
expect(suggestions[0]?.score).toBe(1.0);
|
||||
});
|
||||
|
||||
it("does not suggest already-linked contacts", () => {
|
||||
const contact = store.createContact("John");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John WA",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Signal",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty for no phone matches", () => {
|
||||
const contact1 = store.createContact("Alice");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-alice",
|
||||
username: null,
|
||||
phone: "+14155551111",
|
||||
displayName: "Alice",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Bob");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-bob",
|
||||
username: null,
|
||||
phone: "+14155552222",
|
||||
displayName: "Bob",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findNameMatches", () => {
|
||||
it("finds contacts with similar names", () => {
|
||||
const contact1 = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John Doe"); // Same name
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findNameMatches(store);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0]?.reason).toBe("name_similarity");
|
||||
expect(suggestions[0]?.score).toBe(1.0);
|
||||
});
|
||||
|
||||
it("finds contacts with slightly different names", () => {
|
||||
const contact1 = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John D"); // Shorter version
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John D",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// With default threshold of 0.85, these may or may not match
|
||||
const suggestions = findNameMatches(store, { minScore: 0.6 });
|
||||
// At least should find something with low threshold
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects minimum score threshold", () => {
|
||||
const contact1 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Bob Jones");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-bob",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Bob Jones",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// Completely different names should not match
|
||||
const suggestions = findNameMatches(store, { minScore: 0.85 });
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLinkSuggestions", () => {
|
||||
it("combines phone and name matches", () => {
|
||||
// Phone match
|
||||
const contact1 = store.createContact("Phone User 1");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Phone User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Phone User 2");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Phone User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// Name match (different person)
|
||||
const contact3 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact3.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact4 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact4.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
expect(suggestions.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Phone matches should come first (high confidence)
|
||||
const phoneMatch = suggestions.find((s) => s.reason === "phone_match");
|
||||
expect(phoneMatch).toBeDefined();
|
||||
expect(phoneMatch?.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("sorts by confidence then score", () => {
|
||||
// Create multiple potential matches with different confidence levels
|
||||
const contact1 = store.createContact("Test User A");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-a",
|
||||
username: null,
|
||||
phone: "+14155559999",
|
||||
displayName: "Test User A",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Test User A");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-a",
|
||||
username: null,
|
||||
phone: "+14155559999",
|
||||
displayName: "Test User A",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify sorted by confidence
|
||||
for (let i = 1; i < suggestions.length; i++) {
|
||||
const prev = suggestions[i - 1]!;
|
||||
const curr = suggestions[i]!;
|
||||
const confidenceOrder = { high: 3, medium: 2, low: 1 };
|
||||
const prevConf = confidenceOrder[prev.confidence];
|
||||
const currConf = confidenceOrder[curr.confidence];
|
||||
expect(prevConf).toBeGreaterThanOrEqual(currConf);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkContacts", () => {
|
||||
it("merges two contacts", () => {
|
||||
const contact1 = store.createContact("John on Telegram");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe_tg",
|
||||
phone: null,
|
||||
displayName: "John TG",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John on Discord");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: "johndoe_dc",
|
||||
phone: null,
|
||||
displayName: "John DC",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, contact1.canonicalId, contact2.canonicalId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Primary contact should have both identities
|
||||
const merged = store.getContactWithIdentities(contact1.canonicalId);
|
||||
expect(merged?.identities.length).toBe(2);
|
||||
expect(merged?.identities.map((i) => i.platform)).toContain("telegram");
|
||||
expect(merged?.identities.map((i) => i.platform)).toContain("discord");
|
||||
|
||||
// Secondary contact should be deleted
|
||||
expect(store.getContact(contact2.canonicalId)).toBeNull();
|
||||
|
||||
// Aliases should include secondary contact's name
|
||||
expect(merged?.aliases).toContain("John on Discord");
|
||||
});
|
||||
|
||||
it("returns error for non-existent primary contact", () => {
|
||||
const contact = store.createContact("Test");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-test",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, "fake-id", contact.canonicalId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Primary contact not found");
|
||||
});
|
||||
|
||||
it("returns error for non-existent secondary contact", () => {
|
||||
const contact = store.createContact("Test");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-test",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, contact.canonicalId, "fake-id");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Secondary contact not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinkIdentity", () => {
|
||||
it("creates new contact for unlinked identity", () => {
|
||||
const contact = store.createContact("Multi Platform User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-user",
|
||||
username: "user_tg",
|
||||
phone: null,
|
||||
displayName: "TG User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-user",
|
||||
username: "user_dc",
|
||||
phone: null,
|
||||
displayName: "DC User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "discord", "dc-user");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newContactId).toBeDefined();
|
||||
|
||||
// Original contact should only have telegram identity
|
||||
const original = store.getContactWithIdentities(contact.canonicalId);
|
||||
expect(original?.identities.length).toBe(1);
|
||||
expect(original?.identities[0]?.platform).toBe("telegram");
|
||||
|
||||
// New contact should have discord identity
|
||||
const newContact = store.getContactWithIdentities(result.newContactId!);
|
||||
expect(newContact?.identities.length).toBe(1);
|
||||
expect(newContact?.identities[0]?.platform).toBe("discord");
|
||||
});
|
||||
|
||||
it("returns error for non-existent identity", () => {
|
||||
const result = unlinkIdentity(store, "telegram", "fake-id");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Identity not found");
|
||||
});
|
||||
|
||||
it("returns error when trying to unlink only identity", () => {
|
||||
const contact = store.createContact("Single Identity User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-single",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "telegram", "tg-single");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Cannot unlink the only identity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoLinkHighConfidence", () => {
|
||||
it("automatically links high confidence matches", () => {
|
||||
// Create contacts with same phone (high confidence)
|
||||
const contact1 = store.createContact("Auto Link User 1");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-auto",
|
||||
username: null,
|
||||
phone: "+14155557777",
|
||||
displayName: "Auto User WA",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Auto Link User 2");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-auto",
|
||||
username: null,
|
||||
phone: "+14155557777",
|
||||
displayName: "Auto User Signal",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const initialCount = store.listContacts().length;
|
||||
expect(initialCount).toBe(2);
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
expect(result.linked).toBe(1);
|
||||
|
||||
// Should now have only one contact
|
||||
const finalCount = store.listContacts().length;
|
||||
expect(finalCount).toBe(1);
|
||||
|
||||
// The remaining contact should have both identities
|
||||
const contacts = store.listContacts();
|
||||
const merged = store.getContactWithIdentities(contacts[0]!.canonicalId);
|
||||
expect(merged?.identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not link medium/low confidence matches", () => {
|
||||
// Create contacts with similar but not exact names (medium confidence)
|
||||
const contact1 = store.createContact("John Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John Smyth"); // Similar but not same
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Smyth",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const initialCount = store.listContacts().length;
|
||||
const result = autoLinkHighConfidence(store);
|
||||
|
||||
// Name similarity below threshold should not auto-link
|
||||
const finalCount = store.listContacts().length;
|
||||
// They may or may not be linked depending on exact similarity
|
||||
// But we verify auto-link only processes high confidence
|
||||
expect(result.suggestions.every((s) => s.confidence === "high")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
386
src/contacts/linker.ts
Normal file
386
src/contacts/linker.ts
Normal file
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Auto-linking heuristics for the unified contact graph.
|
||||
*
|
||||
* This module provides algorithms to automatically detect and suggest
|
||||
* links between platform identities that likely belong to the same person.
|
||||
*
|
||||
* Linking heuristics (in priority order):
|
||||
* 1. Phone match: Same E.164 phone across platforms (high confidence)
|
||||
* 2. Name similarity: Fuzzy name matching with high threshold (medium confidence)
|
||||
*/
|
||||
|
||||
import type { ContactStore } from "./store.js";
|
||||
import type { LinkConfidence, LinkSuggestion, PlatformIdentity } from "./types.js";
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings.
|
||||
*/
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
const matrix: number[][] = [];
|
||||
|
||||
// Initialize matrix
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0]![j] = j;
|
||||
}
|
||||
|
||||
// Fill in the rest of the matrix
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
|
||||
matrix[i]![j] = Math.min(
|
||||
matrix[i - 1]![j]! + 1, // deletion
|
||||
matrix[i]![j - 1]! + 1, // insertion
|
||||
matrix[i - 1]![j - 1]! + cost, // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length]![a.length]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity score between two strings (0-1).
|
||||
* 1 = identical, 0 = completely different.
|
||||
*/
|
||||
function calculateSimilarity(a: string, b: string): number {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
// Normalize: lowercase, trim, remove extra whitespace
|
||||
const normalizedA = a.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
const normalizedB = b.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
|
||||
if (normalizedA === normalizedB) return 1;
|
||||
if (normalizedA.length === 0 || normalizedB.length === 0) return 0;
|
||||
|
||||
const maxLength = Math.max(normalizedA.length, normalizedB.length);
|
||||
const distance = levenshteinDistance(normalizedA, normalizedB);
|
||||
|
||||
return 1 - distance / maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone number for comparison.
|
||||
* Strips non-digit characters except leading +.
|
||||
*/
|
||||
function normalizePhone(phone: string | null): string | null {
|
||||
if (!phone) return null;
|
||||
// Keep only digits and leading +
|
||||
let normalized = phone.replace(/[^+\d]/g, "");
|
||||
// Ensure it starts with +
|
||||
if (!normalized.startsWith("+") && normalized.length >= 10) {
|
||||
// Assume US if 10 digits without country code
|
||||
if (normalized.length === 10) {
|
||||
normalized = `+1${normalized}`;
|
||||
} else {
|
||||
normalized = `+${normalized}`;
|
||||
}
|
||||
}
|
||||
return normalized.length >= 10 ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find link suggestions based on phone number matching.
|
||||
* This is the highest confidence match since phone numbers are unique identifiers.
|
||||
*/
|
||||
export function findPhoneMatches(store: ContactStore): LinkSuggestion[] {
|
||||
const suggestions: LinkSuggestion[] = [];
|
||||
|
||||
// Get all contacts with their identities
|
||||
const contacts = store.listContacts();
|
||||
|
||||
// Build phone → identities map
|
||||
const phoneToIdentities = new Map<string, PlatformIdentity[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const withIdentities = store.getContactWithIdentities(contact.canonicalId);
|
||||
if (!withIdentities) continue;
|
||||
|
||||
for (const identity of withIdentities.identities) {
|
||||
const phone = normalizePhone(identity.phone);
|
||||
if (!phone) continue;
|
||||
|
||||
const existing = phoneToIdentities.get(phone) ?? [];
|
||||
existing.push(identity);
|
||||
phoneToIdentities.set(phone, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Find identities with same phone but different contacts
|
||||
for (const [_phone, identities] of phoneToIdentities) {
|
||||
if (identities.length < 2) continue;
|
||||
|
||||
// Group by contact ID
|
||||
const byContact = new Map<string, PlatformIdentity[]>();
|
||||
for (const identity of identities) {
|
||||
const existing = byContact.get(identity.contactId) ?? [];
|
||||
existing.push(identity);
|
||||
byContact.set(identity.contactId, existing);
|
||||
}
|
||||
|
||||
// If all belong to same contact, already linked
|
||||
if (byContact.size < 2) continue;
|
||||
|
||||
// Create suggestions for each pair of contacts
|
||||
const contactIds = Array.from(byContact.keys());
|
||||
for (let i = 0; i < contactIds.length; i++) {
|
||||
for (let j = i + 1; j < contactIds.length; j++) {
|
||||
const sourceIdentity = byContact.get(contactIds[i]!)![0]!;
|
||||
const targetIdentity = byContact.get(contactIds[j]!)![0]!;
|
||||
|
||||
suggestions.push({
|
||||
sourceIdentity,
|
||||
targetIdentity,
|
||||
reason: "phone_match",
|
||||
confidence: "high",
|
||||
score: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find link suggestions based on name similarity.
|
||||
* Uses fuzzy matching with a configurable threshold.
|
||||
*/
|
||||
export function findNameMatches(
|
||||
store: ContactStore,
|
||||
options: { minScore?: number } = {},
|
||||
): LinkSuggestion[] {
|
||||
const { minScore = 0.85 } = options;
|
||||
const suggestions: LinkSuggestion[] = [];
|
||||
|
||||
// Get all contacts with their identities
|
||||
const contacts = store.listContacts();
|
||||
const contactsWithIdentities = contacts
|
||||
.map((c) => store.getContactWithIdentities(c.canonicalId))
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
// Compare each pair of contacts
|
||||
for (let i = 0; i < contactsWithIdentities.length; i++) {
|
||||
for (let j = i + 1; j < contactsWithIdentities.length; j++) {
|
||||
const contactA = contactsWithIdentities[i]!;
|
||||
const contactB = contactsWithIdentities[j]!;
|
||||
|
||||
// Skip if already same contact
|
||||
if (contactA.canonicalId === contactB.canonicalId) continue;
|
||||
|
||||
// Compare display names
|
||||
const similarity = calculateSimilarity(contactA.displayName, contactB.displayName);
|
||||
|
||||
if (similarity >= minScore) {
|
||||
// Get representative identities for the suggestion
|
||||
const sourceIdentity = contactA.identities[0];
|
||||
const targetIdentity = contactB.identities[0];
|
||||
|
||||
if (sourceIdentity && targetIdentity) {
|
||||
suggestions.push({
|
||||
sourceIdentity,
|
||||
targetIdentity,
|
||||
reason: "name_similarity",
|
||||
confidence: similarity >= 0.95 ? "high" : "medium",
|
||||
score: similarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also compare identity display names
|
||||
for (const identityA of contactA.identities) {
|
||||
for (const identityB of contactB.identities) {
|
||||
if (!identityA.displayName || !identityB.displayName) continue;
|
||||
|
||||
const identitySimilarity = calculateSimilarity(
|
||||
identityA.displayName,
|
||||
identityB.displayName,
|
||||
);
|
||||
|
||||
if (identitySimilarity >= minScore) {
|
||||
// Avoid duplicate suggestions
|
||||
const alreadySuggested = suggestions.some(
|
||||
(s) =>
|
||||
(s.sourceIdentity.id === identityA.id && s.targetIdentity.id === identityB.id) ||
|
||||
(s.sourceIdentity.id === identityB.id && s.targetIdentity.id === identityA.id),
|
||||
);
|
||||
|
||||
if (!alreadySuggested) {
|
||||
suggestions.push({
|
||||
sourceIdentity: identityA,
|
||||
targetIdentity: identityB,
|
||||
reason: "name_similarity",
|
||||
confidence: identitySimilarity >= 0.95 ? "high" : "medium",
|
||||
score: identitySimilarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all link suggestions across all heuristics.
|
||||
* Returns suggestions sorted by confidence and score.
|
||||
*/
|
||||
export function findLinkSuggestions(
|
||||
store: ContactStore,
|
||||
options: { minNameScore?: number } = {},
|
||||
): LinkSuggestion[] {
|
||||
const phoneSuggestions = findPhoneMatches(store);
|
||||
const nameSuggestions = findNameMatches(store, { minScore: options.minNameScore });
|
||||
|
||||
// Combine and sort by confidence (high first) then score
|
||||
const all = [...phoneSuggestions, ...nameSuggestions];
|
||||
|
||||
const confidenceOrder: Record<LinkConfidence, number> = {
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
return all.sort((a, b) => {
|
||||
const confDiff = confidenceOrder[b.confidence] - confidenceOrder[a.confidence];
|
||||
if (confDiff !== 0) return confDiff;
|
||||
return b.score - a.score;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link two contacts by merging all identities into the primary contact.
|
||||
* The secondary contact is deleted.
|
||||
*/
|
||||
export function linkContacts(
|
||||
store: ContactStore,
|
||||
primaryContactId: string,
|
||||
secondaryContactId: string,
|
||||
): { success: boolean; error?: string } {
|
||||
const primary = store.getContactWithIdentities(primaryContactId);
|
||||
const secondary = store.getContactWithIdentities(secondaryContactId);
|
||||
|
||||
if (!primary) {
|
||||
return { success: false, error: `Primary contact not found: ${primaryContactId}` };
|
||||
}
|
||||
if (!secondary) {
|
||||
return { success: false, error: `Secondary contact not found: ${secondaryContactId}` };
|
||||
}
|
||||
|
||||
// Move all identities from secondary to primary
|
||||
for (const identity of secondary.identities) {
|
||||
store.addIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: identity.platform,
|
||||
platformId: identity.platformId,
|
||||
username: identity.username,
|
||||
phone: identity.phone,
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge aliases
|
||||
const newAliases = [...primary.aliases];
|
||||
if (!newAliases.includes(secondary.displayName)) {
|
||||
newAliases.push(secondary.displayName);
|
||||
}
|
||||
for (const alias of secondary.aliases) {
|
||||
if (!newAliases.includes(alias)) {
|
||||
newAliases.push(alias);
|
||||
}
|
||||
}
|
||||
store.updateContact(primary.canonicalId, { aliases: newAliases });
|
||||
|
||||
// Delete secondary contact
|
||||
store.deleteContact(secondaryContactId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a platform identity from its current contact.
|
||||
* Creates a new contact for the identity.
|
||||
*/
|
||||
export function unlinkIdentity(
|
||||
store: ContactStore,
|
||||
platform: string,
|
||||
platformId: string,
|
||||
): { success: boolean; newContactId?: string; error?: string } {
|
||||
const identity = store.getIdentityByPlatformId(platform, platformId);
|
||||
if (!identity) {
|
||||
return { success: false, error: `Identity not found: ${platform}:${platformId}` };
|
||||
}
|
||||
|
||||
const currentContact = store.getContactWithIdentities(identity.contactId);
|
||||
if (!currentContact) {
|
||||
return { success: false, error: `Contact not found: ${identity.contactId}` };
|
||||
}
|
||||
|
||||
// If this is the only identity, nothing to unlink
|
||||
if (currentContact.identities.length === 1) {
|
||||
return { success: false, error: "Cannot unlink the only identity from a contact" };
|
||||
}
|
||||
|
||||
// Create new contact for this identity
|
||||
const displayName = identity.displayName || identity.username || identity.platformId;
|
||||
const newContact = store.createContact(displayName);
|
||||
|
||||
// Move the identity to the new contact
|
||||
store.addIdentity({
|
||||
contactId: newContact.canonicalId,
|
||||
platform: identity.platform,
|
||||
platformId: identity.platformId,
|
||||
username: identity.username,
|
||||
phone: identity.phone,
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
|
||||
return { success: true, newContactId: newContact.canonicalId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-apply high-confidence link suggestions.
|
||||
* Returns the number of links applied.
|
||||
*/
|
||||
export function autoLinkHighConfidence(store: ContactStore): {
|
||||
linked: number;
|
||||
suggestions: LinkSuggestion[];
|
||||
} {
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
const highConfidence = suggestions.filter((s) => s.confidence === "high");
|
||||
|
||||
let linked = 0;
|
||||
const processedContacts = new Set<string>();
|
||||
|
||||
for (const suggestion of highConfidence) {
|
||||
const sourceContactId = suggestion.sourceIdentity.contactId;
|
||||
const targetContactId = suggestion.targetIdentity.contactId;
|
||||
|
||||
// Skip if either contact was already processed (merged into another)
|
||||
if (processedContacts.has(sourceContactId) || processedContacts.has(targetContactId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if same contact (already linked)
|
||||
if (sourceContactId === targetContactId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = linkContacts(store, sourceContactId, targetContactId);
|
||||
if (result.success) {
|
||||
linked++;
|
||||
processedContacts.add(targetContactId);
|
||||
}
|
||||
}
|
||||
|
||||
return { linked, suggestions: highConfidence };
|
||||
}
|
||||
125
src/contacts/schema.ts
Normal file
125
src/contacts/schema.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Ensures the contact store schema is created in the SQLite database.
|
||||
* Creates tables for contacts, platform identities, and indexed messages.
|
||||
*/
|
||||
export function ensureContactStoreSchema(db: DatabaseSync): {
|
||||
ftsAvailable: boolean;
|
||||
ftsError?: string;
|
||||
} {
|
||||
// Unified contacts table - canonical contact records
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
canonical_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
aliases TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Platform identities table - links platform-specific IDs to canonical contacts
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS platform_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id TEXT NOT NULL REFERENCES contacts(canonical_id) ON DELETE CASCADE,
|
||||
platform TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
username TEXT,
|
||||
phone TEXT,
|
||||
display_name TEXT,
|
||||
last_seen_at INTEGER,
|
||||
UNIQUE(platform, platform_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Indexed messages table - for cross-platform message search
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS indexed_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
contact_id TEXT REFERENCES contacts(canonical_id) ON DELETE SET NULL,
|
||||
platform TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
embedding TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Indexes for efficient queries
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_contact_id ON platform_identities(contact_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_platform ON platform_identities(platform);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_phone ON platform_identities(phone);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_username ON platform_identities(username);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_contact_id ON indexed_messages(contact_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_platform ON indexed_messages(platform);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_sender_id ON indexed_messages(sender_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_channel_id ON indexed_messages(channel_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_timestamp ON indexed_messages(timestamp);`,
|
||||
);
|
||||
|
||||
// Full-text search virtual table for message content
|
||||
let ftsAvailable = false;
|
||||
let ftsError: string | undefined;
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
content,
|
||||
id UNINDEXED,
|
||||
contact_id UNINDEXED,
|
||||
platform UNINDEXED,
|
||||
sender_id UNINDEXED,
|
||||
channel_id UNINDEXED,
|
||||
timestamp UNINDEXED
|
||||
);
|
||||
`);
|
||||
ftsAvailable = true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ftsAvailable = false;
|
||||
ftsError = message;
|
||||
}
|
||||
|
||||
// Migration helper - add columns if they don't exist
|
||||
ensureColumn(db, "contacts", "aliases", "TEXT NOT NULL DEFAULT '[]'");
|
||||
|
||||
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a column exists on a table, adding it if missing.
|
||||
*/
|
||||
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (rows.some((row) => row.name === column)) return;
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all contact store tables (for testing/reset).
|
||||
*/
|
||||
export function dropContactStoreTables(db: DatabaseSync): void {
|
||||
db.exec(`DROP TABLE IF EXISTS messages_fts;`);
|
||||
db.exec(`DROP TABLE IF EXISTS indexed_messages;`);
|
||||
db.exec(`DROP TABLE IF EXISTS platform_identities;`);
|
||||
db.exec(`DROP TABLE IF EXISTS contacts;`);
|
||||
}
|
||||
518
src/contacts/store.test.ts
Normal file
518
src/contacts/store.test.ts
Normal file
@ -0,0 +1,518 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import type { Platform } from "./types.js";
|
||||
|
||||
describe("ContactStore", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("contacts", () => {
|
||||
it("creates a contact with generated canonical ID", () => {
|
||||
const contact = store.createContact("Sarah Jones");
|
||||
expect(contact.canonicalId).toMatch(/^sarah-jones-[a-f0-9]{8}$/);
|
||||
expect(contact.displayName).toBe("Sarah Jones");
|
||||
expect(contact.aliases).toEqual([]);
|
||||
expect(contact.createdAt).toBeGreaterThan(0);
|
||||
expect(contact.updatedAt).toBe(contact.createdAt);
|
||||
});
|
||||
|
||||
it("creates a contact with aliases", () => {
|
||||
const contact = store.createContact("Bob Smith", ["Bobby", "Bob S"]);
|
||||
expect(contact.aliases).toEqual(["Bobby", "Bob S"]);
|
||||
});
|
||||
|
||||
it("retrieves a contact by canonical ID", () => {
|
||||
const created = store.createContact("Alice Doe");
|
||||
const retrieved = store.getContact(created.canonicalId);
|
||||
expect(retrieved).toEqual(created);
|
||||
});
|
||||
|
||||
it("returns null for non-existent contact", () => {
|
||||
const retrieved = store.getContact("non-existent-id");
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("updates contact display name", () => {
|
||||
const contact = store.createContact("Old Name");
|
||||
const success = store.updateContact(contact.canonicalId, { displayName: "New Name" });
|
||||
expect(success).toBe(true);
|
||||
|
||||
const updated = store.getContact(contact.canonicalId);
|
||||
expect(updated?.displayName).toBe("New Name");
|
||||
// updatedAt should be >= createdAt (may be same millisecond in fast tests)
|
||||
expect(updated?.updatedAt).toBeGreaterThanOrEqual(contact.updatedAt);
|
||||
});
|
||||
|
||||
it("updates contact aliases", () => {
|
||||
const contact = store.createContact("Test User");
|
||||
store.updateContact(contact.canonicalId, { aliases: ["Tester", "TU"] });
|
||||
|
||||
const updated = store.getContact(contact.canonicalId);
|
||||
expect(updated?.aliases).toEqual(["Tester", "TU"]);
|
||||
});
|
||||
|
||||
it("returns false when updating non-existent contact", () => {
|
||||
const success = store.updateContact("fake-id", { displayName: "Test" });
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a contact", () => {
|
||||
const contact = store.createContact("To Delete");
|
||||
expect(store.getContact(contact.canonicalId)).not.toBeNull();
|
||||
|
||||
const deleted = store.deleteContact(contact.canonicalId);
|
||||
expect(deleted).toBe(true);
|
||||
expect(store.getContact(contact.canonicalId)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false when deleting non-existent contact", () => {
|
||||
const deleted = store.deleteContact("fake-id");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("lists all contacts", () => {
|
||||
store.createContact("Alpha User");
|
||||
store.createContact("Beta User");
|
||||
store.createContact("Gamma User");
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(3);
|
||||
});
|
||||
|
||||
it("lists contacts with query filter", () => {
|
||||
store.createContact("John Doe");
|
||||
store.createContact("Jane Doe", ["Janey"]);
|
||||
store.createContact("Bob Smith");
|
||||
|
||||
const contacts = store.listContacts({ query: "doe" });
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("lists contacts with limit", () => {
|
||||
store.createContact("User 1");
|
||||
store.createContact("User 2");
|
||||
store.createContact("User 3");
|
||||
|
||||
const contacts = store.listContacts({ limit: 2 });
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform identities", () => {
|
||||
it("adds a platform identity to a contact", () => {
|
||||
const contact = store.createContact("Test User");
|
||||
const identity = store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "testuser",
|
||||
phone: null,
|
||||
displayName: "Test User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
expect(identity.id).toBeGreaterThan(0);
|
||||
expect(identity.contactId).toBe(contact.canonicalId);
|
||||
expect(identity.platform).toBe("telegram");
|
||||
expect(identity.platformId).toBe("123456789");
|
||||
});
|
||||
|
||||
it("retrieves identities by contact ID", () => {
|
||||
const contact = store.createContact("Multi Platform User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-123",
|
||||
username: "teleuser",
|
||||
phone: null,
|
||||
displayName: "Tele User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-456",
|
||||
username: "discorduser",
|
||||
phone: null,
|
||||
displayName: "Discord User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identities = store.getIdentitiesByContact(contact.canonicalId);
|
||||
expect(identities.length).toBe(2);
|
||||
expect(identities.map((i) => i.platform)).toContain("telegram");
|
||||
expect(identities.map((i) => i.platform)).toContain("discord");
|
||||
});
|
||||
|
||||
it("retrieves identity by platform and platform ID", () => {
|
||||
const contact = store.createContact("User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "+14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "WA User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identity = store.getIdentityByPlatformId("whatsapp", "+14155551234@s.whatsapp.net");
|
||||
expect(identity).not.toBeNull();
|
||||
expect(identity?.contactId).toBe(contact.canonicalId);
|
||||
expect(identity?.phone).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null for non-existent identity", () => {
|
||||
const identity = store.getIdentityByPlatformId("telegram", "fake-id");
|
||||
expect(identity).toBeNull();
|
||||
});
|
||||
|
||||
it("finds identities by phone number", () => {
|
||||
const contact = store.createContact("Phone User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-jid-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "WA User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-uuid-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Signal User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identities = store.findIdentitiesByPhone("+14155551234");
|
||||
expect(identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("updates last seen timestamp", () => {
|
||||
const contact = store.createContact("User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-id",
|
||||
username: "user",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.updateIdentityLastSeen("telegram", "tg-id");
|
||||
const identity = store.getIdentityByPlatformId("telegram", "tg-id");
|
||||
expect(identity?.lastSeenAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolves platform sender to contact ID", () => {
|
||||
const contact = store.createContact("Resolvable User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "discord-user-id",
|
||||
username: "discorduser",
|
||||
phone: null,
|
||||
displayName: "Discord Display",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const resolved = store.resolveContact("discord", "discord-user-id");
|
||||
expect(resolved).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("returns null when resolving unknown sender", () => {
|
||||
const resolved = store.resolveContact("telegram", "unknown-id");
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("contact search", () => {
|
||||
it("searches contacts by display name", () => {
|
||||
store.createContact("Alice Wonderland");
|
||||
store.createContact("Bob Builder");
|
||||
store.createContact("Alice Cooper");
|
||||
|
||||
const results = store.searchContacts("alice");
|
||||
expect(results.length).toBe(2);
|
||||
expect(results.map((r) => r.displayName)).toContain("Alice Wonderland");
|
||||
expect(results.map((r) => r.displayName)).toContain("Alice Cooper");
|
||||
});
|
||||
|
||||
it("searches contacts by username", () => {
|
||||
const contact = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const results = store.searchContacts("johndoe");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.displayName).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("returns contact with all identities", () => {
|
||||
const contact = store.createContact("Multi User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-multi",
|
||||
username: "multi_tg",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "slack",
|
||||
platformId: "slack-multi",
|
||||
username: "multi_slack",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const results = store.searchContacts("multi");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.identities.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message indexing", () => {
|
||||
it("indexes a message", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-1",
|
||||
content: "Hello, this is a test message",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-123",
|
||||
channelId: "channel-456",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const stats = store.getStats();
|
||||
expect(stats.messages).toBe(1);
|
||||
});
|
||||
|
||||
it("links message to contact when sender is resolved", () => {
|
||||
const contact = store.createContact("Known Sender");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "sender-known",
|
||||
username: "known",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-linked",
|
||||
content: "Message from known sender",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-known",
|
||||
channelId: "chat-1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Search should find the message
|
||||
const results = store.searchMessages({ query: "known sender" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("searches messages by content", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-search-1",
|
||||
content: "The quick brown fox jumps over the lazy dog",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-search-2",
|
||||
content: "A slow red turtle crawls under the fence",
|
||||
platform: "discord" as Platform,
|
||||
senderId: "s2",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({ query: "quick fox" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.id).toBe("msg-search-1");
|
||||
});
|
||||
|
||||
it("filters messages by platform", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-tg",
|
||||
content: "Telegram message about deadlines",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-dc",
|
||||
content: "Discord message about deadlines",
|
||||
platform: "discord" as Platform,
|
||||
senderId: "s2",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "deadlines",
|
||||
platforms: ["telegram"],
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.message.platform).toBe("telegram");
|
||||
});
|
||||
|
||||
it("filters messages by timestamp range", () => {
|
||||
const now = Date.now();
|
||||
store.indexMessage({
|
||||
id: "msg-old",
|
||||
content: "Old message about projects",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: now - 7 * 24 * 60 * 60 * 1000, // 7 days ago
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-new",
|
||||
content: "New message about projects",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "projects",
|
||||
since: now - 24 * 60 * 60 * 1000, // Last 24 hours
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.message.id).toBe("msg-new");
|
||||
});
|
||||
|
||||
it("creates snippet with context", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-snippet",
|
||||
content:
|
||||
"This is a very long message that contains the word deadline somewhere in the middle and continues with more text after that point to test the snippet creation functionality.",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({ query: "deadline" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.snippet).toContain("deadline");
|
||||
expect(results[0]?.snippet.length).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactWithIdentities", () => {
|
||||
it("returns contact with all platform identities", () => {
|
||||
const contact = store.createContact("Full Contact");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-full",
|
||||
username: "full_tg",
|
||||
phone: "+14155551111",
|
||||
displayName: "TG Full",
|
||||
lastSeenAt: Date.now(),
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-full",
|
||||
username: null,
|
||||
phone: "+14155551111",
|
||||
displayName: "WA Full",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = store.getContactWithIdentities(contact.canonicalId);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.displayName).toBe("Full Contact");
|
||||
expect(result?.identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for non-existent contact", () => {
|
||||
const result = store.getContactWithIdentities("fake-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("statistics", () => {
|
||||
it("returns accurate stats", () => {
|
||||
const contact1 = store.createContact("Stats User 1");
|
||||
const contact2 = store.createContact("Stats User 2");
|
||||
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-stats-1",
|
||||
username: "stats1",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-stats-1",
|
||||
username: "stats1dc",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-stats-2",
|
||||
username: "stats2",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "stats-msg-1",
|
||||
content: "Stats test message",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "tg-stats-1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const stats = store.getStats();
|
||||
expect(stats.contacts).toBe(2);
|
||||
expect(stats.identities).toBe(3);
|
||||
expect(stats.messages).toBe(1);
|
||||
expect(stats.platforms.telegram).toBe(2);
|
||||
expect(stats.platforms.discord).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
784
src/contacts/store.ts
Normal file
784
src/contacts/store.ts
Normal file
@ -0,0 +1,784 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
|
||||
import { requireNodeSqlite } from "../memory/sqlite.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { ensureContactStoreSchema } from "./schema.js";
|
||||
import type {
|
||||
Contact,
|
||||
ContactSearchOptions,
|
||||
ContactWithIdentities,
|
||||
IndexedMessage,
|
||||
MessageSearchOptions,
|
||||
MessageSearchResult,
|
||||
Platform,
|
||||
PlatformIdentity,
|
||||
PlatformIdentityInput,
|
||||
} from "./types.js";
|
||||
|
||||
const CONTACTS_DB_FILENAME = "contacts.sqlite";
|
||||
|
||||
/**
|
||||
* ContactStore manages the unified contact graph and message index.
|
||||
*
|
||||
* Key capabilities:
|
||||
* - Store and retrieve canonical contacts
|
||||
* - Link platform-specific identities to canonical contacts
|
||||
* - Index messages for cross-platform search
|
||||
* - Resolve sender identities to canonical contacts
|
||||
*/
|
||||
export class ContactStore {
|
||||
private db: DatabaseSync;
|
||||
private ftsAvailable: boolean;
|
||||
|
||||
// Prepared statements for performance
|
||||
private stmtInsertContact: StatementSync;
|
||||
private stmtUpdateContact: StatementSync;
|
||||
private stmtGetContact: StatementSync;
|
||||
private stmtDeleteContact: StatementSync;
|
||||
private stmtInsertIdentity: StatementSync;
|
||||
private stmtGetIdentitiesByContact: StatementSync;
|
||||
private stmtGetIdentityByPlatformId: StatementSync;
|
||||
private stmtUpdateIdentityLastSeen: StatementSync;
|
||||
private stmtInsertMessage: StatementSync;
|
||||
private stmtInsertMessageFts: StatementSync | null;
|
||||
|
||||
private constructor(db: DatabaseSync, ftsAvailable: boolean) {
|
||||
this.db = db;
|
||||
this.ftsAvailable = ftsAvailable;
|
||||
|
||||
// Prepare statements
|
||||
this.stmtInsertContact = db.prepare(`
|
||||
INSERT INTO contacts (canonical_id, display_name, aliases, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
this.stmtUpdateContact = db.prepare(`
|
||||
UPDATE contacts SET display_name = ?, aliases = ?, updated_at = ? WHERE canonical_id = ?
|
||||
`);
|
||||
this.stmtGetContact = db.prepare(`
|
||||
SELECT canonical_id, display_name, aliases, created_at, updated_at
|
||||
FROM contacts WHERE canonical_id = ?
|
||||
`);
|
||||
this.stmtDeleteContact = db.prepare(`DELETE FROM contacts WHERE canonical_id = ?`);
|
||||
|
||||
this.stmtInsertIdentity = db.prepare(`
|
||||
INSERT OR REPLACE INTO platform_identities
|
||||
(contact_id, platform, platform_id, username, phone, display_name, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
this.stmtGetIdentitiesByContact = db.prepare(`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE contact_id = ?
|
||||
`);
|
||||
this.stmtGetIdentityByPlatformId = db.prepare(`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE platform = ? AND platform_id = ?
|
||||
`);
|
||||
this.stmtUpdateIdentityLastSeen = db.prepare(`
|
||||
UPDATE platform_identities SET last_seen_at = ? WHERE platform = ? AND platform_id = ?
|
||||
`);
|
||||
|
||||
this.stmtInsertMessage = db.prepare(`
|
||||
INSERT OR REPLACE INTO indexed_messages
|
||||
(id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.stmtInsertMessageFts = ftsAvailable
|
||||
? db.prepare(`
|
||||
INSERT OR REPLACE INTO messages_fts
|
||||
(content, id, contact_id, platform, sender_id, channel_id, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or create a contact store database.
|
||||
*/
|
||||
static open(dbPath?: string): ContactStore {
|
||||
const nodeSqlite = requireNodeSqlite();
|
||||
const resolvedPath = dbPath ?? path.join(resolveStateDir(), "contacts", CONTACTS_DB_FILENAME);
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(resolvedPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new nodeSqlite.DatabaseSync(resolvedPath);
|
||||
|
||||
// Enable foreign keys
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
// Set up schema
|
||||
const { ftsAvailable } = ensureContactStoreSchema(db);
|
||||
|
||||
return new ContactStore(db, ftsAvailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new in-memory store (for testing).
|
||||
*/
|
||||
static openInMemory(): ContactStore {
|
||||
const nodeSqlite = requireNodeSqlite();
|
||||
const db = new nodeSqlite.DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const { ftsAvailable } = ensureContactStoreSchema(db);
|
||||
return new ContactStore(db, ftsAvailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONTACT OPERATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a canonical ID from a display name.
|
||||
*/
|
||||
private generateCanonicalId(displayName: string): string {
|
||||
const slug = displayName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 30);
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
return `${slug || "contact"}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new canonical contact.
|
||||
*/
|
||||
createContact(displayName: string, aliases: string[] = []): Contact {
|
||||
const now = Date.now();
|
||||
const canonicalId = this.generateCanonicalId(displayName);
|
||||
this.stmtInsertContact.run(canonicalId, displayName, JSON.stringify(aliases), now, now);
|
||||
return {
|
||||
canonicalId,
|
||||
displayName,
|
||||
aliases,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contact by canonical ID.
|
||||
*/
|
||||
getContact(canonicalId: string): Contact | null {
|
||||
const row = this.stmtGetContact.get(canonicalId) as
|
||||
| {
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
aliases: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
canonicalId: row.canonical_id,
|
||||
displayName: row.display_name,
|
||||
aliases: JSON.parse(row.aliases) as string[],
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact's display name and/or aliases.
|
||||
*/
|
||||
updateContact(
|
||||
canonicalId: string,
|
||||
updates: { displayName?: string; aliases?: string[] },
|
||||
): boolean {
|
||||
const existing = this.getContact(canonicalId);
|
||||
if (!existing) return false;
|
||||
|
||||
const displayName = updates.displayName ?? existing.displayName;
|
||||
const aliases = updates.aliases ?? existing.aliases;
|
||||
const now = Date.now();
|
||||
|
||||
this.stmtUpdateContact.run(displayName, JSON.stringify(aliases), now, canonicalId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact and all its platform identities.
|
||||
*/
|
||||
deleteContact(canonicalId: string): boolean {
|
||||
const result = this.stmtDeleteContact.run(canonicalId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all contacts with optional filtering.
|
||||
*/
|
||||
listContacts(options: ContactSearchOptions = {}): Contact[] {
|
||||
let sql = `SELECT canonical_id, display_name, aliases, created_at, updated_at FROM contacts`;
|
||||
const params: (string | number)[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (options.query) {
|
||||
conditions.push(`(display_name LIKE ? OR aliases LIKE ?)`);
|
||||
const pattern = `%${options.query}%`;
|
||||
params.push(pattern, pattern);
|
||||
}
|
||||
|
||||
if (options.platform) {
|
||||
conditions.push(
|
||||
`canonical_id IN (SELECT contact_id FROM platform_identities WHERE platform = ?)`,
|
||||
);
|
||||
params.push(options.platform);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += ` WHERE ${conditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY updated_at DESC`;
|
||||
|
||||
if (options.limit) {
|
||||
sql += ` LIMIT ?`;
|
||||
params.push(options.limit);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(sql);
|
||||
const rows = stmt.all(...params) as Array<{
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
aliases: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
canonicalId: row.canonical_id,
|
||||
displayName: row.display_name,
|
||||
aliases: JSON.parse(row.aliases) as string[],
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contact with all its platform identities.
|
||||
*/
|
||||
getContactWithIdentities(canonicalId: string): ContactWithIdentities | null {
|
||||
const contact = this.getContact(canonicalId);
|
||||
if (!contact) return null;
|
||||
|
||||
const identities = this.getIdentitiesByContact(canonicalId);
|
||||
return { ...contact, identities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts by name, alias, or username.
|
||||
*/
|
||||
searchContacts(query: string, limit = 10): ContactWithIdentities[] {
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
// Search in contacts table
|
||||
const contactRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT c.canonical_id
|
||||
FROM contacts c
|
||||
LEFT JOIN platform_identities pi ON c.canonical_id = pi.contact_id
|
||||
WHERE c.display_name LIKE ?
|
||||
OR c.aliases LIKE ?
|
||||
OR pi.username LIKE ?
|
||||
OR pi.display_name LIKE ?
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(pattern, pattern, pattern, pattern, limit) as Array<{ canonical_id: string }>;
|
||||
|
||||
return contactRows
|
||||
.map((row) => this.getContactWithIdentities(row.canonical_id))
|
||||
.filter((c): c is ContactWithIdentities => c !== null);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PLATFORM IDENTITY OPERATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a platform identity to a contact.
|
||||
*/
|
||||
addIdentity(input: PlatformIdentityInput): PlatformIdentity {
|
||||
this.stmtInsertIdentity.run(
|
||||
input.contactId,
|
||||
input.platform,
|
||||
input.platformId,
|
||||
input.username,
|
||||
input.phone,
|
||||
input.displayName,
|
||||
input.lastSeenAt,
|
||||
);
|
||||
|
||||
// Get the inserted row to return
|
||||
const identity = this.getIdentityByPlatformId(input.platform, input.platformId);
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
`Failed to retrieve inserted identity: ${input.platform}:${input.platformId}`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platform identities for a contact.
|
||||
*/
|
||||
getIdentitiesByContact(contactId: string): PlatformIdentity[] {
|
||||
const rows = this.stmtGetIdentitiesByContact.all(contactId) as Array<{
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a platform identity by platform and platform-specific ID.
|
||||
*/
|
||||
getIdentityByPlatformId(
|
||||
platform: Platform | string,
|
||||
platformId: string,
|
||||
): PlatformIdentity | null {
|
||||
const row = this.stmtGetIdentityByPlatformId.get(platform, platformId) as
|
||||
| {
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find identities by phone number across all platforms.
|
||||
*/
|
||||
findIdentitiesByPhone(phone: string): PlatformIdentity[] {
|
||||
const normalized = this.normalizePhone(phone);
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE phone = ?
|
||||
`,
|
||||
)
|
||||
.all(normalized) as Array<{
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last seen timestamp for a platform identity.
|
||||
*/
|
||||
updateIdentityLastSeen(platform: Platform | string, platformId: string): void {
|
||||
this.stmtUpdateIdentityLastSeen.run(Date.now(), platform, platformId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a platform sender to a canonical contact ID.
|
||||
* Returns null if the sender is not in the contact graph.
|
||||
*/
|
||||
resolveContact(platform: Platform | string, platformId: string): string | null {
|
||||
const identity = this.getIdentityByPlatformId(platform, platformId);
|
||||
return identity?.contactId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone number to E.164 format.
|
||||
*/
|
||||
private normalizePhone(phone: string): string {
|
||||
// Strip everything except digits and leading +
|
||||
let normalized = phone.replace(/[^+\d]/g, "");
|
||||
// Ensure it starts with +
|
||||
if (!normalized.startsWith("+") && normalized.length >= 10) {
|
||||
// Assume US if no country code and 10 digits
|
||||
if (normalized.length === 10) {
|
||||
normalized = `+1${normalized}`;
|
||||
} else {
|
||||
normalized = `+${normalized}`;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MESSAGE INDEXING
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Index a message for cross-platform search.
|
||||
*/
|
||||
indexMessage(message: Omit<IndexedMessage, "embedding"> & { embedding?: string | null }): void {
|
||||
// Try to resolve the sender to a canonical contact
|
||||
const contactId = this.resolveContact(message.platform, message.senderId);
|
||||
|
||||
this.stmtInsertMessage.run(
|
||||
message.id,
|
||||
message.content,
|
||||
contactId,
|
||||
message.platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
message.embedding ?? null,
|
||||
);
|
||||
|
||||
// Also insert into FTS table
|
||||
if (this.stmtInsertMessageFts && message.content) {
|
||||
this.stmtInsertMessageFts.run(
|
||||
message.content,
|
||||
message.id,
|
||||
contactId,
|
||||
message.platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
// Update last seen timestamp for the sender
|
||||
if (contactId) {
|
||||
this.updateIdentityLastSeen(message.platform, message.senderId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search indexed messages.
|
||||
*/
|
||||
searchMessages(options: MessageSearchOptions): MessageSearchResult[] {
|
||||
const results: MessageSearchResult[] = [];
|
||||
|
||||
if (!options.query) return results;
|
||||
|
||||
// Resolve "from" filter to contact IDs
|
||||
let contactIds: string[] | null = null;
|
||||
if (options.from) {
|
||||
// Try to find contact by canonical ID, name, or username
|
||||
const matches = this.searchContacts(options.from, 10);
|
||||
if (matches.length === 0) {
|
||||
// No matching contacts, return empty results
|
||||
return results;
|
||||
}
|
||||
contactIds = matches.map((m) => m.canonicalId);
|
||||
}
|
||||
|
||||
// Build query based on FTS availability
|
||||
if (this.ftsAvailable) {
|
||||
return this.searchMessagesFts(options, contactIds);
|
||||
}
|
||||
return this.searchMessagesLike(options, contactIds);
|
||||
}
|
||||
|
||||
private searchMessagesFts(
|
||||
options: MessageSearchOptions,
|
||||
contactIds: string[] | null,
|
||||
): MessageSearchResult[] {
|
||||
let sql = `
|
||||
SELECT m.id, m.content, m.contact_id, m.platform, m.sender_id, m.channel_id, m.timestamp, m.embedding,
|
||||
bm25(messages_fts) as score
|
||||
FROM messages_fts fts
|
||||
JOIN indexed_messages m ON fts.id = m.id
|
||||
WHERE messages_fts MATCH ?
|
||||
`;
|
||||
const params: (string | number)[] = [options.query];
|
||||
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
const placeholders = contactIds.map(() => "?").join(",");
|
||||
sql += ` AND m.contact_id IN (${placeholders})`;
|
||||
params.push(...contactIds);
|
||||
}
|
||||
|
||||
if (options.platforms && options.platforms.length > 0) {
|
||||
const placeholders = options.platforms.map(() => "?").join(",");
|
||||
sql += ` AND m.platform IN (${placeholders})`;
|
||||
params.push(...options.platforms);
|
||||
}
|
||||
|
||||
if (options.channelId) {
|
||||
sql += ` AND m.channel_id = ?`;
|
||||
params.push(options.channelId);
|
||||
}
|
||||
|
||||
if (options.since) {
|
||||
sql += ` AND m.timestamp >= ?`;
|
||||
params.push(options.since);
|
||||
}
|
||||
|
||||
if (options.until) {
|
||||
sql += ` AND m.timestamp <= ?`;
|
||||
params.push(options.until);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY score LIMIT ?`;
|
||||
params.push(options.limit ?? 50);
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
contact_id: string | null;
|
||||
platform: string;
|
||||
sender_id: string;
|
||||
channel_id: string;
|
||||
timestamp: number;
|
||||
embedding: string | null;
|
||||
score: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => {
|
||||
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
|
||||
return {
|
||||
message: {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
senderId: row.sender_id,
|
||||
channelId: row.channel_id,
|
||||
timestamp: row.timestamp,
|
||||
embedding: row.embedding,
|
||||
},
|
||||
contact,
|
||||
score: Math.abs(row.score), // BM25 returns negative scores
|
||||
snippet: this.createSnippet(row.content, options.query),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private searchMessagesLike(
|
||||
options: MessageSearchOptions,
|
||||
contactIds: string[] | null,
|
||||
): MessageSearchResult[] {
|
||||
let sql = `
|
||||
SELECT id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding
|
||||
FROM indexed_messages
|
||||
WHERE content LIKE ?
|
||||
`;
|
||||
const params: (string | number)[] = [`%${options.query}%`];
|
||||
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
const placeholders = contactIds.map(() => "?").join(",");
|
||||
sql += ` AND contact_id IN (${placeholders})`;
|
||||
params.push(...contactIds);
|
||||
}
|
||||
|
||||
if (options.platforms && options.platforms.length > 0) {
|
||||
const placeholders = options.platforms.map(() => "?").join(",");
|
||||
sql += ` AND platform IN (${placeholders})`;
|
||||
params.push(...options.platforms);
|
||||
}
|
||||
|
||||
if (options.channelId) {
|
||||
sql += ` AND channel_id = ?`;
|
||||
params.push(options.channelId);
|
||||
}
|
||||
|
||||
if (options.since) {
|
||||
sql += ` AND timestamp >= ?`;
|
||||
params.push(options.since);
|
||||
}
|
||||
|
||||
if (options.until) {
|
||||
sql += ` AND timestamp <= ?`;
|
||||
params.push(options.until);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
params.push(options.limit ?? 50);
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
contact_id: string | null;
|
||||
platform: string;
|
||||
sender_id: string;
|
||||
channel_id: string;
|
||||
timestamp: number;
|
||||
embedding: string | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => {
|
||||
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
|
||||
return {
|
||||
message: {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
senderId: row.sender_id,
|
||||
channelId: row.channel_id,
|
||||
timestamp: row.timestamp,
|
||||
embedding: row.embedding,
|
||||
},
|
||||
contact,
|
||||
score: 1.0, // Simple LIKE doesn't provide scoring
|
||||
snippet: this.createSnippet(row.content, options.query),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snippet with the query highlighted.
|
||||
*/
|
||||
private createSnippet(content: string, query: string, maxLength = 200): string {
|
||||
const lowerContent = content.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const index = lowerContent.indexOf(lowerQuery);
|
||||
|
||||
if (index === -1) {
|
||||
return content.slice(0, maxLength) + (content.length > maxLength ? "..." : "");
|
||||
}
|
||||
|
||||
// Center the snippet around the match
|
||||
const contextBefore = 50;
|
||||
const contextAfter = 100;
|
||||
let start = Math.max(0, index - contextBefore);
|
||||
let end = Math.min(content.length, index + query.length + contextAfter);
|
||||
|
||||
// Adjust to word boundaries if possible
|
||||
if (start > 0) {
|
||||
const spaceIndex = content.lastIndexOf(" ", start + 10);
|
||||
if (spaceIndex > start - 20) start = spaceIndex + 1;
|
||||
}
|
||||
if (end < content.length) {
|
||||
const spaceIndex = content.indexOf(" ", end - 10);
|
||||
if (spaceIndex !== -1 && spaceIndex < end + 20) end = spaceIndex;
|
||||
}
|
||||
|
||||
let snippet = content.slice(start, end);
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < content.length) snippet = snippet + "...";
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// STATISTICS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get statistics about the contact store.
|
||||
*/
|
||||
getStats(): {
|
||||
contacts: number;
|
||||
identities: number;
|
||||
messages: number;
|
||||
platforms: Record<string, number>;
|
||||
} {
|
||||
const contactCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM contacts`).get() as { count: number }
|
||||
).count;
|
||||
|
||||
const identityCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM platform_identities`).get() as {
|
||||
count: number;
|
||||
}
|
||||
).count;
|
||||
|
||||
const messageCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM indexed_messages`).get() as { count: number }
|
||||
).count;
|
||||
|
||||
const platformRows = this.db
|
||||
.prepare(`SELECT platform, COUNT(*) as count FROM platform_identities GROUP BY platform`)
|
||||
.all() as Array<{ platform: string; count: number }>;
|
||||
|
||||
const platforms: Record<string, number> = {};
|
||||
for (const row of platformRows) {
|
||||
platforms[row.platform] = row.count;
|
||||
}
|
||||
|
||||
return {
|
||||
contacts: contactCount,
|
||||
identities: identityCount,
|
||||
messages: messageCount,
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _store: ContactStore | null = null;
|
||||
|
||||
/**
|
||||
* Get the global contact store instance.
|
||||
*/
|
||||
export function getContactStore(): ContactStore {
|
||||
if (!_store) {
|
||||
_store = ContactStore.open();
|
||||
}
|
||||
return _store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the global contact store instance.
|
||||
*/
|
||||
export function closeContactStore(): void {
|
||||
if (_store) {
|
||||
_store.close();
|
||||
_store = null;
|
||||
}
|
||||
}
|
||||
171
src/contacts/types.ts
Normal file
171
src/contacts/types.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Types for the unified contact graph.
|
||||
*
|
||||
* The contact graph allows cross-platform identity resolution:
|
||||
* - Multiple platform identities can be linked to a single canonical contact
|
||||
* - Enables unified message search across all messaging channels
|
||||
*/
|
||||
|
||||
/**
|
||||
* A canonical contact in the unified contact graph.
|
||||
* Represents a single person who may have multiple platform identities.
|
||||
*/
|
||||
export type Contact = {
|
||||
/** Unique canonical identifier (e.g., "sarah-jones-abc123") */
|
||||
canonicalId: string;
|
||||
/** Primary display name for this contact */
|
||||
displayName: string;
|
||||
/** Alternative names/aliases for this contact */
|
||||
aliases: string[];
|
||||
/** When this contact was first created */
|
||||
createdAt: number;
|
||||
/** When this contact was last updated */
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Supported messaging platforms.
|
||||
*/
|
||||
export type Platform =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "matrix"
|
||||
| "msteams";
|
||||
|
||||
/**
|
||||
* A platform-specific identity linked to a canonical contact.
|
||||
* Each person may have one or more of these across different platforms.
|
||||
*/
|
||||
export type PlatformIdentity = {
|
||||
/** Database row ID */
|
||||
id: number;
|
||||
/** Reference to the canonical contact */
|
||||
contactId: string;
|
||||
/** Which platform this identity belongs to */
|
||||
platform: Platform;
|
||||
/** Platform-specific user identifier (JID, user ID, etc.) */
|
||||
platformId: string;
|
||||
/** Platform-specific username (@handle) if available */
|
||||
username: string | null;
|
||||
/** E.164 phone number if available */
|
||||
phone: string | null;
|
||||
/** Platform-specific display name */
|
||||
displayName: string | null;
|
||||
/** When this identity was last seen in a message */
|
||||
lastSeenAt: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input for creating a new platform identity.
|
||||
*/
|
||||
export type PlatformIdentityInput = Omit<PlatformIdentity, "id">;
|
||||
|
||||
/**
|
||||
* Result of a contact search/lookup.
|
||||
*/
|
||||
export type ContactWithIdentities = Contact & {
|
||||
/** All platform identities associated with this contact */
|
||||
identities: PlatformIdentity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-link match confidence levels.
|
||||
*/
|
||||
export type LinkConfidence = "high" | "medium" | "low";
|
||||
|
||||
/**
|
||||
* A suggested link between platform identities.
|
||||
*/
|
||||
export type LinkSuggestion = {
|
||||
/** The source identity that was analyzed */
|
||||
sourceIdentity: PlatformIdentity;
|
||||
/** The target identity to potentially link with */
|
||||
targetIdentity: PlatformIdentity;
|
||||
/** Why this link is suggested */
|
||||
reason: "phone_match" | "email_match" | "name_similarity";
|
||||
/** How confident we are in this match */
|
||||
confidence: LinkConfidence;
|
||||
/** Score for ranking (0-1) */
|
||||
score: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Contact import result from a platform.
|
||||
*/
|
||||
export type ImportResult = {
|
||||
platform: Platform;
|
||||
imported: number;
|
||||
linked: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for contact search.
|
||||
*/
|
||||
export type ContactSearchOptions = {
|
||||
/** Search query (matches name, aliases, username) */
|
||||
query?: string;
|
||||
/** Filter by platform */
|
||||
platform?: Platform;
|
||||
/** Maximum results to return */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message indexed for cross-platform search.
|
||||
*/
|
||||
export type IndexedMessage = {
|
||||
/** Unique message ID */
|
||||
id: string;
|
||||
/** Message content (may be empty for media) */
|
||||
content: string;
|
||||
/** Reference to canonical contact ID of sender */
|
||||
contactId: string | null;
|
||||
/** Platform this message came from */
|
||||
platform: Platform;
|
||||
/** Platform-specific sender ID */
|
||||
senderId: string;
|
||||
/** Platform-specific channel/chat ID */
|
||||
channelId: string;
|
||||
/** When the message was sent */
|
||||
timestamp: number;
|
||||
/** Optional: pre-computed embedding for semantic search */
|
||||
embedding: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for message search.
|
||||
*/
|
||||
export type MessageSearchOptions = {
|
||||
/** Text query to search for */
|
||||
query: string;
|
||||
/** Filter by sender (canonical contact ID or platform identity) */
|
||||
from?: string;
|
||||
/** Filter by platform */
|
||||
platforms?: Platform[];
|
||||
/** Filter by channel/chat ID */
|
||||
channelId?: string;
|
||||
/** Filter messages after this timestamp */
|
||||
since?: number;
|
||||
/** Filter messages before this timestamp */
|
||||
until?: number;
|
||||
/** Maximum results */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message search result.
|
||||
*/
|
||||
export type MessageSearchResult = {
|
||||
message: IndexedMessage;
|
||||
/** The contact who sent this message (if resolved) */
|
||||
contact: Contact | null;
|
||||
/** Search relevance score */
|
||||
score: number;
|
||||
/** Snippet with highlighted match */
|
||||
snippet: string;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user