commit 4b8f6dad647d8f0797546b11c7fe9a4d48852a93
parent 1cea5328c23781524d524c9f218ef76f7ee807a3
Author: Sivaldo <sivaldodavi@disroot.org>
Date: Mon, 30 Mar 2026 12:18:53 -0300
Adição de interface
Diffstat:
| M | main.go | | | 485 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------- |
1 file changed, 330 insertions(+), 155 deletions(-)
diff --git a/main.go b/main.go
@@ -1,14 +1,18 @@
package main
import (
+ "bufio"
"context"
+ "encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
+ "time"
"github.com/charmbracelet/bubbles/textinput"
+ "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mdp/qrterminal/v3"
@@ -22,175 +26,336 @@ import (
"google.golang.org/protobuf/proto"
)
-// --- Estilos ---
+// ─── Estilos (aerc-like) ──────────────────────────────────────────────────────
+
var (
- senderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
- receiverStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Bold(true)
- systemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)
- boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).BorderForeground(lipgloss.Color("63"))
+ styleHeader = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("15")).
+ Background(lipgloss.Color("238")).
+ Padding(0, 1)
+
+ styleStatus = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("243")).
+ Background(lipgloss.Color("236")).
+ Padding(0, 1)
+
+ styleDivider = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("240"))
+
+ styleMyName = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("10")).
+ Bold(true)
+
+ styleTheirName = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("12")).
+ Bold(true)
+
+ styleTimestamp = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("240"))
+
+ styleMyMsg = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("252"))
+
+ styleTheirMsg = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("255"))
+
+ styleInputPrompt = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("10")).
+ Bold(true)
+
+ styleInputBar = lipgloss.NewStyle().
+ BorderTop(true).
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("240"))
)
-type chatMsg struct {
- sender string
- content string
- isMe bool
-}
+// ─── Histórico persistente ────────────────────────────────────────────────────
-type model struct {
- client *whatsmeow.Client
- history []chatMsg
- textInput textinput.Model
- target string
- name string
- mode string
- contacts map[string]string
+type storedMsg struct {
+ Sender string `json:"sender"`
+ Content string `json:"content"`
+ IsMe bool `json:"is_me"`
+ Time time.Time `json:"time"`
}
-// --- Lógica de Identificação de Contatos ---
-
-// cleanNumber remove tudo que não é dígito
-func cleanNumber(phone string) string {
- return strings.Map(func(r rune) rune {
- if r >= '0' && r <= '9' {
- return r
- }
- return -1
- }, phone)
+func historyPath(phone string) string {
+ dir := filepath.Join(os.Getenv("HOME"), ".local", "share", "zap-tui")
+ os.MkdirAll(dir, 0700)
+ return filepath.Join(dir, phone+".json")
}
-// findContactName busca o nome comparando o final do número (últimos 8 dígitos)
-// Isso evita problemas com prefixos 55, +55 ou nono dígito variável.
-func findContactName(jidNumber string, contacts map[string]string) string {
- cleanJid := cleanNumber(jidNumber)
- if len(cleanJid) < 8 {
- return jidNumber
- }
-
- suffixJid := cleanJid[len(cleanJid)-8:]
-
- for phone, name := range contacts {
- cleanStored := cleanNumber(phone)
- if len(cleanStored) >= 8 {
- suffixStored := cleanStored[len(cleanStored)-8:]
- if suffixJid == suffixStored {
- return name
- }
- }
+func loadHistory(phone string) []storedMsg {
+ data, err := os.ReadFile(historyPath(phone))
+ if err != nil {
+ return nil
}
- return jidNumber
+ var msgs []storedMsg
+ json.Unmarshal(data, &msgs)
+ return msgs
}
-func loadContactsMap() map[string]string {
- contacts := make(map[string]string)
- home, _ := os.UserHomeDir()
- path := filepath.Join(home, ".local/share/contatos")
+func saveHistory(phone string, msgs []storedMsg) {
+ data, _ := json.Marshal(msgs)
+ os.WriteFile(historyPath(phone), data, 0600)
+}
+
+// ─── Contatos (formato abook) ─────────────────────────────────────────────────
+
+type contact struct {
+ name string
+ mobile string
+}
- data, err := os.ReadFile(path)
+func loadAbook() []contact {
+ path := filepath.Join(os.Getenv("HOME"), ".local", "share", "abook", "addressbook")
+ f, err := os.Open(path)
if err != nil {
- return contacts
+ return nil
}
-
- lines := strings.Split(string(data), "\n")
- for _, line := range lines {
- parts := strings.Split(line, ",")
- if len(parts) >= 2 {
- name := strings.TrimSpace(parts[0])
- phone := strings.TrimSpace(parts[1])
- if name != "" && phone != "" && name != "Nome" {
- contacts[phone] = name
+ defer f.Close()
+
+ var contacts []contact
+ var cur contact
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if strings.HasPrefix(line, "[") && line != "[format]" {
+ if cur.name != "" && cur.mobile != "" {
+ contacts = append(contacts, cur)
+ }
+ cur = contact{}
+ continue
+ }
+ 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)
}
}
}
+ if cur.name != "" && cur.mobile != "" {
+ contacts = append(contacts, cur)
+ }
return contacts
}
-func getContactViaFzf() (string, string) {
- home, _ := os.UserHomeDir()
- path := filepath.Join(home, ".local/share/contatos")
+func pickContactFzf(contacts []contact) (contact, bool) {
+ if len(contacts) == 0 {
+ return contact{}, false
+ }
+ var lines []string
+ for _, c := range contacts {
+ lines = append(lines, fmt.Sprintf("%s\t%s", c.name, c.mobile))
+ }
- cmdStr := fmt.Sprintf("tail -n +2 %s | fzf --header 'Selecionar Contato' --delimiter ',' --with-nth 1", path)
- out, err := exec.Command("sh", "-c", cmdStr).Output()
+ cmd := exec.Command("fzf",
+ "--prompt=Contato: ",
+ "--delimiter=\t",
+ "--with-nth=1",
+ "--header=Enter para abrir Esc para sair",
+ )
+ cmd.Stdin = strings.NewReader(strings.Join(lines, "\n"))
+ cmd.Stderr = os.Stderr
+ out, err := cmd.Output()
if err != nil {
- return "", ""
+ return contact{}, false
}
-
- parts := strings.Split(strings.TrimSpace(string(out)), ",")
+ parts := strings.SplitN(strings.TrimSpace(string(out)), "\t", 2)
if len(parts) < 2 {
- return "", ""
+ return contact{}, false
}
- return parts[0], cleanNumber(parts[1])
+ return contact{name: parts[0], mobile: parts[1]}, true
+}
+
+func cleanNumber(s string) string {
+ return strings.Map(func(r rune) rune {
+ if r >= '0' && r <= '9' {
+ return r
+ }
+ return -1
+ }, s)
}
-// --- Bubble Tea Model ---
+// ─── Modelo Bubble Tea ────────────────────────────────────────────────────────
+
+type incomingMsg struct {
+ sender string
+ content string
+ isMe bool
+ ts time.Time
+}
+
+type model struct {
+ client *whatsmeow.Client
+ target contact
+ phone string
+ history []storedMsg
+ viewport viewport.Model
+ textInput textinput.Model
+ width int
+ height int
+ ready bool
+}
+
+func newModel(client *whatsmeow.Client, target contact) model {
+ phone := cleanNumber(target.mobile)
+
+ ti := textinput.New()
+ ti.Placeholder = "mensagem..."
+ ti.Focus()
+ ti.CharLimit = 1000
+
+ return model{
+ client: client,
+ target: target,
+ phone: phone,
+ history: loadHistory(phone),
+ textInput: ti,
+ }
+}
-func (m model) Init() tea.Cmd { return textinput.Blink }
+func (m model) Init() tea.Cmd {
+ return textinput.Blink
+}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
switch msg := msg.(type) {
+
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ vpH := m.height - 4
+ if vpH < 1 {
+ vpH = 1
+ }
+ if !m.ready {
+ m.viewport = viewport.New(m.width, vpH)
+ m.ready = true
+ } else {
+ m.viewport.Width = m.width
+ m.viewport.Height = vpH
+ }
+ m.viewport.SetContent(m.renderHistory())
+ m.viewport.GotoBottom()
+
case tea.KeyMsg:
- if msg.Type == tea.KeyCtrlC {
+ switch msg.Type {
+ case tea.KeyCtrlC:
+ saveHistory(m.phone, m.history)
return m, tea.Quit
- }
- if m.mode == "send" && msg.Type == tea.KeyEnter {
- input := strings.TrimSpace(m.textInput.Value())
- if input != "" && m.target != "" {
- jid, _ := types.ParseJID(m.target + "@s.whatsapp.net")
- go m.client.SendMessage(context.Background(), jid, &waE2E.Message{Conversation: proto.String(input)})
- m.history = append(m.history, chatMsg{sender: "Você", content: input, isMe: true})
- m.textInput.Reset()
+
+ case tea.KeyEnter:
+ text := strings.TrimSpace(m.textInput.Value())
+ if text == "" {
+ break
+ }
+ jid, err := types.ParseJID(m.phone + "@s.whatsapp.net")
+ if err == nil {
+ go m.client.SendMessage(context.Background(), jid, &waE2E.Message{
+ Conversation: proto.String(text),
+ })
}
+ entry := storedMsg{Sender: "eu", Content: text, IsMe: true, Time: time.Now()}
+ m.history = append(m.history, entry)
+ saveHistory(m.phone, m.history)
+ m.textInput.Reset()
+ m.viewport.SetContent(m.renderHistory())
+ m.viewport.GotoBottom()
}
- case chatMsg:
- m.history = append(m.history, msg)
- }
- var cmd tea.Cmd
- if m.mode == "send" {
- m.textInput, cmd = m.textInput.Update(msg)
+
+ case incomingMsg:
+ entry := storedMsg{
+ Sender: msg.sender,
+ Content: msg.content,
+ IsMe: msg.isMe,
+ Time: msg.ts,
+ }
+ m.history = append(m.history, entry)
+ saveHistory(m.phone, m.history)
+ m.viewport.SetContent(m.renderHistory())
+ m.viewport.GotoBottom()
}
- return m, cmd
+
+ var vpCmd tea.Cmd
+ m.viewport, vpCmd = m.viewport.Update(msg)
+ cmds = append(cmds, vpCmd)
+
+ var tiCmd tea.Cmd
+ m.textInput, tiCmd = m.textInput.Update(msg)
+ cmds = append(cmds, tiCmd)
+
+ return m, tea.Batch(cmds...)
}
-func (m model) View() string {
- var body strings.Builder
- for _, c := range m.history {
- style := receiverStyle
- if c.isMe {
- style = senderStyle
+func (m model) renderHistory() string {
+ if len(m.history) == 0 {
+ return styleTimestamp.Render(" sem mensagens ainda")
+ }
+
+ var sb strings.Builder
+ lastDate := ""
+
+ for _, msg := range m.history {
+ date := msg.Time.Format("02 Jan 2006")
+ if date != lastDate {
+ lastDate = date
+ pad := (m.width - len(date) - 4) / 2
+ if pad < 1 {
+ pad = 1
+ }
+ line := strings.Repeat("─", pad) + " " + date + " " + strings.Repeat("─", pad)
+ sb.WriteString("\n" + styleDivider.Render(line) + "\n\n")
+ }
+
+ ts := styleTimestamp.Render(msg.Time.Format("15:04"))
+
+ if msg.IsMe {
+ name := styleMyName.Render("eu")
+ content := styleMyMsg.Render(msg.Content)
+ sb.WriteString(fmt.Sprintf(" %s %s %s\n", name, ts, content))
+ } else {
+ name := styleTheirName.Render(msg.Sender)
+ content := styleTheirMsg.Render(msg.Content)
+ sb.WriteString(fmt.Sprintf(" %s %s %s\n", name, ts, content))
}
- body.WriteString(fmt.Sprintf("%s %s\n", style.Render(c.sender+":"), c.content))
}
+ return sb.String()
+}
- header := "[ Monitor de Mensagens ]"
- if m.mode == "send" {
- header = fmt.Sprintf("[ Chat: %s ]", m.name)
+func (m model) View() string {
+ if !m.ready {
+ return ""
}
- view := systemStyle.Render(header) + "\n" + boxStyle.Width(65).Height(15).Render(body.String())
- footer := systemStyle.Render("\n Ctrl+C para encerrar")
+ header := styleHeader.Width(m.width).Render(
+ fmt.Sprintf("zap ─ %s (%s)", m.target.name, m.target.mobile),
+ )
- if m.mode == "send" {
- return view + footer + "\n" + m.textInput.View()
- }
- return view + footer
+ chat := m.viewport.View()
+
+ prompt := styleInputPrompt.Render(" > ")
+ inputArea := styleInputBar.Width(m.width).Render(prompt + m.textInput.View())
+
+ status := styleStatus.Width(m.width).Render(
+ fmt.Sprintf(" %d mensagens │ PgUp/PgDn rolar │ Ctrl+C sair", len(m.history)),
+ )
+
+ return lipgloss.JoinVertical(lipgloss.Left, header, chat, inputArea, status)
}
-// --- Main ---
+// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
- if len(os.Args) < 2 {
- fmt.Println("Uso: zap-tui [send|receive]")
- return
- }
-
- mode := os.Args[1]
- contactsMap := loadContactsMap()
- var contactName, contactPhone string
+ contacts := loadAbook()
- if mode == "send" {
- contactName, contactPhone = getContactViaFzf()
- if contactPhone == "" {
- return
- }
+ chosen, ok := pickContactFzf(contacts)
+ if !ok {
+ return
}
exePath, _ := os.Executable()
@@ -198,49 +363,59 @@ func main() {
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))
+ container, err := sqlstore.New(
+ context.Background(), "sqlite3", dbURI,
+ waLog.Stdout("Database", "ERROR", true),
+ )
if err != nil {
- panic(err)
+ fmt.Fprintln(os.Stderr, "erro ao abrir db:", err)
+ os.Exit(1)
}
device, _ := container.GetFirstDevice(context.Background())
client := whatsmeow.NewClient(device, waLog.Stdout("Client", "ERROR", true))
- ti := textinput.New()
- ti.Placeholder = "Mensagem..."
- ti.Focus()
+ m := newModel(client, chosen)
+ p := tea.NewProgram(m, tea.WithAltScreen())
- p := tea.NewProgram(model{
- client: client,
- textInput: ti,
- target: contactPhone,
- name: contactName,
- mode: mode,
- contacts: contactsMap,
- })
+ phoneClean := cleanNumber(chosen.mobile)
client.AddEventHandler(func(evt interface{}) {
- if v, ok := evt.(*events.Message); ok {
- txt := v.Message.GetConversation()
- if txt == "" {
- txt = v.Message.GetExtendedTextMessage().GetText()
- }
- if txt != "" {
- // Usa a nova lógica de comparação parcial para identificar o remetente
- senderName := findContactName(v.Info.Sender.User, contactsMap)
-
- // Se não achou no CSV, tenta o PushName do WhatsApp
- if senderName == v.Info.Sender.User && v.Info.PushName != "" {
- senderName = v.Info.PushName
- }
-
- p.Send(chatMsg{
- sender: senderName,
- content: txt,
- isMe: v.Info.IsFromMe,
- })
- }
+ v, ok := evt.(*events.Message)
+ if !ok {
+ return
+ }
+
+ txt := v.Message.GetConversation()
+ if txt == "" {
+ txt = v.Message.GetExtendedTextMessage().GetText()
+ }
+ if txt == "" {
+ return
+ }
+
+ senderClean := cleanNumber(v.Info.Sender.User)
+ mine := v.Info.IsFromMe
+
+ relevant := mine
+ if !mine && len(senderClean) >= 8 && len(phoneClean) >= 8 {
+ relevant = senderClean[len(senderClean)-8:] == phoneClean[len(phoneClean)-8:]
}
+ if !relevant {
+ return
+ }
+
+ senderName := chosen.name
+ if mine {
+ senderName = "eu"
+ }
+
+ p.Send(incomingMsg{
+ sender: senderName,
+ content: txt,
+ isMe: mine,
+ ts: v.Info.Timestamp,
+ })
})
if client.Store.ID == nil {
@@ -259,4 +434,5 @@ func main() {
os.Exit(1)
}
client.Disconnect()
-}
-\ No newline at end of file
+}
+