mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
85251a2905
- Remove user-level model settings (chat_model_id, memory_model_id, embedding_model_id, max_context_load_time, language) from users table - Merge migration 0002 into 0001, remove compatibility migrations - Delete dead conversation/resolver.go (1177 lines, only flow/resolver.go used) - Remove type aliases (Chat=Conversation, types_alias.go) - Fix SQL: remove AND false stub, fix UpdateChatTitle model_id, reset model IDs in DeleteSettings, add preauth expiry filter, add ListMessages limit, remove 10 dead queries - Extract shared handler helpers (RequireChannelIdentityID, AuthorizeBotAccess) - Rename internal/router to internal/channel/inbound - Fix identity confusion: remove UserID->ChannelIdentityID fallbacks - Fix all _ = var patterns with proper error logging - Fix error propagation: storeMessages, rescheduleJob, botContainerID - Fix naming: ModelId->ModelID, active->is_active, Duration semantic fix - Remove dead code: mcpService, ReplyTarget, callMCPServer, sshShellQuote, buildSessionMetadata, ChatRequest.Language, TriggerPayload.ChatID - Fix code quality: errors.Is(), remove goto, CreateHuman deprecated - Remove Enable model endpoint and user-level settings CLI commands - Regenerate sqlc, swagger, SDK
319 lines
8.2 KiB
Go
319 lines
8.2 KiB
Go
package feishu
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
|
|
|
"github.com/memohai/memoh/internal/channel"
|
|
)
|
|
|
|
// extractFeishuInbound converts a Feishu P2MessageReceiveV1 event into a channel.InboundMessage.
|
|
// botOpenID is the bot's own open_id used to filter mentions; if empty, any mention is treated as bot mention.
|
|
func extractFeishuInbound(event *larkim.P2MessageReceiveV1, botOpenID string) channel.InboundMessage {
|
|
if event == nil || event.Event == nil || event.Event.Message == nil {
|
|
return channel.InboundMessage{Channel: Type}
|
|
}
|
|
message := event.Event.Message
|
|
|
|
var msg channel.Message
|
|
if message.MessageId != nil {
|
|
msg.ID = *message.MessageId
|
|
}
|
|
|
|
var contentMap map[string]any
|
|
if message.Content != nil {
|
|
if err := json.Unmarshal([]byte(*message.Content), &contentMap); err != nil {
|
|
slog.Warn("feishu inbound: unmarshal content failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
isMentioned := isFeishuBotMentioned(contentMap, message.Mentions, botOpenID)
|
|
|
|
if message.MessageType != nil {
|
|
switch *message.MessageType {
|
|
case larkim.MsgTypeText:
|
|
if txt, ok := contentMap["text"].(string); ok {
|
|
msg.Text = txt
|
|
}
|
|
case larkim.MsgTypePost:
|
|
if postText := extractFeishuPostText(contentMap); postText != "" {
|
|
msg.Text = postText
|
|
}
|
|
case larkim.MsgTypeImage:
|
|
if key, ok := contentMap["image_key"].(string); ok {
|
|
msg.Attachments = append(msg.Attachments, channel.Attachment{
|
|
Type: channel.AttachmentImage,
|
|
PlatformKey: key,
|
|
SourcePlatform: Type.String(),
|
|
})
|
|
}
|
|
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
|
if key, ok := contentMap["file_key"].(string); ok {
|
|
name, _ := contentMap["file_name"].(string)
|
|
attType := channel.AttachmentFile
|
|
switch *message.MessageType {
|
|
case larkim.MsgTypeAudio:
|
|
attType = channel.AttachmentAudio
|
|
case larkim.MsgTypeMedia:
|
|
attType = channel.AttachmentVideo
|
|
}
|
|
msg.Attachments = append(msg.Attachments, channel.Attachment{
|
|
Type: attType,
|
|
PlatformKey: key,
|
|
SourcePlatform: Type.String(),
|
|
Name: name,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if message.ParentId != nil && *message.ParentId != "" {
|
|
msg.Reply = &channel.ReplyRef{
|
|
MessageID: *message.ParentId,
|
|
}
|
|
}
|
|
|
|
senderID, senderOpenID := "", ""
|
|
if event.Event.Sender != nil && event.Event.Sender.SenderId != nil {
|
|
if event.Event.Sender.SenderId.UserId != nil {
|
|
senderID = strings.TrimSpace(*event.Event.Sender.SenderId.UserId)
|
|
}
|
|
if event.Event.Sender.SenderId.OpenId != nil {
|
|
senderOpenID = strings.TrimSpace(*event.Event.Sender.SenderId.OpenId)
|
|
}
|
|
}
|
|
chatID := ""
|
|
chatType := ""
|
|
if message.ChatId != nil {
|
|
chatID = strings.TrimSpace(*message.ChatId)
|
|
}
|
|
if message.ChatType != nil {
|
|
chatType = strings.TrimSpace(*message.ChatType)
|
|
}
|
|
replyTo := senderOpenID
|
|
if replyTo == "" {
|
|
replyTo = senderID
|
|
}
|
|
if chatType != "" && chatType != "p2p" && chatID != "" {
|
|
replyTo = "chat_id:" + chatID
|
|
}
|
|
attrs := map[string]string{}
|
|
if senderID != "" {
|
|
attrs["user_id"] = senderID
|
|
}
|
|
if senderOpenID != "" {
|
|
attrs["open_id"] = senderOpenID
|
|
}
|
|
subjectID := senderOpenID
|
|
if subjectID == "" {
|
|
subjectID = senderID
|
|
}
|
|
|
|
return channel.InboundMessage{
|
|
Channel: Type,
|
|
Message: msg,
|
|
ReplyTarget: replyTo,
|
|
Sender: channel.Identity{
|
|
SubjectID: subjectID,
|
|
Attributes: attrs,
|
|
},
|
|
Conversation: channel.Conversation{
|
|
ID: chatID,
|
|
Type: chatType,
|
|
},
|
|
ReceivedAt: time.Now().UTC(),
|
|
Source: "feishu",
|
|
Metadata: map[string]any{
|
|
"is_mentioned": isMentioned,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 []*larkim.MentionEvent, botOpenID string) bool {
|
|
botOpenID = strings.TrimSpace(botOpenID)
|
|
if botOpenID == "" {
|
|
return hasAnyFeishuMention(contentMap, mentions)
|
|
}
|
|
for _, m := range mentions {
|
|
if m == nil || m.Id == nil || m.Id.OpenId == nil {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(*m.Id.OpenId) == botOpenID {
|
|
return true
|
|
}
|
|
}
|
|
if matchFeishuContentMention(contentMap, botOpenID) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasAnyFeishuMention is the fallback when the bot's open_id is unknown.
|
|
func hasAnyFeishuMention(contentMap map[string]any, mentions []*larkim.MentionEvent) bool {
|
|
if len(mentions) > 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
|
|
}
|
|
|
|
func extractFeishuPostText(contentMap map[string]any) string {
|
|
zhCN, ok := contentMap["zh_cn"].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
linesRaw, ok := zhCN["content"].([]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, 8)
|
|
for _, rawLine := range linesRaw {
|
|
line, ok := rawLine.([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, rawPart := range line {
|
|
part, ok := rawPart.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
tag := strings.ToLower(strings.TrimSpace(stringValue(part["tag"])))
|
|
switch tag {
|
|
case "text", "a":
|
|
text := strings.TrimSpace(stringValue(part["text"]))
|
|
if text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
case "at":
|
|
name := strings.TrimSpace(stringValue(part["text"]))
|
|
if name == "" {
|
|
name = strings.TrimSpace(stringValue(part["name"]))
|
|
}
|
|
if name == "" {
|
|
name = strings.TrimSpace(stringValue(part["user_name"]))
|
|
}
|
|
if name == "" {
|
|
parts = append(parts, "@")
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(name, "@") {
|
|
name = "@" + name
|
|
}
|
|
parts = append(parts, name)
|
|
default:
|
|
text := strings.TrimSpace(stringValue(part["text"]))
|
|
if text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func stringValue(raw any) string {
|
|
if raw == nil {
|
|
return ""
|
|
}
|
|
value, ok := raw.(string)
|
|
if ok {
|
|
return value
|
|
}
|
|
return fmt.Sprint(raw)
|
|
}
|
|
|
|
// resolveFeishuReceiveID parses target (open_id:/user_id:/chat_id: prefix) and returns receiveID and receiveType.
|
|
func resolveFeishuReceiveID(raw string) (string, string, error) {
|
|
if raw == "" {
|
|
return "", "", fmt.Errorf("feishu target is required")
|
|
}
|
|
if strings.HasPrefix(raw, "open_id:") {
|
|
return strings.TrimPrefix(raw, "open_id:"), larkim.ReceiveIdTypeOpenId, nil
|
|
}
|
|
if strings.HasPrefix(raw, "user_id:") {
|
|
return strings.TrimPrefix(raw, "user_id:"), larkim.ReceiveIdTypeUserId, nil
|
|
}
|
|
if strings.HasPrefix(raw, "chat_id:") {
|
|
return strings.TrimPrefix(raw, "chat_id:"), larkim.ReceiveIdTypeChatId, nil
|
|
}
|
|
return raw, larkim.ReceiveIdTypeOpenId, nil
|
|
}
|