feat: add contacts search plugin

This commit is contained in:
Peter Steinberger committed 2026-01-24 08:39:46 +00:00
1 parent cab8dcf456
commit 0dc131e9f3
44 files changed
+1331 -448

No files matched your search

+10 -20
View File
@@ -1,5 +1,5 @@
import { listChannelDocks } from "../channels/dock.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { requireActivePluginRegistry } from "../plugins/runtime.js";
import { listThinkingLevels } from "./thinking.js";
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js";
@@ -113,9 +113,9 @@ function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
}
let cachedCommands: ChatCommandDefinition[] | null = null;
let cachedRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
let cachedRegistry: ReturnType<typeof requireActivePluginRegistry> | null = null;
let cachedNativeCommandSurfaces: Set<string> | null = null;
let cachedNativeRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
let cachedNativeRegistry: ReturnType<typeof requireActivePluginRegistry> | null = null;
function buildChatCommands(): ChatCommandDefinition[] {
const commands: ChatCommandDefinition[] = [
@@ -558,26 +558,16 @@ 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)),
];
const registry = requireActivePluginRegistry();
if (registry.chatCommands.length > 0) {
commands.push(...registry.chatCommands.map((entry) => entry.command));
}
registerAlias(commands, "whoami", "/id");
registerAlias(commands, "think", "/thinking", "/t");
registerAlias(commands, "verbose", "/v");
@@ -589,7 +579,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
}
export function getChatCommands(): ChatCommandDefinition[] {
const registry = getActivePluginRegistry();
const registry = requireActivePluginRegistry();
if (cachedCommands && registry === cachedRegistry) return cachedCommands;
const commands = buildChatCommands();
cachedCommands = commands;
@@ -599,7 +589,7 @@ export function getChatCommands(): ChatCommandDefinition[] {
}
export function getNativeCommandSurfaces(): Set<string> {
const registry = getActivePluginRegistry();
const registry = requireActivePluginRegistry();
if (cachedNativeCommandSurfaces && registry === cachedNativeRegistry) {
return cachedNativeCommandSurfaces;
}
+20
View File
@@ -85,6 +85,26 @@ describe("commands registry", () => {
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("includes plugin chat commands", () => {
const registry = createTestRegistry([]);
registry.chatCommands = [
{
pluginId: "demo-plugin",
source: "test",
command: {
key: "demo",
description: "Demo command",
textAliases: ["/demo"],
scope: "text",
},
handler: async () => null,
},
];
setActivePluginRegistry(registry);
const commands = listChatCommands();
expect(commands.find((spec) => spec.key === "demo")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);
+20 -35
View File
@@ -1,41 +1,26 @@
/**
* Plugin Command Handler
*
* Handles commands registered by plugins, bypassing the LLM agent.
* This handler is called before built-in command handlers.
*/
import { requireActivePluginRegistry } from "../../plugins/runtime.js";
import { parseCommandArgs, resolveTextCommand } from "../commands-registry.js";
import type { CommandHandler } from "./commands-types.js";
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
export const handlePluginCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const registry = requireActivePluginRegistry();
if (registry.chatCommands.length === 0) return null;
/**
* Handle plugin-registered commands.
* Returns a result if a plugin command was matched and executed,
* or null to continue to the next handler.
*/
export const handlePluginCommand: CommandHandler = async (
params,
_allowTextCommands,
): Promise<CommandHandlerResult | null> => {
const { command, cfg } = params;
const raw = params.command.commandBodyNormalized;
if (!raw.startsWith("/")) return null;
// Try to match a plugin command
const match = matchPluginCommand(command.commandBodyNormalized);
if (!match) return null;
const resolved = resolveTextCommand(raw, params.cfg);
if (!resolved) return null;
// Execute the plugin command (always returns a result)
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId: command.senderId,
channel: command.channel,
isAuthorizedSender: command.isAuthorizedSender,
commandBody: command.commandBodyNormalized,
config: cfg,
});
const registration = registry.chatCommands.find(
(entry) => entry.command.key === resolved.command.key,
);
if (!registration) return null;
return {
shouldContinue: false,
reply: { text: result.text },
};
if (resolved.args) {
params.ctx.CommandArgs = parseCommandArgs(resolved.command, resolved.args);
}
return await registration.handler(params, allowTextCommands);
};
-208
View File
@@ -1,208 +0,0 @@
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)}` },
};
}
};
@@ -6,6 +6,7 @@ import {
logMessageQueued,
logSessionStateChange,
} from "../../logging/diagnostic.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -80,6 +81,56 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_received")) {
const timestamp =
typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp)
? ctx.Timestamp
: undefined;
const messageIdForHook =
ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
const content =
typeof ctx.BodyForCommands === "string"
? ctx.BodyForCommands
: typeof ctx.RawBody === "string"
? ctx.RawBody
: typeof ctx.Body === "string"
? ctx.Body
: "";
const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase();
const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined;
void hookRunner
.runMessageReceived(
{
from: ctx.From ?? "",
content,
timestamp,
messageId: messageIdForHook,
senderId: ctx.SenderId,
senderName: ctx.SenderName,
senderUsername: ctx.SenderUsername,
senderE164: ctx.SenderE164,
metadata: {
to: ctx.To,
provider: ctx.Provider,
surface: ctx.Surface,
threadId: ctx.MessageThreadId,
originatingChannel: ctx.OriginatingChannel,
originatingTo: ctx.OriginatingTo,
},
},
{
channelId,
accountId: ctx.AccountId,
conversationId,
},
)
.catch((err) => {
logVerbose(`dispatch-from-config: message_received hook failed: ${String(err)}`);
});
}
// Check if we should route replies to originating channel instead of dispatcher.
// Only route when the originating channel is DIFFERENT from the current surface.
// This handles cross-provider routing (e.g., message from Telegram being processed
+1
View File
@@ -87,6 +87,7 @@ export type MsgContext = {
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Timestamp?: number;
/** Provider label (e.g. whatsapp, telegram). */
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
-474
View File
@@ -1,474 +0,0 @@
import type { Command } from "commander";
import {
autoLinkHighConfidence,
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);
}
});
}
-16
View File
@@ -222,22 +222,6 @@ 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) {
-178
View File
@@ -1,178 +0,0 @@
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 { 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
View File
@@ -1,384 +0,0 @@
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
View File
@@ -1,385 +0,0 @@
/**
* 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}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to list Slack users: ${String(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}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to list Discord members: ${String(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}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to get group metadata for ${groupJid}: ${String(err)}`);
}
return result;
}
-49
View File
@@ -1,49 +0,0 @@
/**
* 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";
-502
View File
@@ -1,502 +0,0 @@
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 result = autoLinkHighConfidence(store);
// Name similarity below threshold should not auto-link
// 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
View File
@@ -1,386 +0,0 @@
/**
* 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
View File
@@ -1,125 +0,0 @@
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
View File
@@ -1,518 +0,0 @@
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);
});
});
});
-781
View File
@@ -1,781 +0,0 @@
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: 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: 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: 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
View File
@@ -1,171 +0,0 @@
/**
* 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;
};
@@ -11,6 +11,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
chatCommands: [],
services: [],
commands: [],
diagnostics: [],
+1
View File
@@ -139,6 +139,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
chatCommands: [],
services: [],
commands: [],
diagnostics: [],
+4
View File
@@ -62,6 +62,9 @@ export type {
ClawdbotPluginApi,
ClawdbotPluginService,
ClawdbotPluginServiceContext,
PluginChatCommandHandler,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
} from "../plugins/types.js";
export type { PluginRuntime } from "../plugins/runtime/types.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
@@ -106,6 +109,7 @@ export type { WizardPrompter } from "../wizard/prompts.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export { resolveAckReaction } from "../agents/identity.js";
export type { ReplyPayload } from "../auto-reply/types.js";
export type { ChatCommandDefinition } from "../auto-reply/commands-registry.types.js";
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
export {
buildPendingHistoryContextFromMap,
+1
View File
@@ -147,6 +147,7 @@ function createPluginRecord(params: {
providerIds: [],
gatewayMethods: [],
cliCommands: [],
chatCommands: [],
services: [],
commands: [],
httpHandlers: 0,
+38
View File
@@ -1,4 +1,5 @@
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ChatCommandDefinition } from "../auto-reply/commands-registry.types.js";
import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type {
@@ -16,6 +17,7 @@ import type {
ClawdbotPluginHookOptions,
ProviderPlugin,
ClawdbotPluginService,
PluginChatCommandHandler,
ClawdbotPluginToolContext,
ClawdbotPluginToolFactory,
PluginConfigUiHint,
@@ -47,6 +49,13 @@ export type PluginCliRegistration = {
source: string;
};
export type PluginChatCommandRegistration = {
pluginId: string;
command: ChatCommandDefinition;
handler: PluginChatCommandHandler;
source: string;
};
export type PluginHttpRegistration = {
pluginId: string;
handler: ClawdbotPluginHttpHandler;
@@ -103,6 +112,7 @@ export type PluginRecord = {
providerIds: string[];
gatewayMethods: string[];
cliCommands: string[];
chatCommands: string[];
services: string[];
commands: string[];
httpHandlers: number;
@@ -122,6 +132,7 @@ export type PluginRegistry = {
gatewayHandlers: GatewayRequestHandlers;
httpHandlers: PluginHttpRegistration[];
cliRegistrars: PluginCliRegistration[];
chatCommands: PluginChatCommandRegistration[];
services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
diagnostics: PluginDiagnostic[];
@@ -144,6 +155,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
chatCommands: [],
services: [],
commands: [],
diagnostics: [],
@@ -352,6 +364,30 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerChatCommand = (
record: PluginRecord,
command: ChatCommandDefinition,
handler: PluginChatCommandHandler,
) => {
const key = command.key?.trim();
if (!key) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "chat command registration missing key",
});
return;
}
record.chatCommands.push(key);
registry.chatCommands.push({
pluginId: record.id,
command,
handler,
source: record.source,
});
};
const registerService = (record: PluginRecord, service: ClawdbotPluginService) => {
const id = service.id.trim();
if (!id) return;
@@ -443,6 +479,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerProvider: (provider) => registerProvider(record, provider),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerChatCommand: (command, handler) => registerChatCommand(record, command, handler),
registerService: (service) => registerService(record, service),
registerCommand: (command) => registerCommand(record, command),
resolvePath: (input: string) => resolveUserPath(input),
@@ -459,6 +496,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerProvider,
registerGatewayMethod,
registerCli,
registerChatCommand,
registerService,
registerCommand,
registerHook,
+1
View File
@@ -10,6 +10,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
chatCommands: [],
services: [],
commands: [],
diagnostics: [],
+16
View File
@@ -5,6 +5,11 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ChatCommandDefinition } from "../auto-reply/commands-registry.types.js";
import type {
CommandHandlerResult,
HandleCommandsParams,
} from "../auto-reply/reply/commands-types.js";
import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -196,6 +201,11 @@ export type ClawdbotPluginCliContext = {
export type ClawdbotPluginCliRegistrar = (ctx: ClawdbotPluginCliContext) => void | Promise<void>;
export type PluginChatCommandHandler = (
params: HandleCommandsParams,
allowTextCommands: boolean,
) => Promise<CommandHandlerResult | null> | CommandHandlerResult | null;
export type ClawdbotPluginServiceContext = {
config: ClawdbotConfig;
workspaceDir?: string;
@@ -252,6 +262,7 @@ export type ClawdbotPluginApi = {
registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerChatCommand: (command: ChatCommandDefinition, handler: PluginChatCommandHandler) => void;
registerService: (service: ClawdbotPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void;
/**
@@ -349,6 +360,11 @@ export type PluginHookMessageReceivedEvent = {
from: string;
content: string;
timestamp?: number;
messageId?: string;
senderId?: string;
senderName?: string;
senderUsername?: string;
senderE164?: string;
metadata?: Record<string, unknown>;
};
+1
View File
@@ -18,6 +18,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
chatCommands: [],
services: [],
commands: [],
diagnostics: [],