zap-go

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

commit 0828da88ac5e15e48231e5163113ae807387935e
parent 2a5c76fa1a972a35413591c7e0018465c4e99905
Author: Sivaldo <sivaldodavi@disroot.org>
Date:   Sat, 11 Apr 2026 12:04:21 -0300

Possibilidade de enviar arquivos

Diffstat:
Mgo.mod | 1+
Mgo.sum | 2++
Mmain.go | 379++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Dzap | 0
Mzap.lock | 2+-
5 files changed, 272 insertions(+), 112 deletions(-)

diff --git a/go.mod b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( github.com/BurntSushi/toml v1.6.0 + github.com/gabriel-vasile/mimetype v1.4.13 github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/mattn/go-sqlite3 v1.14.40 github.com/mdp/qrterminal/v3 v3.2.1 diff --git a/go.sum b/go.sum @@ -17,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/main.go b/main.go @@ -6,6 +6,7 @@ import ( "context" "crypto/tls" "fmt" + "math/rand" "net/smtp" "os" "os/exec" @@ -17,6 +18,7 @@ import ( "time" "github.com/BurntSushi/toml" + "github.com/gabriel-vasile/mimetype" "github.com/jordan-wright/email" "github.com/mdp/qrterminal/v3" _ "github.com/mattn/go-sqlite3" @@ -50,10 +52,12 @@ func initLog(dir string) { func rotateIfNeeded(path string) { info, err := os.Stat(path) - if err != nil || info.Size() < 1<<20 { // 1 MB + if os.IsNotExist(err) || info == nil || info.Size() < 1<<20 { return } - os.Rename(path, path+".1") + if err := os.Rename(path, path+".1"); err != nil { + fmt.Fprintln(os.Stderr, "rotate log:", err) + } } func logf(format string, args ...interface{}) { @@ -103,8 +107,7 @@ func acquireLock() (*os.File, bool) { if err != nil { return nil, false } - err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err != nil { + if err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { f.Close() return nil, false } @@ -119,13 +122,47 @@ func releaseLock(f *os.File) { } // --------------------------------------------------------------------------- -// Abook & helpers +// Abook & índice // --------------------------------------------------------------------------- type Contact struct { Name, Mobile string } +type AbookIndex struct { + bySuffix map[string]string // últimos 8 dígitos → nome +} + +func buildIndex(contacts []Contact) AbookIndex { + idx := AbookIndex{bySuffix: make(map[string]string, len(contacts))} + for _, c := range contacts { + n := cleanNumber(c.Mobile) + if len(n) >= 8 { + idx.bySuffix[n[len(n)-8:]] = c.Name + } + } + return idx +} + +func (idx AbookIndex) Lookup(jidUser, pushName string) string { + clean := cleanNumber(jidUser) + variants := []string{clean} + if alt := removeNinthDigit(clean); alt != "" { + variants = append(variants, alt) + } + for _, v := range variants { + if len(v) >= 8 { + if name, ok := idx.bySuffix[v[len(v)-8:]]; ok { + return name + } + } + } + if pushName != "" { + return pushName + } + return jidUser +} + func loadAbook() []Contact { path := filepath.Join(os.Getenv("HOME"), ".local", "share", "abook", "addressbook") f, err := os.Open(path) @@ -176,40 +213,12 @@ func removeNinthDigit(n string) string { return "" } -func findName(jidUser string, contacts []Contact, pushName string) string { - 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 jidUser -} - func portugueseWeekday(d time.Weekday) string { return [...]string{"Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sab"}[d] } // --------------------------------------------------------------------------- -// Batcher (Com suporte a múltiplos arquivos) +// Batcher // --------------------------------------------------------------------------- const flushDelay = 2 * time.Minute @@ -217,31 +226,32 @@ const flushDelay = 2 * time.Minute type FileAttachment struct { Name string Data []byte + Mime string } type PendingMsg struct { msgType string txt string - Files []FileAttachment + files []FileAttachment 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 + mu sync.Mutex + pending map[string][]PendingMsg + timers map[string]*time.Timer + names map[string]string + cfg Config + idx AbookIndex } -func newBatcher(cfg Config, contacts []Contact) *Batcher { +func newBatcher(cfg Config, idx AbookIndex) *Batcher { return &Batcher{ - pending: make(map[string][]PendingMsg), - timers: make(map[string]*time.Timer), - names: make(map[string]string), - cfg: cfg, - contacts: contacts, + pending: make(map[string][]PendingMsg), + timers: make(map[string]*time.Timer), + names: make(map[string]string), + cfg: cfg, + idx: idx, } } @@ -251,8 +261,8 @@ func (b *Batcher) Add(jid, pushName string, msg PendingMsg) { b.pending[jid] = append(b.pending[jid], msg) - if _, seen := b.names[jid]; !seen || b.names[jid] == jid || b.names[jid] == "" { - b.names[jid] = findName(jid, b.contacts, pushName) + if b.names[jid] == "" || b.names[jid] == jid { + b.names[jid] = b.idx.Lookup(jid, pushName) } if t, ok := b.timers[jid]; ok { @@ -265,9 +275,7 @@ func (b *Batcher) Add(jid, pushName string, msg PendingMsg) { } jidCopy := jid - b.timers[jid] = time.AfterFunc(flushDelay, func() { - b.flush(jidCopy) - }) + b.timers[jid] = time.AfterFunc(flushDelay, func() { b.flush(jidCopy) }) } func (b *Batcher) flush(jid string) { @@ -286,6 +294,7 @@ func (b *Batcher) flush(jid string) { e := email.NewEmail() e.From = fmt.Sprintf("Zap <%s>", b.cfg.SMTP.User) e.To = []string{b.cfg.SMTP.User} + e.Subject = fmt.Sprintf("Zap de %s [%d msg]", name, len(msgs)) var sb strings.Builder fmt.Fprintf(&sb, "De: %s\n%d mensagem(ns)\n\n", name, len(msgs)) @@ -298,17 +307,18 @@ func (b *Batcher) flush(jid string) { if m.txt != "" { fmt.Fprintf(&sb, "%s\n", m.txt) } - - // Anexa todos os arquivos desta mensagem específica - for _, file := range m.Files { - if len(file.Data) > 0 { - e.Attach(bytes.NewReader(file.Data), file.Name, "application/octet-stream") + for _, fa := range m.files { + if len(fa.Data) > 0 { + mime := fa.Mime + if mime == "" { + mime = "application/octet-stream" + } + e.Attach(bytes.NewReader(fa.Data), fa.Name, mime) } } sb.WriteString("\n") } - e.Subject = fmt.Sprintf("Zap de %s [%d msg]", name, len(msgs)) e.Text = []byte(sb.String()) addr := fmt.Sprintf("%s:%d", b.cfg.SMTP.Host, b.cfg.SMTP.Port) @@ -386,16 +396,87 @@ func connectClient(client *whatsmeow.Client) error { } // --------------------------------------------------------------------------- -// Handler de mensagens +// Handler de mensagens recebidas // --------------------------------------------------------------------------- +type msgInfo struct { + msgType string + txt string + fileName string + mimeType string + media whatsmeow.DownloadableMessage +} + +func classifyMessage(raw *waE2E.Message) (msgInfo, bool) { + switch { + case raw.GetConversation() != "": + return msgInfo{msgType: "Texto", txt: raw.GetConversation()}, true + + case raw.GetExtendedTextMessage() != nil: + return msgInfo{msgType: "Texto", txt: raw.GetExtendedTextMessage().GetText()}, true + + case raw.GetImageMessage() != nil: + m := raw.GetImageMessage() + return msgInfo{ + msgType: "Imagem", + txt: m.GetCaption(), + fileName: "imagem.jpg", + mimeType: m.GetMimetype(), + media: m, + }, true + + case raw.GetStickerMessage() != nil: + return msgInfo{ + msgType: "Figurinha", + fileName: "sticker.webp", + mimeType: "image/webp", + media: raw.GetStickerMessage(), + }, true + + case raw.GetAudioMessage() != nil: + m := raw.GetAudioMessage() + return msgInfo{ + msgType: "Áudio", + txt: fmt.Sprintf("Duração: %ds", m.GetSeconds()), + fileName: "audio.ogg", + mimeType: m.GetMimetype(), + media: m, + }, true + + case raw.GetVideoMessage() != nil: + m := raw.GetVideoMessage() + return msgInfo{ + msgType: "Vídeo", + txt: m.GetCaption(), + fileName: "video.mp4", + mimeType: m.GetMimetype(), + media: m, + }, true + + case raw.GetDocumentMessage() != nil: + m := raw.GetDocumentMessage() + name := m.GetFileName() + if name == "" { + name = "arquivo_recebido" + } + return msgInfo{ + msgType: "Documento", + txt: name, + fileName: name, + mimeType: m.GetMimetype(), + media: m, + }, true + } + + return msgInfo{}, false +} + func buildHandler(client *whatsmeow.Client, batcher *Batcher) func(interface{}) { return func(evt interface{}) { v, ok := evt.(*events.Message) if !ok || v.Info.IsFromMe { return } - // Ignora grupos e status if v.Info.Chat.Server == "g.us" || v.Info.Chat.User == "status" { return } @@ -405,62 +486,106 @@ func buildHandler(client *whatsmeow.Client, batcher *Batcher) func(interface{}) rawMsg = rawMsg.GetDeviceSentMessage().GetMessage() } - var txt, msgType, fileName string - var fileData []byte - var err error - - 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) - case rawMsg.GetStickerMessage() != nil: - msgType, txt, fileName = "Figurinha", "[Figurinha]", "sticker.webp" - 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) - 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) + info, ok := classifyMessage(rawMsg) + if !ok { + return } - if msgType == "" { - return + msg := PendingMsg{ + msgType: info.msgType, + txt: info.txt, + ts: v.Info.Timestamp, } + + if info.media != nil { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + data, err := client.Download(ctx, info.media) + if err != nil { + logf("erro download: %v", err) + } else if len(data) > 0 { + msg.files = []FileAttachment{{ + Name: info.fileName, + Data: data, + Mime: info.mimeType, + }} + } + } + + batcher.Add(v.Info.Sender.User, v.Info.PushName, msg) + } +} + +// --------------------------------------------------------------------------- +// Envio de arquivos +// --------------------------------------------------------------------------- + +func sendFile(client *whatsmeow.Client, jid types.JID, path, caption string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("leitura: %w", err) + } + + mime := mimetype.Detect(data).String() + ctx := context.Background() + + switch { + case strings.HasPrefix(mime, "image/"): + uploaded, err := client.Upload(ctx, data, whatsmeow.MediaImage) if err != nil { - logf("erro download: %v", err) + return fmt.Errorf("upload: %w", err) } + _, err = client.SendMessage(ctx, jid, &waE2E.Message{ + ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }) + return err - var files []FileAttachment - if len(fileData) > 0 { - files = append(files, FileAttachment{Name: fileName, Data: fileData}) + case strings.HasPrefix(mime, "video/"): + uploaded, err := client.Upload(ctx, data, whatsmeow.MediaVideo) + if err != nil { + return fmt.Errorf("upload: %w", err) } + _, err = client.SendMessage(ctx, jid, &waE2E.Message{ + VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }) + return err - batcher.Add(v.Info.Sender.User, v.Info.PushName, PendingMsg{ - msgType: msgType, - txt: txt, - Files: files, - ts: v.Info.Timestamp, + default: // PDF, DOCX e qualquer outro documento + uploaded, err := client.Upload(ctx, data, whatsmeow.MediaDocument) + if err != nil { + return fmt.Errorf("upload: %w", err) + } + _, err = client.SendMessage(ctx, jid, &waE2E.Message{ + DocumentMessage: &waE2E.DocumentMessage{ + FileName: proto.String(filepath.Base(path)), + Mimetype: proto.String(mime), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, }) + return err } } @@ -479,8 +604,9 @@ func runDaemon() { cfg := loadConfig() initLog(exeDir) contacts := loadAbook() + idx := buildIndex(contacts) client := newClient() - batcher := newBatcher(cfg, contacts) + batcher := newBatcher(cfg, idx) client.AddEventHandler(buildHandler(client, batcher)) @@ -491,7 +617,8 @@ func runDaemon() { logf("desconectado — tentando reconectar...") backoff := 5 * time.Second for { - time.Sleep(backoff) + jitter := time.Duration(rand.Intn(3000)) * time.Millisecond + time.Sleep(backoff + jitter) if err := connectClient(client); err == nil { logf("reconectado com sucesso") return @@ -523,6 +650,12 @@ func runDaemon() { // --------------------------------------------------------------------------- // Send +// +// Uso: +// zap send → escolhe contato via fzf, loop de texto +// zap send foto.jpg → envia imagem +// zap send doc.pdf "legenda" → envia documento com legenda/caption +// zap send video.mp4 "legenda" → envia vídeo com legenda // --------------------------------------------------------------------------- func runSend() { @@ -531,17 +664,21 @@ func runSend() { 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")) + cmd.Stderr = os.Stderr out, err := cmd.Output() 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() if err := connectClient(client); err != nil { @@ -551,6 +688,21 @@ func runSend() { defer client.Disconnect() fmt.Printf("Para: %s\n", parts[0]) + + // Envio de arquivo passado como argumento + if len(os.Args) >= 3 { + filePath := os.Args[2] + caption := "" + if len(os.Args) >= 4 { + caption = os.Args[3] + } + if err := sendFile(client, jid, filePath, caption); err != nil { + fmt.Fprintln(os.Stderr, "erro ao enviar arquivo:", err) + } + return + } + + // Loop interativo de texto scanner := bufio.NewScanner(os.Stdin) for { fmt.Print("> ") @@ -561,7 +713,9 @@ func runSend() { if t == "" { break } - client.SendMessage(context.Background(), jid, &waE2E.Message{Conversation: proto.String(t)}) + client.SendMessage(context.Background(), jid, &waE2E.Message{ + Conversation: proto.String(t), + }) } } @@ -575,13 +729,16 @@ func main() { exeDir = filepath.Dir(real) if len(os.Args) < 2 { - fmt.Println("uso: zap <daemon|send>") + fmt.Println("uso: zap <daemon|send> [arquivo] [legenda]") return } + switch os.Args[1] { case "daemon": runDaemon() case "send": runSend() + default: + fmt.Println("uso: zap <daemon|send> [arquivo] [legenda]") } } diff --git a/zap b/zap Binary files differ. diff --git a/zap.lock b/zap.lock @@ -1 +1 @@ -2607 +2594