Files
Memoh/internal/channel/adapters/feishu/inbound_mentions.go
T
2026-04-06 06:18:03 +08:00

232 lines
5.9 KiB
Go

package feishu
import (
"sort"
"strings"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
type feishuMention struct {
Key string
Name string
OpenID string
UserID string
UnionID string
TenantKey string
}
func normalizeFeishuMentions(mentions []*larkim.MentionEvent) []feishuMention {
result := make([]feishuMention, 0, len(mentions))
for _, m := range mentions {
if m == nil {
continue
}
item := feishuMention{
Key: strings.TrimSpace(ptrStr(m.Key)),
Name: strings.TrimSpace(ptrStr(m.Name)),
}
if m.Id != nil {
item.OpenID = strings.TrimSpace(ptrStr(m.Id.OpenId))
item.UserID = strings.TrimSpace(ptrStr(m.Id.UserId))
item.UnionID = strings.TrimSpace(ptrStr(m.Id.UnionId))
}
item.TenantKey = strings.TrimSpace(ptrStr(m.TenantKey))
result = append(result, item)
}
return result
}
func feishuMentionDisplayName(m feishuMention) string {
name := strings.TrimSpace(m.Name)
if name != "" {
if strings.HasPrefix(name, "@") {
return name
}
return "@" + name
}
if strings.TrimSpace(m.OpenID) != "" {
return "@open_id:" + strings.TrimSpace(m.OpenID)
}
if strings.TrimSpace(m.UserID) != "" {
return "@user_id:" + strings.TrimSpace(m.UserID)
}
return "@user"
}
// rewriteFeishuMentionKeys converts Feishu text placeholders (e.g. @_user_1)
// into stable mention labels so downstream logic can identify who is mentioned.
func rewriteFeishuMentionKeys(text string, mentions []feishuMention) string {
if strings.TrimSpace(text) == "" || len(mentions) == 0 {
return text
}
type kv struct {
key string
value string
}
replacements := make([]kv, 0, len(mentions))
for _, mention := range mentions {
if mention.Key == "" {
continue
}
replacements = append(replacements, kv{
key: mention.Key,
value: feishuMentionDisplayName(mention),
})
}
// Replace longer keys first to avoid partial replacement (@_user_1 vs @_user_10).
sort.Slice(replacements, func(i, j int) bool {
return len(replacements[i].key) > len(replacements[j].key)
})
rewritten := text
for _, item := range replacements {
rewritten = strings.ReplaceAll(rewritten, item.key, item.value)
}
return rewritten
}
func feishuMentionsMetadata(mentions []feishuMention) []map[string]any {
if len(mentions) == 0 {
return nil
}
result := make([]map[string]any, 0, len(mentions))
for _, mention := range mentions {
entry := map[string]any{
"key": mention.Key,
"name": mention.Name,
"open_id": mention.OpenID,
"user_id": mention.UserID,
"union_id": mention.UnionID,
"tenant_key": mention.TenantKey,
}
if target := feishuMentionTarget(mention); target != "" {
entry["target"] = target
}
result = append(result, entry)
}
return result
}
func feishuMentionTarget(mention feishuMention) string {
if strings.TrimSpace(mention.OpenID) != "" {
return "open_id:" + strings.TrimSpace(mention.OpenID)
}
if strings.TrimSpace(mention.UserID) != "" {
return "user_id:" + strings.TrimSpace(mention.UserID)
}
return ""
}
func feishuMentionTargets(mentions []feishuMention) []string {
if len(mentions) == 0 {
return nil
}
seen := make(map[string]struct{}, len(mentions))
result := make([]string, 0, len(mentions))
for _, mention := range mentions {
target := feishuMentionTarget(mention)
if target == "" {
continue
}
if _, ok := seen[target]; ok {
continue
}
seen[target] = struct{}{}
result = append(result, target)
}
return result
}
// isFeishuBotMentioned checks whether the bot itself is mentioned in the message.
// When botOpenID is provided, only mentions matching the bot's open_id count.
// When botOpenID is empty (fallback), any mention is treated as a bot mention.
func isFeishuBotMentioned(contentMap map[string]any, mentions []feishuMention, botOpenID string) bool {
botOpenID = strings.TrimSpace(botOpenID)
if botOpenID == "" {
return hasAnyFeishuMention(contentMap, len(mentions))
}
for _, m := range mentions {
if strings.TrimSpace(m.OpenID) == botOpenID {
return true
}
}
return matchFeishuContentMention(contentMap, botOpenID)
}
// hasAnyFeishuMention is the fallback when the bot's open_id is unknown.
func hasAnyFeishuMention(contentMap map[string]any, mentionCount int) bool {
if mentionCount > 0 {
return true
}
if len(contentMap) == 0 {
return false
}
if raw, ok := contentMap["mentions"]; ok {
switch values := raw.(type) {
case []any:
if len(values) > 0 {
return true
}
case []map[string]any:
if len(values) > 0 {
return true
}
}
}
if text, ok := contentMap["text"].(string); ok {
normalized := strings.ToLower(strings.TrimSpace(text))
if strings.Contains(normalized, "@_user_") || strings.Contains(normalized, "<at ") || strings.Contains(normalized, "</at>") {
return true
}
}
return hasFeishuAtTag(contentMap)
}
// matchFeishuContentMention checks rich-text at tags for the bot's open_id.
func matchFeishuContentMention(raw any, botOpenID string) bool {
switch value := raw.(type) {
case map[string]any:
if tag, ok := value["tag"].(string); ok && strings.EqualFold(strings.TrimSpace(tag), "at") {
if uid, ok := value["user_id"].(string); ok && strings.TrimSpace(uid) == botOpenID {
return true
}
if uid, ok := value["open_id"].(string); ok && strings.TrimSpace(uid) == botOpenID {
return true
}
}
for _, child := range value {
if matchFeishuContentMention(child, botOpenID) {
return true
}
}
case []any:
for _, child := range value {
if matchFeishuContentMention(child, botOpenID) {
return true
}
}
}
return false
}
func hasFeishuAtTag(raw any) bool {
switch value := raw.(type) {
case map[string]any:
if tag, ok := value["tag"].(string); ok && strings.EqualFold(strings.TrimSpace(tag), "at") {
return true
}
for _, child := range value {
if hasFeishuAtTag(child) {
return true
}
}
case []any:
for _, child := range value {
if hasFeishuAtTag(child) {
return true
}
}
}
return false
}