732 lines
25 KiB
Bash
Executable file
732 lines
25 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# convo — CLI query tool for conversation-assistant production API
|
|
#
|
|
# Usage:
|
|
# convo search <name> Search contacts by name
|
|
# convo conversations [contact-id] List conversations (optionally filter by contact)
|
|
# convo read <id> [--limit N] Read messages from a conversation
|
|
# convo messages <query> Search messages across all conversations
|
|
# convo chat <name> Quick: find contact → show recent messages
|
|
# convo stats Show server sync statistics
|
|
# convo process [--limit N] Trigger server-side message processing
|
|
# convo process-stats Show message processing progress
|
|
# convo contacts-update Sync contact names from Mac AddressBook
|
|
# convo named [--limit N] List conversations with resolved human names
|
|
#
|
|
set -euo pipefail
|
|
|
|
API_BASE="${CONVO_API_BASE:-https://conversations.nasty.sh}"
|
|
MAC_HOST="${CONVO_MAC_HOST:-plum}"
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
MAGENTA='\033[0;35m'
|
|
DIM='\033[2m'
|
|
BOLD='\033[1m'
|
|
RESET='\033[0m'
|
|
|
|
die() { echo -e "${RED}Error: $1${RESET}" >&2; exit 1; }
|
|
|
|
require_jq() {
|
|
command -v jq &>/dev/null || die "jq is required. Install with: brew install jq (macOS) or sudo dnf install jq (Fedora)"
|
|
}
|
|
|
|
api_get() {
|
|
local path="$1"
|
|
local timeout="${2:-15}"
|
|
local response http_code
|
|
response=$(curl -s --max-time "$timeout" -w '\n%{http_code}' "${API_BASE}${path}" 2>&1)
|
|
http_code=$(echo "$response" | tail -1)
|
|
response=$(echo "$response" | sed '$d')
|
|
|
|
if [[ "$http_code" -ge 400 ]]; then
|
|
local err_msg
|
|
err_msg=$(echo "$response" | jq -r '.message // .error // "Unknown error"' 2>/dev/null || echo "$response")
|
|
die "API ${http_code}: ${err_msg} (${API_BASE}${path})"
|
|
fi
|
|
echo "$response"
|
|
}
|
|
|
|
api_post() {
|
|
local path="$1"
|
|
local data="${2:-}"
|
|
local auth="${3:-}"
|
|
local timeout="${4:-60}"
|
|
local headers=(-H "Content-Type: application/json")
|
|
|
|
if [[ -n "$auth" ]]; then
|
|
headers+=(-H "Authorization: Bearer ${auth}")
|
|
fi
|
|
|
|
local response http_code
|
|
if [[ -n "$data" ]]; then
|
|
response=$(curl -s --max-time "$timeout" -w '\n%{http_code}' -X POST "${headers[@]}" -d "$data" "${API_BASE}${path}" 2>&1)
|
|
else
|
|
response=$(curl -s --max-time "$timeout" -w '\n%{http_code}' -X POST "${headers[@]}" "${API_BASE}${path}" 2>&1)
|
|
fi
|
|
http_code=$(echo "$response" | tail -1)
|
|
response=$(echo "$response" | sed '$d')
|
|
|
|
if [[ "$http_code" -ge 400 ]]; then
|
|
local err_msg
|
|
err_msg=$(echo "$response" | jq -r '.message // .error // "Unknown error"' 2>/dev/null || echo "$response")
|
|
die "API ${http_code}: ${err_msg} (${API_BASE}${path})"
|
|
fi
|
|
echo "$response"
|
|
}
|
|
|
|
get_auth_token() {
|
|
local token
|
|
token=$(ssh "$MAC_HOST" 'defaults read com.lilith.conversation-assistant _secure_authToken 2>/dev/null' 2>/dev/null) || true
|
|
if [[ -z "$token" ]]; then
|
|
die "No auth token found. Is the sync app running on ${MAC_HOST}?"
|
|
fi
|
|
echo "$token"
|
|
}
|
|
|
|
# ─── search: find contacts by name ──────────────────────────────────────────
|
|
cmd_search() {
|
|
local query="${1:?Usage: convo search <name>}"
|
|
local encoded
|
|
encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$query'))")
|
|
|
|
local response
|
|
response=$(api_get "/api/contacts?search=${encoded}")
|
|
|
|
local count
|
|
count=$(echo "$response" | jq '.data | length')
|
|
|
|
if [[ "$count" == "0" ]]; then
|
|
echo -e "${YELLOW}No contacts found matching '${query}'${RESET}"
|
|
return
|
|
fi
|
|
|
|
echo -e "${BOLD}Found ${count} contacts matching '${query}':${RESET}\n"
|
|
echo "$response" | jq -r '.data[] | [
|
|
.id,
|
|
.displayName,
|
|
(.phoneNumber // .email // "—"),
|
|
(.classification // "unknown"),
|
|
(.lastMessageAt // "never")
|
|
] | @tsv' | while IFS=$'\t' read -r id name phone class last_msg; do
|
|
echo -e " ${CYAN}${id}${RESET}"
|
|
echo -e " Name: ${BOLD}${name}${RESET}"
|
|
echo -e " Phone: ${phone}"
|
|
echo -e " Class: ${class}"
|
|
echo -e " Last msg: ${last_msg}"
|
|
echo ""
|
|
done
|
|
}
|
|
|
|
# ─── conversations: list conversations, optionally filtered ─────────────────
|
|
cmd_conversations() {
|
|
local filter="${1:-}"
|
|
local response
|
|
|
|
if [[ -z "$filter" ]]; then
|
|
response=$(api_get "/api/conversations?limit=50")
|
|
else
|
|
# If it looks like a UUID, it's a contact ID — fetch all and filter
|
|
response=$(api_get "/api/conversations?limit=500")
|
|
fi
|
|
|
|
local data
|
|
data=$(echo "$response" | jq '.data')
|
|
local count
|
|
count=$(echo "$data" | jq 'length')
|
|
|
|
if [[ -n "$filter" ]]; then
|
|
# Filter conversations by participant ID
|
|
data=$(echo "$data" | jq --arg id "$filter" '[.[] | select(.participantIds[] == $id or .participants[]?.id == $id)]')
|
|
count=$(echo "$data" | jq 'length')
|
|
fi
|
|
|
|
if [[ "$count" == "0" ]]; then
|
|
echo -e "${YELLOW}No conversations found${RESET}"
|
|
return
|
|
fi
|
|
|
|
echo -e "${BOLD}${count} conversations:${RESET}\n"
|
|
echo "$data" | jq -r 'sort_by(.lastMessageAt) | reverse | .[] | [
|
|
.id,
|
|
.displayName,
|
|
(.messageCount | tostring),
|
|
(.lastMessageAt // "—"),
|
|
(if .isGroup then "group" else "1:1" end)
|
|
] | @tsv' | while IFS=$'\t' read -r id name msgs last_msg type; do
|
|
echo -e " ${CYAN}${id}${RESET}"
|
|
echo -e " ${BOLD}${name}${RESET} (${type}, ${msgs} messages)"
|
|
echo -e " Last: ${last_msg}"
|
|
echo ""
|
|
done
|
|
}
|
|
|
|
# ─── read: show messages from a conversation ────────────────────────────────
|
|
cmd_read() {
|
|
local conv_id="${1:?Usage: convo read <conversation-id> [--limit N]}"
|
|
shift
|
|
local limit=""
|
|
local load_all="true"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--limit|-n)
|
|
limit="$2"
|
|
load_all="false"
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
local url="/api/conversations/${conv_id}/messages?loadAll=${load_all}"
|
|
if [[ -n "$limit" ]]; then
|
|
url="/api/conversations/${conv_id}/messages?limit=${limit}"
|
|
fi
|
|
|
|
local response
|
|
response=$(api_get "$url" 30)
|
|
|
|
# Get conversation info
|
|
local conv_info
|
|
conv_info=$(api_get "/api/conversations/${conv_id}")
|
|
local conv_name
|
|
conv_name=$(echo "$conv_info" | jq -r '.data.displayName // "Unknown"')
|
|
|
|
# Resolve conversation name through contacts
|
|
local resolved_name="$conv_name"
|
|
local participant_id
|
|
participant_id=$(echo "$conv_info" | jq -r '.data.participants[0].id // empty' 2>/dev/null)
|
|
if [[ -n "$participant_id" ]]; then
|
|
local contact_info
|
|
contact_info=$(api_get "/api/contacts/${participant_id}" 5 2>/dev/null) || true
|
|
if [[ -n "$contact_info" ]]; then
|
|
local contact_name
|
|
contact_name=$(echo "$contact_info" | jq -r '.data.displayName // empty' 2>/dev/null)
|
|
if [[ -n "$contact_name" && "$contact_name" != "$conv_name" ]]; then
|
|
resolved_name="${contact_name} (${conv_name})"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
local count
|
|
count=$(echo "$response" | jq '.data | length')
|
|
local total
|
|
total=$(echo "$response" | jq '.pagination.total // 0')
|
|
|
|
echo -e "${BOLD}Conversation: ${resolved_name}${RESET}"
|
|
echo -e "${DIM}Showing ${count} of ${total} messages${RESET}\n"
|
|
echo -e "────────────────────────────────────────────────────────────\n"
|
|
|
|
echo "$response" | jq -r '.data | sort_by(.sentAt) | .[] | [
|
|
.sentAt,
|
|
.direction,
|
|
(.text // "[no text]"),
|
|
(.messageType // "text")
|
|
] | @tsv' | while IFS=$'\t' read -r sent_at direction text msg_type; do
|
|
local time_str
|
|
time_str=$(date -d "$sent_at" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$sent_at")
|
|
|
|
# Skip tapbacks in display unless they have meaningful text
|
|
if [[ "$msg_type" == "tapback" && "$text" != "[no text]" ]]; then
|
|
echo -e " ${DIM}${time_str} ${text}${RESET}"
|
|
continue
|
|
elif [[ "$msg_type" == "tapback" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$direction" == "outgoing" ]]; then
|
|
echo -e " ${DIM}${time_str}${RESET} ${GREEN}→ You:${RESET} ${text}"
|
|
else
|
|
echo -e " ${DIM}${time_str}${RESET} ${BLUE}← ${resolved_name%%(*}:${RESET} ${text}"
|
|
fi
|
|
done
|
|
|
|
echo -e "\n────────────────────────────────────────────────────────────"
|
|
}
|
|
|
|
# ─── messages: search messages across all conversations ─────────────────────
|
|
cmd_messages() {
|
|
local query="${1:?Usage: convo messages <search-query>}"
|
|
local encoded
|
|
encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$query'))")
|
|
|
|
local response
|
|
response=$(api_get "/api/messages/search?q=${encoded}&limit=30")
|
|
|
|
local count
|
|
count=$(echo "$response" | jq '.data | length')
|
|
local total
|
|
total=$(echo "$response" | jq '.meta.totalFound // 0')
|
|
local search_time
|
|
search_time=$(echo "$response" | jq '.meta.searchTimeMs // 0')
|
|
|
|
if [[ "$count" == "0" ]]; then
|
|
echo -e "${YELLOW}No messages found matching '${query}'${RESET}"
|
|
return
|
|
fi
|
|
|
|
echo -e "${BOLD}Found ${total} messages matching '${query}' (${search_time}ms):${RESET}\n"
|
|
|
|
echo "$response" | jq -r '.data[] | [
|
|
.sentAt,
|
|
.direction,
|
|
(.contactName // "unknown"),
|
|
(.conversationName // "—"),
|
|
.text,
|
|
(.similarityScore // 0 | tostring)
|
|
] | @tsv' | while IFS=$'\t' read -r sent_at direction contact conv text score; do
|
|
local time_str
|
|
time_str=$(date -d "$sent_at" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$sent_at")
|
|
|
|
local dir_indicator
|
|
if [[ "$direction" == "outgoing" ]]; then
|
|
dir_indicator="${GREEN}→${RESET}"
|
|
else
|
|
dir_indicator="${BLUE}←${RESET}"
|
|
fi
|
|
|
|
echo -e " ${dir_indicator} ${DIM}${time_str}${RESET} ${BOLD}${contact}${RESET} ${DIM}(${conv})${RESET}"
|
|
echo -e " ${text}"
|
|
echo -e " ${DIM}score: ${score}${RESET}"
|
|
echo ""
|
|
done
|
|
}
|
|
|
|
# ─── chat: quick shortcut — find contact and show recent messages ───────────
|
|
cmd_chat() {
|
|
local name="${1:?Usage: convo chat <name>}"
|
|
shift
|
|
local limit="50"
|
|
local load_all=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--all|-a) load_all="true"; shift ;;
|
|
--limit|-n) limit="$2"; shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
echo -e "${DIM}Searching for '${name}'...${RESET}\n"
|
|
|
|
# First: search contacts by name (most reliable after contacts-update)
|
|
local encoded
|
|
encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$name'))")
|
|
local contacts_response
|
|
contacts_response=$(api_get "/api/contacts?search=${encoded}")
|
|
local contact_count
|
|
contact_count=$(echo "$contacts_response" | jq '.data | length')
|
|
|
|
# Fetch all conversations (needed for several lookups)
|
|
local convos_response
|
|
convos_response=$(api_get "/api/conversations?limit=500" 30)
|
|
|
|
if [[ "$contact_count" -gt 0 ]]; then
|
|
# Show matching contacts
|
|
if [[ "$contact_count" -gt 1 ]]; then
|
|
echo -e "${BOLD}Found ${contact_count} matching contacts:${RESET}"
|
|
echo "$contacts_response" | jq -r '.data[] | " \(.displayName) (\(.phoneNumber // .email // "—")) — \(.id)"'
|
|
echo ""
|
|
fi
|
|
|
|
local contact_id
|
|
contact_id=$(echo "$contacts_response" | jq -r '.data[0].id')
|
|
local contact_name
|
|
contact_name=$(echo "$contacts_response" | jq -r '.data[0].displayName')
|
|
|
|
echo -e "${DIM}Finding conversations with ${contact_name}...${RESET}"
|
|
|
|
# Find conversations with this contact
|
|
local matching
|
|
matching=$(echo "$convos_response" | jq --arg id "$contact_id" '[.data[] | select(.participantIds[] == $id or (.participants[]?.id == $id))] | sort_by(.lastMessageAt) | reverse')
|
|
local match_count
|
|
match_count=$(echo "$matching" | jq 'length')
|
|
|
|
if [[ "$match_count" -gt 0 ]]; then
|
|
local conv_id
|
|
conv_id=$(echo "$matching" | jq -r '.[0].id')
|
|
local msg_count
|
|
msg_count=$(echo "$matching" | jq -r '.[0].messageCount')
|
|
|
|
if [[ "$match_count" -gt 1 ]]; then
|
|
echo -e "${DIM}Found ${match_count} conversations, using most recent (${msg_count} messages)${RESET}\n"
|
|
else
|
|
echo -e "${DIM}Found conversation: ${msg_count} messages${RESET}\n"
|
|
fi
|
|
|
|
if [[ "$load_all" == "true" ]]; then
|
|
cmd_read "$conv_id"
|
|
else
|
|
cmd_read "$conv_id" --limit "$limit"
|
|
fi
|
|
return
|
|
fi
|
|
fi
|
|
|
|
# Fallback: search conversation display names
|
|
local conv_matches
|
|
conv_matches=$(echo "$convos_response" | jq --arg name "$name" '[.data[] | select(.displayName | ascii_downcase | contains($name | ascii_downcase))] | sort_by(.lastMessageAt) | reverse')
|
|
local conv_match_count
|
|
conv_match_count=$(echo "$conv_matches" | jq 'length')
|
|
|
|
if [[ "$conv_match_count" -gt 0 ]]; then
|
|
if [[ "$conv_match_count" -gt 1 ]]; then
|
|
echo -e "${BOLD}Found ${conv_match_count} matching conversations:${RESET}"
|
|
echo "$conv_matches" | jq -r '.[] | " \(.displayName) (\(.messageCount) msgs, last: \(.lastMessageAt // "—")) — \(.id)"'
|
|
echo ""
|
|
fi
|
|
|
|
local conv_id
|
|
conv_id=$(echo "$conv_matches" | jq -r '.[0].id')
|
|
local conv_name
|
|
conv_name=$(echo "$conv_matches" | jq -r '.[0].displayName')
|
|
echo -e " Using: ${BOLD}${conv_name}${RESET}\n"
|
|
|
|
if [[ "$load_all" == "true" ]]; then
|
|
cmd_read "$conv_id"
|
|
else
|
|
cmd_read "$conv_id" --limit "$limit"
|
|
fi
|
|
return
|
|
fi
|
|
|
|
# Last resort: search participant display names
|
|
local participant_matches
|
|
participant_matches=$(echo "$convos_response" | jq --arg name "$name" '[.data[] | select(.participants[]?.displayName | ascii_downcase | contains($name | ascii_downcase))] | sort_by(.lastMessageAt) | reverse')
|
|
local pm_count
|
|
pm_count=$(echo "$participant_matches" | jq 'length')
|
|
|
|
if [[ "$pm_count" -gt 0 ]]; then
|
|
local conv_id
|
|
conv_id=$(echo "$participant_matches" | jq -r '.[0].id')
|
|
echo -e " Found via participant match\n"
|
|
if [[ "$load_all" == "true" ]]; then
|
|
cmd_read "$conv_id"
|
|
else
|
|
cmd_read "$conv_id" --limit "$limit"
|
|
fi
|
|
return
|
|
fi
|
|
|
|
die "No contacts or conversations found matching '${name}'"
|
|
}
|
|
|
|
# ─── process: trigger server-side message processing ────────────────────────
|
|
cmd_process() {
|
|
local limit="2000"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--limit|-n) limit="$2"; shift 2 ;;
|
|
--all|-a) limit="50000"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
# Check current state
|
|
local stats
|
|
stats=$(api_get "/api/processing/stats")
|
|
local unprocessed
|
|
unprocessed=$(echo "$stats" | jq '.data.unprocessed')
|
|
local total
|
|
total=$(echo "$stats" | jq '.data.total')
|
|
|
|
if [[ "$unprocessed" == "0" ]]; then
|
|
echo -e "${GREEN}All ${total} messages already processed${RESET}"
|
|
return
|
|
fi
|
|
|
|
echo -e "${BOLD}Processing messages...${RESET}"
|
|
echo -e "${DIM}${unprocessed} of ${total} unprocessed${RESET}\n"
|
|
|
|
local batch=0
|
|
local total_processed=0
|
|
local total_errors=0
|
|
|
|
while true; do
|
|
batch=$((batch + 1))
|
|
local remaining=$((limit - total_processed))
|
|
if [[ "$remaining" -le 0 ]]; then
|
|
break
|
|
fi
|
|
|
|
local batch_size=2000
|
|
if [[ "$remaining" -lt "$batch_size" ]]; then
|
|
batch_size=$remaining
|
|
fi
|
|
|
|
local result
|
|
result=$(api_post "/api/processing/process?limit=${batch_size}" "" "" 120)
|
|
local processed
|
|
processed=$(echo "$result" | jq '.data.processed')
|
|
local errors
|
|
errors=$(echo "$result" | jq '.data.errors')
|
|
|
|
total_processed=$((total_processed + processed))
|
|
total_errors=$((total_errors + errors))
|
|
|
|
echo -e " Batch ${batch}: ${GREEN}${processed}${RESET} processed, ${errors} errors (total: ${total_processed})"
|
|
|
|
if [[ "$processed" == "0" ]]; then
|
|
break
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
# Final stats
|
|
stats=$(api_get "/api/processing/stats")
|
|
local final_processed
|
|
final_processed=$(echo "$stats" | jq '.data.processed')
|
|
local final_unprocessed
|
|
final_unprocessed=$(echo "$stats" | jq '.data.unprocessed')
|
|
|
|
echo -e "${BOLD}Done:${RESET} ${GREEN}${final_processed}${RESET} processed, ${YELLOW}${final_unprocessed}${RESET} remaining, ${RED}${total_errors}${RESET} errors"
|
|
}
|
|
|
|
# ─── process-stats: show processing progress ────────────────────────────────
|
|
cmd_process_stats() {
|
|
local stats
|
|
stats=$(api_get "/api/processing/stats")
|
|
|
|
local total processed unprocessed
|
|
total=$(echo "$stats" | jq '.data.total')
|
|
processed=$(echo "$stats" | jq '.data.processed')
|
|
unprocessed=$(echo "$stats" | jq '.data.unprocessed')
|
|
|
|
local pct=0
|
|
if [[ "$total" -gt 0 ]]; then
|
|
pct=$(( (processed * 100) / total ))
|
|
fi
|
|
|
|
echo -e "${BOLD}Message Processing:${RESET}\n"
|
|
echo -e " Total: ${BOLD}${total}${RESET}"
|
|
echo -e " Processed: ${GREEN}${processed}${RESET}"
|
|
echo -e " Unprocessed: ${YELLOW}${unprocessed}${RESET}"
|
|
echo -e " Progress: ${BOLD}${pct}%${RESET}"
|
|
}
|
|
|
|
# ─── contacts-update: sync contact names from Mac AddressBook ───────────────
|
|
cmd_contacts_update() {
|
|
echo -e "${BOLD}Syncing contacts from Mac AddressBook...${RESET}\n"
|
|
|
|
# Get auth token
|
|
echo -e "${DIM}Getting auth token from ${MAC_HOST}...${RESET}"
|
|
local token
|
|
token=$(get_auth_token)
|
|
|
|
# Query AddressBook on Mac
|
|
echo -e "${DIM}Querying AddressBook on ${MAC_HOST}...${RESET}"
|
|
local ab_data
|
|
ab_data=$(ssh "$MAC_HOST" '/usr/bin/sqlite3 "/Users/natalie/Library/Application Support/AddressBook/Sources/45F9105C-5659-4BBB-A0DB-1213F696F4D9/AddressBook-v22.abcddb" "SELECT COALESCE(r.ZFIRSTNAME, '"'"''"'"') || '"'"'|'"'"' || COALESCE(r.ZLASTNAME, '"'"''"'"') || '"'"'|'"'"' || p.ZFULLNUMBER FROM ZABCDRECORD r JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK WHERE p.ZFULLNUMBER IS NOT NULL AND (r.ZFIRSTNAME IS NOT NULL OR r.ZLASTNAME IS NOT NULL);"' 2>/dev/null)
|
|
|
|
if [[ -z "$ab_data" ]]; then
|
|
die "No contacts found in Mac AddressBook"
|
|
fi
|
|
|
|
# Build JSON
|
|
local contacts_json
|
|
contacts_json=$(echo "$ab_data" | python3 -c "
|
|
import sys, json, re
|
|
|
|
contacts = []
|
|
seen = set()
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
parts = line.split('|')
|
|
if len(parts) < 3:
|
|
continue
|
|
first = parts[0].strip()
|
|
last = parts[1].strip()
|
|
phone = parts[2].strip()
|
|
name = (first + ' ' + last).strip()
|
|
if not name or not phone:
|
|
continue
|
|
phone_clean = re.sub(r'[\s\-\(\)\.]', '', phone)
|
|
if not phone_clean.startswith('+'):
|
|
if phone_clean.startswith('1') and len(phone_clean) == 11:
|
|
phone_clean = '+' + phone_clean
|
|
elif len(phone_clean) == 10:
|
|
phone_clean = '+1' + phone_clean
|
|
else:
|
|
phone_clean = '+' + phone_clean
|
|
key = phone_clean
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
contacts.append({'phoneNumber': phone_clean, 'displayName': name})
|
|
|
|
print(json.dumps({'contacts': contacts}))
|
|
")
|
|
|
|
local contact_count
|
|
contact_count=$(echo "$contacts_json" | jq '.contacts | length')
|
|
echo -e "${DIM}Found ${contact_count} contacts with names${RESET}"
|
|
|
|
# Sync to server
|
|
echo -e "${DIM}Syncing to server...${RESET}"
|
|
local result
|
|
result=$(api_post "/api/sync/contacts" "$contacts_json" "$token" 30)
|
|
local synced
|
|
synced=$(echo "$result" | jq '.data.synced')
|
|
|
|
echo -e "\n${GREEN}Synced ${synced} contacts with human names${RESET}"
|
|
}
|
|
|
|
# ─── named: list conversations with resolved human names ────────────────────
|
|
cmd_named() {
|
|
local limit="40"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--limit|-n) limit="$2"; shift 2 ;;
|
|
--all|-a) limit="999"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
# Fetch conversations and contacts
|
|
local convos
|
|
convos=$(api_get "/api/conversations?limit=500" 30)
|
|
local contacts
|
|
contacts=$(api_get "/api/contacts")
|
|
|
|
# Build contact lookup and join with conversations
|
|
echo "$convos" | jq --argjson contacts "$(echo "$contacts" | jq '.data')" --argjson limit "$limit" '
|
|
# Build phone→name lookup from contacts
|
|
($contacts | map(select(.phoneNumber != null)) | map({(.phoneNumber): .displayName}) | add // {}) as $lookup |
|
|
($contacts | map(select(.id != null)) | map({(.id): .displayName}) | add // {}) as $id_lookup |
|
|
|
|
.data |
|
|
sort_by(.lastMessageAt) | reverse |
|
|
.[:$limit] |
|
|
map({
|
|
id: .id,
|
|
phone: .displayName,
|
|
name: (
|
|
# Try participant ID lookup first
|
|
(.participantIds[0] as $pid | $id_lookup[$pid]) //
|
|
# Then phone lookup
|
|
($lookup[.displayName]) //
|
|
# Fallback to display name
|
|
.displayName
|
|
),
|
|
messages: .messageCount,
|
|
lastMessage: .lastMessageAt,
|
|
isGroup: .isGroup
|
|
})
|
|
' | jq -r '.[] | [
|
|
.name,
|
|
(.phone // "—"),
|
|
(.messages | tostring),
|
|
(.lastMessage // "—"),
|
|
(if .isGroup then "group" else "1:1" end),
|
|
.id
|
|
] | @tsv' | while IFS=$'\t' read -r name phone msgs last_msg type id; do
|
|
local time_str=""
|
|
if [[ "$last_msg" != "—" ]]; then
|
|
time_str=$(date -d "$last_msg" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$last_msg")
|
|
fi
|
|
|
|
# Highlight if name differs from phone (resolved)
|
|
if [[ "$name" != "$phone" && "$name" != "—" ]]; then
|
|
printf " ${BOLD}%-25s${RESET} ${DIM}%-16s${RESET} %4s msgs ${DIM}%s${RESET}\n" "$name" "$phone" "$msgs" "$time_str"
|
|
else
|
|
printf " %-25s ${DIM}%-16s${RESET} %4s msgs ${DIM}%s${RESET}\n" "$phone" "$type" "$msgs" "$time_str"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ─── stats: show server statistics ──────────────────────────────────────────
|
|
cmd_stats() {
|
|
# Fetch all conversations
|
|
local all_convos
|
|
all_convos=$(api_get "/api/conversations?limit=500" 30)
|
|
local convo_count
|
|
convo_count=$(echo "$all_convos" | jq '.data | length')
|
|
|
|
local total_messages
|
|
total_messages=$(echo "$all_convos" | jq '[.data[].messageCount] | add // 0')
|
|
|
|
local latest_msg
|
|
latest_msg=$(echo "$all_convos" | jq -r '[.data[].lastMessageAt] | sort | reverse | .[0] // "none"')
|
|
|
|
local oldest_msg
|
|
oldest_msg=$(echo "$all_convos" | jq -r '[.data[].lastMessageAt] | sort | .[0] // "none"')
|
|
|
|
# Contacts
|
|
local contacts
|
|
contacts=$(api_get "/api/contacts")
|
|
local contact_total
|
|
contact_total=$(echo "$contacts" | jq '.meta.total // 0')
|
|
|
|
# Processing stats
|
|
local proc_stats
|
|
proc_stats=$(api_get "/api/processing/stats")
|
|
local proc_total proc_done proc_pending
|
|
proc_total=$(echo "$proc_stats" | jq '.data.total // 0')
|
|
proc_done=$(echo "$proc_stats" | jq '.data.processed // 0')
|
|
proc_pending=$(echo "$proc_stats" | jq '.data.unprocessed // 0')
|
|
|
|
echo -e "${BOLD}Server Statistics:${RESET}\n"
|
|
echo -e " Conversations: ${BOLD}${convo_count}${RESET}"
|
|
echo -e " Messages: ${BOLD}${total_messages}${RESET}"
|
|
echo -e " Contacts: ${BOLD}${contact_total}${RESET}"
|
|
echo -e " Latest message: ${latest_msg}"
|
|
echo -e " Oldest message: ${oldest_msg}"
|
|
echo -e ""
|
|
echo -e " ${BOLD}Processing:${RESET}"
|
|
echo -e " Processed: ${GREEN}${proc_done}${RESET} / ${proc_total}"
|
|
echo -e " Pending: ${YELLOW}${proc_pending}${RESET}"
|
|
echo -e ""
|
|
echo -e " API: ${API_BASE}"
|
|
}
|
|
|
|
# ─── main ───────────────────────────────────────────────────────────────────
|
|
require_jq
|
|
|
|
cmd="${1:-help}"
|
|
shift || true
|
|
|
|
case "$cmd" in
|
|
search) cmd_search "$@" ;;
|
|
conversations) cmd_conversations "$@" ;;
|
|
read) cmd_read "$@" ;;
|
|
messages) cmd_messages "$@" ;;
|
|
chat) cmd_chat "$@" ;;
|
|
stats) cmd_stats ;;
|
|
process) cmd_process "$@" ;;
|
|
process-stats) cmd_process_stats ;;
|
|
contacts-update) cmd_contacts_update ;;
|
|
named) cmd_named "$@" ;;
|
|
help|--help|-h)
|
|
echo -e "${BOLD}convo${RESET} — CLI for querying conversation-assistant data\n"
|
|
echo "Usage:"
|
|
echo " convo search <name> Search contacts by name"
|
|
echo " convo conversations [contact-id] List conversations (optionally filter by contact)"
|
|
echo " convo read <conv-id> [--limit N] Read messages from a conversation"
|
|
echo " convo messages <query> Search messages (semantic search)"
|
|
echo " convo chat <name> [--all] Quick: find contact → show recent messages"
|
|
echo " convo stats Show server statistics + processing progress"
|
|
echo ""
|
|
echo "Admin:"
|
|
echo " convo process [--limit N|--all] Trigger server-side message text extraction"
|
|
echo " convo process-stats Show message processing progress"
|
|
echo " convo contacts-update Sync contact names from Mac AddressBook"
|
|
echo " convo named [--limit N|--all] List conversations with resolved human names"
|
|
echo ""
|
|
echo "Environment:"
|
|
echo " CONVO_API_BASE Override API URL (default: https://conversations.nasty.sh)"
|
|
echo " CONVO_MAC_HOST Override Mac SSH host (default: plum)"
|
|
;;
|
|
*)
|
|
die "Unknown command: ${cmd}. Run 'convo help' for usage."
|
|
;;
|
|
esac
|