picoclaw/pkg/channels/gmessages/events.go
google-labs-jules[bot] 9df0dd6359 feat: add native gmessages channel using libgm
Added a new native `gmessages` channel implementation to JANE that connects directly to Google Messages via the `go.mau.fi/mautrix-gmessages` (libgm) library.

This enables JANE to autonomously read/send SMS and RCS messages, parse incoming media, fetch contacts, and gracefully handle pairing by logging a QR code to the terminal if the session is not yet authenticated. A local SQLite database in WAL mode is included to sync and persist conversations, contacts, and message states.

Key features:
- Core integration with JANE's bus (`HandleMessage`, `Send`, `SendMedia`).
- QR code pairing workflow in terminal.
- Background sync of `contacts`, `conversations`, and `messages`.
- Downloading incoming MMS/RCS attachments into JANE's `mediaStore`.
- Uploading and sending rich media with retry logic.
- Reaction support by converting and appending them to text bodies.

Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
2026-03-18 09:16:33 +00:00

396 lines
10 KiB
Go

package gmessages
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"strings"
"jane/pkg/bus"
"jane/pkg/channels"
"jane/pkg/logger"
mediaPkg "jane/pkg/media"
"go.mau.fi/mautrix-gmessages/pkg/libgm"
"go.mau.fi/mautrix-gmessages/pkg/libgm/events"
"go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto"
)
type EventHandler struct {
Store *Store
Channel *GMessagesChannel
SessionPath string
Client *GMClient
}
func (h *EventHandler) Handle(rawEvt any) {
switch evt := rawEvt.(type) {
case *events.ClientReady:
h.handleClientReady(evt)
case *libgm.WrappedMessage:
h.handleMessage(evt)
case *gmproto.Conversation:
h.handleConversation(evt)
case *events.AuthTokenRefreshed:
h.handleAuthRefresh()
case *events.ListenFatalError:
logger.ErrorCF("channels.gmessages", "Listen fatal error", map[string]any{"err": evt.Error})
h.Channel.Stop(context.Background())
case *events.ListenTemporaryError:
logger.WarnCF("channels.gmessages", "Listen temporary error", map[string]any{"err": evt.Error})
case *events.ListenRecovered:
logger.InfoCF("channels.gmessages", "Listen recovered", nil)
case *events.PhoneNotResponding:
logger.WarnCF("channels.gmessages", "Phone not responding", nil)
case *events.PhoneRespondingAgain:
logger.InfoCF("channels.gmessages", "Phone responding again", nil)
default:
logger.DebugCF("channels.gmessages", "Unhandled event", map[string]any{"type": fmt.Sprintf("%T", evt)})
}
}
func (h *EventHandler) handleClientReady(evt *events.ClientReady) {
logger.InfoCF("channels.gmessages", "Client ready", map[string]any{
"session_id": evt.SessionID,
"conversations": len(evt.Conversations),
})
for _, conv := range evt.Conversations {
h.handleConversation(conv)
}
// Fetch contacts in background to populate our DB
go func() {
resp, err := h.Client.GM.ListContacts()
if err != nil {
logger.WarnCF("channels.gmessages", "Failed to list contacts", map[string]any{"err": err})
return
}
for _, contact := range resp.GetContacts() {
name := contact.GetName()
if num := contact.GetNumber(); num != nil && num.GetNumber() != "" {
h.Store.UpsertContact(num.GetNumber(), name)
}
}
logger.InfoCF("channels.gmessages", "Finished syncing contacts", map[string]any{"count": len(resp.GetContacts())})
}()
}
func (h *EventHandler) handleMessage(evt *libgm.WrappedMessage) {
msg := evt.Message
body := ExtractMessageBody(msg)
senderName, senderNumber := ExtractSenderInfo(msg)
status := "unknown"
if ms := msg.GetMessageStatus(); ms != nil {
status = ms.GetStatus().String()
}
dbMsg := &Message{
MessageID: msg.GetMessageID(),
ConversationID: msg.GetConversationID(),
SenderName: senderName,
SenderNumber: senderNumber,
Body: body,
TimestampMS: msg.GetTimestamp() / 1000,
Status: status,
IsFromMe: msg.GetSenderParticipant() != nil && msg.GetSenderParticipant().GetIsMe(),
}
var mediaURLs []string
if media := ExtractMediaInfo(msg); media != nil {
dbMsg.MediaID = media.MediaID
dbMsg.MimeType = media.MimeType
dbMsg.DecryptionKey = hex.EncodeToString(media.DecryptionKey)
saveToStore := func(data []byte, mimeType string) {
if h.Channel.GetMediaStore() == nil {
return
}
// Save to a temporary file
tmpFile, err := os.CreateTemp("", "gmessages-media-*")
if err != nil {
logger.ErrorCF("channels.gmessages", "Failed to create temp file for media", map[string]any{"err": err})
return
}
defer tmpFile.Close()
if _, err := tmpFile.Write(data); err != nil {
logger.ErrorCF("channels.gmessages", "Failed to write media data to temp file", map[string]any{"err": err})
return
}
scope := channels.BuildMediaScope(h.Channel.Name(), msg.GetConversationID(), msg.GetMessageID())
meta := mediaPkg.MediaMeta{ContentType: mimeType, Source: "gmessages"}
ref, err := h.Channel.GetMediaStore().Store(tmpFile.Name(), meta, scope)
if err == nil {
mediaURLs = append(mediaURLs, ref)
} else {
logger.ErrorCF("channels.gmessages", "Failed to save media to store", map[string]any{"err": err})
}
}
if media.InlineData != nil {
saveToStore(media.InlineData, media.MimeType)
} else if media.MediaID != "" && media.DecryptionKey != nil {
data, err := h.Client.GM.DownloadMedia(media.MediaID, media.DecryptionKey)
if err == nil {
saveToStore(data, media.MimeType)
} else {
logger.ErrorCF("channels.gmessages", "Failed to download media from Google Messages", map[string]any{"err": err})
}
}
}
if reactions := ExtractReactions(msg); reactions != nil {
if b, err := json.Marshal(reactions); err == nil {
dbMsg.Reactions = string(b)
// Append reactions to body for bot visibility if body isn't empty
var reactionStrs []string
for _, r := range reactions {
reactionStrs = append(reactionStrs, r.Emoji)
}
if len(reactionStrs) > 0 {
body = body + "\n[Reactions: " + strings.Join(reactionStrs, " ") + "]"
dbMsg.Body = body
}
}
}
dbMsg.ReplyToID = ExtractReplyToID(msg)
if err := h.Store.UpsertMessage(dbMsg); err != nil {
logger.ErrorCF("channels.gmessages", "Failed to store message", map[string]any{"err": err, "msg_id": dbMsg.MessageID})
return
}
if dbMsg.IsFromMe && !strings.HasPrefix(dbMsg.MessageID, "tmp_") {
h.Store.DeleteTmpMessages(dbMsg.ConversationID)
return // we don't dispatch our own messages usually, unless requested
}
// Dispatch to bot
if !evt.IsOld && !dbMsg.IsFromMe {
senderID := senderNumber
if senderID == "" {
senderID = senderName
}
// Map ChatID to phone number or conversation ID. In 1:1 we'll use phone number or conv ID
chatID := msg.GetConversationID() // fallback to group/thread ID
if chatID == "" {
chatID = senderID
}
// determine if it's a group from DB (we do it in the next steps)
peer := bus.Peer{
ID: chatID,
}
senderInfo := bus.SenderInfo{
PlatformID: senderID,
CanonicalID: senderID,
DisplayName: senderName,
}
metadata := map[string]string{
"conversation_id": msg.GetConversationID(),
}
h.Channel.HandleMessage(
context.Background(),
peer,
msg.GetMessageID(),
senderID,
chatID,
body,
mediaURLs,
metadata,
senderInfo,
)
}
logger.DebugCF("channels.gmessages", "Stored message", map[string]any{
"msg_id": dbMsg.MessageID,
"from": senderName,
"is_old": evt.IsOld,
})
}
func (h *EventHandler) handleConversation(conv *gmproto.Conversation) {
participantsJSON := "[]"
if ps := conv.GetParticipants(); len(ps) > 0 {
type pInfo struct {
Name string `json:"name"`
Number string `json:"number"`
IsMe bool `json:"is_me,omitempty"`
}
var infos []pInfo
for _, p := range ps {
info := pInfo{
Name: p.GetFullName(),
IsMe: p.GetIsMe(),
}
if id := p.GetID(); id != nil {
info.Number = id.GetNumber()
}
if info.Number == "" {
info.Number = p.GetFormattedNumber()
}
infos = append(infos, info)
}
if b, err := json.Marshal(infos); err == nil {
participantsJSON = string(b)
}
}
unread := 0
if conv.GetUnread() {
unread = 1
}
dbConv := &Conversation{
ConversationID: conv.GetConversationID(),
Name: conv.GetName(),
IsGroup: conv.GetIsGroupChat(),
Participants: participantsJSON,
LastMessageTS: conv.GetLastMessageTimestamp() / 1000,
UnreadCount: unread,
}
if err := h.Store.UpsertConversation(dbConv); err != nil {
logger.ErrorCF("channels.gmessages", "Failed to store conversation", map[string]any{"err": err, "conv_id": dbConv.ConversationID})
return
}
}
func (h *EventHandler) handleAuthRefresh() {
if h.Client == nil || h.SessionPath == "" {
return
}
sessionData, err := h.Client.SessionData()
if err != nil {
logger.ErrorCF("channels.gmessages", "Failed to get session data for save", map[string]any{"err": err})
return
}
if err := saveSession(h.SessionPath, sessionData); err != nil {
logger.ErrorCF("channels.gmessages", "Failed to save refreshed session", map[string]any{"err": err})
return
}
logger.DebugCF("channels.gmessages", "Saved refreshed auth token", nil)
}
// Extractors mapped from libgm
func ExtractMessageBody(msg *gmproto.Message) string {
for _, info := range msg.GetMessageInfo() {
if mc := info.GetMessageContent(); mc != nil {
return mc.GetContent()
}
}
return ""
}
type MediaInfo struct {
MediaID string
MimeType string
MediaName string
DecryptionKey []byte
Size int64
ThumbnailMediaID string
ThumbnailDecryptionKey []byte
InlineData []byte
}
func ExtractMediaInfo(msg *gmproto.Message) *MediaInfo {
for _, info := range msg.GetMessageInfo() {
if mc := info.GetMediaContent(); mc != nil {
mime := mc.GetMimeType()
if mime == "" {
switch {
case mc.GetFormat() >= 1 && mc.GetFormat() <= 7:
mime = "image/jpeg"
default:
mime = "application/octet-stream"
}
}
mi := &MediaInfo{
MediaID: mc.GetMediaID(),
MimeType: mime,
MediaName: mc.GetMediaName(),
DecryptionKey: mc.GetDecryptionKey(),
Size: mc.GetSize(),
ThumbnailMediaID: mc.GetThumbnailMediaID(),
ThumbnailDecryptionKey: mc.GetThumbnailDecryptionKey(),
InlineData: mc.GetMediaData(),
}
if mi.MediaID == "" && mi.ThumbnailMediaID != "" {
mi.MediaID = mi.ThumbnailMediaID
mi.DecryptionKey = mi.ThumbnailDecryptionKey
}
return mi
}
}
return nil
}
type Reaction struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
}
func ExtractReactions(msg *gmproto.Message) []Reaction {
entries := msg.GetReactions()
if len(entries) == 0 {
return nil
}
var reactions []Reaction
for _, entry := range entries {
if data := entry.GetData(); data != nil {
emoji := data.GetUnicode()
if emoji == "" {
continue
}
reactions = append(reactions, Reaction{
Emoji: emoji,
Count: len(entry.GetParticipantIDs()),
})
}
}
if len(reactions) == 0 {
return nil
}
return reactions
}
func ExtractReplyToID(msg *gmproto.Message) string {
if rm := msg.GetReplyMessage(); rm != nil {
return rm.GetMessageID()
}
return ""
}
func ExtractSenderInfo(msg *gmproto.Message) (name, number string) {
if p := msg.GetSenderParticipant(); p != nil {
name = p.GetFullName()
if name == "" {
name = p.GetFirstName()
}
if id := p.GetID(); id != nil {
number = id.GetNumber()
}
if number == "" {
number = p.GetFormattedNumber()
}
}
return
}