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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user