Complete the whatsapp native channel implementation based on the new channel interface
This commit is contained in:
parent
a8644ca1c5
commit
42ee9ab1e3
6 changed files with 69 additions and 53 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -44,3 +44,6 @@ tasks/
|
||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
akalro-dietpi.pub
|
||||||
|
akalro-dietpi
|
||||||
|
.gitignore
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/devices"
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -213,27 +212,8 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.WhatsApp.Enabled {
|
if m.config.Channels.WhatsApp.Enabled {
|
||||||
waCfg := m.config.Channels.WhatsApp
|
waCfg := m.config.Channels.WhatsApp
|
||||||
useNative := waCfg.UseNative
|
if waCfg.UseNative {
|
||||||
if useNative {
|
m.initChannel("whatsapp_native", "WhatsApp Native")
|
||||||
logger.DebugC("channels", "Attempting to initialize WhatsApp native channel (whatsmeow)")
|
|
||||||
storePath := waCfg.SessionStorePath
|
|
||||||
if storePath == "" {
|
|
||||||
storePath = filepath.Join(m.config.WorkspacePath(), "whatsapp")
|
|
||||||
}
|
|
||||||
newNative := getWhatsAppNativeFactory()
|
|
||||||
if newNative == nil {
|
|
||||||
logger.ErrorCF("channels", "WhatsApp native not linked; import _ github.com/sipeed/picoclaw/pkg/channels/whatsapp or build with -tags whatsapp_native", nil)
|
|
||||||
} else {
|
|
||||||
ch, err := newNative(waCfg, m.bus, storePath)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["whatsapp"] = ch
|
|
||||||
logger.InfoC("channels", "WhatsApp native channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if waCfg.BridgeURL != "" {
|
} else if waCfg.BridgeURL != "" {
|
||||||
m.initChannel("whatsapp", "WhatsApp")
|
m.initChannel("whatsapp", "WhatsApp")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
pkg/channels/whatsapp_native/init.go
Normal file
20
pkg/channels/whatsapp_native/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
waCfg := cfg.Channels.WhatsApp
|
||||||
|
storePath := waCfg.SessionStorePath
|
||||||
|
if storePath == "" {
|
||||||
|
storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
|
||||||
|
}
|
||||||
|
return NewWhatsAppNativeChannel(waCfg, b, storePath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//
|
//
|
||||||
// Copyright (c) 2026 PicoClaw contributors
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
package channels
|
package whatsapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -24,29 +24,30 @@ import (
|
||||||
"go.mau.fi/whatsmeow/store/sqlstore"
|
"go.mau.fi/whatsmeow/store/sqlstore"
|
||||||
"go.mau.fi/whatsmeow/types/events"
|
"go.mau.fi/whatsmeow/types/events"
|
||||||
waLog "go.mau.fi/whatsmeow/util/log"
|
waLog "go.mau.fi/whatsmeow/util/log"
|
||||||
|
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||||
|
"go.mau.fi/whatsmeow/types"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
|
||||||
"go.mau.fi/whatsmeow/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sqliteDriver = "sqlite"
|
sqliteDriver = "sqlite"
|
||||||
whatsappDBName = "store.db"
|
whatsappDBName = "store.db"
|
||||||
|
|
||||||
reconnectInitial = 5 * time.Second
|
reconnectInitial = 5 * time.Second
|
||||||
reconnectMax = 5 * time.Minute
|
reconnectMax = 5 * time.Minute
|
||||||
reconnectMultiplier = 2.0
|
reconnectMultiplier = 2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).
|
// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).
|
||||||
type WhatsAppNativeChannel struct {
|
type WhatsAppNativeChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.WhatsAppConfig
|
config config.WhatsAppConfig
|
||||||
storePath string
|
storePath string
|
||||||
client *whatsmeow.Client
|
client *whatsmeow.Client
|
||||||
|
|
@ -60,8 +61,8 @@ type WhatsAppNativeChannel struct {
|
||||||
|
|
||||||
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
|
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
|
||||||
// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).
|
// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).
|
||||||
func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) {
|
func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) {
|
||||||
base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))
|
||||||
if storePath == "" {
|
if storePath == "" {
|
||||||
storePath = "whatsapp"
|
storePath = "whatsapp"
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +75,7 @@ func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, st
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoCF("channels", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
|
logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
|
||||||
|
|
||||||
if err := os.MkdirAll(c.storePath, 0700); err != nil {
|
if err := os.MkdirAll(c.storePath, 0700); err != nil {
|
||||||
return fmt.Errorf("create session store dir: %w", err)
|
return fmt.Errorf("create session store dir: %w", err)
|
||||||
|
|
@ -83,7 +84,6 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
||||||
dbPath := filepath.Join(c.storePath, whatsappDBName)
|
dbPath := filepath.Join(c.storePath, whatsappDBName)
|
||||||
connStr := "file:" + dbPath + "?_foreign_keys=on"
|
connStr := "file:" + dbPath + "?_foreign_keys=on"
|
||||||
|
|
||||||
// Open DB and enable foreign keys explicitly (modernc.org/sqlite does not set them from URI).
|
|
||||||
db, err := sql.Open(sqliteDriver, connStr)
|
db, err := sql.Open(sqliteDriver, connStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open whatsapp store: %w", err)
|
return fmt.Errorf("open whatsapp store: %w", err)
|
||||||
|
|
@ -128,14 +128,14 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
for evt := range qrChan {
|
for evt := range qrChan {
|
||||||
if evt.Event == "code" {
|
if evt.Event == "code" {
|
||||||
logger.InfoCF("channels", "Scan this QR code with WhatsApp (Linked Devices):", nil)
|
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
|
||||||
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
|
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
|
||||||
Level: qrterminal.L,
|
Level: qrterminal.L,
|
||||||
Writer: os.Stdout,
|
Writer: os.Stdout,
|
||||||
HalfBlocks: true,
|
HalfBlocks: true,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.InfoCF("channels", "WhatsApp login event", map[string]any{"event": evt.Event})
|
logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -147,12 +147,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
c.runCtx, c.runCancel = context.WithCancel(ctx)
|
c.runCtx, c.runCancel = context.WithCancel(ctx)
|
||||||
c.SetRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoCF("channels", "WhatsApp native channel connected", nil)
|
logger.InfoC("whatsapp", "WhatsApp native channel connected")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
|
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoCF("channels", "Stopping WhatsApp native channel", nil)
|
logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
|
||||||
if c.runCancel != nil {
|
if c.runCancel != nil {
|
||||||
c.runCancel()
|
c.runCancel()
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +178,7 @@ func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) {
|
||||||
case *events.Message:
|
case *events.Message:
|
||||||
c.handleIncoming(evt.(*events.Message))
|
c.handleIncoming(evt.(*events.Message))
|
||||||
case *events.Disconnected:
|
case *events.Disconnected:
|
||||||
logger.InfoCF("channels", "WhatsApp disconnected, will attempt reconnection", nil)
|
logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil)
|
||||||
c.reconnectMu.Lock()
|
c.reconnectMu.Lock()
|
||||||
if c.reconnecting {
|
if c.reconnecting {
|
||||||
c.reconnectMu.Unlock()
|
c.reconnectMu.Unlock()
|
||||||
|
|
@ -212,14 +212,14 @@ func (c *WhatsAppNativeChannel) reconnectWithBackoff() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()})
|
logger.InfoCF("whatsapp", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()})
|
||||||
err := client.Connect()
|
err := client.Connect()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.InfoCF("channels", "WhatsApp reconnected", nil)
|
logger.InfoC("whatsapp", "WhatsApp reconnected")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WarnCF("channels", "WhatsApp reconnect failed", map[string]any{"error": err.Error()})
|
logger.WarnCF("whatsapp", "WhatsApp reconnect failed", map[string]any{"error": err.Error()})
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-c.runCtx.Done():
|
case <-c.runCtx.Done():
|
||||||
|
|
@ -248,11 +248,11 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
}
|
}
|
||||||
content = utils.SanitizeMessageContent(content)
|
content = utils.SanitizeMessageContent(content)
|
||||||
|
|
||||||
if content == "" { return } // ignore empty messages
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
// Optional: resolve media to local paths if needed; for now we only forward text to the bus.
|
|
||||||
_ = mediaPaths
|
|
||||||
|
|
||||||
metadata := make(map[string]string)
|
metadata := make(map[string]string)
|
||||||
metadata["message_id"] = evt.Info.ID
|
metadata["message_id"] = evt.Info.ID
|
||||||
|
|
@ -276,21 +276,34 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
Platform: "whatsapp",
|
Platform: "whatsapp",
|
||||||
PlatformID: senderID,
|
PlatformID: senderID,
|
||||||
CanonicalID: "whatsapp:" + senderID,
|
CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
|
||||||
DisplayName: evt.Info.PushName,
|
DisplayName: evt.Info.PushName,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)})
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("whatsapp", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)})
|
||||||
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
client := c.client
|
client := c.client
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if client == nil || !client.IsConnected() {
|
if client == nil || !client.IsConnected() {
|
||||||
return fmt.Errorf("whatsapp connection not established")
|
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
to, err := parseJID(msg.ChatID)
|
to, err := parseJID(msg.ChatID)
|
||||||
|
|
@ -302,9 +315,8 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
|
||||||
Conversation: proto.String(msg.Content),
|
Conversation: proto.String(msg.Content),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = client.SendMessage(ctx, to, waMsg)
|
if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
|
||||||
return fmt.Errorf("send message: %w", err)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -318,6 +330,5 @@ func parseJID(s string) (types.JID, error) {
|
||||||
if strings.Contains(s, "@") {
|
if strings.Contains(s, "@") {
|
||||||
return types.ParseJID(s)
|
return types.ParseJID(s)
|
||||||
}
|
}
|
||||||
// Assume phone number for user chat.
|
|
||||||
return types.NewJID(s, types.DefaultUserServer), nil
|
return types.NewJID(s, types.DefaultUserServer), nil
|
||||||
}
|
}
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
//go:build !whatsapp_native
|
//go:build !whatsapp_native
|
||||||
|
|
||||||
package channels
|
package whatsapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.
|
// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.
|
||||||
// Build with: go build -tags whatsapp_native ./cmd/...
|
// Build with: go build -tags whatsapp_native ./cmd/...
|
||||||
func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) {
|
func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) {
|
||||||
return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native")
|
return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native")
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue