Parsers and commands live in internal/chats. bin/chats/{sync,import,facts,apply}.go
are tagged shebang mains. Brain ingest is not a chats command.
318 lines
7.7 KiB
Go
318 lines
7.7 KiB
Go
package chats
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
type ooContact struct {
|
|
ID int `json:"id"`
|
|
DisplayName string `json:"displayName"`
|
|
FirstName string `json:"firstName"`
|
|
LastName string `json:"lastName"`
|
|
About string `json:"about"`
|
|
CommonData []struct {
|
|
InfoType int `json:"infoType"`
|
|
Data string `json:"data"`
|
|
Category string `json:"categoryName"`
|
|
} `json:"commonData"`
|
|
}
|
|
|
|
func RunApply(args []string) int {
|
|
fs := flag.NewFlagSet("chats apply", flag.ContinueOnError)
|
|
dryRun := fs.Bool("dry-run", false, "show what would be done without writing")
|
|
help := fs.Bool("help", false, "")
|
|
fs.SetOutput(os.Stderr)
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
if *help {
|
|
fmt.Fprintln(os.Stderr, "usage: chats apply [--dry-run]")
|
|
return 0
|
|
}
|
|
|
|
ooCLI := findOO()
|
|
if ooCLI == "" {
|
|
fmt.Fprintln(os.Stderr, "chats apply: oo CLI not found; set OO_CLI or install go-onlyoffice")
|
|
return 1
|
|
}
|
|
|
|
facts, err := loadFacts()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "chats apply: %v\n", err)
|
|
return 1
|
|
}
|
|
if len(facts) == 0 {
|
|
fmt.Println("chats apply: no facts to process")
|
|
return 0
|
|
}
|
|
|
|
phoneFacts := filterFacts(facts, "phone")
|
|
emailFacts := filterFacts(facts, "email")
|
|
|
|
phoneFacts = dedupeFacts(phoneFacts)
|
|
emailFacts = dedupeFacts(emailFacts)
|
|
|
|
type resolvedFact struct {
|
|
Fact ExtractedFact
|
|
OoID int
|
|
OoName string
|
|
Action string // "info-add" or "persons-create"
|
|
}
|
|
|
|
var resolved []resolvedFact
|
|
|
|
for _, f := range phoneFacts {
|
|
contact, err := searchContact(ooCLI, f.ChatName)
|
|
if err != nil || contact == nil {
|
|
fmt.Printf(" ✗ %s: phone %s — not found in CRM\n", f.ChatName, f.Value)
|
|
resolved = append(resolved, resolvedFact{Fact: f, Action: "persons-create"})
|
|
continue
|
|
}
|
|
hasPhone := false
|
|
for _, d := range contact.CommonData {
|
|
if d.InfoType == 2 {
|
|
hasPhone = true
|
|
break
|
|
}
|
|
}
|
|
if hasPhone {
|
|
fmt.Printf(" ✓ %s (ID %d): phone %s — already has phone, skip\n", contact.DisplayName, contact.ID, f.Value)
|
|
continue
|
|
}
|
|
fmt.Printf(" → %s (ID %d): add phone %s\n", contact.DisplayName, contact.ID, f.Value)
|
|
resolved = append(resolved, resolvedFact{
|
|
Fact: f, OoID: contact.ID, OoName: contact.DisplayName, Action: "info-add",
|
|
})
|
|
}
|
|
|
|
for _, f := range emailFacts {
|
|
if strings.EqualFold(f.Value, envVar("ONLYOFFICE_USER", "")) ||
|
|
strings.EqualFold(f.Value, envVar("OO_USER", "")) ||
|
|
strings.EqualFold(f.Value, os.Getenv("EMAIL")) {
|
|
continue
|
|
}
|
|
contact, err := searchContact(ooCLI, f.ChatName)
|
|
if err != nil || contact == nil {
|
|
fmt.Printf(" ✗ %s: email %s — not found in CRM\n", f.ChatName, f.Value)
|
|
resolved = append(resolved, resolvedFact{Fact: f, Action: "persons-create"})
|
|
continue
|
|
}
|
|
hasEmail := false
|
|
for _, d := range contact.CommonData {
|
|
if d.InfoType == 1 && d.Data == f.Value {
|
|
hasEmail = true
|
|
break
|
|
}
|
|
}
|
|
if hasEmail {
|
|
fmt.Printf(" ✓ %s (ID %d): email %s — already exists\n", contact.DisplayName, contact.ID, f.Value)
|
|
continue
|
|
}
|
|
fmt.Printf(" → %s (ID %d): add email %s\n", contact.DisplayName, contact.ID, f.Value)
|
|
resolved = append(resolved, resolvedFact{
|
|
Fact: f, OoID: contact.ID, OoName: contact.DisplayName, Action: "info-add",
|
|
})
|
|
}
|
|
|
|
if len(resolved) == 0 {
|
|
fmt.Println("chats apply: nothing to apply")
|
|
return 0
|
|
}
|
|
|
|
fmt.Printf("\nchats apply: %d actions to apply\n", len(resolved))
|
|
|
|
if *dryRun {
|
|
for _, r := range resolved {
|
|
switch r.Action {
|
|
case "info-add":
|
|
infoType := "Phone"
|
|
if r.Fact.FactType == "email" {
|
|
infoType = "Email"
|
|
}
|
|
fmt.Printf(" [dry-run] oo contacts info-add %d --type %s --value %s\n",
|
|
r.OoID, infoType, r.Fact.Value)
|
|
case "persons-create":
|
|
fmt.Printf(" [dry-run] oo persons create --first %q --about %q\n",
|
|
r.Fact.ChatName, "Contact from Telegram chat")
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
success := 0
|
|
failed := 0
|
|
for _, r := range resolved {
|
|
switch r.Action {
|
|
case "info-add":
|
|
infoType := "Phone"
|
|
if r.Fact.FactType == "email" {
|
|
infoType = "Email"
|
|
}
|
|
if err := ooInfoAdd(ooCLI, r.OoID, infoType, r.Fact.Value); err != nil {
|
|
fmt.Fprintf(os.Stderr, " ✗ info-add %s: %v\n", r.Fact.Value, err)
|
|
failed++
|
|
} else {
|
|
fmt.Printf(" ✓ %s → %s (ID %d)\n", r.Fact.Value, r.OoName, r.OoID)
|
|
success++
|
|
}
|
|
case "persons-create":
|
|
fmt.Printf(" - create %s (skipped — needs review)\n", r.Fact.ChatName)
|
|
success++
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nchats apply: %d succeeded, %d failed\n", success, failed)
|
|
if failed > 0 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func loadFacts() ([]ExtractedFact, error) {
|
|
factsPath := filepath.Join(Dir(), "facts", "chat-facts.json")
|
|
data, err := os.ReadFile(factsPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("no facts at %s; run 'chats facts' first", factsPath)
|
|
}
|
|
return nil, fmt.Errorf("read facts: %w", err)
|
|
}
|
|
var facts []ExtractedFact
|
|
if err := json.Unmarshal(data, &facts); err != nil {
|
|
return nil, fmt.Errorf("parse facts: %w", err)
|
|
}
|
|
return facts, nil
|
|
}
|
|
|
|
func dedupeFacts(facts []ExtractedFact) []ExtractedFact {
|
|
seen := make(map[string]bool)
|
|
var result []ExtractedFact
|
|
for _, f := range facts {
|
|
norm := normalizePhone(f.Value)
|
|
key := f.ChatName + ":" + factTypeKey(f.FactType) + ":" + norm
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
f.Value = norm
|
|
result = append(result, f)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizePhone(s string) string {
|
|
var digits []rune
|
|
for _, r := range s {
|
|
if r >= '0' && r <= '9' {
|
|
digits = append(digits, r)
|
|
}
|
|
}
|
|
if len(digits) > 0 {
|
|
return string(digits)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func factTypeKey(t string) string {
|
|
switch t {
|
|
case "phone":
|
|
return "p"
|
|
case "email":
|
|
return "e"
|
|
default:
|
|
return t
|
|
}
|
|
}
|
|
|
|
func findOO() string {
|
|
if v := os.Getenv("OO_CLI"); v != "" {
|
|
if _, err := os.Stat(v); err == nil {
|
|
return v
|
|
}
|
|
}
|
|
candidates := []string{
|
|
filepath.Join(os.Getenv("HOME"), "go", "bin", "oo"),
|
|
}
|
|
for _, c := range candidates {
|
|
if _, err := os.Stat(c); err == nil {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func searchContact(ooCLI, name string) (*ooContact, error) {
|
|
query := name
|
|
// Try full name first
|
|
if c, _ := searchByQuery(ooCLI, query); c != nil {
|
|
return c, nil
|
|
}
|
|
// Try first word
|
|
firstWord := strings.Fields(name)[0]
|
|
if firstWord != name {
|
|
if c, _ := searchByQuery(ooCLI, firstWord); c != nil {
|
|
return c, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func searchByQuery(ooCLI, query string) (*ooContact, error) {
|
|
cmd := exec.Command(ooCLI, "persons", "list", "--search", query, "-o", "json")
|
|
var outBuf, errBuf bytes.Buffer
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
cmd.Env = os.Environ()
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("oo persons list: %w\n%s", err, errBuf.String())
|
|
}
|
|
var contacts []ooContact
|
|
if err := json.Unmarshal(outBuf.Bytes(), &contacts); err != nil {
|
|
return nil, nil
|
|
}
|
|
for _, c := range contacts {
|
|
lower := strings.ToLower(c.DisplayName)
|
|
lowerQuery := strings.ToLower(query)
|
|
if strings.EqualFold(c.DisplayName, query) ||
|
|
strings.Contains(lower, lowerQuery) ||
|
|
strings.Contains(lowerQuery, strings.ToLower(c.FirstName)) {
|
|
return &c, nil
|
|
}
|
|
for _, d := range c.CommonData {
|
|
if d.InfoType == 1 && strings.Contains(strings.ToLower(d.Data), lowerQuery) {
|
|
return &c, nil
|
|
}
|
|
}
|
|
}
|
|
if len(contacts) > 0 {
|
|
return &contacts[0], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func ooInfoAdd(ooCLI string, contactID int, infoType, value string) error {
|
|
cmd := exec.Command(ooCLI, "contacts", "info-add",
|
|
fmt.Sprintf("%d", contactID),
|
|
"--type", infoType,
|
|
"--value", value,
|
|
"--category", "Work",
|
|
"-o", "json",
|
|
)
|
|
var outBuf, errBuf bytes.Buffer
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
cmd.Env = os.Environ()
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("info-add: %w\n%s", err, errBuf.String())
|
|
}
|
|
return nil
|
|
}
|