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.
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+106
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user