Files
picoclaw/pkg/bus/inbound_context.go
T

265 lines
6.7 KiB
Go

package bus
import "strings"
const (
metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id"
metadataKeyTeamID = "team_id"
metadataKeyReplyToMessage = "reply_to_message_id"
metadataKeyReplyToSender = "reply_to_sender_id"
metadataKeyParentPeerKind = "parent_peer_kind"
metadataKeyParentPeerID = "parent_peer_id"
metadataKeyIsMentioned = "is_mentioned"
)
// ContextFromLegacyInbound builds a normalized inbound context from the legacy
// top-level fields on InboundMessage. This keeps older producers working while
// new producers migrate to writing Context directly.
func ContextFromLegacyInbound(msg InboundMessage) InboundContext {
ctx := InboundContext{
Channel: strings.TrimSpace(msg.Channel),
ChatID: strings.TrimSpace(msg.ChatID),
ChatType: normalizeKind(msg.Peer.Kind),
SenderID: firstNonEmpty(
strings.TrimSpace(msg.SenderID),
strings.TrimSpace(msg.Sender.CanonicalID),
strings.TrimSpace(msg.Sender.PlatformID),
),
MessageID: strings.TrimSpace(msg.MessageID),
Raw: cloneStringMap(msg.Metadata),
}
if account := metadataValue(msg.Metadata, metadataKeyAccountID); account != "" {
ctx.Account = account
}
if replyToMsgID := metadataValue(msg.Metadata, metadataKeyReplyToMessage); replyToMsgID != "" {
ctx.ReplyToMessageID = replyToMsgID
}
if replyToSenderID := metadataValue(msg.Metadata, metadataKeyReplyToSender); replyToSenderID != "" {
ctx.ReplyToSenderID = replyToSenderID
}
if isTruthy(metadataValue(msg.Metadata, metadataKeyIsMentioned)) {
ctx.Mentioned = true
}
parentKind := normalizeKind(metadataValue(msg.Metadata, metadataKeyParentPeerKind))
parentID := metadataValue(msg.Metadata, metadataKeyParentPeerID)
if parentKind == "topic" && parentID != "" {
ctx.TopicID = parentID
}
switch {
case metadataValue(msg.Metadata, metadataKeyGuildID) != "":
ctx.SpaceType = "guild"
ctx.SpaceID = metadataValue(msg.Metadata, metadataKeyGuildID)
case metadataValue(msg.Metadata, metadataKeyTeamID) != "":
ctx.SpaceType = "team"
ctx.SpaceID = metadataValue(msg.Metadata, metadataKeyTeamID)
}
return normalizeInboundContext(ctx)
}
// NormalizeInboundMessage ensures the normalized Context is present and mirrors
// missing legacy fields from it so older consumers continue to work during the
// migration period.
func NormalizeInboundMessage(msg InboundMessage) InboundMessage {
if msg.Context.isZero() {
msg.Context = ContextFromLegacyInbound(msg)
} else {
msg.Context = normalizeInboundContext(msg.Context)
}
if msg.Channel == "" {
msg.Channel = msg.Context.Channel
}
if msg.SenderID == "" {
msg.SenderID = msg.Context.SenderID
}
if msg.ChatID == "" {
msg.ChatID = msg.Context.ChatID
}
if msg.MessageID == "" {
msg.MessageID = msg.Context.MessageID
}
if msg.Peer.Kind == "" {
msg.Peer = peerFromContext(msg.Context)
}
msg.Metadata = mergeLegacyMetadata(msg.Metadata, msg.Context)
return msg
}
func (ctx InboundContext) isZero() bool {
return ctx.Channel == "" &&
ctx.Account == "" &&
ctx.ChatID == "" &&
ctx.ChatType == "" &&
ctx.TopicID == "" &&
ctx.SpaceID == "" &&
ctx.SpaceType == "" &&
ctx.SenderID == "" &&
ctx.MessageID == "" &&
!ctx.Mentioned &&
ctx.ReplyToMessageID == "" &&
ctx.ReplyToSenderID == "" &&
len(ctx.ReplyHandles) == 0 &&
len(ctx.Raw) == 0
}
func normalizeInboundContext(ctx InboundContext) InboundContext {
ctx.Channel = strings.TrimSpace(ctx.Channel)
ctx.Account = strings.TrimSpace(ctx.Account)
ctx.ChatID = strings.TrimSpace(ctx.ChatID)
ctx.ChatType = normalizeKind(ctx.ChatType)
ctx.TopicID = strings.TrimSpace(ctx.TopicID)
ctx.SpaceID = strings.TrimSpace(ctx.SpaceID)
ctx.SpaceType = normalizeKind(ctx.SpaceType)
ctx.SenderID = strings.TrimSpace(ctx.SenderID)
ctx.MessageID = strings.TrimSpace(ctx.MessageID)
ctx.ReplyToMessageID = strings.TrimSpace(ctx.ReplyToMessageID)
ctx.ReplyToSenderID = strings.TrimSpace(ctx.ReplyToSenderID)
ctx.ReplyHandles = cloneStringMap(ctx.ReplyHandles)
ctx.Raw = cloneStringMap(ctx.Raw)
return ctx
}
func peerFromContext(ctx InboundContext) Peer {
kind := normalizeKind(ctx.ChatType)
if kind == "" {
return Peer{}
}
switch kind {
case "direct":
return Peer{
Kind: "direct",
ID: firstNonEmpty(strings.TrimSpace(ctx.SenderID), strings.TrimSpace(ctx.ChatID)),
}
case "group", "channel":
return Peer{
Kind: kind,
ID: strings.TrimSpace(ctx.ChatID),
}
default:
return Peer{
Kind: kind,
ID: strings.TrimSpace(ctx.ChatID),
}
}
}
func mergeLegacyMetadata(existing map[string]string, ctx InboundContext) map[string]string {
merged := cloneStringMap(existing)
if len(merged) == 0 {
merged = cloneStringMap(ctx.Raw)
} else {
for k, v := range ctx.Raw {
if _, ok := merged[k]; !ok {
merged[k] = v
}
}
}
if ctx.Account != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyAccountID, ctx.Account)
}
if ctx.ReplyToMessageID != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyReplyToMessage, ctx.ReplyToMessageID)
}
if ctx.ReplyToSenderID != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyReplyToSender, ctx.ReplyToSenderID)
}
if ctx.Mentioned {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyIsMentioned, "true")
}
if ctx.TopicID != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyParentPeerKind, "topic")
setMissing(merged, metadataKeyParentPeerID, ctx.TopicID)
}
switch normalizeKind(ctx.SpaceType) {
case "guild":
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyGuildID, ctx.SpaceID)
case "team", "workspace":
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyTeamID, ctx.SpaceID)
}
if len(merged) == 0 {
return nil
}
return merged
}
func setMissing(dst map[string]string, key, value string) {
if value == "" {
return
}
if _, ok := dst[key]; !ok {
dst[key] = value
}
}
func metadataValue(metadata map[string]string, key string) string {
if metadata == nil {
return ""
}
return strings.TrimSpace(metadata[key])
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func normalizeKind(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func isTruthy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "t", "true", "y", "yes", "on":
return true
default:
return false
}
}