main.go (15580B)
1 package main 2 3 import ( 4 "bufio" 5 "bytes" 6 "context" 7 "crypto/tls" 8 "fmt" 9 "math/rand" 10 "net/smtp" 11 "os" 12 "os/exec" 13 "os/signal" 14 "path/filepath" 15 "strings" 16 "sync" 17 "syscall" 18 "time" 19 20 "github.com/BurntSushi/toml" 21 "github.com/gabriel-vasile/mimetype" 22 "github.com/jordan-wright/email" 23 "github.com/mdp/qrterminal/v3" 24 _ "github.com/mattn/go-sqlite3" 25 "go.mau.fi/whatsmeow" 26 "go.mau.fi/whatsmeow/proto/waE2E" 27 "go.mau.fi/whatsmeow/store/sqlstore" 28 "go.mau.fi/whatsmeow/types" 29 "go.mau.fi/whatsmeow/types/events" 30 waLog "go.mau.fi/whatsmeow/util/log" 31 "google.golang.org/protobuf/proto" 32 ) 33 34 // --------------------------------------------------------------------------- 35 // Globals 36 // --------------------------------------------------------------------------- 37 38 var ( 39 exeDir string 40 logFile *os.File 41 logMu sync.Mutex 42 ) 43 44 // --------------------------------------------------------------------------- 45 // Log 46 // --------------------------------------------------------------------------- 47 48 func logf(format string, args ...interface{}) { 49 msg := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), fmt.Sprintf(format, args...)) 50 fmt.Print(msg) 51 if logFile != nil { 52 logMu.Lock() 53 logFile.WriteString(msg) 54 logMu.Unlock() 55 } 56 } 57 58 func initLog() { 59 path := filepath.Join(exeDir, "zap.log") 60 if info, err := os.Stat(path); err == nil && info.Size() >= 1<<20 { 61 os.Rename(path, path+".1") 62 } 63 logFile, _ = os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) 64 } 65 66 // --------------------------------------------------------------------------- 67 // Config 68 // --------------------------------------------------------------------------- 69 70 type Config struct { 71 SMTP struct { 72 Host, User, Password string 73 Port int 74 } `toml:"smtp"` 75 } 76 77 func loadConfig() (cfg Config) { 78 if _, err := toml.DecodeFile(filepath.Join(exeDir, "config.toml"), &cfg); err != nil { 79 fmt.Fprintln(os.Stderr, "config.toml:", err) 80 os.Exit(1) 81 } 82 return 83 } 84 85 // --------------------------------------------------------------------------- 86 // Lock 87 // --------------------------------------------------------------------------- 88 89 func acquireLock() (*os.File, bool) { 90 f, err := os.OpenFile(filepath.Join(exeDir, "zap.lock"), os.O_CREATE|os.O_WRONLY, 0644) 91 if err != nil { 92 return nil, false 93 } 94 if syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { 95 f.Close() 96 return nil, false 97 } 98 fmt.Fprintf(f, "%d\n", os.Getpid()) 99 return f, true 100 } 101 102 func releaseLock(f *os.File) { 103 syscall.Flock(int(f.Fd()), syscall.LOCK_UN) 104 f.Close() 105 os.Remove(filepath.Join(exeDir, "zap.lock")) 106 } 107 108 // --------------------------------------------------------------------------- 109 // Abook 110 // --------------------------------------------------------------------------- 111 112 type Contact struct{ Name, Mobile string } 113 114 func loadAbook() []Contact { 115 f, err := os.Open(filepath.Join(os.Getenv("HOME"), ".local/share/abook/addressbook")) 116 if err != nil { 117 return nil 118 } 119 defer f.Close() 120 var contacts []Contact 121 var cur Contact 122 for s := bufio.NewScanner(f); s.Scan(); { 123 line := strings.TrimSpace(s.Text()) 124 if strings.HasPrefix(line, "[") && line != "[format]" { 125 if cur.Name != "" && cur.Mobile != "" { 126 contacts = append(contacts, cur) 127 } 128 cur = Contact{} 129 } else if k, v, ok := strings.Cut(line, "="); ok { 130 switch strings.TrimSpace(k) { 131 case "name": 132 cur.Name = strings.TrimSpace(v) 133 case "mobile": 134 cur.Mobile = strings.TrimSpace(v) 135 } 136 } 137 } 138 if cur.Name != "" && cur.Mobile != "" { 139 contacts = append(contacts, cur) 140 } 141 return contacts 142 } 143 144 func digitsOnly(s string) string { 145 return strings.Map(func(r rune) rune { 146 if r >= '0' && r <= '9' { 147 return r 148 } 149 return -1 150 }, s) 151 } 152 153 func lookupName(jidUser, pushName string, idx map[string]string) string { 154 n := digitsOnly(jidUser) 155 for _, v := range []string{n, func() string { 156 if len(n) == 13 && strings.HasPrefix(n, "55") { 157 return n[:4] + n[5:] 158 } 159 return "" 160 }()} { 161 if len(v) >= 8 { 162 if name, ok := idx[v[len(v)-8:]]; ok { 163 return name 164 } 165 } 166 } 167 if pushName != "" { 168 return pushName 169 } 170 return jidUser 171 } 172 173 func buildIndex(contacts []Contact) map[string]string { 174 idx := make(map[string]string, len(contacts)) 175 for _, c := range contacts { 176 n := digitsOnly(c.Mobile) 177 if len(n) >= 8 { 178 idx[n[len(n)-8:]] = c.Name 179 } 180 } 181 return idx 182 } 183 184 // --------------------------------------------------------------------------- 185 // Batcher 186 // --------------------------------------------------------------------------- 187 188 const flushDelay = 2 * time.Minute 189 190 type PendingMsg struct { 191 msgType, txt string 192 files []struct{ Name, Mime string; Data []byte } 193 ts time.Time 194 } 195 196 type Batcher struct { 197 mu sync.Mutex 198 pending map[string][]PendingMsg 199 timers map[string]*time.Timer 200 names map[string]string 201 cfg Config 202 idx map[string]string 203 } 204 205 func newBatcher(cfg Config, idx map[string]string) *Batcher { 206 return &Batcher{cfg: cfg, idx: idx, 207 pending: map[string][]PendingMsg{}, 208 timers: map[string]*time.Timer{}, 209 names: map[string]string{}, 210 } 211 } 212 213 func (b *Batcher) Add(jid, pushName string, msg PendingMsg) { 214 b.mu.Lock() 215 defer b.mu.Unlock() 216 b.pending[jid] = append(b.pending[jid], msg) 217 if b.names[jid] == "" { 218 b.names[jid] = lookupName(jid, pushName, b.idx) 219 } 220 if t := b.timers[jid]; t != nil { 221 t.Stop() 222 } 223 b.timers[jid] = time.AfterFunc(flushDelay, func() { b.flush(jid) }) 224 } 225 226 func (b *Batcher) flush(jid string) { 227 b.mu.Lock() 228 msgs, name := b.pending[jid], b.names[jid] 229 delete(b.pending, jid) 230 delete(b.timers, jid) 231 delete(b.names, jid) 232 b.mu.Unlock() 233 if len(msgs) == 0 { 234 return 235 } 236 237 e := email.NewEmail() 238 e.From = fmt.Sprintf("Zap <%s>", b.cfg.SMTP.User) 239 e.To = []string{b.cfg.SMTP.User} 240 e.Subject = fmt.Sprintf("Zap de %s [%d msg]", name, len(msgs)) 241 242 weekday := [...]string{"Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sab"} 243 var sb strings.Builder 244 fmt.Fprintf(&sb, "De: %s\n%d mensagem(ns)\n\n", name, len(msgs)) 245 for i, m := range msgs { 246 fmt.Fprintf(&sb, "── %d [%s] %s %s ──\n%s\n\n", 247 i+1, m.msgType, weekday[m.ts.Weekday()], m.ts.Format("02/01 15:04:05"), m.txt) 248 for _, fa := range m.files { 249 if len(fa.Data) > 0 { 250 mime := fa.Mime 251 if mime == "" { 252 mime = "application/octet-stream" 253 } 254 e.Attach(bytes.NewReader(fa.Data), fa.Name, mime) 255 } 256 } 257 } 258 e.Text = []byte(sb.String()) 259 260 addr := fmt.Sprintf("%s:%d", b.cfg.SMTP.Host, b.cfg.SMTP.Port) 261 auth := smtp.PlainAuth("", b.cfg.SMTP.User, b.cfg.SMTP.Password, b.cfg.SMTP.Host) 262 var err error 263 if b.cfg.SMTP.Port == 465 { 264 err = e.SendWithTLS(addr, auth, &tls.Config{ServerName: b.cfg.SMTP.Host}) 265 } else { 266 err = e.Send(addr, auth) 267 } 268 if err != nil { 269 logf("erro ao enviar email: %v", err) 270 } else { 271 logf("email enviado: %s", e.Subject) 272 } 273 } 274 275 func (b *Batcher) FlushAll() { 276 b.mu.Lock() 277 jids := make([]string, 0, len(b.pending)) 278 for jid, t := range b.timers { 279 t.Stop() 280 jids = append(jids, jid) 281 } 282 b.mu.Unlock() 283 for _, jid := range jids { 284 b.flush(jid) 285 } 286 } 287 288 // --------------------------------------------------------------------------- 289 // WhatsApp 290 // --------------------------------------------------------------------------- 291 292 func newClient() *whatsmeow.Client { 293 dbURI := fmt.Sprintf("file:%s?_foreign_keys=on", filepath.Join(exeDir, "whatsapp.db")) 294 container, err := sqlstore.New(context.Background(), "sqlite3", dbURI, waLog.Stdout("Database", "ERROR", true)) 295 if err != nil { 296 logf("erro db: %v", err) 297 os.Exit(1) 298 } 299 device, _ := container.GetFirstDevice(context.Background()) 300 return whatsmeow.NewClient(device, waLog.Stdout("Client", "ERROR", true)) 301 } 302 303 func connectClient(client *whatsmeow.Client) error { 304 if client.Store.ID == nil { 305 qrChan, err := client.GetQRChannel(context.Background()) 306 if err != nil { 307 return err 308 } 309 if err := client.Connect(); err != nil { 310 return err 311 } 312 for evt := range qrChan { 313 if evt.Event == "code" { 314 qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) 315 } 316 } 317 } else { 318 return client.Connect() 319 } 320 return nil 321 } 322 323 // --------------------------------------------------------------------------- 324 // Handler de mensagens 325 // --------------------------------------------------------------------------- 326 327 func buildHandler(client *whatsmeow.Client, batcher *Batcher) func(interface{}) { 328 type msgSpec struct { 329 msgType, txt, fileName, mime string 330 media whatsmeow.DownloadableMessage 331 } 332 333 classify := func(r *waE2E.Message) (msgSpec, bool) { 334 switch { 335 case r.GetConversation() != "": 336 return msgSpec{msgType: "Texto", txt: r.GetConversation()}, true 337 case r.GetExtendedTextMessage() != nil: 338 return msgSpec{msgType: "Texto", txt: r.GetExtendedTextMessage().GetText()}, true 339 case r.GetImageMessage() != nil: 340 m := r.GetImageMessage() 341 return msgSpec{"Imagem", m.GetCaption(), "imagem.jpg", m.GetMimetype(), m}, true 342 case r.GetStickerMessage() != nil: 343 return msgSpec{"Figurinha", "", "sticker.webp", "image/webp", r.GetStickerMessage()}, true 344 case r.GetAudioMessage() != nil: 345 m := r.GetAudioMessage() 346 return msgSpec{"Áudio", fmt.Sprintf("Duração: %ds", m.GetSeconds()), "audio.ogg", m.GetMimetype(), m}, true 347 case r.GetVideoMessage() != nil: 348 m := r.GetVideoMessage() 349 return msgSpec{"Vídeo", m.GetCaption(), "video.mp4", m.GetMimetype(), m}, true 350 case r.GetDocumentMessage() != nil: 351 m := r.GetDocumentMessage() 352 name := m.GetFileName() 353 if name == "" { 354 name = "arquivo_recebido" 355 } 356 return msgSpec{"Documento", name, name, m.GetMimetype(), m}, true 357 } 358 return msgSpec{}, false 359 } 360 361 return func(evt interface{}) { 362 v, ok := evt.(*events.Message) 363 if !ok || v.Info.IsFromMe || v.Info.Chat.Server == "g.us" || v.Info.Chat.User == "status" { 364 return 365 } 366 raw := v.Message 367 if raw.GetDeviceSentMessage() != nil { 368 raw = raw.GetDeviceSentMessage().GetMessage() 369 } 370 spec, ok := classify(raw) 371 if !ok { 372 return 373 } 374 msg := PendingMsg{msgType: spec.msgType, txt: spec.txt, ts: v.Info.Timestamp} 375 if spec.media != nil { 376 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) 377 defer cancel() 378 if data, err := client.Download(ctx, spec.media); err == nil && len(data) > 0 { 379 msg.files = []struct{ Name, Mime string; Data []byte }{{spec.fileName, spec.mime, data}} 380 } else if err != nil { 381 logf("erro download: %v", err) 382 } 383 } 384 batcher.Add(v.Info.Sender.User, v.Info.PushName, msg) 385 } 386 } 387 388 // --------------------------------------------------------------------------- 389 // Send file 390 // --------------------------------------------------------------------------- 391 392 func sendFile(client *whatsmeow.Client, jid types.JID, path, caption string) error { 393 data, err := os.ReadFile(path) 394 if err != nil { 395 return err 396 } 397 mime := mimetype.Detect(data).String() 398 ctx := context.Background() 399 base := filepath.Base(path) 400 401 switch { 402 case strings.HasPrefix(mime, "image/"): 403 up, err := client.Upload(ctx, data, whatsmeow.MediaImage) 404 if err != nil { 405 return err 406 } 407 _, err = client.SendMessage(ctx, jid, &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ 408 Caption: proto.String(caption), Mimetype: proto.String(mime), 409 URL: proto.String(up.URL), DirectPath: proto.String(up.DirectPath), 410 MediaKey: up.MediaKey, FileEncSHA256: up.FileEncSHA256, 411 FileSHA256: up.FileSHA256, FileLength: proto.Uint64(uint64(len(data))), 412 }}) 413 return err 414 415 case strings.HasPrefix(mime, "video/"): 416 up, err := client.Upload(ctx, data, whatsmeow.MediaVideo) 417 if err != nil { 418 return err 419 } 420 _, err = client.SendMessage(ctx, jid, &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ 421 Caption: proto.String(caption), Mimetype: proto.String(mime), 422 URL: proto.String(up.URL), DirectPath: proto.String(up.DirectPath), 423 MediaKey: up.MediaKey, FileEncSHA256: up.FileEncSHA256, 424 FileSHA256: up.FileSHA256, FileLength: proto.Uint64(uint64(len(data))), 425 }}) 426 return err 427 428 default: 429 up, err := client.Upload(ctx, data, whatsmeow.MediaDocument) 430 if err != nil { 431 return err 432 } 433 _, err = client.SendMessage(ctx, jid, &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ 434 FileName: proto.String(base), Mimetype: proto.String(mime), 435 URL: proto.String(up.URL), DirectPath: proto.String(up.DirectPath), 436 MediaKey: up.MediaKey, FileEncSHA256: up.FileEncSHA256, 437 FileSHA256: up.FileSHA256, FileLength: proto.Uint64(uint64(len(data))), 438 }}) 439 return err 440 } 441 } 442 443 // --------------------------------------------------------------------------- 444 // Daemon 445 // --------------------------------------------------------------------------- 446 447 func runDaemon() { 448 lock, ok := acquireLock() 449 if !ok { 450 fmt.Fprintln(os.Stderr, "outra instância já está rodando") 451 os.Exit(1) 452 } 453 defer releaseLock(lock) 454 455 cfg := loadConfig() 456 initLog() 457 idx := buildIndex(loadAbook()) 458 client := newClient() 459 batcher := newBatcher(cfg, idx) 460 client.AddEventHandler(buildHandler(client, batcher)) 461 client.AddEventHandler(func(evt interface{}) { 462 if _, ok := evt.(*events.Disconnected); !ok { 463 return 464 } 465 logf("desconectado — tentando reconectar...") 466 for backoff := 5 * time.Second; ; backoff = min(backoff*2, 5*time.Minute) { 467 time.Sleep(backoff + time.Duration(rand.Intn(3000))*time.Millisecond) 468 if err := connectClient(client); err == nil { 469 logf("reconectado") 470 return 471 } 472 } 473 }) 474 475 if err := connectClient(client); err != nil { 476 logf("erro ao conectar: %v", err) 477 os.Exit(1) 478 } 479 logf("daemon iniciado") 480 481 quit := make(chan os.Signal, 1) 482 signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 483 <-quit 484 485 logf("encerrando...") 486 batcher.FlushAll() 487 client.Disconnect() 488 logf("encerrado") 489 } 490 491 // --------------------------------------------------------------------------- 492 // Send 493 // --------------------------------------------------------------------------- 494 495 func runSend() { 496 contacts := loadAbook() 497 var lines []string 498 for _, c := range contacts { 499 lines = append(lines, fmt.Sprintf("%s\t%s", c.Name, c.Mobile)) 500 } 501 cmd := exec.Command("fzf", "--prompt=para: ", "--delimiter=\t", "--with-nth=1") 502 cmd.Stdin = strings.NewReader(strings.Join(lines, "\n")) 503 cmd.Stderr = os.Stderr 504 out, err := cmd.Output() 505 if err != nil { 506 return 507 } 508 parts := strings.SplitN(strings.TrimSpace(string(out)), "\t", 2) 509 if len(parts) < 2 { 510 fmt.Fprintln(os.Stderr, "contato inválido") 511 return 512 } 513 jid, _ := types.ParseJID(digitsOnly(parts[1]) + "@s.whatsapp.net") 514 client := newClient() 515 if err := connectClient(client); err != nil { 516 fmt.Fprintln(os.Stderr, "erro ao conectar:", err) 517 return 518 } 519 defer client.Disconnect() 520 fmt.Printf("Para: %s\n", parts[0]) 521 522 if len(os.Args) >= 3 { 523 caption := "" 524 if len(os.Args) >= 4 { 525 caption = os.Args[3] 526 } 527 if err := sendFile(client, jid, os.Args[2], caption); err != nil { 528 fmt.Fprintln(os.Stderr, "erro ao enviar arquivo:", err) 529 } 530 return 531 } 532 533 for s := bufio.NewScanner(os.Stdin); ; { 534 fmt.Print("> ") 535 if !s.Scan() { 536 break 537 } 538 if t := strings.TrimSpace(s.Text()); t != "" { 539 client.SendMessage(context.Background(), jid, &waE2E.Message{Conversation: proto.String(t)}) 540 } else { 541 break 542 } 543 } 544 } 545 546 // --------------------------------------------------------------------------- 547 // Main 548 // --------------------------------------------------------------------------- 549 550 func main() { 551 exe, _ := os.Executable() 552 real, _ := filepath.EvalSymlinks(exe) 553 exeDir = filepath.Dir(real) 554 555 if len(os.Args) < 2 { 556 fmt.Println("uso: zap <daemon|send> [arquivo] [legenda]") 557 return 558 } 559 switch os.Args[1] { 560 case "daemon": 561 runDaemon() 562 case "send": 563 runSend() 564 default: 565 fmt.Println("uso: zap <daemon|send> [arquivo] [legenda]") 566 } 567 }