zap-go

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

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 }