From faeda0b21dfe8dfe7871219843549fa1dc1eb01f Mon Sep 17 00:00:00 2001 From: Fdvkey Date: Mon, 6 Jul 2026 18:53:04 +0700 Subject: [PATCH] feat: initial whatsmeow bot with command handler and context helpers Core framework (Handler, Command, Default muxer) in core/ package. Command self-registration via init() with blank imports in cmds/. Context helpers (send, reply, download, upload) in context/ package. Permission system with GROUP_ONLY / PRIVATE_ONLY checks. WhatsApp client configured as Safari with history sync disabled. --- .gitignore | 4 + cmds/misc/ping.go | 24 ++++++ cmds/register.go | 5 ++ context/ctx.go | 57 +++++++++++++ context/download.go | 50 +++++++++++ context/message.go | 85 +++++++++++++++++++ context/send.go | 106 +++++++++++++++++++++++ context/upload.go | 153 ++++++++++++++++++++++++++++++++++ core/core.go | 139 +++++++++++++++++++++++++++++++ core/permission.go | 45 ++++++++++ go.mod | 35 ++++++++ go.sum | 71 ++++++++++++++++ main.go | 81 ++++++++++++++++++ util/message.go | 199 ++++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 1054 insertions(+) create mode 100644 .gitignore create mode 100644 cmds/misc/ping.go create mode 100644 cmds/register.go create mode 100644 context/ctx.go create mode 100644 context/download.go create mode 100644 context/message.go create mode 100644 context/send.go create mode 100644 context/upload.go create mode 100644 core/core.go create mode 100644 core/permission.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 util/message.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ea1796 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.db +.env +doc.json +neo \ No newline at end of file diff --git a/cmds/misc/ping.go b/cmds/misc/ping.go new file mode 100644 index 0000000..d78209e --- /dev/null +++ b/cmds/misc/ping.go @@ -0,0 +1,24 @@ +package misc + +import ( + "fmt" + "time" + + hc "neo/context" + "neo/core" +) + +func init() { core.Default.Register(Ping) } + +var Ping = &core.Command{ + Name: "ping", + Aliases: []string{"test", "tes", "p"}, + Category: "general", + Description: "Check bot response time", + Permissions: []core.Permission{core.PermissionGroupOnly}, + Run: func(ctx *hc.Ctx) { + t := ctx.Event().Info.Timestamp + speed := time.Since(t).Milliseconds() + ctx.Reply(fmt.Sprintf("Pong! 🏓\n\nSpeed: %d ms", speed)) + }, +} diff --git a/cmds/register.go b/cmds/register.go new file mode 100644 index 0000000..17c1484 --- /dev/null +++ b/cmds/register.go @@ -0,0 +1,5 @@ +package cmds + +import ( + _ "neo/cmds/misc" +) diff --git a/context/ctx.go b/context/ctx.go new file mode 100644 index 0000000..f6bcef0 --- /dev/null +++ b/context/ctx.go @@ -0,0 +1,57 @@ +package context + +import ( + "strings" + + "go.mau.fi/whatsmeow" + waE2E "go.mau.fi/whatsmeow/proto/waE2E" + waTypes "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +type Ctx struct { + client *whatsmeow.Client + event *events.Message + message *waE2E.Message + arguments []string + prefix string + cmd string +} + +func NewCtx(client *whatsmeow.Client, evt *events.Message, prefix, cmd string, args []string) *Ctx { + var msg *waE2E.Message + if evt.IsViewOnce { + msg = evt.Message.GetViewOnceMessage().GetMessage() + } else if evt.IsEphemeral { + msg = evt.Message.GetEphemeralMessage().GetMessage() + } else { + msg = evt.Message + } + return &Ctx{ + client: client, + event: evt, + message: msg, + arguments: args, + prefix: prefix, + cmd: cmd, + } +} + +func (c *Ctx) Client() *whatsmeow.Client { return c.client } +func (c *Ctx) Event() *events.Message { return c.event } +func (c *Ctx) Message() *waE2E.Message { return c.message } +func (c *Ctx) SenderJID() waTypes.JID { return c.event.Info.Sender.ToNonAD() } +func (c *Ctx) ChatJID() waTypes.JID { return c.event.Info.Chat } +func (c *Ctx) IsGroup() bool { return c.event.Info.IsGroup } +func (c *Ctx) IsFromMe() bool { return c.event.Info.IsFromMe } +func (c *Ctx) Number() string { return c.event.Info.Sender.ToNonAD().String() } +func (c *Ctx) Prefix() string { return c.prefix } +func (c *Ctx) Cmd() string { return c.cmd } +func (c *Ctx) Arguments() []string { return c.arguments } +func (c *Ctx) FullArgs() string { return strings.Join(c.arguments, " ") } +func (c *Ctx) Arg(index int) string { + if index < len(c.arguments) { + return c.arguments[index] + } + return "" +} diff --git a/context/download.go b/context/download.go new file mode 100644 index 0000000..1a04e50 --- /dev/null +++ b/context/download.go @@ -0,0 +1,50 @@ +package context + +import ( + stdctx "context" + "fmt" + + "go.mau.fi/whatsmeow" + waE2E "go.mau.fi/whatsmeow/proto/waE2E" +) + +func (c *Ctx) Download() ([]byte, error) { + msg := getDownloadable(c.message) + if msg == nil { + return nil, fmt.Errorf("message has no downloadable media") + } + return c.client.Download(stdctx.Background(), msg) +} + +func (c *Ctx) DownloadQuoted() ([]byte, error) { + quoted := GetQuotedMessage(c.event.Message) + if quoted == nil { + return nil, fmt.Errorf("no quoted message") + } + msg := getDownloadable(quoted) + if msg == nil { + return nil, fmt.Errorf("quoted message has no downloadable media") + } + return c.client.Download(stdctx.Background(), msg) +} + +func (c *Ctx) DownloadMessage(msg whatsmeow.DownloadableMessage) ([]byte, error) { + return c.client.Download(stdctx.Background(), msg) +} + +func getDownloadable(m *waE2E.Message) whatsmeow.DownloadableMessage { + switch { + case m.GetImageMessage() != nil: + return m.GetImageMessage() + case m.GetVideoMessage() != nil: + return m.GetVideoMessage() + case m.GetAudioMessage() != nil: + return m.GetAudioMessage() + case m.GetDocumentMessage() != nil: + return m.GetDocumentMessage() + case m.GetStickerMessage() != nil: + return m.GetStickerMessage() + default: + return nil + } +} diff --git a/context/message.go b/context/message.go new file mode 100644 index 0000000..a051f26 --- /dev/null +++ b/context/message.go @@ -0,0 +1,85 @@ +package context + +import ( + "strings" + + waE2E "go.mau.fi/whatsmeow/proto/waE2E" + waTypes "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +// ParseMessageText extracts text content from a message event. +func ParseMessageText(evt *events.Message) string { + var msg *waE2E.Message + if evt.IsViewOnce { + msg = evt.Message.GetViewOnceMessage().GetMessage() + } else if evt.IsEphemeral { + msg = evt.Message.GetEphemeralMessage().GetMessage() + } else { + msg = evt.Message + } + + switch { + case msg.GetConversation() != "": + return msg.GetConversation() + case msg.GetExtendedTextMessage().GetText() != "": + return msg.GetExtendedTextMessage().GetText() + case msg.GetImageMessage().GetCaption() != "": + return msg.GetImageMessage().GetCaption() + case msg.GetVideoMessage().GetCaption() != "": + return msg.GetVideoMessage().GetCaption() + default: + return "" + } +} + +// GetQuotedMessage extracts the quoted message from context info. +func GetQuotedMessage(m *waE2E.Message) *waE2E.Message { + if m == nil { + return nil + } + switch { + case m.GetExtendedTextMessage().GetContextInfo() != nil: + return m.GetExtendedTextMessage().GetContextInfo().GetQuotedMessage() + case m.GetImageMessage().GetContextInfo() != nil: + return m.GetImageMessage().GetContextInfo().GetQuotedMessage() + case m.GetVideoMessage().GetContextInfo() != nil: + return m.GetVideoMessage().GetContextInfo().GetQuotedMessage() + case m.GetDocumentMessage().GetContextInfo() != nil: + return m.GetDocumentMessage().GetContextInfo().GetQuotedMessage() + case m.GetAudioMessage().GetContextInfo() != nil: + return m.GetAudioMessage().GetContextInfo().GetQuotedMessage() + case m.GetStickerMessage().GetContextInfo() != nil: + return m.GetStickerMessage().GetContextInfo().GetQuotedMessage() + default: + return nil + } +} + +// WithReply creates ContextInfo that quotes the original message. +func WithReply(evt *events.Message) *waE2E.ContextInfo { + return &waE2E.ContextInfo{ + StanzaID: &evt.Info.ID, + Participant: proto.String(evt.Info.Sender.String()), + QuotedMessage: evt.Message, + } +} + +// ParseJID parses a JID string, adding @s.whatsapp.net if no server is specified. +func ParseJID(arg string) (waTypes.JID, bool) { + if arg == "" { + return waTypes.JID{}, false + } + if arg[0] == '+' { + arg = arg[1:] + } + if !strings.ContainsRune(arg, '@') { + return waTypes.NewJID(arg, waTypes.DefaultUserServer), true + } + recipient, err := waTypes.ParseJID(arg) + if err != nil || recipient.User == "" { + return recipient, false + } + return recipient, true +} diff --git a/context/send.go b/context/send.go new file mode 100644 index 0000000..58b6e7b --- /dev/null +++ b/context/send.go @@ -0,0 +1,106 @@ +package context + +import ( + stdctx "context" + + waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waE2E "go.mau.fi/whatsmeow/proto/waE2E" + waTypes "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" +) + +func (c *Ctx) Reply(text string) { + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String(text), + ContextInfo: &waE2E.ContextInfo{ + StanzaID: &c.event.Info.ID, + Participant: proto.String(c.event.Info.Sender.String()), + QuotedMessage: c.event.Message, + }, + }, + } + c.client.SendMessage(stdctx.Background(), c.event.Info.Chat, msg) +} + +func (c *Ctx) Send(text string) { + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String(text), + }, + } + c.client.SendMessage(stdctx.Background(), c.event.Info.Chat, msg) +} + +func (c *Ctx) SendMessage(msg *waE2E.Message) { + c.client.SendMessage(stdctx.Background(), c.event.Info.Chat, msg) +} + +func (c *Ctx) React(emoji string) { + msg := &waE2E.Message{ + ReactionMessage: &waE2E.ReactionMessage{ + Key: &waCommon.MessageKey{ + RemoteJID: proto.String(c.event.Info.Chat.String()), + FromMe: proto.Bool(c.event.Info.IsFromMe), + ID: proto.String(c.event.Info.ID), + }, + Text: proto.String(emoji), + SenderTimestampMS: proto.Int64(c.event.Info.Timestamp.UnixMilli()), + }, + } + c.client.SendMessage(stdctx.Background(), c.event.Info.Chat, msg) +} + +func (c *Ctx) Edit(text string) error { + msgKey := &waCommon.MessageKey{ + RemoteJID: proto.String(c.ChatJID().String()), + FromMe: proto.Bool(true), + ID: proto.String(c.event.Info.ID), + } + + if c.message.GetExtendedTextMessage() != nil { + c.message.ExtendedTextMessage.Text = proto.String(text) + } else { + c.message = &waE2E.Message{ + Conversation: proto.String(text), + } + } + + editMsg := &waE2E.Message{ + ProtocolMessage: &waE2E.ProtocolMessage{ + Key: msgKey, + Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(), + EditedMessage: c.message, + }, + } + + _, err := c.client.SendMessage(stdctx.Background(), c.event.Info.Chat, editMsg) + return err +} + +func (c *Ctx) Revoke() { + _, _ = c.client.SendMessage( + stdctx.Background(), + c.event.Info.Chat, + c.client.BuildRevoke(c.event.Info.Chat, c.event.Info.Sender, c.event.Info.ID), + ) +} + +func (c *Ctx) MarkRead() { + _ = c.client.MarkRead( + stdctx.Background(), + []string{c.event.Info.ID}, + c.event.Info.Timestamp, + c.event.Info.Chat, + c.event.Info.Sender, + ) +} + +func (c *Ctx) SendPresence(composing bool) { + state := waTypes.ChatPresencePaused + if composing { + state = waTypes.ChatPresenceComposing + } + _ = c.client.SubscribePresence(stdctx.Background(), c.event.Info.Chat) + _ = c.client.SendChatPresence(stdctx.Background(), c.event.Info.Chat, state, waTypes.ChatPresenceMediaText) +} diff --git a/context/upload.go b/context/upload.go new file mode 100644 index 0000000..52de294 --- /dev/null +++ b/context/upload.go @@ -0,0 +1,153 @@ +package context + +import ( + stdctx "context" + "strings" + + "github.com/gabriel-vasile/mimetype" + "go.mau.fi/whatsmeow" + waE2E "go.mau.fi/whatsmeow/proto/waE2E" + "google.golang.org/protobuf/proto" +) + +func (c *Ctx) UploadImage(bytes []byte, caption string) (*waE2E.ImageMessage, error) { + mime := mimetype.Detect(bytes) + resp, err := c.client.Upload(stdctx.Background(), bytes, whatsmeow.MediaImage) + if err != nil { + return nil, err + } + return &waE2E.ImageMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime.String()), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + }, nil +} + +func (c *Ctx) UploadVideo(bytes []byte, caption string) (*waE2E.VideoMessage, error) { + mime := mimetype.Detect(bytes) + resp, err := c.client.Upload(stdctx.Background(), bytes, whatsmeow.MediaVideo) + if err != nil { + return nil, err + } + return &waE2E.VideoMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime.String()), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + }, nil +} + +func (c *Ctx) UploadAudio(bytes []byte) (*waE2E.AudioMessage, error) { + mime := mimetype.Detect(bytes) + resp, err := c.client.Upload(stdctx.Background(), bytes, whatsmeow.MediaAudio) + if err != nil { + return nil, err + } + return &waE2E.AudioMessage{ + Mimetype: proto.String(mime.String()), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + }, nil +} + +func (c *Ctx) UploadDocument(bytes []byte, title, filename string) (*waE2E.DocumentMessage, error) { + mime := mimetype.Detect(bytes) + resp, err := c.client.Upload(stdctx.Background(), bytes, whatsmeow.MediaDocument) + if err != nil { + return nil, err + } + return &waE2E.DocumentMessage{ + Title: proto.String(title), + FileName: proto.String(filename), + Mimetype: proto.String(mime.String()), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + }, nil +} + +func (c *Ctx) UploadSticker(bytes []byte) (*waE2E.StickerMessage, error) { + resp, err := c.client.Upload(stdctx.Background(), bytes, whatsmeow.MediaImage) + if err != nil { + return nil, err + } + return &waE2E.StickerMessage{ + Mimetype: proto.String("image/webp"), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + }, nil +} + +func (c *Ctx) SendImage(bytes []byte, caption string) { + img, err := c.UploadImage(bytes, caption) + if err != nil { + c.Reply("Failed to upload image: " + err.Error()) + return + } + c.SendMessage(&waE2E.Message{ImageMessage: img}) +} + +func (c *Ctx) SendVideo(bytes []byte, caption string) { + vid, err := c.UploadVideo(bytes, caption) + if err != nil { + c.Reply("Failed to upload video: " + err.Error()) + return + } + c.SendMessage(&waE2E.Message{VideoMessage: vid}) +} + +func (c *Ctx) SendAudio(bytes []byte) { + aud, err := c.UploadAudio(bytes) + if err != nil { + c.Reply("Failed to upload audio: " + err.Error()) + return + } + c.SendMessage(&waE2E.Message{AudioMessage: aud}) +} + +func (c *Ctx) SendDocument(bytes []byte, title, filename string) { + doc, err := c.UploadDocument(bytes, title, filename) + if err != nil { + c.Reply("Failed to upload document: " + err.Error()) + return + } + c.SendMessage(&waE2E.Message{DocumentMessage: doc}) +} + +func (c *Ctx) SendSticker(bytes []byte) { + sticker, err := c.UploadSticker(bytes) + if err != nil { + c.Reply("Failed to upload sticker: " + err.Error()) + return + } + c.SendMessage(&waE2E.Message{StickerMessage: sticker}) +} + +func matchExtension(ext string, list []string) bool { + for _, e := range list { + if strings.EqualFold(ext, e) { + return true + } + } + return false +} diff --git a/core/core.go b/core/core.go new file mode 100644 index 0000000..709910a --- /dev/null +++ b/core/core.go @@ -0,0 +1,139 @@ +package core + +import ( + "fmt" + "strings" + "sync" + + hc "neo/context" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types/events" +) + +type CommandFunc func(ctx *hc.Ctx) + +type Command struct { + Name string + Aliases []string + Description string + Category string + Permissions []Permission + Run CommandFunc +} + +// Default is the global command muxer. Each command registers itself +// via init() — see neo/cmds for the registration pattern. +var Default = New() + +type Handler struct { + mu sync.RWMutex + commands map[string]*Command + prefixes []string +} + +func New(prefixes ...string) *Handler { + if len(prefixes) == 0 { + prefixes = []string{"!", "/", "."} + } + return &Handler{ + commands: make(map[string]*Command), + prefixes: prefixes, + } +} + +func (h *Handler) Register(cmds ...*Command) { + h.mu.Lock() + defer h.mu.Unlock() + for _, cmd := range cmds { + if cmd.Name == "" { + panic("core: command name cannot be empty") + } + if cmd.Run == nil { + panic(fmt.Sprintf("core: command %q has no Run function", cmd.Name)) + } + h.commands[cmd.Name] = cmd + for _, alias := range cmd.Aliases { + h.commands[alias] = cmd + } + } +} + +func (h *Handler) GetCommands() []*Command { + h.mu.RLock() + defer h.mu.RUnlock() + seen := make(map[string]bool) + var result []*Command + for _, cmd := range h.commands { + if !seen[cmd.Name] { + seen[cmd.Name] = true + result = append(result, cmd) + } + } + return result +} + +func (h *Handler) EventHandler(client *whatsmeow.Client) func(any) { + return func(rawEvt any) { + evt, ok := rawEvt.(*events.Message) + if !ok { + return + } + go h.processMessage(client, evt) + } +} + +func (h *Handler) processMessage(client *whatsmeow.Client, evt *events.Message) { + defer func() { + if r := recover(); r != nil { + fmt.Printf("[CMD] panic from %s: %v\n", formatSenderInfo(evt), r) + } + }() + + text := hc.ParseMessageText(evt) + prefix, cmd, args, isCmd := parseCommand(text, h.prefixes) + if !isCmd { + return + } + + h.mu.RLock() + command, exists := h.commands[cmd] + h.mu.RUnlock() + if !exists { + return + } + + if msg := CheckCommandPermissions(command, evt); msg != "" { + sendPermissionDenied(client, evt, msg) + return + } + + ctx := hc.NewCtx(client, evt, prefix, cmd, args) + fmt.Printf("[CMD] %s > %s%s\n", formatSenderInfo(evt), prefix, cmd) + command.Run(ctx) +} + +func parseCommand(text string, prefixes []string) (prefix, cmd string, args []string, ok bool) { + if text == "" { + return + } + for _, p := range prefixes { + if strings.HasPrefix(text, p) { + rest := text[len(p):] + parts := strings.Fields(rest) + if len(parts) == 0 { + return + } + return p, strings.ToLower(parts[0]), parts[1:], true + } + } + return +} + +func formatSenderInfo(evt *events.Message) string { + sender := evt.Info.Sender.ToNonAD().String() + if evt.Info.IsGroup { + return fmt.Sprintf("[%s] %s", evt.Info.Chat.String(), sender) + } + return sender +} diff --git a/core/permission.go b/core/permission.go new file mode 100644 index 0000000..222bc15 --- /dev/null +++ b/core/permission.go @@ -0,0 +1,45 @@ +package core + +import ( + "context" + + "go.mau.fi/whatsmeow" + waE2E "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +type Permission string + +const ( + PermissionGroupOnly Permission = "GROUP_ONLY" + PermissionPrivateOnly Permission = "PRIVATE_ONLY" +) + +// CheckCommandPermissions returns an error message if the command's +// permissions deny the event context, or empty string if allowed. +func CheckCommandPermissions(cmd *Command, evt *events.Message) string { + for _, p := range cmd.Permissions { + switch p { + case PermissionGroupOnly: + if !evt.Info.IsGroup { + return "This command can only be used in groups" + } + case PermissionPrivateOnly: + if evt.Info.IsGroup { + return "This command can only be used in private chat" + } + } + } + return "" +} + +// sendPermissionDenied sends a plain text reply indicating why the +// command was blocked, so the caller gets feedback instead of silence. +func sendPermissionDenied(client *whatsmeow.Client, evt *events.Message, text string) { + client.SendMessage(context.Background(), evt.Info.Chat, &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String(text), + }, + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9680887 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module neo + +go 1.25.5 + +require ( + github.com/mattn/go-sqlite3 v1.14.47 + github.com/mdp/qrterminal/v3 v3.2.1 + go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect + go.mau.fi/libsignal v0.2.2 // indirect + go.mau.fi/util v0.9.10 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..81afb20 --- /dev/null +++ b/go.sum @@ -0,0 +1,71 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= +github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= +github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= +go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= +go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= +go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b h1:ZUk1ErarDNpnbosXR/MeOz2gkqA4S1bh8zjaSRj7N+Y= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b/go.mod h1:9dmNTYZ/1pHjPw/bz+azBsGjAkcrZbqzMrKcvG5bJ8U= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/main.go b/main.go new file mode 100644 index 0000000..53847c3 --- /dev/null +++ b/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + _ "neo/cmds" + "neo/core" + + "github.com/joho/godotenv" + _ "github.com/mattn/go-sqlite3" + "github.com/mdp/qrterminal/v3" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waCompanionReg" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" +) + +func init() { + godotenv.Load() + + store.SetOSInfo("macOS", [3]uint32{10, 15, 7}) + store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_SAFARI.Enum() + store.DeviceProps.HistorySyncConfig.InlineInitialPayloadInE2EeMsg = proto.Bool(false) + store.DeviceProps.HistorySyncConfig.RecentSyncDaysLimit = nil + store.DeviceProps.HistorySyncConfig.FullSyncDaysLimit = nil + store.DeviceProps.HistorySyncConfig.FullSyncSizeMbLimit = nil +} + +func main() { + dbLog := waLog.Stdout("Database", "DEBUG", true) + ctx := context.Background() + container, err := sqlstore.New(ctx, "sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", os.Getenv("SESSION_PATH")), dbLog) + if err != nil { + panic(err) + } + + deviceStore, err := container.GetFirstDevice(ctx) + if err != nil { + panic(err) + } + + clientLog := waLog.Stdout("Client", "DEBUG", true) + client := whatsmeow.NewClient(deviceStore, clientLog) + + client.ManualHistorySyncDownload = false + client.DisableManualHistorySyncReceipt = true + + client.AddEventHandler(core.Default.EventHandler(client)) + + if client.Store.ID == nil { + qrChan, _ := client.GetQRChannel(context.Background()) + err = client.Connect() + if err != nil { + panic(err) + } + for evt := range qrChan { + if evt.Event == "code" { + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + } else { + fmt.Println("Login event:", evt.Event) + } + } + } else { + err = client.Connect() + if err != nil { + panic(err) + } + } + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + <-c + + client.Disconnect() +} diff --git a/util/message.go b/util/message.go new file mode 100644 index 0000000..65ef100 --- /dev/null +++ b/util/message.go @@ -0,0 +1,199 @@ +package util + +import ( + "strings" + + "go.mau.fi/whatsmeow/proto/waE2E" + waProto "go.mau.fi/whatsmeow/proto/waE2E" + waTypes "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +func ParseMessageText(m *events.Message) string { + var msg *waE2E.Message + if m.IsViewOnce { + msg = m.Message.GetViewOnceMessage().GetMessage() + } else if m.IsEphemeral { + msg = m.Message.GetEphemeralMessage().GetMessage() + } else { + msg = m.Message + } + if msg.GetConversation() != "" { + return msg.GetConversation() + } else if msg.GetVideoMessage().GetCaption() != "" { + return msg.GetVideoMessage().GetCaption() + } else if msg.GetImageMessage().GetCaption() != "" { + return msg.GetImageMessage().GetCaption() + } else if msg.GetExtendedTextMessage().GetText() != "" { + return msg.GetExtendedTextMessage().GetText() + } else { + return "" + } +} + +func ParseQuotedMessage(m *waProto.Message) *waProto.Message { + if m.GetExtendedTextMessage().GetContextInfo() != nil { + return m.GetExtendedTextMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetImageMessage().GetContextInfo() != nil { + return m.GetImageMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetVideoMessage().GetContextInfo() != nil { + return m.GetVideoMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetDocumentMessage().GetContextInfo() != nil { + return m.GetDocumentMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetAudioMessage().GetContextInfo() != nil { + return m.GetAudioMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetStickerMessage().GetContextInfo() != nil { + return m.GetStickerMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetButtonsMessage().GetContextInfo() != nil { + return m.GetButtonsMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetGroupInviteMessage().GetContextInfo() != nil { + return m.GetGroupInviteMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetProductMessage().GetContextInfo() != nil { + return m.GetProductMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetListMessage().GetContextInfo() != nil { + return m.GetListMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetTemplateMessage().GetContextInfo() != nil { + return m.GetTemplateMessage().GetContextInfo().GetQuotedMessage() + } else if m.GetContactMessage().GetContextInfo() != nil { + return m.GetContactMessage().GetContextInfo().GetQuotedMessage() + } else { + return m + } +} + +func ParseQuotedRemoteJid(m *waProto.Message) *string { + if m.GetExtendedTextMessage().GetContextInfo() != nil { + return m.GetExtendedTextMessage().GetContextInfo().Participant + } else if m.GetImageMessage().GetContextInfo() != nil { + return m.GetImageMessage().GetContextInfo().Participant + } else if m.GetVideoMessage().GetContextInfo() != nil { + return m.GetVideoMessage().GetContextInfo().Participant + } else if m.GetDocumentMessage().GetContextInfo() != nil { + return m.GetDocumentMessage().GetContextInfo().Participant + } else if m.GetAudioMessage().GetContextInfo() != nil { + return m.GetAudioMessage().GetContextInfo().Participant + } else if m.GetStickerMessage().GetContextInfo() != nil { + return m.GetStickerMessage().GetContextInfo().Participant + } else if m.GetButtonsMessage().GetContextInfo() != nil { + return m.GetButtonsMessage().GetContextInfo().Participant + } else if m.GetGroupInviteMessage().GetContextInfo() != nil { + return m.GetGroupInviteMessage().GetContextInfo().Participant + } else if m.GetProductMessage().GetContextInfo() != nil { + return m.GetProductMessage().GetContextInfo().Participant + } else if m.GetListMessage().GetContextInfo() != nil { + return m.GetListMessage().GetContextInfo().Participant + } else if m.GetTemplateMessage().GetContextInfo() != nil { + return m.GetTemplateMessage().GetContextInfo().Participant + } else if m.GetContactMessage().GetContextInfo() != nil { + return m.GetContactMessage().GetContextInfo().Participant + } else { + return nil + } +} + +func ParseMentionedJid(m *waProto.Message) []string { + if m.GetExtendedTextMessage().GetContextInfo() != nil { + return m.GetExtendedTextMessage().GetContextInfo().GetMentionedJID() + } else if m.GetImageMessage().GetContextInfo() != nil { + return m.GetImageMessage().GetContextInfo().GetMentionedJID() + } else if m.GetVideoMessage().GetContextInfo() != nil { + return m.GetVideoMessage().GetContextInfo().GetMentionedJID() + } else if m.GetDocumentMessage().GetContextInfo() != nil { + return m.GetDocumentMessage().GetContextInfo().GetMentionedJID() + } else if m.GetAudioMessage().GetContextInfo() != nil { + return m.GetAudioMessage().GetContextInfo().GetMentionedJID() + } else if m.GetStickerMessage().GetContextInfo() != nil { + return m.GetStickerMessage().GetContextInfo().GetMentionedJID() + } else if m.GetButtonsMessage().GetContextInfo() != nil { + return m.GetButtonsMessage().GetContextInfo().GetMentionedJID() + } else if m.GetGroupInviteMessage().GetContextInfo() != nil { + return m.GetGroupInviteMessage().GetContextInfo().GetMentionedJID() + } else if m.GetProductMessage().GetContextInfo() != nil { + return m.GetProductMessage().GetContextInfo().GetMentionedJID() + } else if m.GetListMessage().GetContextInfo() != nil { + return m.GetListMessage().GetContextInfo().GetMentionedJID() + } else if m.GetTemplateMessage().GetContextInfo() != nil { + return m.GetTemplateMessage().GetContextInfo().GetMentionedJID() + } else if m.GetContactMessage().GetContextInfo() != nil { + return m.GetContactMessage().GetContextInfo().GetMentionedJID() + } else { + return make([]string, 0) + } +} + +func ParseQuotedMessageId(m *waProto.Message) *string { + if m.GetExtendedTextMessage().GetContextInfo() != nil { + return m.GetExtendedTextMessage().GetContextInfo().StanzaID + } else if m.GetImageMessage().GetContextInfo() != nil { + return m.GetImageMessage().GetContextInfo().StanzaID + } else if m.GetVideoMessage().GetContextInfo() != nil { + return m.GetVideoMessage().GetContextInfo().StanzaID + } else if m.GetDocumentMessage().GetContextInfo() != nil { + return m.GetDocumentMessage().GetContextInfo().StanzaID + } else if m.GetAudioMessage().GetContextInfo() != nil { + return m.GetAudioMessage().GetContextInfo().StanzaID + } else if m.GetStickerMessage().GetContextInfo() != nil { + return m.GetStickerMessage().GetContextInfo().StanzaID + } else if m.GetButtonsMessage().GetContextInfo() != nil { + return m.GetButtonsMessage().GetContextInfo().StanzaID + } else if m.GetGroupInviteMessage().GetContextInfo() != nil { + return m.GetGroupInviteMessage().GetContextInfo().StanzaID + } else if m.GetProductMessage().GetContextInfo() != nil { + return m.GetProductMessage().GetContextInfo().StanzaID + } else if m.GetListMessage().GetContextInfo() != nil { + return m.GetListMessage().GetContextInfo().StanzaID + } else if m.GetTemplateMessage().GetContextInfo() != nil { + return m.GetTemplateMessage().GetContextInfo().StanzaID + } else if m.GetContactMessage().GetContextInfo() != nil { + return m.GetContactMessage().GetContextInfo().StanzaID + } else { + return nil + } +} + +func ParseQuotedBy(m *waProto.Message, str string) *waProto.Message { + switch str { + case "text": + return m.GetExtendedTextMessage().GetContextInfo().GetQuotedMessage() + case "image": + return m.GetImageMessage().GetContextInfo().GetQuotedMessage() + case "video": + return m.GetVideoMessage().GetContextInfo().GetQuotedMessage() + case "sticker": + return m.GetStickerMessage().GetContextInfo().GetQuotedMessage() + case "document": + return m.GetDocumentMessage().GetContextInfo().GetQuotedMessage() + case "audio": + return m.GetAudioMessage().GetContextInfo().GetQuotedMessage() + case "location": + return m.GetAudioMessage().GetContextInfo().GetQuotedMessage() + default: + return ParseQuotedMessage(m) + } +} + +func WithReply(m *events.Message) *waProto.ContextInfo { + return &waProto.ContextInfo{ + StanzaID: &m.Info.ID, + Participant: proto.String(m.Info.Sender.String()), + QuotedMessage: m.Message, + } +} + +func ParseJID(arg string) (waTypes.JID, bool) { + if arg[0] == '+' { + arg = arg[1:] + } + if !strings.ContainsRune(arg, '@') { + return waTypes.NewJID(arg, waTypes.DefaultUserServer), true + } else { + recipient, err := waTypes.ParseJID(arg) + if err != nil { + return recipient, false + } else if recipient.User == "" { + return recipient, false + } + return recipient, true + } +}