mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(identity): add unified user identity with canonical platform:id format
Introduce SenderInfo struct and pkg/identity package to standardize user identification across all channels. Each channel now constructs structured sender info (platform, platformID, canonicalID, username, displayName) instead of ad-hoc string IDs. Allow-list matching supports all legacy formats (numeric ID, @username, id|username) plus the new canonical "platform:id" format. Session key resolution also handles canonical peerIDs for backward-compatible identity link matching.
This commit is contained in:
@@ -6,9 +6,19 @@ type Peer struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// SenderInfo provides structured sender identity information.
|
||||
type SenderInfo struct {
|
||||
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
|
||||
PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456"
|
||||
CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format
|
||||
Username string `json:"username,omitempty"` // username (e.g. @alice)
|
||||
DisplayName string `json:"display_name,omitempty"` // display name
|
||||
}
|
||||
|
||||
type InboundMessage struct {
|
||||
Channel string `json:"channel"`
|
||||
SenderID string `json:"sender_id"`
|
||||
Sender SenderInfo `json:"sender"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
|
||||
+41
-3
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
@@ -20,6 +21,7 @@ type Channel interface {
|
||||
Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||
IsRunning() bool
|
||||
IsAllowed(senderID string) bool
|
||||
IsAllowedSender(sender bus.SenderInfo) bool
|
||||
}
|
||||
|
||||
// BaseChannelOption is a functional option for configuring a BaseChannel.
|
||||
@@ -168,22 +170,58 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list.
|
||||
// It delegates to identity.MatchAllowed for each entry, providing unified matching
|
||||
// across all legacy formats and the new canonical "platform:id" format.
|
||||
func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {
|
||||
if len(c.allowList) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, allowed := range c.allowList {
|
||||
if identity.MatchAllowed(sender, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *BaseChannel) HandleMessage(
|
||||
ctx context.Context,
|
||||
peer bus.Peer,
|
||||
messageID, senderID, chatID, content string,
|
||||
media []string,
|
||||
metadata map[string]string,
|
||||
senderOpts ...bus.SenderInfo,
|
||||
) {
|
||||
if !c.IsAllowed(senderID) {
|
||||
return
|
||||
// Use SenderInfo-based allow check when available, else fall back to string
|
||||
var sender bus.SenderInfo
|
||||
if len(senderOpts) > 0 {
|
||||
sender = senderOpts[0]
|
||||
}
|
||||
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !c.IsAllowed(senderID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set SenderID to canonical if available, otherwise keep the raw senderID
|
||||
resolvedSenderID := senderID
|
||||
if sender.CanonicalID != "" {
|
||||
resolvedSenderID = sender.CanonicalID
|
||||
}
|
||||
|
||||
scope := BuildMediaScope(c.name, chatID, messageID)
|
||||
|
||||
msg := bus.InboundMessage{
|
||||
Channel: c.name,
|
||||
SenderID: senderID,
|
||||
SenderID: resolvedSenderID,
|
||||
Sender: sender,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
Media: media,
|
||||
|
||||
@@ -3,6 +3,7 @@ package channels
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
@@ -175,3 +176,90 @@ func TestShouldRespondInGroup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedSender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowList []string
|
||||
sender bus.SenderInfo
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty allowlist allows all",
|
||||
allowList: nil,
|
||||
sender: bus.SenderInfo{PlatformID: "anyone"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric ID matches PlatformID",
|
||||
allowList: []string{"123456"},
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "123456",
|
||||
CanonicalID: "telegram:123456",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canonical format matches",
|
||||
allowList: []string{"telegram:123456"},
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "123456",
|
||||
CanonicalID: "telegram:123456",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canonical format wrong platform",
|
||||
allowList: []string{"discord:123456"},
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "123456",
|
||||
CanonicalID: "telegram:123456",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "@username matches",
|
||||
allowList: []string{"@alice"},
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "123456",
|
||||
CanonicalID: "telegram:123456",
|
||||
Username: "alice",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound id|username matches by ID",
|
||||
allowList: []string{"123456|alice"},
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "123456",
|
||||
CanonicalID: "telegram:123456",
|
||||
Username: "alice",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non matching sender denied",
|
||||
allowList: []string{"654321"},
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "123456",
|
||||
CanonicalID: "telegram:123456",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ch := NewBaseChannel("test", nil, nil, tt.allowList)
|
||||
if got := ch.IsAllowedSender(tt.sender); got != tt.want {
|
||||
t.Fatalf("IsAllowedSender(%+v) = %v, want %v", tt.sender, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
@@ -182,8 +183,20 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Build sender info
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "dingtalk",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("dingtalk", senderID),
|
||||
DisplayName: senderNick,
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata)
|
||||
c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender)
|
||||
|
||||
// Return nil to indicate we've handled the message asynchronously
|
||||
// The response will be sent through the message bus
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
@@ -263,7 +264,20 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
}
|
||||
|
||||
// Check allowlist first to avoid downloading attachments for rejected users
|
||||
if !c.IsAllowed(m.Author.ID) {
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "discord",
|
||||
PlatformID: m.Author.ID,
|
||||
CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID),
|
||||
Username: m.Author.Username,
|
||||
}
|
||||
// Build display name
|
||||
displayName := m.Author.Username
|
||||
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
||||
displayName += "#" + m.Author.Discriminator
|
||||
}
|
||||
sender.DisplayName = displayName
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": m.Author.ID,
|
||||
})
|
||||
@@ -297,10 +311,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
}
|
||||
|
||||
senderID := m.Author.ID
|
||||
senderName := m.Author.Username
|
||||
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
||||
senderName += "#" + m.Author.Discriminator
|
||||
}
|
||||
|
||||
mediaPaths := make([]string, 0, len(m.Attachments))
|
||||
|
||||
@@ -358,7 +368,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
}
|
||||
|
||||
logger.DebugCF("discord", "Received message", map[string]any{
|
||||
"sender_name": senderName,
|
||||
"sender_name": sender.DisplayName,
|
||||
"sender_id": senderID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
@@ -375,13 +385,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
metadata := map[string]string{
|
||||
"user_id": senderID,
|
||||
"username": m.Author.Username,
|
||||
"display_name": senderName,
|
||||
"display_name": sender.DisplayName,
|
||||
"guild_id": m.GuildID,
|
||||
"channel_id": m.ChannelID,
|
||||
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata)
|
||||
c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)
|
||||
}
|
||||
|
||||
// startTyping starts a continuous typing indicator loop for the given chatID.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
@@ -189,7 +190,17 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
||||
"preview": utils.Truncate(content, 80),
|
||||
})
|
||||
|
||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata)
|
||||
senderInfo := bus.SenderInfo{
|
||||
Platform: "feishu",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("feishu", senderID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(senderInfo) {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
@@ -370,7 +371,17 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
// Show typing/loading indicator (requires user ID, not group ID)
|
||||
c.sendLoading(senderID)
|
||||
|
||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata)
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "line",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("line", senderID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||
}
|
||||
|
||||
// isBotMentioned checks if the bot is mentioned in the message.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -179,6 +180,16 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||
"h": fmt.Sprintf("%.0f", h),
|
||||
}
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "maixcam",
|
||||
PlatformID: "maixcam",
|
||||
CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
}
|
||||
|
||||
c.HandleMessage(
|
||||
c.ctx,
|
||||
bus.Peer{Kind: "channel", ID: "default"},
|
||||
@@ -188,6 +199,7 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||
content,
|
||||
[]string{},
|
||||
metadata,
|
||||
sender,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
@@ -823,7 +824,13 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||
switch raw.PostType {
|
||||
case "message":
|
||||
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
|
||||
if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
|
||||
// Build minimal sender for allowlist check
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "onebot",
|
||||
PlatformID: strconv.FormatInt(userID, 10),
|
||||
CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)),
|
||||
}
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": userID,
|
||||
})
|
||||
@@ -1040,7 +1047,21 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata)
|
||||
senderInfo := bus.SenderInfo{
|
||||
Platform: "onebot",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("onebot", senderID),
|
||||
DisplayName: sender.Nickname,
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(senderInfo) {
|
||||
logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{
|
||||
"sender": senderID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo)
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -420,7 +421,17 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata)
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "pico",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("pico", senderID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)
|
||||
}
|
||||
|
||||
// truncate truncates a string to maxLen runes.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -168,6 +169,16 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||
// 转发到消息总线
|
||||
metadata := map[string]string{}
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "qq",
|
||||
PlatformID: data.Author.ID,
|
||||
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx,
|
||||
bus.Peer{Kind: "direct", ID: senderID},
|
||||
data.ID,
|
||||
@@ -176,6 +187,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||
content,
|
||||
[]string{},
|
||||
metadata,
|
||||
sender,
|
||||
)
|
||||
|
||||
return nil
|
||||
@@ -224,6 +236,16 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||
"group_id": data.GroupID,
|
||||
}
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "qq",
|
||||
PlatformID: data.Author.ID,
|
||||
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx,
|
||||
bus.Peer{Kind: "group", ID: data.GroupID},
|
||||
data.ID,
|
||||
@@ -232,6 +254,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||
content,
|
||||
[]string{},
|
||||
metadata,
|
||||
sender,
|
||||
)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
@@ -252,7 +253,12 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(ev.User) {
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "slack",
|
||||
PlatformID: ev.User,
|
||||
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
|
||||
}
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
@@ -360,7 +366,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||
"has_thread": threadTS != "",
|
||||
})
|
||||
|
||||
c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata)
|
||||
c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||
}
|
||||
|
||||
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||
@@ -368,7 +374,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
if !c.IsAllowed(ev.User) {
|
||||
if !c.IsAllowedSender(bus.SenderInfo{
|
||||
Platform: "slack",
|
||||
PlatformID: ev.User,
|
||||
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
|
||||
}) {
|
||||
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
@@ -376,6 +386,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||
}
|
||||
|
||||
senderID := ev.User
|
||||
mentionSender := bus.SenderInfo{
|
||||
Platform: "slack",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("slack", senderID),
|
||||
}
|
||||
channelID := ev.Channel
|
||||
threadTS := ev.ThreadTimeStamp
|
||||
messageTS := ev.TimeStamp
|
||||
@@ -433,7 +448,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||
"team_id": c.teamID,
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata)
|
||||
c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender)
|
||||
}
|
||||
|
||||
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||
@@ -446,7 +461,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||
c.socketClient.Ack(*event.Request)
|
||||
}
|
||||
|
||||
if !c.IsAllowed(cmd.UserID) {
|
||||
cmdSender := bus.SenderInfo{
|
||||
Platform: "slack",
|
||||
PlatformID: cmd.UserID,
|
||||
CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID),
|
||||
}
|
||||
if !c.IsAllowedSender(cmdSender) {
|
||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
|
||||
"user_id": cmd.UserID,
|
||||
})
|
||||
@@ -476,7 +496,17 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||
"text": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
c.HandleMessage(c.ctx, bus.Peer{Kind: "channel", ID: channelID}, "", senderID, chatID, content, nil, metadata)
|
||||
c.HandleMessage(
|
||||
c.ctx,
|
||||
bus.Peer{Kind: "channel", ID: channelID},
|
||||
"",
|
||||
senderID,
|
||||
chatID,
|
||||
content,
|
||||
nil,
|
||||
metadata,
|
||||
cmdSender,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
@@ -289,21 +290,25 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||
return fmt.Errorf("message sender (user) is nil")
|
||||
}
|
||||
|
||||
senderID := fmt.Sprintf("%d", user.ID)
|
||||
if user.Username != "" {
|
||||
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
||||
platformID := fmt.Sprintf("%d", user.ID)
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: platformID,
|
||||
CanonicalID: identity.BuildCanonicalID("telegram", platformID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.FirstName,
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(senderID) {
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": senderID,
|
||||
"user_id": platformID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
chatID := message.Chat.ID
|
||||
c.chatIDs[senderID] = chatID
|
||||
c.chatIDs[platformID] = chatID
|
||||
|
||||
content := ""
|
||||
mediaPaths := []string{}
|
||||
@@ -401,7 +406,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||
}
|
||||
|
||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"sender_id": sender.CanonicalID,
|
||||
"chat_id": fmt.Sprintf("%d", chatID),
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
@@ -451,11 +456,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||
c.HandleMessage(c.ctx,
|
||||
peer,
|
||||
messageID,
|
||||
fmt.Sprintf("%d", user.ID),
|
||||
platformID,
|
||||
fmt.Sprintf("%d", chatID),
|
||||
content,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
sender,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
@@ -629,8 +630,15 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Build sender info
|
||||
appSender := bus.SenderInfo{
|
||||
Platform: "wecom",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
|
||||
}
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata)
|
||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender)
|
||||
}
|
||||
|
||||
// tokenRefreshLoop periodically refreshes the access token
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
@@ -398,8 +399,19 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Build sender info
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "wecom",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata)
|
||||
c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)
|
||||
}
|
||||
|
||||
// sendWebhookReply sends a reply using the webhook URL
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
@@ -224,5 +225,18 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata)
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "whatsapp",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
|
||||
}
|
||||
if display, ok := metadata["user_name"]; ok {
|
||||
sender.DisplayName = display
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package identity provides unified user identity utilities for PicoClaw.
|
||||
// It introduces a canonical "platform:id" format and matching logic
|
||||
// that is backward-compatible with all legacy allow-list formats.
|
||||
package identity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
)
|
||||
|
||||
// BuildCanonicalID constructs a canonical "platform:id" identifier.
|
||||
// Both platform and platformID are lowercased and trimmed.
|
||||
func BuildCanonicalID(platform, platformID string) string {
|
||||
p := strings.ToLower(strings.TrimSpace(platform))
|
||||
id := strings.TrimSpace(platformID)
|
||||
if p == "" || id == "" {
|
||||
return ""
|
||||
}
|
||||
return p + ":" + id
|
||||
}
|
||||
|
||||
// ParseCanonicalID splits a canonical ID ("platform:id") into its parts.
|
||||
// Returns ok=false if the input does not contain a colon separator.
|
||||
func ParseCanonicalID(canonical string) (platform, id string, ok bool) {
|
||||
canonical = strings.TrimSpace(canonical)
|
||||
idx := strings.Index(canonical, ":")
|
||||
if idx <= 0 || idx == len(canonical)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return canonical[:idx], canonical[idx+1:], true
|
||||
}
|
||||
|
||||
// MatchAllowed checks whether the given sender matches a single allow-list entry.
|
||||
// It is backward-compatible with all legacy formats:
|
||||
//
|
||||
// - "123456" → matches sender.PlatformID
|
||||
// - "@alice" → matches sender.Username
|
||||
// - "123456|alice" → matches PlatformID or Username
|
||||
// - "telegram:123456" → exact match on sender.CanonicalID
|
||||
func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
||||
allowed = strings.TrimSpace(allowed)
|
||||
if allowed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try canonical match first: "platform:id" format
|
||||
if platform, id, ok := ParseCanonicalID(allowed); ok {
|
||||
// Only treat as canonical if the platform portion looks like a known platform name
|
||||
// (not a pure-numeric string, which could be a compound ID)
|
||||
if !isNumeric(platform) {
|
||||
candidate := BuildCanonicalID(platform, id)
|
||||
if candidate != "" && sender.CanonicalID != "" {
|
||||
return strings.EqualFold(sender.CanonicalID, candidate)
|
||||
}
|
||||
// If sender has no canonical ID, try matching platform + platformID
|
||||
return strings.EqualFold(platform, sender.Platform) &&
|
||||
sender.PlatformID == id
|
||||
}
|
||||
}
|
||||
|
||||
// Strip leading "@" for username matching
|
||||
trimmed := strings.TrimPrefix(allowed, "@")
|
||||
|
||||
// Split compound "id|username" format
|
||||
allowedID := trimmed
|
||||
allowedUser := ""
|
||||
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
||||
allowedID = trimmed[:idx]
|
||||
allowedUser = trimmed[idx+1:]
|
||||
}
|
||||
|
||||
// Match against PlatformID
|
||||
if sender.PlatformID != "" && sender.PlatformID == allowedID {
|
||||
return true
|
||||
}
|
||||
|
||||
// Match against Username
|
||||
if sender.Username != "" {
|
||||
if sender.Username == trimmed || sender.Username == allowedUser {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Match compound sender format against allowed parts
|
||||
if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID {
|
||||
return true
|
||||
}
|
||||
if allowedUser != "" && sender.Username != "" && sender.Username == allowedUser {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isNumeric returns true if s consists entirely of digits.
|
||||
func isNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
)
|
||||
|
||||
func TestBuildCanonicalID(t *testing.T) {
|
||||
tests := []struct {
|
||||
platform string
|
||||
platformID string
|
||||
want string
|
||||
}{
|
||||
{"telegram", "123456", "telegram:123456"},
|
||||
{"Discord", "98765432", "discord:98765432"},
|
||||
{"SLACK", "U123ABC", "slack:U123ABC"},
|
||||
{"", "123", ""},
|
||||
{"telegram", "", ""},
|
||||
{" telegram ", " 123 ", "telegram:123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := BuildCanonicalID(tt.platform, tt.platformID)
|
||||
if got != tt.want {
|
||||
t.Errorf("BuildCanonicalID(%q, %q) = %q, want %q",
|
||||
tt.platform, tt.platformID, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCanonicalID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantPlatform string
|
||||
wantID string
|
||||
wantOk bool
|
||||
}{
|
||||
{"telegram:123456", "telegram", "123456", true},
|
||||
{"discord:98765432", "discord", "98765432", true},
|
||||
{"slack:U123ABC", "slack", "U123ABC", true},
|
||||
{"nocolon", "", "", false},
|
||||
{"", "", "", false},
|
||||
{":missing", "", "", false},
|
||||
{"missing:", "", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
platform, id, ok := ParseCanonicalID(tt.input)
|
||||
if ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID {
|
||||
t.Errorf("ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tt.input, platform, id, ok,
|
||||
tt.wantPlatform, tt.wantID, tt.wantOk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAllowed(t *testing.T) {
|
||||
telegramSender := bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "123456",
|
||||
CanonicalID: "telegram:123456",
|
||||
Username: "alice",
|
||||
DisplayName: "Alice Smith",
|
||||
}
|
||||
|
||||
discordSender := bus.SenderInfo{
|
||||
Platform: "discord",
|
||||
PlatformID: "98765432",
|
||||
CanonicalID: "discord:98765432",
|
||||
Username: "bob",
|
||||
DisplayName: "bob#1234",
|
||||
}
|
||||
|
||||
noCanonicalSender := bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "999",
|
||||
Username: "carol",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sender bus.SenderInfo
|
||||
allowed string
|
||||
want bool
|
||||
}{
|
||||
// Pure numeric ID matching
|
||||
{
|
||||
name: "numeric ID matches PlatformID",
|
||||
sender: telegramSender,
|
||||
allowed: "123456",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric ID does not match",
|
||||
sender: telegramSender,
|
||||
allowed: "654321",
|
||||
want: false,
|
||||
},
|
||||
// Username matching
|
||||
{
|
||||
name: "@username matches Username",
|
||||
sender: telegramSender,
|
||||
allowed: "@alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "@username does not match",
|
||||
sender: telegramSender,
|
||||
allowed: "@bob",
|
||||
want: false,
|
||||
},
|
||||
// Compound format "id|username"
|
||||
{
|
||||
name: "compound matches by ID",
|
||||
sender: telegramSender,
|
||||
allowed: "123456|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound matches by username",
|
||||
sender: telegramSender,
|
||||
allowed: "999|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound does not match",
|
||||
sender: telegramSender,
|
||||
allowed: "654321|bob",
|
||||
want: false,
|
||||
},
|
||||
// Canonical format "platform:id"
|
||||
{
|
||||
name: "canonical matches exactly",
|
||||
sender: telegramSender,
|
||||
allowed: "telegram:123456",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canonical case-insensitive platform",
|
||||
sender: telegramSender,
|
||||
allowed: "Telegram:123456",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canonical wrong platform",
|
||||
sender: telegramSender,
|
||||
allowed: "discord:123456",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "canonical wrong ID",
|
||||
sender: telegramSender,
|
||||
allowed: "telegram:654321",
|
||||
want: false,
|
||||
},
|
||||
// Cross-platform canonical
|
||||
{
|
||||
name: "discord canonical match",
|
||||
sender: discordSender,
|
||||
allowed: "discord:98765432",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "telegram canonical does not match discord sender",
|
||||
sender: discordSender,
|
||||
allowed: "telegram:98765432",
|
||||
want: false,
|
||||
},
|
||||
// Sender without canonical ID
|
||||
{
|
||||
name: "canonical match falls back to platform+platformID",
|
||||
sender: noCanonicalSender,
|
||||
allowed: "telegram:999",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "platform mismatch on fallback",
|
||||
sender: noCanonicalSender,
|
||||
allowed: "discord:999",
|
||||
want: false,
|
||||
},
|
||||
// Empty allowed string
|
||||
{
|
||||
name: "empty allowed never matches",
|
||||
sender: telegramSender,
|
||||
allowed: "",
|
||||
want: false,
|
||||
},
|
||||
// Whitespace handling
|
||||
{
|
||||
name: "trimmed allowed matches",
|
||||
sender: telegramSender,
|
||||
allowed: " 123456 ",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := MatchAllowed(tt.sender, tt.allowed)
|
||||
if got != tt.want {
|
||||
t.Errorf("MatchAllowed(%+v, %q) = %v, want %v",
|
||||
tt.sender, tt.allowed, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"123456", true},
|
||||
{"0", true},
|
||||
{"", false},
|
||||
{"abc", false},
|
||||
{"12a34", false},
|
||||
{"telegram", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := isNumeric(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,15 @@ func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID stri
|
||||
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
|
||||
candidates[scopedCandidate] = true
|
||||
}
|
||||
|
||||
// If peerID is already in canonical "platform:id" format, also add the
|
||||
// bare ID part as a candidate for backward compatibility with identity_links
|
||||
// that use raw IDs (e.g. "123" instead of "telegram:123").
|
||||
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
|
||||
bareID := rawCandidate[idx+1:]
|
||||
candidates[bareID] = true
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -115,6 +115,51 @@ func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {
|
||||
// When peerID is already in canonical "platform:id" format,
|
||||
// it should match identity_links that use the bare ID.
|
||||
links := map[string][]string{
|
||||
"john": {"123"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
|
||||
if got != "john" {
|
||||
t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {
|
||||
// When identity_links contain canonical IDs and peerID is canonical too
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:123", "discord:456"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
|
||||
if got != "john" {
|
||||
t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {
|
||||
// When peerID is bare "123" and links have "telegram:123",
|
||||
// the scoped candidate "telegram:123" should match.
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:123"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "telegram", "123")
|
||||
if got != "john" {
|
||||
t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkedPeerID_NoMatch(t *testing.T) {
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:123"},
|
||||
}
|
||||
got := resolveLinkedPeerID(links, "discord", "999")
|
||||
if got != "" {
|
||||
t.Errorf("resolveLinkedPeerID no match = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentSessionKey_Valid(t *testing.T) {
|
||||
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
|
||||
if parsed == nil {
|
||||
|
||||
Reference in New Issue
Block a user