diff --git a/internal/app/app.go b/internal/app/app.go index 811a608c..73185b21 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -599,12 +599,12 @@ func (a *App) startRobotConnections() { if cfg.Robots.Lark.Enabled && cfg.Robots.Lark.AppID != "" && cfg.Robots.Lark.AppSecret != "" { ctx, cancel := context.WithCancel(context.Background()) a.larkCancel = cancel - go robot.StartLark(ctx, cfg.Robots.Lark, a.robotHandler, a.logger.Logger) + go robot.StartLark(ctx, cfg.Robots, a.robotHandler, a.logger.Logger) } if cfg.Robots.Dingtalk.Enabled && cfg.Robots.Dingtalk.ClientID != "" && cfg.Robots.Dingtalk.ClientSecret != "" { ctx, cancel := context.WithCancel(context.Background()) a.dingCancel = cancel - go robot.StartDing(ctx, cfg.Robots.Dingtalk, a.robotHandler, a.logger.Logger) + go robot.StartDing(ctx, cfg.Robots, a.robotHandler, a.logger.Logger) } } diff --git a/internal/handler/robot.go b/internal/handler/robot.go index 7d701fc6..37bbf311 100644 --- a/internal/handler/robot.go +++ b/internal/handler/robot.go @@ -75,14 +75,58 @@ func (h *RobotHandler) sessionKey(platform, userID string) string { return platform + "_" + userID } +func (h *RobotHandler) loadSessionBinding(sk string) (convID, role string) { + if h.db == nil || strings.TrimSpace(sk) == "" { + return "", "" + } + binding, err := h.db.GetRobotSessionBinding(sk) + if err != nil { + h.logger.Warn("读取机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err)) + return "", "" + } + if binding == nil { + return "", "" + } + return binding.ConversationID, binding.RoleName +} + +func (h *RobotHandler) persistSessionBinding(sk, convID, role string) { + if h.db == nil || strings.TrimSpace(sk) == "" || strings.TrimSpace(convID) == "" { + return + } + if err := h.db.UpsertRobotSessionBinding(sk, convID, role); err != nil { + h.logger.Warn("写入机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err)) + } +} + +func (h *RobotHandler) deleteSessionBinding(sk string) { + if h.db == nil || strings.TrimSpace(sk) == "" { + return + } + if err := h.db.DeleteRobotSessionBinding(sk); err != nil { + h.logger.Warn("删除机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err)) + } +} + // getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字) func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) { + sk := h.sessionKey(platform, userID) h.mu.RLock() - convID = h.sessions[h.sessionKey(platform, userID)] + convID = h.sessions[sk] h.mu.RUnlock() if convID != "" { return convID, false } + if persistedConvID, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" { + // 会话绑定持久化:服务重启后也可恢复当前对话和角色。 + h.mu.Lock() + h.sessions[sk] = persistedConvID + if strings.TrimSpace(persistedRole) != "" { + h.sessionRoles[sk] = persistedRole + } + h.mu.Unlock() + return persistedConvID, false + } t := strings.TrimSpace(title) if t == "" { t = "新对话 " + time.Now().Format("01-02 15:04") @@ -96,34 +140,49 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) ( } convID = conv.ID h.mu.Lock() - h.sessions[h.sessionKey(platform, userID)] = convID + role := h.sessionRoles[sk] + h.sessions[sk] = convID h.mu.Unlock() + h.persistSessionBinding(sk, convID, role) return convID, true } // setConversation 切换当前会话 func (h *RobotHandler) setConversation(platform, userID, convID string) { + sk := h.sessionKey(platform, userID) h.mu.Lock() - h.sessions[h.sessionKey(platform, userID)] = convID + role := h.sessionRoles[sk] + h.sessions[sk] = convID h.mu.Unlock() + h.persistSessionBinding(sk, convID, role) } // getRole 获取当前用户使用的角色,未设置时返回"默认" func (h *RobotHandler) getRole(platform, userID string) string { + sk := h.sessionKey(platform, userID) h.mu.RLock() - role := h.sessionRoles[h.sessionKey(platform, userID)] + role := h.sessionRoles[sk] h.mu.RUnlock() - if role == "" { - return "默认" + if strings.TrimSpace(role) != "" { + return role } - return role + if _, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" { + h.mu.Lock() + h.sessionRoles[sk] = persistedRole + h.mu.Unlock() + return persistedRole + } + return "默认" } // setRole 设置当前用户使用的角色 func (h *RobotHandler) setRole(platform, userID, roleName string) { + sk := h.sessionKey(platform, userID) h.mu.Lock() - h.sessionRoles[h.sessionKey(platform, userID)] = roleName + h.sessionRoles[sk] = roleName + convID := h.sessions[sk] h.mu.Unlock() + h.persistSessionBinding(sk, convID, roleName) } // clearConversation 清空当前会话(切换到新对话) @@ -140,7 +199,16 @@ func (h *RobotHandler) clearConversation(platform, userID string) (newConvID str // HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用) func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) { + platform = strings.TrimSpace(platform) + userID = strings.TrimSpace(userID) text = strings.TrimSpace(text) + if platform == "" { + platform = "unknown" + } + if userID == "" { + h.logger.Warn("机器人消息缺少用户标识,已拒绝处理", zap.String("platform", platform)) + return "无法识别发送者身份,请检查机器人事件订阅权限(需返回可用的用户 ID)。" + } if text == "" { return "请输入内容或发送「帮助」/ help 查看命令。" } @@ -345,7 +413,9 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string { // 删除当前对话时,先清空会话绑定 h.mu.Lock() delete(h.sessions, sk) + delete(h.sessionRoles, sk) h.mu.Unlock() + h.deleteSessionBinding(sk) } if err := h.db.DeleteConversation(convID); err != nil { return "删除失败: " + err.Error() @@ -647,8 +717,25 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) { h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content)) } - userID := body.FromUserName + tenantKey := strings.TrimSpace(enterpriseID) + if tenantKey == "" { + tenantKey = strings.TrimSpace(h.config.Robots.Wecom.CorpID) + } + if tenantKey == "" { + tenantKey = "default" + } + rawUserID := strings.TrimSpace(body.FromUserName) + replyUserID := rawUserID + userID := "" + if rawUserID != "" { + userID = "t:" + tenantKey + "|u:" + rawUserID + } text := strings.TrimSpace(body.Content) + if userID == "" { + h.logger.Warn("企业微信消息缺少可用用户标识,已忽略") + c.String(http.StatusOK, "success") + return + } // 限制回复内容长度(企业微信限制 2048 字节) maxReplyLen := 2000 @@ -661,14 +748,14 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) { if body.MsgType != "text" { h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType)) - h.sendWecomReply(c, userID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce) + h.sendWecomReply(c, replyUserID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce) return } // 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。 if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok { h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text)) - h.sendWecomReply(c, userID, enterpriseID, limitReply(cmdReply), timestamp, nonce) + h.sendWecomReply(c, replyUserID, enterpriseID, limitReply(cmdReply), timestamp, nonce) return } @@ -684,7 +771,7 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) { reply = limitReply(reply) h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply)) // 调用企业微信 API 主动发送消息 - h.sendWecomMessageViaAPI(userID, enterpriseID, reply) + h.sendWecomMessageViaAPI(rawUserID, enterpriseID, reply) }() } diff --git a/internal/robot/ding.go b/internal/robot/ding.go index eefebf66..7f469808 100644 --- a/internal/robot/ding.go +++ b/internal/robot/ding.go @@ -23,22 +23,23 @@ const ( // StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。 // 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 -func StartDing(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { +func StartDing(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Dingtalk if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" { return } - go runDingLoop(ctx, cfg, h, logger) + go runDingLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger) } // runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。 -func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { +func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) { backoff := dingReconnectInitial for { streamClient := client.NewStreamClient( client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)), client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get", chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) { - go handleDingMessage(ctx, msg, h, logger) + go handleDingMessage(ctx, msg, cfg, strictUserIdentity, h, logger) return nil, nil }).OnEventReceived), ) @@ -66,7 +67,7 @@ func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageH } } -func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) { +func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) { if msg == nil || msg.SessionWebhook == "" { return } @@ -93,9 +94,22 @@ func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h return } logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content)) - userID := msg.SenderId + tenantKey := strings.TrimSpace(cfg.ClientID) + if tenantKey == "" { + tenantKey = "default" + } + userID := strings.TrimSpace(msg.SenderId) + if userID != "" { + userID = "t:" + tenantKey + "|u:" + userID + } else if cfg.AllowConversationIDFallback && !strictUserIdentity { + conversationID := strings.TrimSpace(msg.ConversationId) + if conversationID != "" { + userID = "t:" + tenantKey + "|c:" + conversationID + } + } if userID == "" { - userID = msg.ConversationId + logger.Warn("钉钉消息缺少可用用户标识,已忽略") + return } reply := h.HandleMessage("dingtalk", userID, content) // 使用 markdown 类型以便正确展示标题、列表、代码块等格式 diff --git a/internal/robot/lark.go b/internal/robot/lark.go index 9e70af0a..2cda0601 100644 --- a/internal/robot/lark.go +++ b/internal/robot/lark.go @@ -27,20 +27,21 @@ type larkTextContent struct { // StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。 // 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 -func StartLark(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { +func StartLark(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Lark if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { return } - go runLarkLoop(ctx, cfg, h, logger) + go runLarkLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger) } // runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。 -func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { +func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) { backoff := larkReconnectInitial for { larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret) eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { - go handleLarkMessage(ctx, event, h, larkClient, logger) + go handleLarkMessage(ctx, event, cfg, strictUserIdentity, h, larkClient, logger) return nil }) wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret, @@ -70,7 +71,7 @@ func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandl } } -func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) { +func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, client *lark.Client, logger *zap.Logger) { if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil { return } @@ -89,9 +90,10 @@ func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h if text == "" { return } - userID := "" - if event.Event.Sender.SenderId.UserId != nil { - userID = *event.Event.Sender.SenderId.UserId + userID := resolveLarkUserID(event, cfg.AllowChatIDFallback && !strictUserIdentity) + if userID == "" { + logger.Warn("飞书消息缺少可用用户标识,已忽略") + return } messageID := larkcore.StringValue(msg.MessageId) reply := h.HandleMessage("lark", userID, text) @@ -109,3 +111,31 @@ func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h } logger.Debug("飞书已回复", zap.String("message_id", messageID)) } + +// resolveLarkUserID 提取飞书会话隔离键: +// tenant_key + 稳定用户标识(user_id/open_id/union_id);按配置可选 chat_id 兜底。 +func resolveLarkUserID(event *larkim.P2MessageReceiveV1, allowChatIDFallback bool) string { + if event == nil || event.Event == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil { + return "" + } + tenantKey := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.TenantKey)) + if tenantKey == "" { + tenantKey = "default" + } + prefix := "t:" + tenantKey + "|" + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UserId)); id != "" { + return prefix + "u:" + id + } + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.OpenId)); id != "" { + return prefix + "o:" + id + } + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UnionId)); id != "" { + return prefix + "n:" + id + } + if allowChatIDFallback && event.Event.Message != nil { + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Message.ChatId)); id != "" { + return prefix + "c:" + id + } + } + return "" +}