mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat: add contacts search plugin
This commit is contained in:
33
CHANGELOG.md
33
CHANGELOG.md
@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
|
||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- Contacts/Search: add the contacts-search plugin for unified contacts + cross-platform message search (CLI + /search). (#1438) Thanks @bluzername. https://docs.clawd.bot/plugins/contacts-search https://docs.clawd.bot/contact https://docs.clawd.bot/cli/contacts https://docs.clawd.bot/cli/search
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
@@ -111,22 +112,22 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
||||
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
|
||||
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
||||
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
||||
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
||||
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
|
||||
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
|
||||
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
|
||||
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
|
||||
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
|
||||
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
|
||||
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
|
||||
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
|
||||
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
|
||||
45
docs/cli/contacts.md
Normal file
45
docs/cli/contacts.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot contacts` (unified contact graph)"
|
||||
read_when:
|
||||
- You want to list or link contacts across channels
|
||||
- You are using the contacts-search plugin
|
||||
---
|
||||
|
||||
# `clawdbot contacts`
|
||||
|
||||
Unified contact graph and identity linking.
|
||||
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
|
||||
Concept overview: [Contact graph](/contact).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot contacts list
|
||||
clawdbot contacts list --query "sarah" --platform slack
|
||||
clawdbot contacts show <contact-id>
|
||||
clawdbot contacts search "alice"
|
||||
clawdbot contacts link <primary-id> <secondary-id>
|
||||
clawdbot contacts unlink slack U12345678
|
||||
clawdbot contacts suggestions
|
||||
clawdbot contacts auto-link --dry-run
|
||||
clawdbot contacts stats
|
||||
clawdbot contacts alias <contact-id> "Alias Name"
|
||||
clawdbot contacts alias <contact-id> "Old Alias" --remove
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `list`: list contacts (supports `--query`, `--platform`, `--limit`, `--json`).
|
||||
- `show <id>`: show a contact + identities (accepts a canonical id or a search query).
|
||||
- `search <query>`: search contacts by name/alias/username.
|
||||
- `link <primary> <secondary>`: merge two contacts.
|
||||
- `unlink <platform> <platformId>`: detach an identity into a new contact.
|
||||
- `suggestions`: show link suggestions.
|
||||
- `auto-link`: link high-confidence matches (use `--dry-run` to preview).
|
||||
- `stats`: store statistics by platform.
|
||||
- `alias <contactId> <alias>`: add or remove aliases (`--remove`).
|
||||
|
||||
## Notes
|
||||
|
||||
- `--platform` expects a channel id (e.g. `slack`, `discord`, `whatsapp`).
|
||||
- `unlink` uses the platform id stored on the identity (not the contact id).
|
||||
@@ -32,6 +32,8 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`system`](/cli/system)
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`contacts`](/cli/contacts) (plugin; if enabled)
|
||||
- [`search`](/cli/search) (plugin; if enabled)
|
||||
- [`nodes`](/cli/nodes)
|
||||
- [`devices`](/cli/devices)
|
||||
- [`node`](/cli/node)
|
||||
@@ -122,6 +124,8 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
status
|
||||
index
|
||||
search
|
||||
contacts
|
||||
search
|
||||
message
|
||||
agent
|
||||
agents
|
||||
|
||||
36
docs/cli/search.md
Normal file
36
docs/cli/search.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot search` (cross-platform message search)"
|
||||
read_when:
|
||||
- You want to search indexed messages across channels
|
||||
- You are using the contacts-search plugin
|
||||
---
|
||||
|
||||
# `clawdbot search`
|
||||
|
||||
Search indexed messages across channels.
|
||||
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot search "meeting tomorrow"
|
||||
clawdbot search "deadline" --from alice
|
||||
clawdbot search "project" --platform slack --since 1w
|
||||
clawdbot search "invoice" --since 2025-12-01 --until 2025-12-31
|
||||
clawdbot search "handoff" --limit 50 --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--from <contact>`: filter by sender name/alias/username or contact id.
|
||||
- `--platform <name>`: filter by channel id (e.g. `slack`, `discord`, `whatsapp`).
|
||||
- `--since <time>`: start time (`1h`, `2d`, `1w`, `1m`, or ISO date).
|
||||
- `--until <time>`: end time (same formats as `--since`).
|
||||
- `--limit <n>`: limit results (default `20`).
|
||||
- `--json`: raw JSON output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Results come from the local contacts store (`~/.clawdbot/contacts/contacts.sqlite`).
|
||||
- Only inbound messages are indexed (no backfill).
|
||||
- Concept overview: [Contact graph](/contact).
|
||||
100
docs/contact.md
Normal file
100
docs/contact.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
summary: "Unified contacts: contact graph, identity linking, and message indexing"
|
||||
read_when:
|
||||
- You want to understand how Clawdbot merges identities across channels
|
||||
- You are using the Contacts + Search plugin
|
||||
---
|
||||
|
||||
# Contact graph
|
||||
|
||||
Clawdbot can maintain a **unified contact graph** that links the same person across multiple channels (Slack, Discord, WhatsApp, etc.).
|
||||
This powers cross-platform message search and manual identity linking.
|
||||
|
||||
The contact graph is provided by the **Contacts + Search** plugin and is **disabled by default**.
|
||||
|
||||
## Enable
|
||||
|
||||
Install/enable the plugin on the **Gateway host**, then restart the Gateway.
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable contacts-search
|
||||
```
|
||||
|
||||
Config equivalent:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"contacts-search": { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Related:
|
||||
- [Contacts + Search plugin](/plugins/contacts-search)
|
||||
- [Plugins overview](/plugin)
|
||||
|
||||
## Data model
|
||||
|
||||
The contact graph has three layers:
|
||||
|
||||
1) **Canonical contact**
|
||||
- One logical person.
|
||||
- Has a `canonicalId`, display name, and optional aliases.
|
||||
|
||||
2) **Platform identity**
|
||||
- One account on one channel (e.g. `slack:U123...`).
|
||||
- Links back to a canonical contact.
|
||||
- Optional username, phone, display name, and last-seen time.
|
||||
|
||||
3) **Indexed message**
|
||||
- Text of inbound messages tied to a platform identity.
|
||||
- Used by cross-platform search.
|
||||
|
||||
## How contacts are created
|
||||
|
||||
Contacts are created automatically when **inbound messages** arrive:
|
||||
|
||||
- The plugin extracts sender identity details from the inbound message.
|
||||
- If the platform identity is new, a new canonical contact is created.
|
||||
- If it already exists, the identity metadata is refreshed.
|
||||
|
||||
There is **no backfill** step today; indexing starts when the plugin is enabled.
|
||||
|
||||
## Linking identities
|
||||
|
||||
You can link identities that belong to the same person:
|
||||
|
||||
- **Manual link**: merge two contacts into one canonical contact.
|
||||
- **Suggestions**: name/phone similarity hints (preview-only).
|
||||
- **Auto-link**: high-confidence matches (same phone number).
|
||||
|
||||
CLI reference: [Contacts CLI](/cli/contacts)
|
||||
|
||||
## Searching messages
|
||||
|
||||
Use the CLI or slash command:
|
||||
|
||||
- `clawdbot search "query"` (CLI)
|
||||
- `/search <query>` (chat)
|
||||
|
||||
Search uses SQLite FTS when available; otherwise it falls back to SQL `LIKE`.
|
||||
|
||||
CLI reference: [Search CLI](/cli/search)
|
||||
Slash commands: [Slash commands](/tools/slash-commands)
|
||||
|
||||
## Storage + privacy
|
||||
|
||||
- Stored locally on the Gateway host at `~/.clawdbot/contacts/contacts.sqlite`.
|
||||
- No cloud sync by default.
|
||||
- Treat this file as **sensitive** (names, handles, phone numbers).
|
||||
|
||||
To reset the graph, disable the plugin and move the SQLite file to Trash, then restart the Gateway.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No results**: the plugin only indexes **new inbound messages**.
|
||||
- **Missing contacts**: ensure the plugin is enabled and the Gateway restarted.
|
||||
- **Search feels shallow**: FTS may be unavailable; check that SQLite FTS5 is supported on your runtime.
|
||||
@@ -848,6 +848,8 @@
|
||||
"cli/skills",
|
||||
"cli/plugins",
|
||||
"cli/memory",
|
||||
"cli/contacts",
|
||||
"cli/search",
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/system",
|
||||
@@ -883,6 +885,7 @@
|
||||
"concepts/session",
|
||||
"concepts/session-pruning",
|
||||
"concepts/sessions",
|
||||
"contact",
|
||||
"concepts/session-tool",
|
||||
"concepts/presence",
|
||||
"concepts/channel-routing",
|
||||
@@ -1003,6 +1006,7 @@
|
||||
"tools/lobster",
|
||||
"tools/llm-task",
|
||||
"plugin",
|
||||
"plugins/contacts-search",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"tools/exec",
|
||||
|
||||
@@ -38,6 +38,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
||||
- [Contacts + Search](/plugins/contacts-search) — bundled unified contacts + cross-platform search (disabled by default)
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
|
||||
70
docs/plugins/contacts-search.md
Normal file
70
docs/plugins/contacts-search.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
summary: "Contacts + Search plugin: unified contacts and cross-platform message search"
|
||||
read_when:
|
||||
- You want unified contacts or cross-platform message search
|
||||
- You are enabling the contacts-search plugin
|
||||
---
|
||||
|
||||
# Contacts + Search (plugin)
|
||||
|
||||
Unified contact graph + cross-platform message search.
|
||||
Indexes incoming messages, links platform identities, and exposes `/search` plus CLI tools.
|
||||
|
||||
## What it adds
|
||||
|
||||
- `clawdbot contacts ...` (link, list, search, stats)
|
||||
- `clawdbot search ...` (message search)
|
||||
- `/search ...` slash command (text surfaces)
|
||||
|
||||
## Where it runs
|
||||
|
||||
Runs inside the Gateway process. Enable it on the **Gateway host**, then restart the Gateway.
|
||||
|
||||
## Enable (bundled)
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable contacts-search
|
||||
```
|
||||
|
||||
Or in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"contacts-search": { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Data location
|
||||
|
||||
The contact store lives under the Clawdbot state directory:
|
||||
|
||||
- `~/.clawdbot/contacts/contacts.sqlite`
|
||||
|
||||
If you run with `--profile <name>` or `--dev`, the state root changes accordingly.
|
||||
|
||||
## Indexing notes
|
||||
|
||||
- Messages are indexed as they arrive (no backfill).
|
||||
- Search uses SQLite FTS when available; otherwise falls back to SQL `LIKE` queries.
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
```bash
|
||||
clawdbot contacts list
|
||||
clawdbot contacts search "sarah"
|
||||
clawdbot contacts show <contact-id>
|
||||
clawdbot search "meeting notes" --from sarah --since 1w
|
||||
```
|
||||
|
||||
Related:
|
||||
- CLI: [contacts](/cli/contacts)
|
||||
- CLI: [search](/cli/search)
|
||||
- Concept: [Contact graph](/contact)
|
||||
- Slash commands: [Slash commands](/tools/slash-commands)
|
||||
- Plugins: [Plugins](/plugin)
|
||||
@@ -61,7 +61,7 @@ Text + native (when enabled):
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/search <query> [--from <contact>] [--platform <name>] [--since <time>]` (search messages across platforms)
|
||||
- `/search <query> [--from <contact>] [--platform <name>] [--since <time>]` (search messages across platforms; requires [Contacts + Search](/plugins/contacts-search))
|
||||
- `/context [list|detail|json]` (explain "context"; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
|
||||
10
extensions/contacts-search/clawdbot.plugin.json
Normal file
10
extensions/contacts-search/clawdbot.plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "contacts-search",
|
||||
"name": "Contacts + Search",
|
||||
"description": "Unified contact graph and cross-platform message search",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
71
extensions/contacts-search/index.ts
Normal file
71
extensions/contacts-search/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
ChatCommandDefinition,
|
||||
ClawdbotPluginApi,
|
||||
PluginHookMessageContext,
|
||||
PluginHookMessageReceivedEvent,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
configureContactStore,
|
||||
closeContactStore,
|
||||
} from "./src/contacts/index.js";
|
||||
import { registerContactsCli } from "./src/cli/contacts-cli.js";
|
||||
import { registerSearchCli } from "./src/cli/search-cli.js";
|
||||
import { handleSearchCommand } from "./src/commands/search-command.js";
|
||||
import { indexInboundMessage } from "./src/hooks/message-indexer.js";
|
||||
|
||||
const SEARCH_COMMAND: ChatCommandDefinition = {
|
||||
key: "search",
|
||||
description: "Search messages across platforms.",
|
||||
textAliases: ["/search"],
|
||||
scope: "text",
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "query",
|
||||
description: "Search query",
|
||||
type: "string",
|
||||
required: true,
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contactsSearchPlugin = {
|
||||
id: "contacts-search",
|
||||
name: "Contacts + Search",
|
||||
description: "Unified contact graph with cross-platform message search",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
const stateDir = api.runtime.state.resolveStateDir();
|
||||
configureContactStore({ stateDir });
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
registerContactsCli(program);
|
||||
registerSearchCli(program);
|
||||
},
|
||||
{ commands: ["contacts", "search"] },
|
||||
);
|
||||
|
||||
api.registerChatCommand(SEARCH_COMMAND, handleSearchCommand);
|
||||
|
||||
api.on(
|
||||
"message_received",
|
||||
(event: PluginHookMessageReceivedEvent, ctx: PluginHookMessageContext) => {
|
||||
indexInboundMessage({ event, ctx, logger: api.logger });
|
||||
},
|
||||
);
|
||||
|
||||
api.registerService({
|
||||
id: "contacts-search",
|
||||
start: () => {},
|
||||
stop: () => {
|
||||
closeContactStore();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default contactsSearchPlugin;
|
||||
9
extensions/contacts-search/package.json
Normal file
9
extensions/contacts-search/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/contacts-search",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot unified contacts and cross-platform search plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
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";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
import { formatDocsLink } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { cli, formatDanger, formatSuccess, renderTable, theme } from "./formatting.js";
|
||||
|
||||
function formatPlatformList(platforms: string[]): string {
|
||||
return platforms.join(", ");
|
||||
@@ -56,7 +55,7 @@ export function registerContactsCli(program: Command) {
|
||||
.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("--platform <name>", "Filter by platform (channel id)")
|
||||
.option("--limit <n>", "Limit results", "50")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
@@ -64,15 +63,12 @@ export function registerContactsCli(program: Command) {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 50;
|
||||
|
||||
const platform = opts.platform
|
||||
? ((opts.platform as string).toLowerCase() as Platform)
|
||||
: undefined;
|
||||
const contactsList = store.listContacts({
|
||||
query: opts.query as string | undefined,
|
||||
platform: opts.platform as
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| undefined,
|
||||
platform,
|
||||
limit,
|
||||
});
|
||||
|
||||
@@ -81,20 +77,20 @@ export function registerContactsCli(program: Command) {
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(contactsWithIdentities, null, 2));
|
||||
cli.log(JSON.stringify(contactsWithIdentities, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (contactsWithIdentities.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No contacts found."));
|
||||
cli.log(theme.muted("No contacts found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
`${theme.heading("Contacts")} ${theme.muted(`(${contactsWithIdentities.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
@@ -107,8 +103,8 @@ export function registerContactsCli(program: Command) {
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,30 +131,30 @@ export function registerContactsCli(program: Command) {
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
defaultRuntime.error(danger(`Contact not found: ${id}`));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(`Contact not found: ${id}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(contact, null, 2));
|
||||
cli.log(JSON.stringify(contact, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(`${theme.heading("Contact")}`);
|
||||
defaultRuntime.log(` ID: ${contact.canonicalId}`);
|
||||
defaultRuntime.log(` Name: ${contact.displayName}`);
|
||||
cli.log(`${theme.heading("Contact")}`);
|
||||
cli.log(` ID: ${contact.canonicalId}`);
|
||||
cli.log(` Name: ${contact.displayName}`);
|
||||
if (contact.aliases.length > 0) {
|
||||
defaultRuntime.log(` Aliases: ${contact.aliases.join(", ")}`);
|
||||
cli.log(` Aliases: ${contact.aliases.join(", ")}`);
|
||||
}
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(
|
||||
cli.log("");
|
||||
cli.log(
|
||||
`${theme.heading("Platform Identities")} (${contact.identities.length})`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
@@ -176,8 +172,8 @@ export function registerContactsCli(program: Command) {
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -198,20 +194,20 @@ export function registerContactsCli(program: Command) {
|
||||
const results = store.searchContacts(query, limit);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(results, null, 2));
|
||||
cli.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
defaultRuntime.log(theme.muted(`No contacts found matching "${query}".`));
|
||||
cli.log(theme.muted(`No contacts found matching "${query}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
@@ -224,8 +220,8 @@ export function registerContactsCli(program: Command) {
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -244,15 +240,15 @@ export function registerContactsCli(program: Command) {
|
||||
const result = linkContacts(store, primary, secondary);
|
||||
|
||||
if (!result.success) {
|
||||
defaultRuntime.error(danger(result.error ?? "Failed to link contacts"));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(result.error ?? "Failed to link contacts"));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(success(`Linked: ${secondary} merged into ${primary}`));
|
||||
cli.log(formatSuccess(`Linked: ${secondary} merged into ${primary}`));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -263,7 +259,7 @@ export function registerContactsCli(program: Command) {
|
||||
contacts
|
||||
.command("unlink")
|
||||
.description("Unlink a platform identity from its contact (creates a new contact)")
|
||||
.argument("<platform>", "Platform (whatsapp, telegram, discord, slack, signal)")
|
||||
.argument("<platform>", "Platform (channel id)")
|
||||
.argument("<platformId>", "Platform-specific user ID")
|
||||
.action(async (platform: string, platformId: string) => {
|
||||
try {
|
||||
@@ -271,17 +267,19 @@ export function registerContactsCli(program: Command) {
|
||||
const result = unlinkIdentity(store, platform, platformId);
|
||||
|
||||
if (!result.success) {
|
||||
defaultRuntime.error(danger(result.error ?? "Failed to unlink identity"));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(result.error ?? "Failed to unlink identity"));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
success(`Unlinked: ${platform}:${platformId} → new contact ${result.newContactId}`),
|
||||
cli.log(
|
||||
formatSuccess(
|
||||
`Unlinked: ${platform}:${platformId} → new contact ${result.newContactId}`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -301,21 +299,21 @@ export function registerContactsCli(program: Command) {
|
||||
const suggestions = findLinkSuggestions(store, { minNameScore: minScore });
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(suggestions, null, 2));
|
||||
cli.log(JSON.stringify(suggestions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No link suggestions found."));
|
||||
cli.log(theme.muted("No link suggestions found."));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
`${theme.heading("Link Suggestions")} ${theme.muted(`(${suggestions.length})`)}`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(100, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
@@ -335,13 +333,13 @@ export function registerContactsCli(program: Command) {
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(
|
||||
cli.log("");
|
||||
cli.log(
|
||||
theme.muted("To link: clawdbot contacts link <source-contact-id> <target-contact-id>"),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -362,15 +360,15 @@ export function registerContactsCli(program: Command) {
|
||||
const highConfidence = suggestions.filter((s) => s.confidence === "high");
|
||||
|
||||
if (highConfidence.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No high-confidence matches found."));
|
||||
cli.log(theme.muted("No high-confidence matches found."));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
`${theme.heading("Would auto-link")} ${theme.muted(`(${highConfidence.length})`)}`,
|
||||
);
|
||||
for (const s of highConfidence) {
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
` ${s.sourceIdentity.contactId} + ${s.targetIdentity.contactId} (${s.reason})`,
|
||||
);
|
||||
}
|
||||
@@ -380,14 +378,14 @@ export function registerContactsCli(program: Command) {
|
||||
const result = autoLinkHighConfidence(store);
|
||||
|
||||
if (result.linked === 0) {
|
||||
defaultRuntime.log(theme.muted("No high-confidence matches found to auto-link."));
|
||||
cli.log(theme.muted("No high-confidence matches found to auto-link."));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(success(`Auto-linked ${result.linked} contact(s)`));
|
||||
cli.log(formatSuccess(`Auto-linked ${result.linked} contact(s)`));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -405,22 +403,22 @@ export function registerContactsCli(program: Command) {
|
||||
const stats = store.getStats();
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(stats, null, 2));
|
||||
cli.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")}`);
|
||||
cli.log(`${theme.heading("Contact Store Statistics")}`);
|
||||
cli.log(` Contacts: ${stats.contacts}`);
|
||||
cli.log(` Identities: ${stats.identities}`);
|
||||
cli.log(` Indexed Messages: ${stats.messages}`);
|
||||
cli.log("");
|
||||
cli.log(`${theme.heading("Identities by Platform")}`);
|
||||
for (const [platform, count] of Object.entries(stats.platforms)) {
|
||||
defaultRuntime.log(` ${platform}: ${count}`);
|
||||
cli.log(` ${platform}: ${count}`);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -440,8 +438,8 @@ export function registerContactsCli(program: Command) {
|
||||
const contact = store.getContact(contactId);
|
||||
|
||||
if (!contact) {
|
||||
defaultRuntime.error(danger(`Contact not found: ${contactId}`));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(`Contact not found: ${contactId}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,24 +449,26 @@ export function registerContactsCli(program: Command) {
|
||||
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.`));
|
||||
cli.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.`));
|
||||
cli.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}"`),
|
||||
cli.log(
|
||||
formatSuccess(
|
||||
opts.remove ? `Removed alias "${alias}"` : `Added alias "${alias}"`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
96
extensions/contacts-search/src/cli/formatting.ts
Normal file
96
extensions/contacts-search/src/cli/formatting.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
type TableColumn = {
|
||||
key: string;
|
||||
header: string;
|
||||
minWidth?: number;
|
||||
flex?: boolean;
|
||||
};
|
||||
|
||||
type TableRow = Record<string, string | number | null | undefined>;
|
||||
|
||||
type TableOptions = {
|
||||
columns: TableColumn[];
|
||||
rows: TableRow[];
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const pad = (value: string, width: number) => value.padEnd(width);
|
||||
|
||||
const truncate = (value: string, width: number) => {
|
||||
if (value.length <= width) return pad(value, width);
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return value.slice(0, width - 3) + "...";
|
||||
};
|
||||
|
||||
export const theme = {
|
||||
heading: (value: string) => value,
|
||||
muted: (value: string) => value,
|
||||
accent: (value: string) => value,
|
||||
accentBright: (value: string) => value,
|
||||
};
|
||||
|
||||
export const cli = {
|
||||
log: (message: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message);
|
||||
},
|
||||
error: (message: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
},
|
||||
exit: (code: number) => {
|
||||
process.exit(code);
|
||||
},
|
||||
};
|
||||
|
||||
export const formatSuccess = (message: string) => message;
|
||||
export const formatDanger = (message: string) => message;
|
||||
|
||||
export function renderTable({ columns, rows, width }: TableOptions): string {
|
||||
const widths = columns.map((column) => {
|
||||
const headerWidth = column.header.length;
|
||||
const minWidth = column.minWidth ?? 0;
|
||||
const maxRowWidth = rows.reduce((max, row) => {
|
||||
const value = String(row[column.key] ?? "");
|
||||
return Math.max(max, value.length);
|
||||
}, 0);
|
||||
return Math.max(minWidth, headerWidth, maxRowWidth);
|
||||
});
|
||||
|
||||
if (width) {
|
||||
const baseWidth = widths.reduce((sum, colWidth) => sum + colWidth, 0);
|
||||
const totalWidth = baseWidth + (columns.length - 1) * 2;
|
||||
if (totalWidth > width) {
|
||||
const flexColumns = columns
|
||||
.map((column, index) => (column.flex ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
if (flexColumns.length > 0) {
|
||||
const excess = totalWidth - width;
|
||||
const shrinkEach = Math.ceil(excess / flexColumns.length);
|
||||
for (const index of flexColumns) {
|
||||
const minWidth = columns[index]!.minWidth ?? 4;
|
||||
widths[index] = Math.max(minWidth, widths[index]! - shrinkEach);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const header = columns
|
||||
.map((column, index) => truncate(column.header, widths[index]!))
|
||||
.join(" ");
|
||||
const separator = columns
|
||||
.map((_, index) => "-".repeat(widths[index]!))
|
||||
.join(" ");
|
||||
|
||||
const lines = [header, separator];
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
columns
|
||||
.map((column, index) =>
|
||||
truncate(String(row[column.key] ?? ""), widths[index]!),
|
||||
)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { formatDocsLink } from "clawdbot/plugin-sdk";
|
||||
|
||||
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";
|
||||
import { cli, formatDanger, theme } from "./formatting.js";
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
@@ -55,15 +54,6 @@ function parseTimestamp(value: string): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const VALID_PLATFORMS: Platform[] = [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"signal",
|
||||
"imessage",
|
||||
];
|
||||
|
||||
export function registerSearchCli(program: Command) {
|
||||
program
|
||||
.command("search")
|
||||
@@ -72,7 +62,7 @@ export function registerSearchCli(program: Command) {
|
||||
.option("--from <contact>", "Filter by sender (contact name, username, or ID)")
|
||||
.option(
|
||||
"--platform <name>",
|
||||
"Filter by platform (whatsapp, telegram, discord, slack, signal, imessage)",
|
||||
"Filter by platform (channel id)",
|
||||
)
|
||||
.option("--since <time>", "Filter messages after this time (e.g., 1h, 2d, 1w, or ISO date)")
|
||||
.option("--until <time>", "Filter messages before this time")
|
||||
@@ -96,13 +86,6 @@ export function registerSearchCli(program: Command) {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -111,13 +94,13 @@ export function registerSearchCli(program: Command) {
|
||||
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);
|
||||
cli.error(formatDanger(`Invalid --since value: ${opts.since}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.until && until === null) {
|
||||
defaultRuntime.error(danger(`Invalid --until value: ${opts.until}`));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(`Invalid --until value: ${opts.until}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,48 +114,48 @@ export function registerSearchCli(program: Command) {
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(results, null, 2));
|
||||
cli.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
defaultRuntime.log(theme.muted(`No messages found matching "${query}".`));
|
||||
cli.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}".`));
|
||||
cli.log(theme.muted(`Note: No contacts found matching "${opts.from}".`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log("");
|
||||
cli.log("");
|
||||
|
||||
for (const result of results) {
|
||||
const { message, contact, snippet } = result;
|
||||
const senderName = contact?.displayName ?? message.senderId;
|
||||
const time = formatTimestamp(message.timestamp);
|
||||
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
`${theme.accent(`[${message.platform}]`)} ${theme.accentBright(senderName)} ${theme.muted(`- ${time}`)}`,
|
||||
);
|
||||
defaultRuntime.log(` ${snippet}`);
|
||||
defaultRuntime.log("");
|
||||
cli.log(` ${snippet}`);
|
||||
cli.log("");
|
||||
}
|
||||
|
||||
if (results.length === limit) {
|
||||
defaultRuntime.log(
|
||||
cli.log(
|
||||
theme.muted(`Showing first ${limit} results. Use --limit to see more.`),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
105
extensions/contacts-search/src/commands/search-args.ts
Normal file
105
extensions/contacts-search/src/commands/search-args.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search command arguments.
|
||||
* Format: /search <query> [--from <contact>] [--platform <name>] [--since <time>]
|
||||
*/
|
||||
function tokenizeArgs(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|\S+/g;
|
||||
for (const match of input.matchAll(pattern)) {
|
||||
const raw = match[1] ?? match[2] ?? match[0];
|
||||
tokens.push(raw.replace(/\\(["'\\])/g, "$1"));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parseSearchArgs(commandBody: string): {
|
||||
query: string;
|
||||
from?: string;
|
||||
platform?: Platform;
|
||||
since?: number;
|
||||
error?: string;
|
||||
} {
|
||||
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;
|
||||
|
||||
const parts = tokenizeArgs(argsStr);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]!;
|
||||
|
||||
if (part === "--from" && i + 1 < parts.length) {
|
||||
const fromParts: string[] = [];
|
||||
while (i + 1 < parts.length && !parts[i + 1]!.startsWith("--")) {
|
||||
fromParts.push(parts[++i]!);
|
||||
}
|
||||
if (fromParts.length === 0) {
|
||||
return { query: "", error: "Missing value for --from" };
|
||||
}
|
||||
from = fromParts.join(" ");
|
||||
} else if (part === "--platform" && i + 1 < parts.length) {
|
||||
platform = parts[++i]!.toLowerCase() as Platform;
|
||||
} 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 };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSearchArgs } from "./search-args.js";
|
||||
|
||||
describe("parseSearchArgs", () => {
|
||||
it("handles multi-word --from without quotes", () => {
|
||||
const parsed = parseSearchArgs('/search budget --from Sarah Smith');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("budget");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
});
|
||||
|
||||
it("handles quoted multi-word --from", () => {
|
||||
const parsed = parseSearchArgs('/search budget --from "Sarah Smith" --since 1w');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("budget");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
expect(parsed.since).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("keeps multi-word query alongside --from", () => {
|
||||
const parsed = parseSearchArgs('/search quarterly report --from Sarah Smith');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("quarterly report");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
});
|
||||
});
|
||||
103
extensions/contacts-search/src/commands/search-command.ts
Normal file
103
extensions/contacts-search/src/commands/search-command.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { PluginChatCommandHandler } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getContactStore } from "../contacts/index.js";
|
||||
import { parseSearchArgs } from "./search-args.js";
|
||||
|
||||
/**
|
||||
* 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" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the /search command for cross-platform message search.
|
||||
*/
|
||||
export const handleSearchCommand: PluginChatCommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (normalized !== "/search" && !normalized.startsWith("/search ")) return null;
|
||||
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
// Parse arguments from commandBodyNormalized (mentions already stripped)
|
||||
const parsed = parseSearchArgs(params.command.commandBodyNormalized);
|
||||
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)}` },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -289,6 +289,29 @@ describe("importContactFromMessage", () => {
|
||||
expect(second.contactId).toBe(first.contactId);
|
||||
});
|
||||
|
||||
it("updates identity metadata for known sender", () => {
|
||||
importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
|
||||
importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johnny",
|
||||
displayName: "John D",
|
||||
phone: "+14155551234",
|
||||
});
|
||||
|
||||
const identity = store.getIdentityByPlatformId("telegram", "123456789");
|
||||
expect(identity?.username).toBe("johnny");
|
||||
expect(identity?.displayName).toBe("John D");
|
||||
expect(identity?.phone).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("uses platformId as displayName fallback", () => {
|
||||
const { contactId } = importContactFromMessage(store, {
|
||||
platform: "whatsapp",
|
||||
@@ -66,8 +66,16 @@ export function importContactFromMessage(
|
||||
// Check if identity already exists
|
||||
const existing = store.getIdentityByPlatformId(data.platform, data.platformId);
|
||||
if (existing) {
|
||||
// Update last seen
|
||||
store.updateIdentityLastSeen(data.platform, data.platformId);
|
||||
const updated = {
|
||||
contactId: existing.contactId,
|
||||
platform: existing.platform,
|
||||
platformId: existing.platformId,
|
||||
username: data.username ?? existing.username,
|
||||
phone: data.phone ?? existing.phone,
|
||||
displayName: data.displayName ?? existing.displayName,
|
||||
lastSeenAt: Date.now(),
|
||||
} satisfies PlatformIdentityInput;
|
||||
store.addIdentity(updated);
|
||||
return { contactId: existing.contactId, isNew: false };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - Auto-linking heuristics based on phone/email/name matching
|
||||
*/
|
||||
|
||||
export { ContactStore, getContactStore, closeContactStore } from "./store.js";
|
||||
export { ContactStore, configureContactStore, getContactStore, closeContactStore } from "./store.js";
|
||||
export { ensureContactStoreSchema, dropContactStoreTables } from "./schema.js";
|
||||
export {
|
||||
importContactFromMessage,
|
||||
@@ -330,6 +330,56 @@ describe("linker", () => {
|
||||
expect(merged?.aliases).toContain("John on Discord");
|
||||
});
|
||||
|
||||
it("reassigns message history when merging contacts", () => {
|
||||
const primary = store.createContact("Merge Primary");
|
||||
store.addIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-merge",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "TG Merge",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
const secondary = store.createContact("Merge Secondary");
|
||||
store.addIdentity({
|
||||
contactId: secondary.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-merge",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "DC Merge",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-merge-1",
|
||||
content: "merge history one",
|
||||
platform: "telegram",
|
||||
senderId: "tg-merge",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-merge-2",
|
||||
content: "merge history two",
|
||||
platform: "discord",
|
||||
senderId: "dc-merge",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = linkContacts(store, primary.canonicalId, secondary.canonicalId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const results = store.searchMessages({ query: "merge history", from: primary.canonicalId });
|
||||
expect(results.length).toBe(2);
|
||||
const ids = results.map((entry) => entry.message.id);
|
||||
expect(ids).toContain("msg-merge-1");
|
||||
expect(ids).toContain("msg-merge-2");
|
||||
expect(results.every((entry) => entry.message.contactId === primary.canonicalId)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns error for non-existent primary contact", () => {
|
||||
const contact = store.createContact("Test");
|
||||
store.addIdentity({
|
||||
@@ -402,6 +452,54 @@ describe("linker", () => {
|
||||
expect(newContact?.identities[0]?.platform).toBe("discord");
|
||||
});
|
||||
|
||||
it("moves message history to new contact when unlinking", () => {
|
||||
const contact = store.createContact("Unlink User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-unlink",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "TG Unlink",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-unlink",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "DC Unlink",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-unlink",
|
||||
content: "unlink history message",
|
||||
platform: "discord",
|
||||
senderId: "dc-unlink",
|
||||
channelId: "c3",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "discord", "dc-unlink");
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const newContactId = result.newContactId!;
|
||||
const newResults = store.searchMessages({
|
||||
query: "unlink history",
|
||||
from: newContactId,
|
||||
});
|
||||
expect(newResults.length).toBe(1);
|
||||
expect(newResults[0]?.message.contactId).toBe(newContactId);
|
||||
|
||||
const oldResults = store.searchMessages({
|
||||
query: "unlink history",
|
||||
from: contact.canonicalId,
|
||||
});
|
||||
expect(oldResults.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns error for non-existent identity", () => {
|
||||
const result = unlinkIdentity(store, "telegram", "fake-id");
|
||||
expect(result.success).toBe(false);
|
||||
@@ -285,6 +285,11 @@ export function linkContacts(
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
store.updateMessageContactForIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: identity.platform,
|
||||
senderId: identity.platformId,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge aliases
|
||||
@@ -343,6 +348,11 @@ export function unlinkIdentity(
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
store.updateMessageContactForIdentity({
|
||||
contactId: newContact.canonicalId,
|
||||
platform: identity.platform,
|
||||
senderId: identity.platformId,
|
||||
});
|
||||
|
||||
return { success: true, newContactId: newContact.canonicalId };
|
||||
}
|
||||
@@ -337,6 +337,35 @@ describe("ContactStore", () => {
|
||||
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("filters messages by canonical contact id", () => {
|
||||
const contact = store.createContact("Filter Sender");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "sender-filter",
|
||||
username: "filter",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-filter",
|
||||
content: "Message for canonical filter",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-filter",
|
||||
channelId: "chat-1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "canonical filter",
|
||||
from: contact.canonicalId,
|
||||
});
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("searches messages by content", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-search-1",
|
||||
@@ -3,8 +3,7 @@ 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 { requireNodeSqlite } from "../sqlite.js";
|
||||
import { ensureContactStoreSchema } from "./schema.js";
|
||||
import type {
|
||||
Contact,
|
||||
@@ -44,6 +43,8 @@ export class ContactStore {
|
||||
private stmtUpdateIdentityLastSeen: StatementSync;
|
||||
private stmtInsertMessage: StatementSync;
|
||||
private stmtInsertMessageFts: StatementSync | null;
|
||||
private stmtUpdateMessageContactBySender: StatementSync;
|
||||
private stmtUpdateMessageFtsContactBySender: StatementSync | null;
|
||||
|
||||
private constructor(db: DatabaseSync, ftsAvailable: boolean) {
|
||||
this.db = db;
|
||||
@@ -93,14 +94,28 @@ export class ContactStore {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
: null;
|
||||
|
||||
this.stmtUpdateMessageContactBySender = db.prepare(`
|
||||
UPDATE indexed_messages SET contact_id = ? WHERE platform = ? AND sender_id = ?
|
||||
`);
|
||||
this.stmtUpdateMessageFtsContactBySender = ftsAvailable
|
||||
? db.prepare(`
|
||||
UPDATE messages_fts SET contact_id = ? WHERE platform = ? AND sender_id = ?
|
||||
`)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or create a contact store database.
|
||||
*/
|
||||
static open(dbPath?: string): ContactStore {
|
||||
static open(params: { dbPath?: string; stateDir?: string } = {}): ContactStore {
|
||||
const nodeSqlite = requireNodeSqlite();
|
||||
const resolvedPath = dbPath ?? path.join(resolveStateDir(), "contacts", CONTACTS_DB_FILENAME);
|
||||
const resolvedPath =
|
||||
params.dbPath ??
|
||||
(params.stateDir ? path.join(params.stateDir, "contacts", CONTACTS_DB_FILENAME) : undefined);
|
||||
if (!resolvedPath) {
|
||||
throw new Error("ContactStore.open requires dbPath or stateDir");
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(resolvedPath);
|
||||
@@ -237,7 +252,7 @@ export class ContactStore {
|
||||
conditions.push(
|
||||
`canonical_id IN (SELECT contact_id FROM platform_identities WHERE platform = ?)`,
|
||||
);
|
||||
params.push(options.platform);
|
||||
params.push(this.normalizePlatform(options.platform));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
@@ -316,21 +331,23 @@ export class ContactStore {
|
||||
* Add a platform identity to a contact.
|
||||
*/
|
||||
addIdentity(input: PlatformIdentityInput): PlatformIdentity {
|
||||
const platform = this.normalizePlatform(input.platform);
|
||||
const phone = input.phone ? this.normalizePhone(input.phone) : null;
|
||||
this.stmtInsertIdentity.run(
|
||||
input.contactId,
|
||||
input.platform,
|
||||
platform,
|
||||
input.platformId,
|
||||
input.username,
|
||||
input.phone,
|
||||
phone,
|
||||
input.displayName,
|
||||
input.lastSeenAt,
|
||||
);
|
||||
|
||||
// Get the inserted row to return
|
||||
const identity = this.getIdentityByPlatformId(input.platform, input.platformId);
|
||||
const identity = this.getIdentityByPlatformId(platform, input.platformId);
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
`Failed to retrieve inserted identity: ${input.platform}:${input.platformId}`,
|
||||
`Failed to retrieve inserted identity: ${platform}:${input.platformId}`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
@@ -367,7 +384,8 @@ export class ContactStore {
|
||||
* 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
|
||||
const normalizedPlatform = this.normalizePlatform(platform);
|
||||
const row = this.stmtGetIdentityByPlatformId.get(normalizedPlatform, platformId) as
|
||||
| {
|
||||
id: number;
|
||||
contact_id: string;
|
||||
@@ -432,7 +450,8 @@ export class ContactStore {
|
||||
* Update last seen timestamp for a platform identity.
|
||||
*/
|
||||
updateIdentityLastSeen(platform: string, platformId: string): void {
|
||||
this.stmtUpdateIdentityLastSeen.run(Date.now(), platform, platformId);
|
||||
const normalizedPlatform = this.normalizePlatform(platform);
|
||||
this.stmtUpdateIdentityLastSeen.run(Date.now(), normalizedPlatform, platformId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,6 +463,19 @@ export class ContactStore {
|
||||
return identity?.contactId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign indexed messages for a platform identity to a contact.
|
||||
*/
|
||||
updateMessageContactForIdentity(params: {
|
||||
contactId: string | null;
|
||||
platform: string;
|
||||
senderId: string;
|
||||
}): void {
|
||||
const platform = this.normalizePlatform(params.platform);
|
||||
this.stmtUpdateMessageContactBySender.run(params.contactId, platform, params.senderId);
|
||||
this.stmtUpdateMessageFtsContactBySender?.run(params.contactId, platform, params.senderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone number to E.164 format.
|
||||
*/
|
||||
@@ -462,6 +494,10 @@ export class ContactStore {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizePlatform(platform: string): string {
|
||||
return platform.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MESSAGE INDEXING
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -470,14 +506,15 @@ export class ContactStore {
|
||||
* Index a message for cross-platform search.
|
||||
*/
|
||||
indexMessage(message: Omit<IndexedMessage, "embedding"> & { embedding?: string | null }): void {
|
||||
const platform = this.normalizePlatform(message.platform);
|
||||
// Try to resolve the sender to a canonical contact
|
||||
const contactId = this.resolveContact(message.platform, message.senderId);
|
||||
const contactId = this.resolveContact(platform, message.senderId);
|
||||
|
||||
this.stmtInsertMessage.run(
|
||||
message.id,
|
||||
message.content,
|
||||
contactId,
|
||||
message.platform,
|
||||
platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
@@ -490,7 +527,7 @@ export class ContactStore {
|
||||
message.content,
|
||||
message.id,
|
||||
contactId,
|
||||
message.platform,
|
||||
platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
@@ -499,7 +536,7 @@ export class ContactStore {
|
||||
|
||||
// Update last seen timestamp for the sender
|
||||
if (contactId) {
|
||||
this.updateIdentityLastSeen(message.platform, message.senderId);
|
||||
this.updateIdentityLastSeen(platform, message.senderId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,23 +548,34 @@ export class ContactStore {
|
||||
|
||||
if (!options.query) return results;
|
||||
|
||||
const normalizedPlatforms = options.platforms?.map((platform) =>
|
||||
this.normalizePlatform(platform),
|
||||
);
|
||||
|
||||
// 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 normalized = options.from.trim();
|
||||
const exact = normalized ? this.getContact(normalized) : null;
|
||||
const matches = this.searchContacts(options.from, 10);
|
||||
if (matches.length === 0) {
|
||||
// No matching contacts, return empty results
|
||||
const ids = new Set<string>(matches.map((m) => m.canonicalId));
|
||||
if (exact) ids.add(exact.canonicalId);
|
||||
if (ids.size === 0) {
|
||||
return results;
|
||||
}
|
||||
contactIds = matches.map((m) => m.canonicalId);
|
||||
contactIds = [...ids];
|
||||
}
|
||||
|
||||
// Build query based on FTS availability
|
||||
const normalizedOptions = {
|
||||
...options,
|
||||
platforms: normalizedPlatforms,
|
||||
};
|
||||
|
||||
if (this.ftsAvailable) {
|
||||
return this.searchMessagesFts(options, contactIds);
|
||||
return this.searchMessagesFts(normalizedOptions, contactIds);
|
||||
}
|
||||
return this.searchMessagesLike(options, contactIds);
|
||||
return this.searchMessagesLike(normalizedOptions, contactIds);
|
||||
}
|
||||
|
||||
private searchMessagesFts(
|
||||
@@ -759,13 +807,22 @@ export class ContactStore {
|
||||
|
||||
// Singleton instance
|
||||
let _store: ContactStore | null = null;
|
||||
let _storeConfig: { dbPath?: string; stateDir?: string } = {};
|
||||
|
||||
export function configureContactStore(params: { dbPath?: string; stateDir?: string }): void {
|
||||
_storeConfig = params;
|
||||
if (_store) {
|
||||
_store.close();
|
||||
_store = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global contact store instance.
|
||||
*/
|
||||
export function getContactStore(): ContactStore {
|
||||
if (!_store) {
|
||||
_store = ContactStore.open();
|
||||
_store = ContactStore.open(_storeConfig);
|
||||
}
|
||||
return _store;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChannelId } from "clawdbot/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Types for the unified contact graph.
|
||||
*
|
||||
@@ -26,15 +28,7 @@ export type Contact = {
|
||||
/**
|
||||
* Supported messaging platforms.
|
||||
*/
|
||||
export type Platform =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "matrix"
|
||||
| "msteams";
|
||||
export type Platform = ChannelId;
|
||||
|
||||
/**
|
||||
* A platform-specific identity linked to a canonical contact.
|
||||
91
extensions/contacts-search/src/hooks/message-indexer.ts
Normal file
91
extensions/contacts-search/src/hooks/message-indexer.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import type {
|
||||
PluginHookMessageContext,
|
||||
PluginHookMessageReceivedEvent,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { importContactFromMessage, getContactStore } from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
|
||||
function normalizePlatform(value: string): Platform {
|
||||
return value.trim().toLowerCase() as Platform;
|
||||
}
|
||||
|
||||
function resolveMessageId(params: {
|
||||
messageId?: string;
|
||||
platform: string;
|
||||
senderId: string;
|
||||
timestamp?: number;
|
||||
content: string;
|
||||
}): string {
|
||||
if (params.messageId) {
|
||||
return `${params.platform}:${params.messageId}`;
|
||||
}
|
||||
if (!params.timestamp) return randomUUID();
|
||||
const hash = createHash("sha1");
|
||||
hash.update(params.platform);
|
||||
hash.update("|");
|
||||
hash.update(params.senderId);
|
||||
hash.update("|");
|
||||
hash.update(String(params.timestamp));
|
||||
hash.update("|");
|
||||
hash.update(params.content);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export function indexInboundMessage(params: {
|
||||
event: PluginHookMessageReceivedEvent;
|
||||
ctx: PluginHookMessageContext;
|
||||
logger?: { warn?: (message: string) => void };
|
||||
}): void {
|
||||
const { event, ctx, logger } = params;
|
||||
const channelId = (ctx.channelId ?? "").trim();
|
||||
if (!channelId) return;
|
||||
|
||||
const senderId = (event.senderId ?? event.from ?? "").trim();
|
||||
if (!senderId) return;
|
||||
|
||||
const content = typeof event.content === "string" ? event.content.trim() : "";
|
||||
const platform = normalizePlatform(channelId);
|
||||
const timestamp =
|
||||
typeof event.timestamp === "number" && Number.isFinite(event.timestamp)
|
||||
? event.timestamp
|
||||
: Date.now();
|
||||
const messageId = resolveMessageId({
|
||||
messageId: event.messageId,
|
||||
platform,
|
||||
senderId,
|
||||
timestamp,
|
||||
content,
|
||||
});
|
||||
const conversationId = (ctx.conversationId ?? "").trim() || senderId;
|
||||
|
||||
try {
|
||||
const store = getContactStore();
|
||||
importContactFromMessage(store, {
|
||||
platform,
|
||||
platformId: senderId,
|
||||
username: event.senderUsername ?? null,
|
||||
phone: event.senderE164 ?? null,
|
||||
displayName: event.senderName ?? null,
|
||||
});
|
||||
|
||||
if (!content) return;
|
||||
|
||||
store.indexMessage({
|
||||
id: messageId,
|
||||
content,
|
||||
contactId: null,
|
||||
platform,
|
||||
senderId,
|
||||
channelId: conversationId,
|
||||
timestamp,
|
||||
embedding: null,
|
||||
});
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`[contacts-search] failed indexing message: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
extensions/contacts-search/src/sqlite.ts
Normal file
22
extensions/contacts-search/src/sqlite.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function requireNodeSqlite(): typeof import("node:sqlite") {
|
||||
const onWarning = (warning: Error & { name?: string; message?: string }) => {
|
||||
if (
|
||||
warning.name === "ExperimentalWarning" &&
|
||||
warning.message?.includes("SQLite is an experimental feature")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`${warning.stack ?? warning.toString()}\n`);
|
||||
};
|
||||
|
||||
process.on("warning", onWarning);
|
||||
try {
|
||||
return require("node:sqlite") as typeof import("node:sqlite");
|
||||
} finally {
|
||||
process.off("warning", onWarning);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
chatCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -139,6 +139,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
chatCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -147,6 +147,7 @@ function createPluginRecord(params: {
|
||||
providerIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
chatCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpHandlers: 0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
chatCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
chatCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
|
||||
Reference in New Issue
Block a user