bin/chats: Phase 1 MVP — Telegram sync/import/index/facts/apply

- bin/chats/ — nested Go module (как bin/kbsearch/)
  - sync telegram — MCP JSON-RPC клиент, 31 личный чат, 922 сообщения
  - import — конвертация JSONL → MD с YAML frontmatter
  - index — делегирует bin/kb/index --corpus (132 leafs в brain)
  - facts — regex extraction phone/email/linkedin с валидацией
    (исключены: даты, суммы, номера карт, инвойсы)
  - apply — oo CLI cross-check + dry-run
- Source interface для будущих WhatsApp/LinkedIn
- 4 system tests (import, facts, empty, roundtrip) — синтетические данные
- bin/chat — build+exec wrapper
- docs/chat-import-plan.md — прогресс, пути к env (без секретов)

Безопасность: var/ в gitignore, credentials в env, тесты без реальных данных.
This commit is contained in:
2026-08-12 23:59:29 +01:00
parent a7cb8d4c76
commit 27d9521e7f
13 changed files with 2447 additions and 0 deletions
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# bin/chats - sync, import, index, facts, apply for Telegram/WhatsApp/LinkedIn.
# Builds the chats binary on first run / when source changes, then execs it.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BIN="$ROOT/var/bin/chats"
SRC="$ROOT/bin/chats"
mkdir -p "$ROOT/var/bin"
need_build=0
if [ ! -x "$BIN" ]; then
need_build=1
else
while IFS= read -r -d '' f; do
if [ "$f" -nt "$BIN" ]; then
need_build=1
break
fi
done < <(find "$SRC" -name '*.go' -print0 2>/dev/null)
fi
if [ "$need_build" -eq 1 ]; then
echo "Building chats..." >&2
(cd "$SRC" && go build -o "$BIN" .) || exit 1
fi
exec "$BIN" "$@"
+318
View File
@@ -0,0 +1,318 @@
package main
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(chatsDir(), "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"),
"/home/ano/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
}
+202
View File
@@ -0,0 +1,202 @@
// System tests for bin/chats.
//
// These are integration tests using real data and real Telegram API (when
// credentials are available). They follow the TDD workflow pattern:
// sync → import → facts → verify.
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// TestChatsImport validates JSONL → MD conversion with a synthetic fixture.
func TestChatsImport(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "var", "chats")
chatDir := filepath.Join(root, "telegram", "test_user_123")
if err := os.MkdirAll(chatDir, 0755); err != nil {
t.Fatal(err)
}
jsonlPath := filepath.Join(chatDir, "messages.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
enc := json.NewEncoder(f)
messages := []Message{
{ID: "tg_1", Timestamp: "2026-01-15T10:30:00Z", From: "Alice", Text: "Hello!", Platform: "telegram"},
{ID: "tg_2", Timestamp: "2026-01-15T10:31:00Z", From: "Bob", Text: "Hi Alice, my phone is +34 612 345 678", Platform: "telegram"},
{ID: "tg_3", Timestamp: "2026-01-15T10:32:00Z", From: "Alice", Text: "Check my LinkedIn: https://linkedin.com/in/alice-test", Platform: "telegram"},
{ID: "tg_4", Timestamp: "2026-01-15T10:33:00Z", From: "Bob", Text: "My email is bob@example.com, working on Project X", Platform: "telegram"},
}
for _, m := range messages {
if err := enc.Encode(m); err != nil {
t.Fatal(err)
}
}
f.Close()
cwd, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(cwd) })
t.Setenv("KB_ROOT", dir)
exitCode := runImport([]string{})
if exitCode != 0 {
t.Fatalf("import exit code %d", exitCode)
}
mdGlob := filepath.Join(root, "md", "telegram", "*", "messages.md")
matches, err := filepath.Glob(mdGlob)
if err != nil {
t.Fatal(err)
}
if len(matches) == 0 {
t.Fatal("no markdown files created by import")
}
mdData, err := os.ReadFile(matches[0])
if err != nil {
t.Fatal(err)
}
content := string(mdData)
if !strings.Contains(content, "Alice") {
t.Error("markdown missing sender name 'Alice'")
}
if !strings.Contains(content, "2026-01-15") {
t.Error("markdown missing date")
}
if !strings.Contains(content, "---") {
t.Error("markdown missing YAML frontmatter")
}
}
// TestChatsFacts validates fact extraction from JSONL fixture.
func TestChatsFacts(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "var", "chats")
chatDir := filepath.Join(root, "telegram", "test_user_facts")
if err := os.MkdirAll(chatDir, 0755); err != nil {
t.Fatal(err)
}
jsonlPath := filepath.Join(chatDir, "messages.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
enc := json.NewEncoder(f)
messages := []Message{
{ID: "tg_10", Timestamp: "2026-06-01T12:00:00Z", From: "Charlie", Text: "Call me at +1 555 123 4567", Platform: "telegram"},
{ID: "tg_11", Timestamp: "2026-06-01T12:01:00Z", From: "Charlie", Text: "My LinkedIn is linkedin.com/in/charlie-dev", Platform: "telegram"},
{ID: "tg_12", Timestamp: "2026-06-01T12:02:00Z", From: "Charlie", Text: "Email: charlie@dev.com", Platform: "telegram"},
{ID: "tg_13", Timestamp: "2026-06-01T12:03:00Z", From: "Charlie", Text: "I work at Acme Corp on Project Mercury", Platform: "telegram"},
}
for _, m := range messages {
if err := enc.Encode(m); err != nil {
t.Fatal(err)
}
}
f.Close()
facts, _ := extractFacts(jsonlPath, "test_user_facts")
if len(facts) == 0 {
t.Fatal("expected facts, got none")
}
types := make(map[string]int)
for _, f := range facts {
types[f.FactType]++
}
if types["phone"] < 1 {
t.Errorf("expected >=1 phone fact, got %d", types["phone"])
}
if types["email"] < 1 {
t.Errorf("expected >=1 email fact, got %d", types["email"])
}
if types["linkedin"] < 1 {
t.Errorf("expected >=1 linkedin fact, got %d", types["linkedin"])
}
if types["skill"] < 1 {
t.Errorf("expected >=1 skill fact, got %d", types["skill"])
}
}
// TestChatsImportEmptyDir tests that import handles no JSONL gracefully.
func TestChatsImportEmpty(t *testing.T) {
dir := t.TempDir()
cwd, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(cwd) })
t.Setenv("KB_ROOT", dir)
exitCode := runImport([]string{})
if exitCode == 0 {
t.Fatal("expected non-zero exit for empty data dir")
}
}
// TestChatsRoundTrip creates a synthetic JSONL, imports it, then verifies
// the markdown structure is parseable and contains YAML frontmatter.
func TestChatsRoundTrip(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "var", "chats")
chatDir := filepath.Join(root, "telegram", "rt_user")
if err := os.MkdirAll(chatDir, 0755); err != nil {
t.Fatal(err)
}
jsonlPath := filepath.Join(chatDir, "messages.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatal(err)
}
enc := json.NewEncoder(f)
enc.Encode(Message{ID: "tg_100", Timestamp: "2026-07-01T08:00:00Z", From: "Diana", Text: "Hey", Platform: "telegram"})
enc.Encode(Message{ID: "tg_101", Timestamp: "2026-07-01T08:01:00Z", From: "Diana", Text: "How are you?", Platform: "telegram"})
f.Close()
cwd, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(cwd) })
t.Setenv("KB_ROOT", dir)
if code := runImport([]string{}); code != 0 {
t.Fatalf("import exit %d", code)
}
mdGlob := filepath.Join(root, "md", "telegram", "*", "messages.md")
matches, _ := filepath.Glob(mdGlob)
if len(matches) == 0 {
t.Fatal("no markdown produced")
}
data, err := os.ReadFile(matches[0])
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.HasPrefix(content, "---") {
t.Error("markdown should start with YAML frontmatter delimiter")
}
if !strings.Contains(content, "platform: telegram") {
t.Error("markdown should contain platform field")
}
if !strings.Contains(content, "message_count: 2") {
t.Error("markdown should contain correct message count")
}
if !strings.Contains(content, "Diana") {
t.Error("markdown should contain participants")
}
}
+316
View File
@@ -0,0 +1,316 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
var (
phoneRegex = regexp.MustCompile(`[+\d][\d\s\-()]{6,25}\d`)
dateRegex = regexp.MustCompile(`^\d{2,4}[-/]\d{1,2}[-/]\d{2,4}$`)
rangeRegex = regexp.MustCompile(`^\d+\s*[-]\s*\d+$`)
linkedinRegex = regexp.MustCompile(`linkedin\.com/in/[\w-]+`)
emailRegex = regexp.MustCompile(`[\w.+-]+@[\w-]+\.[\w.-]+`)
projectRegex = regexp.MustCompile(`(?i)project\s*[:/]\s*(.+)`)
dealRegex = regexp.MustCompile(`(?i)(deal|opportunity)\s*[:/]\s*(.+)`)
skillRegex = regexp.MustCompile(`(?i)(works?|worked|working)\s+(at|on|with)\s+([A-Z][\w\s]+)`)
)
func isValidPhone(s string) bool {
s = strings.TrimSpace(s)
s = strings.Trim(s, "+()-\t ")
if len(s) < 6 || len(s) > 25 {
return false
}
if dateRegex.MatchString(s) || rangeRegex.MatchString(s) {
return false
}
if strings.ContainsAny(s, "/abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
return false
}
if strings.Contains(s, "000") || strings.Contains(s, "500 ") || strings.Contains(s, "000 ") {
return false
}
digits := 0
for _, r := range s {
if r >= '0' && r <= '9' {
digits++
}
}
if digits < 7 || digits > 15 {
return false
}
// Card number pattern: 16 digits with possible spaces
if digits == 16 {
return false
}
// Date-like: 8 digits starting with 20xx or 19xx
if len(s) <= 8 && digits == 8 && (strings.HasPrefix(s, "20") || strings.HasPrefix(s, "19")) {
return false
}
// 11+ digits starting with 2 - unlikely phone
if digits >= 11 && strings.HasPrefix(s, "2") && !strings.HasPrefix(s, "+") {
return false
}
// Must start with + or be at least 7 digits
if !strings.HasPrefix(s, "+") && digits < 7 {
return false
}
return true
}
type ExtractedFact struct {
ChatID string `json:"chat_id"`
ChatName string `json:"chat_name"`
Platform string `json:"platform"`
FactType string `json:"fact_type"`
Value string `json:"value"`
Source string `json:"source"`
MessageID string `json:"message_id"`
}
func runFacts(args []string) int {
fs := flag.NewFlagSet("chats facts", flag.ContinueOnError)
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 facts")
return 0
}
root := chatsDir()
telegramDir := filepath.Join(root, "telegram")
entries, err := os.ReadDir(telegramDir)
if err != nil {
fmt.Fprintf(os.Stderr, "chats facts: read %s: %v\n", telegramDir, err)
return 1
}
var allFacts []ExtractedFact
for _, entry := range entries {
if !entry.IsDir() {
continue
}
chatID := entry.Name()
jsonlPath := filepath.Join(telegramDir, chatID, "messages.jsonl")
info, err := os.Stat(jsonlPath)
if err != nil {
continue
}
if info.Size() == 0 {
continue
}
facts, chatName := extractFacts(jsonlPath, chatID)
allFacts = append(allFacts, facts...)
_ = chatName
}
if len(allFacts) == 0 {
fmt.Println("chats facts: no facts extracted")
return 0
}
phoneFacts := filterFacts(allFacts, "phone")
emailFacts := filterFacts(allFacts, "email")
linkedinFacts := filterFacts(allFacts, "linkedin")
projectFacts := filterFacts(allFacts, "project")
skillFacts := filterFacts(allFacts, "skill")
fmt.Printf("chats facts: extracted %d facts (%d phone, %d email, %d linkedin, %d project, %d skill)\n",
len(allFacts), len(phoneFacts), len(emailFacts), len(linkedinFacts), len(projectFacts), len(skillFacts))
factsDir := filepath.Join(root, "facts")
if err := os.MkdirAll(factsDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "chats facts: mkdir %s: %v\n", factsDir, err)
return 1
}
factsPath := filepath.Join(factsDir, "chat-facts.json")
data, err := json.MarshalIndent(allFacts, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "chats facts: marshal: %v\n", err)
return 1
}
if err := os.WriteFile(factsPath, data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "chats facts: write %s: %v\n", factsPath, err)
return 1
}
fmt.Printf("chats facts: saved to %s\n", factsPath)
writeFactsToBrain(root, allFacts)
return 0
}
func extractFacts(jsonlPath, chatID string) ([]ExtractedFact, string) {
f, err := os.Open(jsonlPath)
if err != nil {
return nil, ""
}
defer f.Close()
var facts []ExtractedFact
chatName := ""
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1<<20), 1<<20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
continue
}
if chatName == "" && msg.From != "" {
chatName = msg.From
}
text := msg.Text
phones := phoneRegex.FindAllString(text, -1)
for _, p := range phones {
p = strings.TrimSpace(p)
p = strings.Trim(p, "()- \t")
if isValidPhone(p) {
facts = append(facts, ExtractedFact{
ChatID: chatID,
ChatName: chatName,
Platform: "telegram",
FactType: "phone",
Value: p,
Source: "chat:" + msg.ID,
MessageID: msg.ID,
})
}
}
emails := emailRegex.FindAllString(text, -1)
for _, e := range emails {
facts = append(facts, ExtractedFact{
ChatID: chatID,
ChatName: chatName,
Platform: "telegram",
FactType: "email",
Value: strings.ToLower(e),
Source: "chat:" + msg.ID,
MessageID: msg.ID,
})
}
linkedins := linkedinRegex.FindAllString(text, -1)
for _, l := range linkedins {
facts = append(facts, ExtractedFact{
ChatID: chatID,
ChatName: chatName,
Platform: "telegram",
FactType: "linkedin",
Value: "https://" + l,
Source: "chat:" + msg.ID,
MessageID: msg.ID,
})
}
if matches := projectRegex.FindStringSubmatch(text); len(matches) > 1 {
facts = append(facts, ExtractedFact{
ChatID: chatID,
ChatName: chatName,
Platform: "telegram",
FactType: "project",
Value: strings.TrimSpace(matches[1]),
Source: "chat:" + msg.ID,
MessageID: msg.ID,
})
}
if matches := dealRegex.FindStringSubmatch(text); len(matches) > 2 {
facts = append(facts, ExtractedFact{
ChatID: chatID,
ChatName: chatName,
Platform: "telegram",
FactType: "deal",
Value: strings.TrimSpace(matches[2]),
Source: "chat:" + msg.ID,
MessageID: msg.ID,
})
}
if matches := skillRegex.FindStringSubmatch(text); len(matches) > 3 {
facts = append(facts, ExtractedFact{
ChatID: chatID,
ChatName: chatName,
Platform: "telegram",
FactType: "skill",
Value: strings.TrimSpace(matches[0]),
Source: "chat:" + msg.ID,
MessageID: msg.ID,
})
}
}
return facts, chatName
}
func filterFacts(facts []ExtractedFact, factType string) []ExtractedFact {
var result []ExtractedFact
for _, f := range facts {
if f.FactType == factType {
result = append(result, f)
}
}
return result
}
func writeFactsToBrain(root string, facts []ExtractedFact) {
indexScript := filepath.Join(root, "bin", "kb", "index")
if _, err := os.Stat(indexScript); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "chats facts: kb/index not found, skipping brain write\n")
return
}
mdDir := filepath.Join(chatsDir(), "facts")
if err := os.MkdirAll(mdDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "chats facts: mkdir %s: %v\n", mdDir, err)
return
}
var sb strings.Builder
sb.WriteString("---\n")
sb.WriteString("root: facts\n")
sb.WriteString("---\n\n")
sb.WriteString("# Chat-Derived Facts\n\n")
for _, f := range facts {
sb.WriteString(fmt.Sprintf("- **%s**: %s (source: %s, chat: %s)\n",
f.FactType, f.Value, f.Source, f.ChatName))
}
sb.WriteString("\n")
factsMD := filepath.Join(mdDir, "chat-facts.md")
if err := os.WriteFile(factsMD, []byte(sb.String()), 0644); err != nil {
fmt.Fprintf(os.Stderr, "chats facts: write %s: %v\n", factsMD, err)
return
}
cmd := exec.Command(indexScript, "--corpus", mdDir, "--skip-indexes")
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
cmd.Dir = root
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "chats facts: brain index: %v\n%s", err, errBuf.String())
return
}
fmt.Printf("chats facts: written to brain (%s)\n", strings.TrimSpace(outBuf.String()))
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/eSlider/2dph/bin/chats
go 1.25.0
View File
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"html"
"os"
"path/filepath"
"sort"
"strings"
)
func runImport(args []string) int {
fs := flag.NewFlagSet("chats import", flag.ContinueOnError)
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 import")
return 0
}
root := chatsDir()
mdRoot := filepath.Join(root, "md")
glob := filepath.Join(root, "telegram", "*", "messages.jsonl")
matches, err := filepath.Glob(glob)
if err != nil {
fmt.Fprintf(os.Stderr, "chats import: glob %s: %v\n", glob, err)
return 1
}
if len(matches) == 0 {
fmt.Fprintf(os.Stderr, "chats import: no messages.jsonl found under %s\n", root)
return 1
}
written := 0
failed := 0
for _, jsonlPath := range matches {
chatID := filepath.Base(filepath.Dir(jsonlPath))
messages, chatName, err := readJSONL(jsonlPath)
if err != nil {
fmt.Fprintf(os.Stderr, "chats import: read %s: %v\n", jsonlPath, err)
failed++
continue
}
if len(messages) == 0 {
continue
}
if chatName == "" {
chatName = chatID
}
participants := collectParticipants(messages)
chatType := "personal"
if len(participants) > 3 {
chatType = "group"
}
firstID := ""
if len(messages) > 0 {
firstID = messages[0].ID
}
var b bytes.Buffer
b.WriteString("---\n")
fmt.Fprintf(&b, "id: %s\n", firstID)
fmt.Fprintf(&b, "platform: telegram\n")
fmt.Fprintf(&b, "chat_id: %s\n", chatID)
fmt.Fprintf(&b, "chat_name: %s\n", escapeYAML(chatName))
fmt.Fprintf(&b, "participants: [")
for i, p := range participants {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(escapeYAML(p))
}
b.WriteString("]\n")
fmt.Fprintf(&b, "message_count: %d\n", len(messages))
fmt.Fprintf(&b, "type: %s\n", chatType)
b.WriteString("---\n\n")
fmt.Fprintf(&b, "# Чат с %s\n\n", chatName)
for _, msg := range messages {
ts := msg.Timestamp
if len(ts) > 10 {
ts = ts[:10]
}
text := msg.Text
text = html.UnescapeString(text)
text = strings.ReplaceAll(text, "\n", "\n ")
line := fmt.Sprintf("**%s** — %s: %s", ts, msg.From, text)
if msg.Media != nil {
line += " *(" + *msg.Media + ")*"
}
b.WriteString(line + "\n\n")
}
mdFile := filepath.Join(mdRoot, "telegram", sanitizeDir(chatName), "messages.md")
if err := os.MkdirAll(filepath.Dir(mdFile), 0755); err != nil {
fmt.Fprintf(os.Stderr, "chats import: mkdir %s: %v\n", filepath.Dir(mdFile), err)
failed++
continue
}
if err := os.WriteFile(mdFile, b.Bytes(), 0644); err != nil {
fmt.Fprintf(os.Stderr, "chats import: write %s: %v\n", mdFile, err)
failed++
continue
}
written++
}
fmt.Printf("chats import: %d chats written", written)
if failed > 0 {
fmt.Printf(", %d failed", failed)
}
fmt.Println()
if failed > 0 {
return 1
}
return 0
}
func readJSONL(path string) ([]Message, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
defer f.Close()
var messages []Message
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1<<20), 1<<20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
continue
}
messages = append(messages, msg)
}
if err := scanner.Err(); err != nil {
return messages, "", err
}
chatName := ""
if len(messages) > 0 {
nameCounts := make(map[string]int)
for _, msg := range messages {
nameCounts[msg.From]++
}
best := ""
bestN := 0
for name, n := range nameCounts {
if name != "" && name != "unknown" && n > bestN {
best = name
bestN = n
}
}
if best != "" {
chatName = best
}
}
return messages, chatName, nil
}
func collectParticipants(messages []Message) []string {
seen := make(map[string]bool)
var result []string
for _, msg := range messages {
if msg.From == "" || seen[msg.From] {
continue
}
seen[msg.From] = true
result = append(result, msg.From)
}
sort.Strings(result)
return result
}
func escapeYAML(s string) string {
if strings.ContainsAny(s, ":#,[]{}'\"") || strings.HasPrefix(s, "-") {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
return s
}
func sanitizeDir(name string) string {
r := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_",
"?", "_", "\"", "_", "<", "_", ">", "_", "|", "_",
" ", "_",
)
return strings.TrimSpace(r.Replace(name))
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"bytes"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func runIndex(args []string) int {
fs := flag.NewFlagSet("chats index", flag.ContinueOnError)
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 index")
return 0
}
root := repoRoot()
mdDir := filepath.Join(chatsDir(), "md")
_, err := os.Stat(mdDir)
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "chats index: no chat markdown at %s; run 'chats import' first\n", mdDir)
return 1
}
indexScript := filepath.Join(root, "bin", "kb", "index")
if _, err := os.Stat(indexScript); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "chats index: %s not found\n", indexScript)
return 1
}
cmd := exec.Command(indexScript, "--corpus", mdDir)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
cmd.Dir = root
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "chats index: %v\n%s", err, errBuf.String())
return 1
}
result := strings.TrimSpace(outBuf.String())
if result == "" {
result = strings.TrimSpace(errBuf.String())
}
fmt.Printf("chats index: %s\n", result)
return 0
}
+116
View File
@@ -0,0 +1,116 @@
// bin/chats - sync, import, index, extract facts, and apply chat data
// from Telegram, WhatsApp, LinkedIn into the brain and OnlyOffice CRM.
//
// Usage:
//
// chats sync telegram [--limit N] [--since DATE] [--phone PHONE]
// chats sync whatsapp [--qr] [--limit N]
// chats sync linkedin [--limit N]
// chats import # JSONL → MD (all sources)
// chats index # rebuild var/kb.lbug with chats
// chats facts # extract + cross-check
// chats apply [--dry-run] # push to OnlyOffice CRM
package main
import (
"fmt"
"os"
"strings"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "sync":
if len(args) < 1 {
usage()
os.Exit(2)
}
platform := args[0]
platformArgs := args[1:]
switch platform {
case "telegram":
os.Exit(runSyncTelegram(platformArgs))
case "whatsapp":
fmt.Fprintf(os.Stderr, "chats: WhatsApp not implemented yet\n")
os.Exit(1)
case "linkedin":
fmt.Fprintf(os.Stderr, "chats: LinkedIn not implemented yet\n")
os.Exit(1)
default:
fmt.Fprintf(os.Stderr, "chats: unknown platform %q\n", platform)
os.Exit(2)
}
case "import":
os.Exit(runImport(args))
case "index":
os.Exit(runIndex(args))
case "facts":
os.Exit(runFacts(args))
case "apply":
os.Exit(runApply(args))
case "help", "-h", "--help":
usage()
return
default:
fmt.Fprintf(os.Stderr, "chats: unknown command %q\n", cmd)
usage()
os.Exit(2)
}
}
func usage() {
w := os.Stderr
fmt.Fprintln(w, `Usage: chats <command> [args]
Commands:
sync telegram [--limit N] [--since DATE] [--phone PHONE]
sync whatsapp [--qr] [--limit N]
sync linkedin [--limit N]
import JSONL → MD (all sources)
index rebuild var/kb.lbug with chats
facts extract + cross-check facts
apply [--dry-run] push to OnlyOffice CRM
Output layout:
var/chats/<platform>/<chat_id>/messages.jsonl
var/chats/md/<platform>/<chat_name>/messages.md`)
}
// repoRoot locates the 2dph project root by walking up from the binary.
func repoRoot() string {
if v := os.Getenv("KB_ROOT"); v != "" {
return v
}
wd, err := os.Getwd()
if err != nil {
return "."
}
for i := 0; i < 10; i++ {
if _, err := os.Stat(wd + "/var"); err == nil {
return wd
}
if _, err := os.Stat(wd + "/.git"); err == nil {
return wd
}
parent := wd
if idx := strings.LastIndex(wd, "/"); idx >= 0 {
parent = wd[:idx]
}
if parent == wd {
break
}
wd = parent
}
return "."
}
// chatsDir returns var/chats under the repo root.
func chatsDir() string {
return repoRoot() + "/var/chats"
}
+424
View File
@@ -0,0 +1,424 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type MCPClient struct {
cmd *exec.Cmd
stdin *bufio.Writer
stdout *bufio.Scanner
msgID int
}
type mcpRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
type mcpResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
type mcpToolResult struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type ListChatsResult struct {
ChatID int64 `json:"chat_id"`
Title string `json:"name"`
Type string `json:"type"`
Username string `json:"username,omitempty"`
}
type listChatsEnvelope struct {
Results []ListChatsResult `json:"results"`
}
type historyEnvelope struct {
Results []GetHistoryResult `json:"results"`
}
type GetHistoryResult struct {
ID int `json:"id"`
Sender string `json:"sender"`
Date string `json:"date"`
Text string `json:"text"`
Media string `json:"media,omitempty"`
Out bool `json:"out,omitempty"`
}
func NewMCPClient(ctx context.Context, apiID int, apiHash, phone, sessionString, mcpDir string) (*MCPClient, error) {
env := os.Environ()
env = append(env,
fmt.Sprintf("TELEGRAM_API_ID=%d", apiID),
fmt.Sprintf("TELEGRAM_API_HASH=%s", apiHash),
fmt.Sprintf("TELEGRAM_PHONE=%s", phone),
fmt.Sprintf("TELEGRAM_SESSION_STRING=%s", sessionString),
"MCP_TRANSPORT=stdio",
)
serverPath := filepath.Join(mcpDir, ".venv", "bin", "python3")
mainPath := filepath.Join(mcpDir, "main.py")
cmd := exec.CommandContext(ctx, serverPath, mainPath)
cmd.Env = env
cmd.Dir = mcpDir
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start mcp: %w", err)
}
c := &MCPClient{
cmd: cmd,
stdin: bufio.NewWriter(stdin),
stdout: bufio.NewScanner(stdout),
msgID: 0,
}
c.stdout.Buffer(make([]byte, 1<<20), 1<<20)
if err := c.initialize(ctx); err != nil {
c.Close()
return nil, fmt.Errorf("initialize: %w", err)
}
return c, nil
}
func (c *MCPClient) nextID() int {
c.msgID++
return c.msgID
}
func (c *MCPClient) sendRequest(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
id := c.nextID()
req := mcpRequest{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: params,
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
if _, err := c.stdin.Write(body); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
if err := c.stdin.WriteByte('\n'); err != nil {
return nil, fmt.Errorf("write newline: %w", err)
}
if err := c.stdin.Flush(); err != nil {
return nil, fmt.Errorf("flush: %w", err)
}
for c.stdout.Scan() {
line := c.stdout.Text()
if line == "" {
continue
}
var resp mcpResponse
if err := json.Unmarshal([]byte(line), &resp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w\nline: %s", err, line[:min(len(line), 500)])
}
if resp.Error != nil {
return nil, fmt.Errorf("rpc error %d: %s", resp.Error.Code, resp.Error.Message)
}
return resp.Result, nil
}
return nil, fmt.Errorf("no response: %w", c.stdout.Err())
}
func (c *MCPClient) initialize(ctx context.Context) error {
params := map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{},
"clientInfo": map[string]string{
"name": "chats-sync",
"version": "0.1.0",
},
}
_, err := c.sendRequest(ctx, "initialize", params)
return err
}
func (c *MCPClient) ListChats(ctx context.Context, chatType string, limit int) ([]ListChatsResult, error) {
args := map[string]interface{}{
"chat_type": chatType,
"limit": limit,
}
result, err := c.sendRequest(ctx, "tools/call", map[string]interface{}{
"name": "list_chats",
"arguments": args,
})
if err != nil {
return nil, err
}
var toolRes mcpToolResult
if err := json.Unmarshal(result, &toolRes); err != nil {
return nil, fmt.Errorf("unmarshal tool result: %w", err)
}
if toolRes.IsError {
msg := "unknown"
if len(toolRes.Content) > 0 {
msg = toolRes.Content[0].Text
}
return nil, fmt.Errorf("list_chats error: %s", msg)
}
if len(toolRes.Content) == 0 {
return nil, nil
}
text := toolRes.Content[0].Text
if text == "" || text == "No chats found matching the criteria." {
return nil, nil
}
var env listChatsEnvelope
if err := json.Unmarshal([]byte(text), &env); err != nil {
var arr []ListChatsResult
if err2 := json.Unmarshal([]byte(text), &arr); err2 != nil {
return nil, fmt.Errorf("parse chats: %w (also tried array: %v)\nbody: %s", err, err2, text[:min(len(text), 500)])
}
return arr, nil
}
return env.Results, nil
}
func (c *MCPClient) GetHistory(ctx context.Context, chatID int64, limit int) ([]GetHistoryResult, error) {
args := map[string]interface{}{
"chat_id": chatID,
"limit": limit,
}
result, err := c.sendRequest(ctx, "tools/call", map[string]interface{}{
"name": "get_history",
"arguments": args,
})
if err != nil {
return nil, err
}
var toolRes mcpToolResult
if err := json.Unmarshal(result, &toolRes); err != nil {
return nil, fmt.Errorf("unmarshal tool result: %w", err)
}
if toolRes.IsError {
msg := "unknown"
if len(toolRes.Content) > 0 {
msg = toolRes.Content[0].Text
}
return nil, fmt.Errorf("get_history error: %s", msg)
}
if len(toolRes.Content) == 0 {
return nil, nil
}
text := toolRes.Content[0].Text
if text == "" || text == "No messages found for this page." || text == "No messages found matching the criteria." {
return nil, nil
}
var env historyEnvelope
if err := json.Unmarshal([]byte(text), &env); err != nil {
var arr []GetHistoryResult
if err2 := json.Unmarshal([]byte(text), &arr); err2 != nil {
return nil, fmt.Errorf("parse history: %w (also tried array: %v)\nbody: %s", err, err2, text[:min(len(text), 500)])
}
return arr, nil
}
return env.Results, nil
}
func (c *MCPClient) Close() error {
if c.stdin != nil {
c.stdin.Flush()
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
return nil
}
type TelegramMCPSource struct {
mcpDir string
apiID int
apiHash string
phone string
sessionStr string
limit int
}
func NewTelegramMCPSource(apiID int, apiHash, phone, sessionString, mcpDir string) *TelegramMCPSource {
return &TelegramMCPSource{
mcpDir: mcpDir,
apiID: apiID,
apiHash: apiHash,
phone: phone,
sessionStr: sessionString,
}
}
func (s *TelegramMCPSource) Name() string { return "telegram" }
func (s *TelegramMCPSource) Sync(ctx context.Context, outDir string, limit int) error {
if limit > 0 {
s.limit = limit
}
client, err := NewMCPClient(ctx, s.apiID, s.apiHash, s.phone, s.sessionStr, s.mcpDir)
if err != nil {
return fmt.Errorf("mcp client: %w", err)
}
defer client.Close()
chats, err := client.ListChats(ctx, "user", 100)
if err != nil {
return fmt.Errorf("list chats: %w", err)
}
if len(chats) == 0 {
fmt.Println("chats: no personal chats found")
return nil
}
fmt.Printf("chats: found %d personal chats\n", len(chats))
var filtered []ListChatsResult
for _, c := range chats {
if strings.Contains(strings.ToLower(c.Username), "bot") {
continue
}
if c.ChatID == 777000 { // Telegram service
continue
}
filtered = append(filtered, c)
}
fmt.Printf("chats: %d after filter (bots excluded)\n", len(filtered))
for _, chat := range filtered {
chatID := fmt.Sprintf("user_%d", chat.ChatID)
chatName := chat.Title
if chatName == "" {
chatName = chatID
}
chatDir := filepath.Join(outDir, "telegram", chatID)
if err := os.MkdirAll(chatDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "chats: mkdir %s: %v\n", chatDir, err)
continue
}
jsonlPath := filepath.Join(chatDir, "messages.jsonl")
f, err := os.Create(jsonlPath)
if err != nil {
fmt.Fprintf(os.Stderr, "chats: create %s: %v\n", jsonlPath, err)
continue
}
msgLimit := 100
if s.limit > 0 {
msgLimit = s.limit
}
msgs, err := client.GetHistory(ctx, chat.ChatID, msgLimit)
if err != nil {
fmt.Fprintf(os.Stderr, "chats: get_history for %s: %v\n", chatName, err)
f.Close()
continue
}
enc := json.NewEncoder(f)
written := 0
for _, m := range msgs {
if m.Out {
continue
}
text := m.Text
if text == "" && m.Media != "" {
text = fmt.Sprintf("[%s]", m.Media)
}
if text == "" {
continue
}
sender := cleanSender(m.Sender)
ts := m.Date
if t, err := time.Parse(time.RFC3339, m.Date); err == nil {
ts = t.UTC().Format(time.RFC3339)
}
chatMsg := Message{
ID: fmt.Sprintf("tg_%d_%d", chat.ChatID, m.ID),
Timestamp: ts,
From: sender,
Text: text,
Platform: "telegram",
}
if m.Media != "" {
desc := fmt.Sprintf("[%s]", m.Media)
chatMsg.Media = &desc
}
if err := enc.Encode(chatMsg); err != nil {
fmt.Fprintf(os.Stderr, "chats: encode msg: %v\n", err)
continue
}
written++
}
f.Close()
if written > 0 {
fmt.Printf("chats: synced %s (%s) — %d messages\n", chatName, chatID, written)
}
}
return nil
}
func cleanSender(sender string) string {
if idx := strings.Index(sender, " ("); idx > 0 {
sender = sender[:idx]
} else if idx := strings.Index(sender, " @"); idx > 0 {
sender = sender[:idx]
}
if idx := strings.Index(sender, " ["); idx > 0 {
sender = sender[:idx]
}
return sender
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"os"
"time"
)
type Source interface {
Name() string
Sync(ctx context.Context, outDir string, limit int) error
}
type Message struct {
ID string `json:"id"`
Timestamp string `json:"ts"`
From string `json:"from"`
Text string `json:"text"`
Media *string `json:"media,omitempty"`
Platform string `json:"platform"`
}
type ChatInfo struct {
ID string `json:"id"`
Platform string `json:"platform"`
Name string `json:"name"`
Participants []string `json:"participants"`
Type string `json:"type"`
MessageCount int `json:"messageCount"`
LastTS string `json:"lastTs,omitempty"`
}
func envVar(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func parseSince(s string) (time.Time, error) {
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse --since %q; use YYYY-MM-DD or RFC3339", s)
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func runSyncTelegram(args []string) int {
fs := flag.NewFlagSet("chats sync telegram", flag.ContinueOnError)
limit := fs.Int("limit", 0, "max messages per chat (0 = all)")
phone := fs.String("phone", "", "phone number (default env TELEGRAM_PHONE)")
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 sync telegram [--limit N] [--phone PHONE]")
return 0
}
apiIDStr := envVar("TELEGRAM_API_ID", "")
apiHash := envVar("TELEGRAM_API_HASH", "")
sessionStr := envVar("TELEGRAM_SESSION_STRING", "")
phoneNum := *phone
if phoneNum == "" {
phoneNum = envVar("TELEGRAM_PHONE", "")
}
if apiIDStr == "" || apiHash == "" || phoneNum == "" {
fmt.Fprintln(os.Stderr, "chats: need TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_PHONE in env")
return 2
}
apiID, err := strconv.Atoi(apiIDStr)
if err != nil {
fmt.Fprintf(os.Stderr, "chats: invalid TELEGRAM_API_ID %q\n", apiIDStr)
return 2
}
// Locate telegram-mcp directory
mcpDirs := []string{
envVar("TELEGRAM_MCP_DIR", ""),
"/mnt/8TB/projects/eslider/mcp-servers/telegram-mcp",
filepath.Join(os.Getenv("HOME"), "projects", "eSlider", "mcp-servers", "telegram-mcp"),
}
var mcpDir string
for _, d := range mcpDirs {
if d != "" {
if _, err := os.Stat(filepath.Join(d, "main.py")); err == nil {
mcpDir = d
break
}
}
}
if mcpDir == "" {
fmt.Fprintln(os.Stderr, "chats: telegram-mcp not found; set TELEGRAM_MCP_DIR")
return 1
}
if sessionStr == "" {
// Fallback: try to read from telegram-mcp .env
envPath := filepath.Join(mcpDir, ".env")
if data, err := os.ReadFile(envPath); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "TELEGRAM_SESSION_STRING=") {
sessionStr = strings.TrimPrefix(line, "TELEGRAM_SESSION_STRING=")
sessionStr = strings.Trim(sessionStr, "\"'")
break
}
}
}
}
if sessionStr == "" {
// Also try the env file at /mnt/8TB/projects/eslider/mcp-servers/telegram.env
envPath := "/mnt/8TB/projects/eslider/mcp-servers/telegram.env"
if data, err := os.ReadFile(envPath); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "TELEGRAM_SESSION_STRING=") {
sessionStr = strings.TrimPrefix(line, "TELEGRAM_SESSION_STRING=")
sessionStr = strings.Trim(sessionStr, "\"'")
break
}
}
}
}
if sessionStr == "" {
fmt.Fprintln(os.Stderr, "chats: TELEGRAM_SESSION_STRING not found in env or .env files")
return 1
}
src := NewTelegramMCPSource(apiID, apiHash, phoneNum, sessionStr, mcpDir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
start := time.Now()
if err := src.Sync(ctx, chatsDir(), *limit); err != nil {
fmt.Fprintf(os.Stderr, "chats sync telegram: %v\n", err)
return 1
}
fmt.Printf("chats sync telegram: completed in %s\n", time.Since(start).Round(time.Millisecond))
return 0
}