fix: send forbidden reply when unauthorized user messages bot
When a user not on the allowlist sends a message, the bot now replies with "You are not authorized to use this bot." instead of silently dropping the message. Fixes the confirmed case in whatsapp_native and also covers telegram and the HandleMessage safety-net in base.go. Closes #49 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b99c3a06c0
commit
024282be26
4 changed files with 240 additions and 19 deletions
|
|
@ -18,6 +18,9 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ForbiddenReplyText is the message sent to unauthorized users who message the bot.
|
||||||
|
const ForbiddenReplyText = "You are not authorized to use this bot."
|
||||||
|
|
||||||
var (
|
var (
|
||||||
uniqueIDCounter uint64
|
uniqueIDCounter uint64
|
||||||
uniqueIDPrefix string
|
uniqueIDPrefix string
|
||||||
|
|
@ -259,10 +262,22 @@ func (c *BaseChannel) HandleMessage(
|
||||||
}
|
}
|
||||||
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
||||||
if !c.IsAllowedSender(sender) {
|
if !c.IsAllowedSender(sender) {
|
||||||
|
logger.DebugCF(c.name, "Message blocked (not in allowlist)", map[string]any{"sender": sender.CanonicalID})
|
||||||
|
if c.owner != nil {
|
||||||
|
_, _ = c.owner.Send(ctx, bus.OutboundMessage{
|
||||||
|
Channel: c.name, ChatID: chatID, Content: ForbiddenReplyText,
|
||||||
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if !c.IsAllowed(senderID) {
|
if !c.IsAllowed(senderID) {
|
||||||
|
logger.DebugCF(c.name, "Message blocked (not in allowlist)", map[string]any{"sender_id": senderID})
|
||||||
|
if c.owner != nil {
|
||||||
|
_, _ = c.owner.Send(ctx, bus.OutboundMessage{
|
||||||
|
Channel: c.name, ChatID: chatID, Content: ForbiddenReplyText,
|
||||||
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func newTestBus() *bus.MessageBus {
|
||||||
|
return bus.NewMessageBus()
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseChannelIsAllowed(t *testing.T) {
|
func TestBaseChannelIsAllowed(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -334,3 +338,65 @@ func TestObserveGroupMessage_SkipsTypingAndPlaceholder(t *testing.T) {
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleMessageForbiddenReply(t *testing.T) {
|
||||||
|
const allowedID = "allowed:123"
|
||||||
|
const chatID = "chat-456"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sender bus.SenderInfo
|
||||||
|
wantReplied bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unauthorized sender receives forbidden reply",
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "test",
|
||||||
|
PlatformID: "999",
|
||||||
|
CanonicalID: "test:999",
|
||||||
|
},
|
||||||
|
wantReplied: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "authorized sender does not receive forbidden reply",
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "test",
|
||||||
|
PlatformID: "123",
|
||||||
|
CanonicalID: allowedID,
|
||||||
|
},
|
||||||
|
wantReplied: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
mock := &mockChannel{}
|
||||||
|
mock.BaseChannel = *NewBaseChannel("test", nil, newTestBus(), []string{allowedID})
|
||||||
|
mock.SetOwner(mock)
|
||||||
|
|
||||||
|
mock.HandleMessage(
|
||||||
|
context.Background(),
|
||||||
|
bus.Peer{Kind: "direct", ID: chatID},
|
||||||
|
"msg-1", tt.sender.PlatformID, chatID, "hello",
|
||||||
|
nil, nil,
|
||||||
|
tt.sender,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.wantReplied {
|
||||||
|
if len(mock.sentMessages) != 1 {
|
||||||
|
t.Fatalf("expected 1 forbidden reply, got %d", len(mock.sentMessages))
|
||||||
|
}
|
||||||
|
if mock.sentMessages[0].Content != ForbiddenReplyText {
|
||||||
|
t.Errorf("reply content = %q, want %q", mock.sentMessages[0].Content, ForbiddenReplyText)
|
||||||
|
}
|
||||||
|
if mock.sentMessages[0].ChatID != chatID {
|
||||||
|
t.Errorf("reply chatID = %q, want %q", mock.sentMessages[0].ChatID, chatID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(mock.sentMessages) != 0 {
|
||||||
|
t.Fatalf("expected no reply for authorized sender, got %d", len(mock.sentMessages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -592,21 +592,25 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
DisplayName: user.FirstName,
|
DisplayName: user.FirstName,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatID := message.Chat.ID
|
||||||
|
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||||
|
|
||||||
// check allowlist to avoid downloading attachments for rejected users
|
// check allowlist to avoid downloading attachments for rejected users
|
||||||
if !c.IsAllowedSender(sender) {
|
if !c.IsAllowedSender(sender) {
|
||||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||||
"user_id": platformID,
|
"user_id": platformID,
|
||||||
})
|
})
|
||||||
|
_, _ = c.Send(ctx, bus.OutboundMessage{
|
||||||
|
Channel: "telegram", ChatID: chatIDStr, Content: channels.ForbiddenReplyText,
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID := message.Chat.ID
|
|
||||||
c.chatIDs[platformID] = chatID
|
c.chatIDs[platformID] = chatID
|
||||||
|
|
||||||
content := ""
|
content := ""
|
||||||
mediaPaths := []string{}
|
mediaPaths := []string{}
|
||||||
|
|
||||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
|
||||||
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
||||||
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/mdp/qrterminal/v3"
|
"github.com/mdp/qrterminal/v3"
|
||||||
"go.mau.fi/whatsmeow"
|
"go.mau.fi/whatsmeow"
|
||||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||||
|
|
@ -33,6 +34,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -355,11 +357,64 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
}
|
}
|
||||||
content = utils.SanitizeMessageContent(content)
|
content = utils.SanitizeMessageContent(content)
|
||||||
|
|
||||||
if content == "" {
|
var mediaPaths []string
|
||||||
return
|
|
||||||
|
// storeMedia registers a downloaded local file with the MediaStore and returns
|
||||||
|
// a media ref (or the raw path as fallback when no store is configured).
|
||||||
|
storeMedia := func(localPath, filename string) string {
|
||||||
|
if store := c.GetMediaStore(); store != nil {
|
||||||
|
scope := channels.BuildMediaScope("whatsapp_native", chatID, evt.Info.ID)
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
Source: "whatsapp_native",
|
||||||
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localPath
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaPaths []string
|
if img := evt.Message.GetImageMessage(); img != nil {
|
||||||
|
if localPath := c.downloadWAMedia(c.runCtx, img, "photo.jpg"); localPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "photo.jpg"))
|
||||||
|
if caption := img.GetCaption(); caption != "" {
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += caption
|
||||||
|
} else if content == "" {
|
||||||
|
content = "[image]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc := evt.Message.GetDocumentMessage(); doc != nil {
|
||||||
|
filename := doc.GetFileName()
|
||||||
|
if filename == "" {
|
||||||
|
filename = "document"
|
||||||
|
}
|
||||||
|
if localPath := c.downloadWAMedia(c.runCtx, doc, filename); localPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(localPath, filename))
|
||||||
|
if content == "" {
|
||||||
|
content = "[file: " + filename + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if audio := evt.Message.GetAudioMessage(); audio != nil {
|
||||||
|
if localPath := c.downloadWAMedia(c.runCtx, audio, "audio.ogg"); localPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.ogg"))
|
||||||
|
if content == "" {
|
||||||
|
content = "[voice]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" && len(mediaPaths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
metadata := make(map[string]string)
|
metadata := make(map[string]string)
|
||||||
metadata["message_id"] = evt.Info.ID
|
metadata["message_id"] = evt.Info.ID
|
||||||
|
|
@ -373,6 +428,9 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
metadata["peer_kind"] = "direct"
|
metadata["peer_kind"] = "direct"
|
||||||
metadata["peer_id"] = senderID
|
metadata["peer_id"] = senderID
|
||||||
}
|
}
|
||||||
|
if len(mediaPaths) > 0 && c.config.EchoTranscription {
|
||||||
|
metadata["echo_transcription"] = "true"
|
||||||
|
}
|
||||||
|
|
||||||
peerKind := "direct"
|
peerKind := "direct"
|
||||||
if evt.Info.Chat.Server == types.GroupServer {
|
if evt.Info.Chat.Server == types.GroupServer {
|
||||||
|
|
@ -393,6 +451,7 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
"WhatsApp message blocked (not in allow_from)",
|
"WhatsApp message blocked (not in allow_from)",
|
||||||
map[string]any{"sender_id": senderID},
|
map[string]any{"sender_id": senderID},
|
||||||
)
|
)
|
||||||
|
_, _ = c.Send(c.runCtx, bus.OutboundMessage{Channel: "whatsapp", ChatID: chatID, Content: channels.ForbiddenReplyText})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,25 +464,20 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
)
|
)
|
||||||
|
|
||||||
if isGroup {
|
if isGroup {
|
||||||
// Detect bot mention via ContextInfo.MentionedJID (populated for @mentions in groups).
|
|
||||||
isMentioned := false
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
botJID := c.client.Store.ID
|
botJID := c.client.Store.ID
|
||||||
|
botLID := c.client.Store.GetLID()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
var ctx2 *waE2E.ContextInfo
|
|
||||||
if ext := evt.Message.GetExtendedTextMessage(); ext != nil {
|
var botUsers []string
|
||||||
ctx2 = ext.GetContextInfo()
|
if botJID != nil && botJID.User != "" {
|
||||||
|
botUsers = append(botUsers, botJID.User)
|
||||||
}
|
}
|
||||||
if ctx2 != nil && botJID != nil {
|
if botLID.User != "" {
|
||||||
botUser := botJID.User
|
botUsers = append(botUsers, botLID.User)
|
||||||
for _, jid := range ctx2.GetMentionedJID() {
|
|
||||||
if strings.HasPrefix(jid, botUser+"@") || jid == botUser {
|
|
||||||
isMentioned = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
respond, cleaned := c.ShouldRespondInGroup(isMentionedInGroup(evt.Message, content, botUsers), content)
|
||||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
|
||||||
if !respond {
|
if !respond {
|
||||||
c.ObserveGroupMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
c.ObserveGroupMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||||
return
|
return
|
||||||
|
|
@ -434,6 +488,48 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isMentionedInGroup returns true if the bot is @mentioned in a group message.
|
||||||
|
//
|
||||||
|
// WhatsApp LID sessions expose two identifiers (phone JID and LID); botUsers should
|
||||||
|
// contain the User portion of both so we match whichever format WhatsApp uses.
|
||||||
|
//
|
||||||
|
// Detection order:
|
||||||
|
// 1. ContextInfo.MentionedJID — the authoritative signal (ExtendedTextMessage only).
|
||||||
|
// 2. Text-based fallback — handles plain Conversation messages where ContextInfo
|
||||||
|
// is absent, which occurs in LID sessions for some WhatsApp clients.
|
||||||
|
func isMentionedInGroup(msg *waE2E.Message, content string, botUsers []string) bool {
|
||||||
|
if len(botUsers) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary: ContextInfo.MentionedJID
|
||||||
|
if ext := msg.GetExtendedTextMessage(); ext != nil {
|
||||||
|
if ctx2 := ext.GetContextInfo(); ctx2 != nil {
|
||||||
|
for _, jid := range ctx2.GetMentionedJID() {
|
||||||
|
for _, u := range botUsers {
|
||||||
|
if strings.HasPrefix(jid, u+"@") || jid == u {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: @<user> in message text
|
||||||
|
for _, u := range botUsers {
|
||||||
|
if strings.Contains(content, "@"+u) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// VoiceCapabilities reports that this channel supports ASR (speech-to-text).
|
||||||
|
func (c *WhatsAppNativeChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
||||||
|
return channels.VoiceCapabilities{ASR: true, TTS: false}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return nil, channels.ErrNotRunning
|
return nil, channels.ErrNotRunning
|
||||||
|
|
@ -473,6 +569,46 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// downloadWAMedia downloads and decrypts a WhatsApp media message using the
|
||||||
|
// whatsmeow client, saves it to a temp file, and returns the local path.
|
||||||
|
// Returns "" on any error (errors are logged at WARN level).
|
||||||
|
func (c *WhatsAppNativeChannel) downloadWAMedia(
|
||||||
|
ctx context.Context,
|
||||||
|
msg whatsmeow.DownloadableMessage,
|
||||||
|
filename string,
|
||||||
|
) string {
|
||||||
|
c.mu.Lock()
|
||||||
|
client := c.client
|
||||||
|
c.mu.Unlock()
|
||||||
|
if client == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.Download(ctx, msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF(
|
||||||
|
"whatsapp",
|
||||||
|
"Failed to download media",
|
||||||
|
map[string]any{"error": err.Error(), "filename": filename},
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaDir := media.TempDir()
|
||||||
|
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||||
|
logger.WarnCF("whatsapp", "Failed to create media dir", map[string]any{"error": err.Error()})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+utils.SanitizeFilename(filename))
|
||||||
|
if err := os.WriteFile(localPath, data, 0o600); err != nil {
|
||||||
|
logger.WarnCF("whatsapp", "Failed to write media file", map[string]any{"error": err.Error()})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return localPath
|
||||||
|
}
|
||||||
|
|
||||||
// parseJID converts a chat ID (phone number or JID string) to types.JID.
|
// parseJID converts a chat ID (phone number or JID string) to types.JID.
|
||||||
func parseJID(s string) (types.JID, error) {
|
func parseJID(s string) (types.JID, error) {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue