Compare commits

..
3 Commits
Author SHA1 Message Date
eSliderandGitHub 140d86a4b9 Add Gmail --query to mail/sync (default in:inbox) (#4)
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
* Add --query to Gmail mail/sync instead of always listing in:inbox.

Callers keep the search string; default remains in:inbox.

* Document Gmail --query on the mail/sync pipeline.

* test(mail): assert Gmail --query reaches ListIDs, not only the CLI flag.

ParseCLI coverage left a hole: an empty query still has to become in:inbox
and a custom q has to be the string the client lists with.
2026-08-13 12:19:22 +01:00
eSlider c96c393a4a feat(chats): LinkedIn source — MCP client via get_inbox + get_conversation
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
- LinkedInMCPSource: MCP JSON-RPC, как TelegramMCPSource
- sync linkedin --limit N: выгрузка сообщений из LinkedIn
- Проверка сессии: uvx mcp-server-linkedin --status
- Вывод инструкции если сессия истекла
- JSONL в var/chats/linkedin/<thread_id>/messages.jsonl
2026-08-13 00:10:42 +01:00
eSlider 3d0d95cf00 docs: add edelweiss to GitHub safety rules
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
2026-08-13 00:07:01 +01:00
7 changed files with 513 additions and 8 deletions
+2 -1
View File
@@ -52,6 +52,7 @@ var/ kb.lbug, var/mail/*, caches (gitignored)
```bash ```bash
bin/mail/sync.go --source onlyoffice,gmail --workers 8 --out var/mail # raw message.json + attachments bin/mail/sync.go --source onlyoffice,gmail --workers 8 --out var/mail # raw message.json + attachments
bin/mail/sync.go --source gmail --query 'from:example.com' --out var/mail # Gmail search (default in:inbox)
bin/mail/import --from-raw var/mail # message.json → message.md (convert only) bin/mail/import --from-raw var/mail # message.json → message.md (convert only)
bin/mail/index_mail # rebuild brain incl. all mail (fresh DB) bin/mail/index_mail # rebuild brain incl. all mail (fresh DB)
``` ```
@@ -89,7 +90,7 @@ parameter. Search before reading whole files.
Test data must be synthetic (Alice, Bob, Charlie, Diana, example.com). Test data must be synthetic (Alice, Bob, Charlie, Diana, example.com).
3. **No credentials/secrets in commits.** API keys, tokens, passwords, session 3. **No credentials/secrets in commits.** API keys, tokens, passwords, session
strings, phone numbers only in gitignored `.env` files, referenced by path. strings, phone numbers only in gitignored `.env` files, referenced by path.
4. **Curasoft — no files, no mentions.** Remove all traces if found. 4. **Curasoft, edelweiss — no files, no mentions.** Remove all traces if found.
5. **Check git history before push.** If any commit contains leaks, rewrite 5. **Check git history before push.** If any commit contains leaks, rewrite
history (rebase + force push) AND delete affected GitHub releases/tags. history (rebase + force push) AND delete affected GitHub releases/tags.
6. **`docs/chat-import-plan.md`** — reference Gitea issue, never embed secrets. 6. **`docs/chat-import-plan.md`** — reference Gitea issue, never embed secrets.
+356
View File
@@ -0,0 +1,356 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type LinkedInMCPSource struct {
userDataDir string
limit int
}
type lnInboxItem struct {
ThreadID string `json:"thread_id"`
Participants string `json:"participants"`
LastMessage string `json:"last_message"`
LastMessageDate string `json:"last_message_date"`
Unread bool `json:"unread"`
}
type lnInboxEnvelope struct {
Results []lnInboxItem `json:"results"`
HasMore bool `json:"hasMore"`
}
type lnMessage struct {
From string `json:"from"`
Date string `json:"date"`
Text string `json:"text"`
}
type lnConvEnvelope struct {
Results []lnMessage `json:"results"`
HasMore bool `json:"hasMore"`
TotalCount int `json:"total_count"`
}
func NewLinkedInMCPSource(userDataDir string) *LinkedInMCPSource {
return &LinkedInMCPSource{userDataDir: userDataDir}
}
func (s *LinkedInMCPSource) Name() string { return "linkedin" }
func (s *LinkedInMCPSource) Sync(ctx context.Context, outDir string, limit int) error {
if limit > 0 {
s.limit = limit
}
client, err := newLinkedInMCP(ctx, s.userDataDir)
if err != nil {
return fmt.Errorf("linkedin mcp: %w", err)
}
defer client.Close()
inbox, err := client.GetInbox(ctx, 50)
if err != nil {
return fmt.Errorf("get_inbox: %w", err)
}
if len(inbox) == 0 {
fmt.Println("chats: no LinkedIn conversations found")
return nil
}
fmt.Printf("chats: found %d LinkedIn conversations\n", len(inbox))
for _, conv := range inbox {
convID := sanitizeDir(conv.ThreadID)
if convID == "" {
convID = fmt.Sprintf("conv_%d", time.Now().UnixNano())
}
parts := strings.SplitN(conv.Participants, ",", 2)
chatName := strings.TrimSpace(parts[0])
if chatName == "" {
chatName = convID
}
chatDir := filepath.Join(outDir, "linkedin", convID)
if err := os.MkdirAll(chatDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "chats: mkdir %s: %v\n", chatDir, err)
continue
}
msgLimit := 100
if s.limit > 0 {
msgLimit = s.limit
}
msgs, err := client.GetConversation(ctx, "", conv.ThreadID, msgLimit)
if err != nil {
fmt.Fprintf(os.Stderr, "chats: get_conversation %s: %v\n", convID, 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
}
enc := json.NewEncoder(f)
written := 0
for i, m := range msgs {
text := m.Text
if text == "" {
continue
}
ts := m.Date
if t, err := time.Parse("2006-01-02T15:04:05Z07:00", m.Date); err == nil {
ts = t.UTC().Format(time.RFC3339)
} else if t, err := time.Parse(time.RFC3339, m.Date); err == nil {
ts = t.UTC().Format(time.RFC3339)
}
chatMsg := Message{
ID: fmt.Sprintf("li_%s_%d", convID, i),
Timestamp: ts,
From: m.From,
Text: text,
Platform: "linkedin",
}
if err := enc.Encode(chatMsg); err != nil {
fmt.Fprintf(os.Stderr, "chats: encode: %v\n", err)
continue
}
written++
}
f.Close()
fmt.Printf("chats: synced %s (%s) — %d messages\n", chatName, convID, written)
}
return nil
}
type linkedInMCPClient struct {
cmd *exec.Cmd
stdin *bufio.Writer
stdout *bufio.Scanner
msgID int
}
func newLinkedInMCP(ctx context.Context, userDataDir string) (*linkedInMCPClient, error) {
args := []string{
"mcp-server-linkedin@latest",
"--user-data-dir", userDataDir,
"--no-auto-import",
"--transport", "stdio",
"--login-timeout", "10",
"--browser-wait", "1",
"--browser-idle-timeout", "10",
"--log-level", "ERROR",
}
cmd := exec.CommandContext(ctx, "uvx", args...)
cmd.Env = os.Environ()
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: %w", err)
}
c := &linkedInMCPClient{
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("init: %w", err)
}
return c, nil
}
func (c *linkedInMCPClient) nextID() int {
c.msgID++
return c.msgID
}
func (c *linkedInMCPClient) 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.send(ctx, "initialize", params)
return err
}
func (c *linkedInMCPClient) send(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
id := c.nextID()
req := map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"method": method,
}
if params != nil {
req["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("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 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"`
}
if err := json.Unmarshal([]byte(line), &resp); err != nil {
return nil, fmt.Errorf("unmarshal: %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 *linkedInMCPClient) GetInbox(ctx context.Context, limit int) ([]lnInboxItem, error) {
params := map[string]interface{}{
"limit": limit,
}
result, err := c.send(ctx, "tools/call", map[string]interface{}{
"name": "get_inbox",
"arguments": params,
})
if err != nil {
return nil, err
}
var toolRes struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(result, &toolRes); err != nil {
return nil, fmt.Errorf("unmarshal tool: %w", err)
}
if toolRes.IsError {
return nil, fmt.Errorf("get_inbox error")
}
if len(toolRes.Content) == 0 {
return nil, nil
}
text := toolRes.Content[0].Text
var env lnInboxEnvelope
if err := json.Unmarshal([]byte(text), &env); err != nil {
var arr []lnInboxItem
if err2 := json.Unmarshal([]byte(text), &arr); err2 == nil {
return arr, nil
}
return nil, fmt.Errorf("parse inbox: %w", err)
}
return env.Results, nil
}
func (c *linkedInMCPClient) GetConversation(ctx context.Context, username, threadID string, limit int) ([]lnMessage, error) {
params := map[string]interface{}{
"linkedin_username": username,
"thread_id": threadID,
"index": limit,
}
result, err := c.send(ctx, "tools/call", map[string]interface{}{
"name": "get_conversation",
"arguments": params,
})
if err != nil {
return nil, err
}
var toolRes struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(result, &toolRes); err != nil {
return nil, fmt.Errorf("unmarshal tool: %w", err)
}
if toolRes.IsError {
return nil, nil
}
if len(toolRes.Content) == 0 {
return nil, nil
}
text := toolRes.Content[0].Text
var env lnConvEnvelope
if err := json.Unmarshal([]byte(text), &env); err != nil {
var arr []lnMessage
if err2 := json.Unmarshal([]byte(text), &arr); err2 == nil {
return arr, nil
}
return nil, fmt.Errorf("parse conv: %w", err)
}
return env.Results, nil
}
func (c *linkedInMCPClient) Close() error {
if c.stdin != nil {
c.stdin.Flush()
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
return nil
}
+1 -2
View File
@@ -40,8 +40,7 @@ func main() {
fmt.Fprintf(os.Stderr, "chats: WhatsApp not implemented yet\n") fmt.Fprintf(os.Stderr, "chats: WhatsApp not implemented yet\n")
os.Exit(1) os.Exit(1)
case "linkedin": case "linkedin":
fmt.Fprintf(os.Stderr, "chats: LinkedIn not implemented yet\n") os.Exit(runSyncLinkedIn(platformArgs))
os.Exit(1)
default: default:
fmt.Fprintf(os.Stderr, "chats: unknown platform %q\n", platform) fmt.Fprintf(os.Stderr, "chats: unknown platform %q\n", platform)
os.Exit(2) os.Exit(2)
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
func checkLinkedInSession(userDataDir string) (bool, error) {
cmd := exec.Command("uvx", "mcp-server-linkedin@latest",
"--user-data-dir", userDataDir,
"--no-auto-import",
"--status",
)
out, err := cmd.CombinedOutput()
if err != nil {
return true, fmt.Errorf("status check: %w\n%s", err, string(out))
}
return !strings.Contains(string(out), "✅"), nil
}
func runSyncLinkedIn(args []string) int {
fs := flag.NewFlagSet("chats sync linkedin", flag.ContinueOnError)
limit := fs.Int("limit", 0, "max messages per conversation (0 = all)")
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 linkedin [--limit N]")
return 0
}
userDataDir := envVar("LINKEDIN_USER_DATA_DIR", "")
if userDataDir == "" {
home, _ := os.UserHomeDir()
userDataDir = home + "/.linkedin-mcp/profile"
}
// Check session first
loginNeeded, err := checkLinkedInSession(userDataDir)
if err != nil {
fmt.Fprintf(os.Stderr, "chats: linkedin status check: %v\n", err)
}
if loginNeeded {
fmt.Fprintf(os.Stderr, "chats: LinkedIn session expired. Run:\n")
fmt.Fprintf(os.Stderr, " uvx mcp-server-linkedin@latest --user-data-dir %s --login\n", userDataDir)
fmt.Fprintf(os.Stderr, "Then retry 'chats sync linkedin'\n")
return 1
}
src := NewLinkedInMCPSource(userDataDir)
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 linkedin: %v\n", err)
return 1
}
fmt.Printf("chats sync linkedin: completed in %s\n", time.Since(start).Round(time.Millisecond))
return 0
}
+3 -1
View File
@@ -32,6 +32,7 @@ func ParseCLI(args []string) (CLIConfig, int, error) {
offset = fs.Int("offset", 0, "skip first N messages per source") offset = fs.Int("offset", 0, "skip first N messages per source")
force = fs.Bool("force", false, "overwrite existing message.json + attachments") force = fs.Bool("force", false, "overwrite existing message.json + attachments")
dryRun = fs.Bool("dry-run", false, "list message counts without writing") dryRun = fs.Bool("dry-run", false, "list message counts without writing")
query = fs.String("query", "in:inbox", "Gmail search query (gmail source only)")
srcs = fs.String("source", "onlyoffice", "comma list: onlyoffice,gmail (default onlyoffice)") srcs = fs.String("source", "onlyoffice", "comma list: onlyoffice,gmail (default onlyoffice)")
help = fs.Bool("help", false, "usage") help = fs.Bool("help", false, "usage")
) )
@@ -60,6 +61,7 @@ func ParseCLI(args []string) (CLIConfig, int, error) {
Offset: *offset, Offset: *offset,
Force: *force, Force: *force,
DryRun: *dryRun, DryRun: *dryRun,
Query: *query,
Policy: RetryPolicy{}, Policy: RetryPolicy{},
} }
cli := CLIConfig{Sync: cfg, Env: *env, Sources: *srcs} cli := CLIConfig{Sync: cfg, Env: *env, Sources: *srcs}
@@ -95,7 +97,7 @@ func Main(args []string) int {
return code return code
} }
if cli.Help { if cli.Help {
fmt.Fprintln(os.Stderr, "usage: bin/mail/sync.go [--source onlyoffice,gmail] [--limit N] [--offset N] [--workers N] [--force] [--dry-run]") fmt.Fprintln(os.Stderr, "usage: bin/mail/sync.go [--source onlyoffice,gmail] [--query GMAIL_Q] [--limit N] [--offset N] [--workers N] [--force] [--dry-run]")
return 0 return 0
} }
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
+16 -3
View File
@@ -112,6 +112,7 @@ type SyncConfig struct {
Offset int // skip first N messages per source Offset int // skip first N messages per source
Force bool // overwrite existing message.json + attachments Force bool // overwrite existing message.json + attachments
DryRun bool // list without writing DryRun bool // list without writing
Query string // Gmail search query; default in:inbox
Policy RetryPolicy Policy RetryPolicy
} }
@@ -136,9 +137,17 @@ type ooSource struct {
c *OOClient c *OOClient
page int page int
} }
// gmailAPI is the Gmail client surface gmailSource needs. *GmailClient implements it.
type gmailAPI interface {
ListIDs(ctx context.Context, q string, maxIDs int, pageToken string) ([]string, string, error)
GetMessage(ctx context.Context, id string) (*Message, error)
DownloadAttachment(ctx context.Context, msgID, attID string) ([]byte, error)
}
type gmailSource struct { type gmailSource struct {
c *GmailClient c gmailAPI
cur string cur string
query string
} }
func (s *ooSource) Folder() string { return "inbox" } func (s *ooSource) Folder() string { return "inbox" }
@@ -171,7 +180,11 @@ func (s *ooSource) DownloadAttachment(ctx context.Context, msg *Message, att Att
} }
func (s *gmailSource) ListIDs(ctx context.Context, limit int, cursor string) ([]string, string, error) { func (s *gmailSource) ListIDs(ctx context.Context, limit int, cursor string) ([]string, string, error) {
ids, next, err := s.c.ListIDs(ctx, "in:inbox", limit, cursor) q := s.query
if q == "" {
q = "in:inbox"
}
ids, next, err := s.c.ListIDs(ctx, q, limit, cursor)
return ids, next, err return ids, next, err
} }
@@ -207,7 +220,7 @@ func Run(ctx context.Context, cfg SyncConfig) (*SyncStats, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("gmail init: %w", err) return nil, fmt.Errorf("gmail init: %w", err)
} }
sources = append(sources, &gmailSource{c: gm}) sources = append(sources, &gmailSource{c: gm, query: cfg.Query})
} }
if len(sources) == 0 { if len(sources) == 0 {
return nil, errors.New("sync: no source configured (need OO, Gmail, or both)") return nil, errors.New("sync: no source configured (need OO, Gmail, or both)")
+65
View File
@@ -221,6 +221,71 @@ func TestCollectParts(t *testing.T) {
} }
} }
type fakeGmailAPI struct {
lastQ string
lastLimit int
ids []string
}
func (f *fakeGmailAPI) ListIDs(_ context.Context, q string, maxIDs int, _ string) ([]string, string, error) {
f.lastQ = q
f.lastLimit = maxIDs
return f.ids, "", nil
}
func (f *fakeGmailAPI) GetMessage(context.Context, string) (*Message, error) {
return nil, errors.New("unused")
}
func (f *fakeGmailAPI) DownloadAttachment(context.Context, string, string) ([]byte, error) {
return nil, errors.New("unused")
}
func TestGmailSourcePassesQueryToListIDs(t *testing.T) {
fake := &fakeGmailAPI{ids: []string{"m1"}}
src := &gmailSource{c: fake, query: "from:alice@example.com"}
ids, _, err := src.ListIDs(context.Background(), 10, "")
if err != nil {
t.Fatal(err)
}
if fake.lastQ != "from:alice@example.com" {
t.Fatalf("ListIDs q=%q, want from:alice@example.com", fake.lastQ)
}
if fake.lastLimit != 10 {
t.Fatalf("ListIDs limit=%d, want 10", fake.lastLimit)
}
if len(ids) != 1 || ids[0] != "m1" {
t.Fatalf("ids=%v", ids)
}
}
func TestGmailSourceEmptyQueryDefaultsToInbox(t *testing.T) {
fake := &fakeGmailAPI{}
src := &gmailSource{c: fake, query: ""}
if _, _, err := src.ListIDs(context.Background(), 5, ""); err != nil {
t.Fatal(err)
}
if fake.lastQ != "in:inbox" {
t.Fatalf("empty query q=%q, want in:inbox", fake.lastQ)
}
}
func TestParseCLIGmailQuery(t *testing.T) {
cli, code, err := ParseCLI([]string{
"--source", "gmail",
"--query", "from:letrado@example.com",
"--out", t.TempDir(),
"--dry-run",
})
if err != nil || code != 0 {
t.Fatalf("ParseCLI: code=%d err=%v", code, err)
}
if cli.Sync.Query != "from:letrado@example.com" {
t.Fatalf("query=%q", cli.Sync.Query)
}
if cli.Sync.Gmail == nil {
t.Fatal("gmail source not configured")
}
}
func b64(s string) string { func b64(s string) string {
return base64.URLEncoding.EncodeToString([]byte(s)) return base64.URLEncoding.EncodeToString([]byte(s))
} }