zap-go

Repositório zap-go
git clone git://git.sivaldodavi.com/zap-go.git
Log | Files | Refs | README

commit 68c1ac630bf328efe8dfe70e246612ad756329cb
parent 5afb351ae9f09056f53247195adf6aba540501ee
Author: Sivaldo <sivaldodavi@disroot.org>
Date:   Tue,  7 Apr 2026 11:15:49 -0300

Agrupamento de mensagens e melhorias técnicas

Diffstat:
Mmain.go | 521+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Azap.lock | 1+
2 files changed, 420 insertions(+), 102 deletions(-)

diff --git a/main.go b/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "context" "crypto/tls" "fmt" @@ -11,6 +12,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -27,7 +29,46 @@ import ( "google.golang.org/protobuf/proto" ) -// --- Config --- +// --------------------------------------------------------------------------- +// Log em arquivo (rotativo simples: renomeia ao passar de 1 MB) +// --------------------------------------------------------------------------- + +var ( + logFile *os.File + logMu sync.Mutex +) + +func initLog(dir string) { + path := filepath.Join(dir, "zap.log") + rotateIfNeeded(path) + var err error + logFile, err = os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + fmt.Fprintln(os.Stderr, "não foi possível abrir log:", err) + } +} + +func rotateIfNeeded(path string) { + info, err := os.Stat(path) + if err != nil || info.Size() < 1<<20 { // 1 MB + return + } + os.Rename(path, path+".1") +} + +func logf(format string, args ...interface{}) { + msg := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), fmt.Sprintf(format, args...)) + fmt.Print(msg) + if logFile != nil { + logMu.Lock() + logFile.WriteString(msg) + logMu.Unlock() + } +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- type SMTPConfig struct { Host string `toml:"host"` @@ -40,11 +81,10 @@ type Config struct { SMTP SMTPConfig `toml:"smtp"` } -func loadConfig() Config { - exePath, _ := os.Executable() - realPath, _ := filepath.EvalSymlinks(exePath) - cfgPath := filepath.Join(filepath.Dir(realPath), "config.toml") +var exeDir string // diretório do binário, calculado uma vez no main +func loadConfig() Config { + cfgPath := filepath.Join(exeDir, "config.toml") var cfg Config if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil { fmt.Fprintln(os.Stderr, "erro ao ler config.toml:", err) @@ -53,7 +93,35 @@ func loadConfig() Config { return cfg } -// --- Email com suporte a Anexo --- +// --------------------------------------------------------------------------- +// Lock file — impede dupla instância +// --------------------------------------------------------------------------- + +func acquireLock() (*os.File, bool) { + path := filepath.Join(exeDir, "zap.lock") + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, false + } + // Tenta lock exclusivo não-bloqueante via syscall + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + f.Close() + return nil, false + } + fmt.Fprintf(f, "%d\n", os.Getpid()) + return f, true +} + +func releaseLock(f *os.File) { + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + f.Close() + os.Remove(filepath.Join(exeDir, "zap.lock")) +} + +// --------------------------------------------------------------------------- +// Email +// --------------------------------------------------------------------------- func sendEmailWithAttachment(cfg Config, subject, body, fileName string, fileData []byte) { e := email.NewEmail() @@ -61,60 +129,27 @@ func sendEmailWithAttachment(cfg Config, subject, body, fileName string, fileDat e.To = []string{cfg.SMTP.User} e.Subject = subject e.Text = []byte(body) - if len(fileData) > 0 { - e.Attach(strings.NewReader(string(fileData)), fileName, "application/octet-stream") + e.Attach(bytes.NewReader(fileData), fileName, "application/octet-stream") } - addr := fmt.Sprintf("%s:%d", cfg.SMTP.Host, cfg.SMTP.Port) auth := smtp.PlainAuth("", cfg.SMTP.User, cfg.SMTP.Password, cfg.SMTP.Host) - var err error if cfg.SMTP.Port == 465 { err = e.SendWithTLS(addr, auth, &tls.Config{ServerName: cfg.SMTP.Host}) } else { err = e.Send(addr, auth) } - if err != nil { - fmt.Fprintln(os.Stderr, "erro ao enviar email:", err) + logf("erro ao enviar email: %v", err) } else { - fmt.Printf("[%s] Email enviado: %s\n", time.Now().Format("15:04:05"), subject) + logf("email enviado: %s", subject) } } -// --- WhatsApp Setup --- - -func newClient() *whatsmeow.Client { - exePath, _ := os.Executable() - realPath, _ := filepath.EvalSymlinks(exePath) - dbPath := filepath.Join(filepath.Dir(realPath), "whatsapp.db") - dbURI := fmt.Sprintf("file:%s?_foreign_keys=on", dbPath) - - container, err := sqlstore.New(context.Background(), "sqlite3", dbURI, waLog.Stdout("Database", "ERROR", true)) - if err != nil { - fmt.Fprintln(os.Stderr, "erro db:", err) - os.Exit(1) - } - device, _ := container.GetFirstDevice(context.Background()) - return whatsmeow.NewClient(device, waLog.Stdout("Client", "ERROR", true)) -} - -func connectClient(client *whatsmeow.Client) { - if client.Store.ID == nil { - qrChan, _ := client.GetQRChannel(context.Background()) - client.Connect() - for evt := range qrChan { - if evt.Event == "code" { - qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) - } - } - } else { - client.Connect() - } -} - -// --- Abook & Helpers --- +// --------------------------------------------------------------------------- +// Abook & helpers +// --------------------------------------------------------------------------- type Contact struct { Name, Mobile string @@ -127,7 +162,6 @@ func loadAbook() []Contact { return nil } defer f.Close() - var contacts []Contact var cur Contact scanner := bufio.NewScanner(f) @@ -142,56 +176,238 @@ func loadAbook() []Contact { } if k, v, ok := strings.Cut(line, "="); ok { switch strings.TrimSpace(k) { - case "name": cur.Name = strings.TrimSpace(v) - case "mobile": cur.Mobile = strings.TrimSpace(v) + case "name": + cur.Name = strings.TrimSpace(v) + case "mobile": + cur.Mobile = strings.TrimSpace(v) } } } - if cur.Name != "" && cur.Mobile != "" { contacts = append(contacts, cur) } + if cur.Name != "" && cur.Mobile != "" { + contacts = append(contacts, cur) + } return contacts } func cleanNumber(s string) string { return strings.Map(func(r rune) rune { - if r >= '0' && r <= '9' { return r } + if r >= '0' && r <= '9' { + return r + } return -1 }, s) } +// removeNinthDigit remove o 9 extra (Brasil): 55 + DD + 9 + 8 dígitos → 55 + DD + 8 dígitos +func removeNinthDigit(n string) string { + if len(n) == 13 && strings.HasPrefix(n, "55") { + return n[:4] + n[5:] + } + return "" +} + func findName(jidUser string, contacts []Contact, pushName string) string { - clean := cleanNumber(jidUser) - if len(clean) >= 8 { - suffix := clean[len(clean)-8:] - for _, c := range contacts { - s := cleanNumber(c.Mobile) - if len(s) >= 8 && s[len(s)-8:] == suffix { return c.Name } + jidClean := cleanNumber(jidUser) + variants := []string{jidClean} + if alt := removeNinthDigit(jidClean); alt != "" { + variants = append(variants, alt) + } + for _, c := range contacts { + cClean := cleanNumber(c.Mobile) + for _, v := range variants { + if v == cClean { + return c.Name + } } + if len(cClean) >= 8 { + suffix := cClean[len(cClean)-8:] + for _, v := range variants { + if len(v) >= 8 && v[len(v)-8:] == suffix { + return c.Name + } + } + } + } + if pushName != "" { + return pushName } - return (func() string { if pushName != "" { return pushName }; return jidUser })() + return jidUser } func portugueseWeekday(d time.Weekday) string { return [...]string{"Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sab"}[d] } -// --- Daemon --- +// --------------------------------------------------------------------------- +// Batcher +// --------------------------------------------------------------------------- -func runDaemon() { - cfg := loadConfig() - contacts := loadAbook() - client := newClient() +const flushDelay = 2 * time.Minute - client.AddEventHandler(func(evt interface{}) { - v, ok := evt.(*events.Message) - if !ok || v.Info.IsFromMe { return } +type PendingMsg struct { + msgType string + txt string + fileName string + fileData []byte + ts time.Time +} + +type Batcher struct { + mu sync.Mutex + pending map[string][]PendingMsg + timers map[string]*time.Timer + names map[string]string + cfg Config + contacts []Contact +} + +func newBatcher(cfg Config, contacts []Contact) *Batcher { + return &Batcher{ + pending: make(map[string][]PendingMsg), + timers: make(map[string]*time.Timer), + names: make(map[string]string), + cfg: cfg, + contacts: contacts, + } +} + +func (b *Batcher) Add(jid, pushName string, msg PendingMsg) { + b.mu.Lock() + defer b.mu.Unlock() - // --- FILTROS DE SEGURANÇA --- - // 1. Bloqueia Mensagens de Grupos (@g.us) - if v.Info.Chat.Server == "g.us" { return } - - // 2. Bloqueia Mensagens de Status/Stories (status@broadcast) - if v.Info.Chat.User == "status" { return } - // ---------------------------- + b.pending[jid] = append(b.pending[jid], msg) + + if _, seen := b.names[jid]; !seen { + b.names[jid] = findName(jid, b.contacts, pushName) + } else if b.names[jid] == jid || b.names[jid] == "" { + b.names[jid] = findName(jid, b.contacts, pushName) + } + + if t, ok := b.timers[jid]; ok { + if !t.Stop() { + select { + case <-t.C: + default: + } + } + } + + jidCopy := jid + b.timers[jid] = time.AfterFunc(flushDelay, func() { + b.flush(jidCopy) + }) +} + +func (b *Batcher) flush(jid string) { + b.mu.Lock() + msgs := b.pending[jid] + name := b.names[jid] + delete(b.pending, jid) + delete(b.timers, jid) + delete(b.names, jid) + b.mu.Unlock() + + if len(msgs) == 0 { + return + } + + var sb strings.Builder + fmt.Fprintf(&sb, "De: %s\n%d mensagem(ns)\n\n", name, len(msgs)) + + var lastFileData []byte + var lastFileName string + + for i, m := range msgs { + fmt.Fprintf(&sb, "── %d [%s] %s %s ──\n", + i+1, m.msgType, + portugueseWeekday(m.ts.Weekday()), + m.ts.Format("02/01 15:04:05")) + if m.txt != "" { + fmt.Fprintf(&sb, "%s\n", m.txt) + } + if len(m.fileData) > 0 { + lastFileData = m.fileData + lastFileName = m.fileName + } + sb.WriteString("\n") + } + + subject := fmt.Sprintf("Zap de %s [%d msg]", name, len(msgs)) + sendEmailWithAttachment(b.cfg, subject, sb.String(), lastFileName, lastFileData) +} + +// FlushAll cancela todos os timers e envia imediatamente os lotes pendentes. +// Chamado no shutdown para não perder mensagens acumuladas. +func (b *Batcher) FlushAll() { + b.mu.Lock() + jids := make([]string, 0, len(b.pending)) + for jid, t := range b.timers { + if !t.Stop() { + select { + case <-t.C: + default: + } + } + jids = append(jids, jid) + } + b.mu.Unlock() + + for _, jid := range jids { + b.flush(jid) + } +} + +// --------------------------------------------------------------------------- +// WhatsApp setup +// --------------------------------------------------------------------------- + +func newClient() *whatsmeow.Client { + dbPath := filepath.Join(exeDir, "whatsapp.db") + dbURI := fmt.Sprintf("file:%s?_foreign_keys=on", dbPath) + container, err := sqlstore.New(context.Background(), "sqlite3", dbURI, waLog.Stdout("Database", "ERROR", true)) + if err != nil { + logf("erro db: %v", err) + os.Exit(1) + } + device, _ := container.GetFirstDevice(context.Background()) + return whatsmeow.NewClient(device, waLog.Stdout("Client", "ERROR", true)) +} + +func connectClient(client *whatsmeow.Client) error { + if client.Store.ID == nil { + qrChan, err := client.GetQRChannel(context.Background()) + if err != nil { + return fmt.Errorf("erro ao obter canal QR: %w", err) + } + if err := client.Connect(); err != nil { + return fmt.Errorf("erro ao conectar: %w", err) + } + for evt := range qrChan { + if evt.Event == "code" { + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + } + } + } else { + if err := client.Connect(); err != nil { + return fmt.Errorf("erro ao conectar: %w", err) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Handler de mensagens +// --------------------------------------------------------------------------- + +func buildHandler(client *whatsmeow.Client, batcher *Batcher) func(interface{}) { + return func(evt interface{}) { + v, ok := evt.(*events.Message) + if !ok || v.Info.IsFromMe { + return + } + if v.Info.Chat.Server == "g.us" || v.Info.Chat.User == "status" { + return + } rawMsg := v.Message if rawMsg.GetDeviceSentMessage() != nil { @@ -201,77 +417,178 @@ func runDaemon() { var txt, msgType, fileName string var fileData []byte var err error - ctx := context.Background() - if m := rawMsg.GetConversation(); m != "" { - txt, msgType = m, "Texto" - } else if m := rawMsg.GetExtendedTextMessage(); m != nil { - txt, msgType = m.GetText(), "Texto" - } else if m := rawMsg.GetImageMessage(); m != nil { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + switch { + case rawMsg.GetConversation() != "": + txt, msgType = rawMsg.GetConversation(), "Texto" + case rawMsg.GetExtendedTextMessage() != nil: + txt, msgType = rawMsg.GetExtendedTextMessage().GetText(), "Texto" + case rawMsg.GetImageMessage() != nil: + m := rawMsg.GetImageMessage() msgType, txt, fileName = "Imagem", "Legenda: "+m.GetCaption(), "imagem.jpg" fileData, err = client.Download(ctx, m) - } else if m := rawMsg.GetStickerMessage(); m != nil { + case rawMsg.GetStickerMessage() != nil: msgType, txt, fileName = "Figurinha", "[Figurinha]", "sticker.webp" - fileData, err = client.Download(ctx, m) - } else if m := rawMsg.GetAudioMessage(); m != nil { + fileData, err = client.Download(ctx, rawMsg.GetStickerMessage()) + case rawMsg.GetAudioMessage() != nil: + m := rawMsg.GetAudioMessage() msgType, txt, fileName = "Áudio", fmt.Sprintf("Duração: %ds", m.GetSeconds()), "audio.ogg" fileData, err = client.Download(ctx, m) - } else if m := rawMsg.GetVideoMessage(); m != nil { + case rawMsg.GetVideoMessage() != nil: + m := rawMsg.GetVideoMessage() msgType, txt, fileName = "Vídeo", "Legenda: "+m.GetCaption(), "video.mp4" fileData, err = client.Download(ctx, m) + case rawMsg.GetDocumentMessage() != nil: + m := rawMsg.GetDocumentMessage() + msgType = "Documento" + txt = "Arquivo original: " + m.GetFileName() + fileName = m.GetFileName() + if fileName == "" { + fileName = "arquivo_recebido" + } + fileData, err = client.Download(ctx, m) } - if msgType == "" { return } - if err != nil { fmt.Fprintln(os.Stderr, "Erro download:", err) } + if msgType == "" { + return + } + if err != nil { + logf("erro download: %v", err) + } + + batcher.Add(v.Info.Sender.User, v.Info.PushName, PendingMsg{ + msgType: msgType, + txt: txt, + fileName: fileName, + fileData: fileData, + ts: v.Info.Timestamp, + }) + } +} + +// --------------------------------------------------------------------------- +// Daemon +// --------------------------------------------------------------------------- + +func runDaemon() { + lock, ok := acquireLock() + if !ok { + fmt.Fprintln(os.Stderr, "outra instância já está rodando") + os.Exit(1) + } + defer releaseLock(lock) + + cfg := loadConfig() + initLog(exeDir) + contacts := loadAbook() + client := newClient() + batcher := newBatcher(cfg, contacts) - name := findName(v.Info.Sender.User, contacts, v.Info.PushName) - body := fmt.Sprintf("De: %s\nData: %s %s\nTipo: %s\n\n%s", - name, portugueseWeekday(v.Info.Timestamp.Weekday()), - v.Info.Timestamp.Format("02/01 15:04"), msgType, txt) + client.AddEventHandler(buildHandler(client, batcher)) - sendEmailWithAttachment(cfg, "Zap de "+name+" ["+msgType+"]", body, fileName, fileData) + // Reconexão automática com backoff exponencial + client.AddEventHandler(func(evt interface{}) { + if _, ok := evt.(*events.Disconnected); !ok { + return + } + logf("desconectado — tentando reconectar...") + backoff := 5 * time.Second + for { + time.Sleep(backoff) + if err := connectClient(client); err == nil { + logf("reconectado com sucesso") + return + } else { + logf("reconexão falhou: %v — tentando em %s", err, backoff) + } + if backoff < 5*time.Minute { + backoff *= 2 + } + } }) - connectClient(client) - fmt.Println("Daemon rodando (Privado apenas + Figurinha). Ctrl+C para parar.") - + if err := connectClient(client); err != nil { + logf("erro ao conectar: %v", err) + os.Exit(1) + } + + logf("daemon iniciado") + quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit + + logf("encerrando — enviando lotes pendentes...") + batcher.FlushAll() client.Disconnect() + logf("encerrado") } +// --------------------------------------------------------------------------- +// Send +// --------------------------------------------------------------------------- + func runSend() { contacts := loadAbook() var lines []string - for _, c := range contacts { lines = append(lines, fmt.Sprintf("%s\t%s", c.Name, c.Mobile)) } + for _, c := range contacts { + lines = append(lines, fmt.Sprintf("%s\t%s", c.Name, c.Mobile)) + } cmd := exec.Command("fzf", "--prompt=para: ", "--delimiter=\t", "--with-nth=1") cmd.Stdin = strings.NewReader(strings.Join(lines, "\n")) out, err := cmd.Output() - if err != nil { return } - + if err != nil { + return + } parts := strings.SplitN(strings.TrimSpace(string(out)), "\t", 2) + if len(parts) < 2 { + fmt.Fprintln(os.Stderr, "contato inválido") + return + } jid, _ := types.ParseJID(cleanNumber(parts[1]) + "@s.whatsapp.net") - client := newClient() - connectClient(client) + if err := connectClient(client); err != nil { + fmt.Fprintln(os.Stderr, "erro ao conectar:", err) + return + } defer client.Disconnect() fmt.Printf("Para: %s\n", parts[0]) scanner := bufio.NewScanner(os.Stdin) for { fmt.Print("> ") - if !scanner.Scan() { break } + if !scanner.Scan() { + break + } t := strings.TrimSpace(scanner.Text()) - if t == "" { break } + if t == "" { + break + } client.SendMessage(context.Background(), jid, &waE2E.Message{Conversation: proto.String(t)}) } } +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + func main() { - if len(os.Args) < 2 { fmt.Println("uso: zap <daemon|send>"); return } + // Calcula exeDir uma vez — usado por loadConfig, newClient, initLog, acquireLock + exe, _ := os.Executable() + real, _ := filepath.EvalSymlinks(exe) + exeDir = filepath.Dir(real) + + if len(os.Args) < 2 { + fmt.Println("uso: zap <daemon|send>") + return + } switch os.Args[1] { - case "daemon": runDaemon() - case "send": runSend() + case "daemon": + runDaemon() + case "send": + runSend() } } diff --git a/zap.lock b/zap.lock @@ -0,0 +1 @@ +2607