diff --git a/config/config_env.yaml b/config/config_env.yaml index 1b1a8bd..9d07c06 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -4,7 +4,7 @@ server: host: "0.0.0.0" ollama: - base_url: "http://192.168.6.109:11434" + base_url: "http://192.168.6.115:11434" model: "qwen3-coder:480b-cloud" generate_model: "qwen3-coder:480b-cloud" mapping_model: "deepseek-v3.2:cloud" @@ -147,6 +147,26 @@ dingtalk: # 机器人群组 bot_group_id: bbxt: 23 + # 互动卡片 + card: + # 卡片回调路由key - https://gateway.dev.cdlsxd.cn/zltx_api/aitest/api/v1//callback/dingtalk-card + callback_route_key: "gateway.dev.cdlsxd.cn-dingtalk-card" + # 卡片调试工具 [show:展示 hide:隐藏] + debug_tool_entry_show: "hide" + # 卡片模板 + template: + # 基础消息卡片(title + content) + base_msg: "291468f8-a048-4132-a37e-a14365e855e9.schema" + # 内容收集卡片(title + textarea + button) + content_collect: "3a447814-6a3e-4a02-b48a-92c57b349d77.schema" + # 创建群聊申请(title + content + button) + create_group_approve: "faad6d5d-726d-467f-a6ba-28c1930aa5f3.schema" + # 场景群 + scene_group: + # 问题处理群模板ID + group_template_id_issue_handling: "aa3aa4fe-e709-4491-b24b-c3d5b27e86d0" + # 问题处理群模板机器人ID + group_template_robot_id_issue_handling: "VqgJYpB91j3RnB217690607273471011" qywx: corp_id: "ww48151f694fb8ec67" @@ -169,6 +189,16 @@ default_prompt: 若图片为文档类(如合同、发票、收据),请结构化输出关键字段(如客户名称、金额、开票日期等)。 ' user_prompt: '识别图片内容' + # 权限配置 permissionConfig: permission_url: "http://api.test.user.1688sup.cn:8001/v1/menu/myCodes?systemId=" + +# 知识库配置 +knowledge_config: + base_url: "http://192.168.6.115:9600" + tenant_id: "default" + mode: "naive" + stream: true + think: false + only_rag: true diff --git a/config/config_test.yaml b/config/config_test.yaml index d0cef3d..8c4a284 100644 --- a/config/config_test.yaml +++ b/config/config_test.yaml @@ -154,6 +154,26 @@ dingtalk: # 机器人群组 bot_group_id: bbxt: 23 + # 互动卡片 + card: + # 卡片回调路由key + callback_route_key: "gateway.dev.cdlsxd.cn-dingtalk-card" + # 卡片调试工具 [show:展示 hide:隐藏] + debug_tool_entry_show: "hide" + # 卡片模板 + template: + # 基础消息卡片(title + content) + base_msg: "291468f8-a048-4132-a37e-a14365e855e9.schema" + # 内容收集卡片(title + textarea + button) + content_collect: "3a447814-6a3e-4a02-b48a-92c57b349d77.schema" + # 创建群聊申请(title + content + button) + create_group_approve: "faad6d5d-726d-467f-a6ba-28c1930aa5f3.schema" + # 场景群 + scene_group: + # 问题处理群模板ID + group_template_id_issue_handling: "aa3aa4fe-e709-4491-b24b-c3d5b27e86d0" + # 问题处理群模板机器人ID + group_template_robot_id_issue_handling: "VqgJYpB91j3RnB217690607273471011" qywx: corp_id: "ww48151f694fb8ec67" @@ -179,6 +199,15 @@ default_prompt: permissionConfig: permission_url: "http://api.test.user.1688sup.cn:8001/v1/menu/myCodes?systemId=" +# 知识库配置 +knowledge_config: + base_url: "http://192.168.6.115:9600" + tenant_id: "default" + mode: "naive" + stream: true + think: false + only_rag: true + # llm 服务配置 llm: providers: diff --git a/internal/biz/callback.go b/internal/biz/callback.go new file mode 100644 index 0000000..40b8e40 --- /dev/null +++ b/internal/biz/callback.go @@ -0,0 +1,290 @@ +package biz + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" + "ai_scheduler/internal/data/impl" + "ai_scheduler/internal/domain/tools/common/knowledge_base" + "ai_scheduler/internal/pkg/dingtalk" + "ai_scheduler/internal/pkg/util" + "ai_scheduler/internal/pkg/utils_ollama" + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/card" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" + "github.com/alibabacloud-go/dingtalk/card_1_0" + "github.com/alibabacloud-go/tea/tea" + "github.com/gofiber/fiber/v2/log" + "github.com/ollama/ollama/api" +) + +type CallbackBiz struct { + cfg *config.Config + ollamaClient *utils_ollama.Client + dingtalkCardClient *dingtalk.CardClient + botConfigImpl *impl.BotConfigImpl +} + +func NewCallbackBiz( + cfg *config.Config, + ollamaClient *utils_ollama.Client, + dingtalkCardClient *dingtalk.CardClient, + botConfigImpl *impl.BotConfigImpl, +) *CallbackBiz { + return &CallbackBiz{ + cfg: cfg, + ollamaClient: ollamaClient, + dingtalkCardClient: dingtalkCardClient, + botConfigImpl: botConfigImpl, + } +} + +// IssueHandlingGroup 问题处理群机器人回调 +// 能力1: 通过[内容提取] 宏,分析用户QA问题,调出QA表单卡片 +// 能力2: 通过[QA收集] 宏,收集用户反馈,写入知识库 +// 能力3: 通过[知识库查询] 宏,查询知识库,返回答案 +func (c *CallbackBiz) IssueHandlingGroup(data chatbot.BotCallbackDataModel) error { + // 能力1、2:分析用户QA问题,写入知识库 + if strings.Contains(data.Text.Content, "[内容提取]") || strings.Contains(data.Text.Content, "[QA收集]") { + c.issueHandlingExtractContent(data) + } + // 能力3:查询知识库,返回答案 + if strings.Contains(data.Text.Content, "[知识库查询]") { + c.issueHandlingQueryKnowledgeBase(data) + } + + return nil +} + +// 问题处理群机器人内容提取 +func (c *CallbackBiz) issueHandlingExtractContent(data chatbot.BotCallbackDataModel) { + // 1.提取用户输入 + prompt := fmt.Sprintf(constants.IssueHandlingExtractContentPrompt, data.Text.Content) + log.Infof("问题提取提示词: %s", prompt) + // LLM 提取 + generateResp, err := c.ollamaClient.Generation(context.Background(), &api.GenerateRequest{ + Model: c.cfg.Ollama.GenerateModel, + Prompt: prompt, + Stream: util.AnyToPoint(false), + }) + if err != nil { + log.Errorf("问题提取失败: %v", err) + return + } + // 解析 JSON 响应 + var resp struct { + Items []struct { + Question string `json:"question"` + Answer string `json:"answer"` + Confidence string `json:"confidence"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(generateResp.Response), &resp); err != nil { + log.Errorf("解析 JSON 响应失败: %v", err) + return + } + + // 2.构建文本域内容 + cardContentTpl := "问题:%s \n答案:%s" + var cardContentList []string + for _, item := range resp.Items { + cardContentList = append(cardContentList, fmt.Sprintf(cardContentTpl, item.Question, item.Answer)) + } + cardContent := strings.Join(cardContentList, "\n\n") + + // 3.获取应用AppKey + appKey, err := c.botConfigImpl.GetRobotAppKey(data.RobotCode) + if err != nil { + log.Errorf("获取应用配置失败: %v", err) + return + } + + // 4.创建并投放卡片 + outTrackId := constants.BuildCardOutTrackId(data.ConversationId, data.RobotCode) // 构建卡片 OutTrackId + _, err = c.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(c.cfg.Dingtalk.Card.Template.ContentCollect), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("HTTP"), + CallbackRouteKey: tea.String(c.cfg.Dingtalk.Card.CallbackRouteKey), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("QA知识收集"), + "button_display": tea.String("true"), + "QA_details_now": tea.String(cardContent), + "textarea_display": tea.String("normal"), + "action_id": tea.String("collect_qa"), + "tenant_id": tea.String(constants.KnowledgeTenantIdDefault), + "_CARD_DEBUG_TOOL_ENTRY": tea.String(c.cfg.Dingtalk.Card.DebugToolEntryShow), // 调试字段 + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + data.ConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(c.cfg.Dingtalk.SceneGroup.GroupTemplateRobotIDIssueHandling), + }, + }, + ) + +} + +// 问题处理群机器人查询知识库 +func (c *CallbackBiz) issueHandlingQueryKnowledgeBase(data chatbot.BotCallbackDataModel) { + // 获取应用配置 + appKey, err := c.botConfigImpl.GetRobotAppKey(data.RobotCode) + if err != nil { + log.Errorf("应用机器人配置不存在: %s, err: %v", data.RobotCode, err) + return + } + // 创建卡片 + outTrackId := constants.BuildCardOutTrackId(data.ConversationId, data.RobotCode) + _, err = c.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(c.cfg.Dingtalk.Card.Template.BaseMsg), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String(data.Text.Content), + "markdown": tea.String("知识库检索中..."), + }, + }, + OutTrackId: tea.String(outTrackId), + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + data.ConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(data.RobotCode), + }, + }, + ) + + // 查询知识库 + knowledgeBase := knowledge_base.New(c.cfg.KnowledgeConfig) + knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{ + TenantID: constants.KnowledgeTenantIdDefault, + Query: data.Text.Content, + Mode: constants.KnowledgeModeMix, + Stream: false, + Think: false, + OnlyRAG: true, + }) + if err != nil { + log.Errorf("查询知识库失败: %v", err) + return + } + knowledgeRespBytes, err := io.ReadAll(knowledgeResp) + if err != nil { + log.Errorf("读取知识库响应失败: %v", err) + return + } + + // 卡片更新 + message, isRetrieved, err := knowledge_base.ParseOpenAIHTTPData(string(knowledgeRespBytes)) + if err != nil { + log.Errorf("读取知识库 SSE 数据失败: %v", err) + return + } + + content := message.Content + if !isRetrieved { + content = "知识库未检测到匹配信息,请核查知识库数据是否正确。" + } + + // 卡片更新 + _, err = c.dingtalkCardClient.UpdateCard( + appKey, + &card_1_0.UpdateCardRequest{ + OutTrackId: tea.String(outTrackId), + CardData: &card_1_0.UpdateCardRequestCardData{ + CardParamMap: map[string]*string{ + "markdown": tea.String(content), + }, + }, + CardUpdateOptions: &card_1_0.UpdateCardRequestCardUpdateOptions{ + UpdateCardDataByKey: tea.Bool(true), + }, + }, + ) + if err != nil { + log.Errorf("更新卡片失败: %v", err) + return + } +} + +// IssueHandlingCollectQA 问题处理群机器人 QA 收集回调 +func (c *CallbackBiz) IssueHandlingCollectQA(data card.CardRequest) *card.CardResponse { + // 确认提交,文本写入知识库 + if data.CardActionData.CardPrivateData.Params["submit"] == "submit" { + content := data.CardActionData.CardPrivateData.Params["QA_details"].(string) + tenantID := data.CardActionData.CardPrivateData.Params["tenant_id"].(string) + + // 协程执行耗时操作,防止阻塞 + util.SafeGo("inject_knowledge_base", func() { + knowledgeBase := knowledge_base.New(c.cfg.KnowledgeConfig) + err := knowledgeBase.IngestText(&knowledge_base.IngestTextRequest{ + TenantID: tenantID, + Text: content, + }) + if err != nil { + log.Errorf("注入知识库失败: %v", err) + } else { + log.Infof("注入知识库成功: tenantID=%s", tenantID) + } + + // 解析当前卡片的 ConversationId 和 robotCode + conversationId, robotCode := constants.ParseCardOutTrackId(data.OutTrackId) + + // 获取应用配置 + appKey, err := c.botConfigImpl.GetRobotAppKey(robotCode) + if err != nil { + log.Errorf("获取应用机器人配置失败: %v", err) + return + } + + // 发送卡片通知用户注入成功 + outTrackId := constants.BuildCardOutTrackId(conversationId, robotCode) + c.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(c.cfg.Dingtalk.Card.Template.BaseMsg), + OutTrackId: tea.String(outTrackId), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("QA知识收集结果"), + "markdown": tea.String("[Get] **成功**"), + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + conversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(robotCode), + }, + }, + ) + }) + } + + // 取消提交,禁用输入框 + resp := &card.CardResponse{ + CardUpdateOptions: &card.CardUpdateOptions{ + UpdateCardDataByKey: true, + }, + CardData: &card.CardDataDto{ + CardParamMap: map[string]string{ + "textarea_display": "disabled", + }, + }, + } + + return resp +} diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 6773353..d26f572 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -21,8 +21,13 @@ import ( "strings" "time" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/card" "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" + dingtalkPkg "ai_scheduler/internal/pkg/dingtalk" + + "github.com/alibabacloud-go/dingtalk/card_1_0" + "github.com/alibabacloud-go/tea/tea" "github.com/gofiber/fiber/v2/log" "xorm.io/builder" ) @@ -46,6 +51,9 @@ type DingTalkBotBiz struct { groupConfigBiz *GroupConfigBiz reportDailyCacheImpl *impl.ReportDailyCacheImpl macro *do.Macro + dingtalkOauth2Client *dingtalkPkg.Oauth2Client + dingTalkOld *dingtalkPkg.OldClient + dingtalkCardClient *dingtalkPkg.CardClient } // NewDingTalkBotBiz @@ -54,6 +62,7 @@ func NewDingTalkBotBiz( handle *do.Handle, botConfigImpl *impl.BotConfigImpl, botGroupImpl *impl.BotGroupImpl, + botGroupConfigImpl *impl.BotGroupConfigImpl, dingTalkUser *dingtalk.User, chatHis *impl.BotChatHisImpl, reportDailyCacheImpl *impl.ReportDailyCacheImpl, @@ -62,6 +71,9 @@ func NewDingTalkBotBiz( cardSend *dingtalk.SendCardClient, groupConfigBiz *GroupConfigBiz, macro *do.Macro, + dingtalkOauth2Client *dingtalkPkg.Oauth2Client, + dingTalkOld *dingtalkPkg.OldClient, + dingtalkCardClient *dingtalkPkg.CardClient, ) *DingTalkBotBiz { return &DingTalkBotBiz{ do: do, @@ -71,12 +83,16 @@ func NewDingTalkBotBiz( dingTalkUser: dingTalkUser, groupConfigBiz: groupConfigBiz, botGroupImpl: botGroupImpl, + botGroupConfigImpl: botGroupConfigImpl, toolManager: toolManager, chatHis: chatHis, conf: conf, cardSend: cardSend, reportDailyCacheImpl: reportDailyCacheImpl, macro: macro, + dingtalkOauth2Client: dingtalkOauth2Client, + dingTalkOld: dingTalkOld, + dingtalkCardClient: dingtalkCardClient, } } @@ -172,7 +188,7 @@ func (d *DingTalkBotBiz) handleGroupChat(ctx context.Context, requireData *entit return } - return d.groupConfigBiz.handleMatch(ctx, rec, groupConfig) + return d.groupConfigBiz.handleMatch(ctx, rec, groupConfig, requireData.Req) } func (d *DingTalkBotBiz) initGroup(ctx context.Context, conversationId string, conversationTitle string, robotCode string) (group *model.AiBotGroup, err error) { @@ -252,6 +268,9 @@ func (d *DingTalkBotBiz) getHis(ctx context.Context, conversationType constants. } messages := make([]entitys.HisMessage, 0, len(his)) for _, v := range his { + if v.Role != "user" { + continue + } messages = append(messages, entitys.HisMessage{ Role: constants.Caller(v.Role), // 用户角色 Content: v.Content, // 用户输入内容 @@ -411,3 +430,209 @@ func (d *DingTalkBotBiz) defaultPrompt() string { -parameters 必须是 **转义后的 JSON 字符串**(如 "{\"product_name\": \"京东月卡\"}")。 当前时间:` + now + `,所有的时间识别精确到秒` } + +// CreateIssueHandlingGroupAndInit 创建问题处理群聊并初始化 +func (d *DingTalkBotBiz) CreateIssueHandlingGroupAndInit(ctx context.Context, data *card.CardRequest) (resp *card.CardResponse, err error) { + + // 解析 OutTrackId 以获取 SpaceId 和 BotId + spaceId, botId := constants.ParseCardOutTrackId(data.OutTrackId) + + // 获取新群聊人员 + var userIds []string + userIds, err = d.buildNewGroupUserIds(spaceId, botId, data.UserId) + if err != nil { + return nil, err + } + + // 创建群聊及群初始化(异步响应) + if data.CardActionData.CardPrivateData.Params["status"] == "confirm" { + go func() { + err := d.createIssueHandlingGroupAndInit(ctx, data.CardActionData.CardPrivateData.Params, spaceId, botId, userIds) + if err != nil { + log.Errorf("创建群聊及群初始化失败: %v", err) + } + }() + } + + // 构建关闭创建群组卡片按钮的响应 + return d.buildCreateGroupCardResp(), nil +} + +// buildNewGroupUserIds 构建新群聊人员列表 +func (d *DingTalkBotBiz) buildNewGroupUserIds(spaceId, botId, groupOwner string) ([]string, error) { + // 群id+机器人id确认一个群配置 + botGroup, err := d.botGroupImpl.GetByConversationIdAndRobotCode(spaceId, botId) + if err != nil { + return nil, err + } + + // 获取群配置 + var groupConfig model.AiBotGroupConfig + cond := builder.NewCond().And(builder.Eq{"config_id": botGroup.ConfigID}) + err = d.botGroupConfigImpl.GetOneBySearchToStrut(&cond, &groupConfig) + if err != nil { + return nil, err + } + + // 获取处理人列表 + issueOwnerJson := groupConfig.IssueOwner + type issueOwnerType struct { + UserId string `json:"userid"` + Name string `json:"name"` + } + var issueOwner []issueOwnerType + if err = json.Unmarshal([]byte(issueOwnerJson), &issueOwner); err != nil { + return nil, err + } + + // 合并所有userid + userIds := []string{groupOwner} // 当前用户为群主 + for _, owner := range issueOwner { + userIds = append(userIds, owner.UserId) + } + + return userIds, nil +} + +// createIssueHandlingGroupAndInit 创建问题处理群聊及群初始化 +func (d *DingTalkBotBiz) createIssueHandlingGroupAndInit(ctx context.Context, callbackParams map[string]any, spaceId, botId string, userIds []string) error { + // 获取应用配置 + appKey, err := d.botConfigImpl.GetRobotAppKey(botId) + if err != nil { + return err + } + + // 获取 access_token + accessToken, err := d.dingtalkOauth2Client.GetAccessToken(appKey) + if err != nil { + return err + } + appKey.AccessToken = accessToken + + // 创建群聊 + _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, userIds) + if err != nil { + return err + } + + // 添加当前机器人到新群 - SDK 有问题,后续再考虑使用 + // _, err = d.dingtalkImClient.AddRobotToConversation( + // appKey, + // &im_1_0.AddRobotToConversationRequest{ + // OpenConversationId: tea.String(openConversationId), + // RobotCode: tea.String(botId), + // }) + // if err != nil { + // fmt.Printf("添加机器人到会话失败: %v", err) + // } + + // 返回新群分享链接,直接进群 - SDK 有问题,后续再考虑使用 + // newGroupShareLink, err = d.dingTalkOld.GetJoinGroupQrcode(ctx, chatId, data.UserId) + // if err != nil { + // fmt.Printf("获取入群二维码失败: %v", err) + // } + + // 初始化群聊 + groupScope := callbackParams["group_scope"].(string) // 群主题 + d.initIssueHandlingGroup(appKey, openConversationId, groupScope) + + return nil +} + +// createIssueHandlingGroup 创建问题处理群聊会话 +func (d *DingTalkBotBiz) createIssueHandlingGroup(ctx context.Context, accessToken string, userIds []string) (chatId, openConversationId string, err error) { + // 是否使用模板群开关 + var useTemplateGroup bool = true + + // 创建内部群会话 + if !useTemplateGroup { + return d.dingTalkOld.CreateInternalGroupConversation(ctx, accessToken, "问题处理群", userIds) + } + + // 根据群模板ID创建群 + if useTemplateGroup { + return d.dingTalkOld.CreateSceneGroupConversation(ctx, accessToken, "问题处理群", userIds, d.conf.Dingtalk.SceneGroup.GroupTemplateIDIssueHandling) + } + + return +} + +// initIssueHandlingGroup 初始化问题处理群聊 +func (d *DingTalkBotBiz) initIssueHandlingGroup(appKey dingtalkPkg.AppKey, openConversationId, groupScope string) error { + // 1.开场白 + outTrackId := constants.BuildCardOutTrackId(openConversationId, d.conf.Dingtalk.SceneGroup.GroupTemplateRobotIDIssueHandling) + _, err := d.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(d.conf.Dingtalk.Card.Template.BaseMsg), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("HTTP"), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("当前会话主题"), + "markdown": tea.String("问题:" + groupScope), + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + openConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(d.conf.Dingtalk.SceneGroup.GroupTemplateRobotIDIssueHandling), + AtUserIds: map[string]*string{ + "@ALL": tea.String("@ALL"), + }, + }, + }, + ) + if err != nil { + return err + } + + // 2. 机器人能力 + // 构建卡片 OutTrackId + outTrackId = constants.BuildCardOutTrackId(openConversationId, d.conf.Dingtalk.SceneGroup.GroupTemplateRobotIDIssueHandling) + _, err = d.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(d.conf.Dingtalk.Card.Template.BaseMsg), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("HTTP"), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("当前机器人能力"), + "markdown": tea.String("- 聊天内容提取(@机器人 [内容提取]{聊天记录/问答描述}) \n - QA知识收集(卡片信息收集) \n - QA知识问答(@机器人 [知识库查询]{问题描述})"), + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + openConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(d.conf.Dingtalk.SceneGroup.GroupTemplateRobotIDIssueHandling), + AtUserIds: map[string]*string{ + "@ALL": tea.String("@ALL"), + }, + }, + }, + ) + if err != nil { + return err + } + + return nil +} + +// buildCreateGroupCardResp 构建关闭创建群组卡片按钮 +func (d *DingTalkBotBiz) buildCreateGroupCardResp() *card.CardResponse { + return &card.CardResponse{ + CardData: &card.CardDataDto{ + CardParamMap: map[string]string{ + "button_display": "false", + }, + }, + CardUpdateOptions: &card.CardUpdateOptions{ + UpdateCardDataByKey: true, + }, + } +} diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 81c70b7..80c42c1 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -8,6 +8,7 @@ import ( errors "ai_scheduler/internal/data/error" "ai_scheduler/internal/data/impl" "ai_scheduler/internal/data/model" + "ai_scheduler/internal/domain/tools/common/knowledge_base" "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/entitys" "ai_scheduler/internal/gateway" @@ -19,6 +20,7 @@ import ( "ai_scheduler/internal/pkg/util" "ai_scheduler/internal/tools" "ai_scheduler/internal/tools/public" + "bufio" errorsSpecial "errors" "io" "net/http" @@ -248,6 +250,101 @@ func (r *Handle) handleKnowle(ctx context.Context, rec *entitys.Recognize, task return } +// 知识库V2 - lightRAG自建 +func (r *Handle) handleKnowleV2(ctx context.Context, rec *entitys.Recognize, task *model.AiTask) (err error) { + // 获取用户session信息 + + ext, err := rec_extra.GetTaskRecExt(rec) + if err != nil { + return + } + // 获取租户ID 形式为 {biz-user} 比如 "zltx-platform" + tenantID := ext.Sys.KnowlegeTenantKey + + // 请求知识库工具 + knowledgeBase := knowledge_base.New(r.conf.KnowledgeConfig) + knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{ + TenantID: tenantID, // 后续动态接参 + Query: rec.UserContent.Text, + Mode: constants.KnowledgeModeMix, + Stream: true, + Think: false, + OnlyRAG: true, + }) + if err != nil { + return fmt.Errorf("请求知识库工具失败,err: %v", err) + } + + // 读取知识库SSE数据 + err = r.readKnowledgeSSE(knowledgeResp, rec.Ch, false) + if err != nil { + return + } + + return +} + +// 读取知识库 SSE 数据 +func (r *Handle) readKnowledgeSSE(resp io.ReadCloser, channel chan entitys.Response, useParagraphMode bool) (err error) { + scanner := bufio.NewScanner(resp) + var buffer strings.Builder + + var taskIndex string = "knowledgeBase" + + for scanner.Scan() { + line := scanner.Text() + + delta, done, err := knowledge_base.ParseOpenAIStreamData(line) + if err != nil { + return fmt.Errorf("解析SSE数据失败: %w", err) + } + if done { + break + } + if delta == nil { + continue + } + + // 推理内容 + if delta.ReasoningContent != "" { + entitys.ResStream(channel, taskIndex, delta.ReasoningContent) + continue + } + // 输出内容 - 段落 + if delta.Content != "" && useParagraphMode { + // 存入缓冲区 + buffer.WriteString(delta.Content) + content := buffer.String() + + // 检查是否有换行符,按段落输出 + if idx := strings.LastIndex(content, "\n"); idx != -1 { + // 发送直到最后一个换行符的内容 + toSend := content[:idx+1] + entitys.ResStream(channel, taskIndex, toSend) + + // 重置缓冲区,保留剩余部分 + remaining := content[idx+1:] + buffer.Reset() + buffer.WriteString(remaining) + } + } + // 输出内容 - 逐字 + if delta.Content != "" && !useParagraphMode { + entitys.ResStream(channel, taskIndex, delta.Content) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("读取SSE流中断: %w", err) + } + + // 发送缓冲区剩余内容(仅在段落模式下需要) + if useParagraphMode && buffer.Len() > 0 { + entitys.ResStream(channel, taskIndex, buffer.String()) + } + + return nil +} + // bot 临时实现,后续转到 eino 工作流 func (r *Handle) HandleBot(ctx context.Context, rec *entitys.Recognize, task *entitys.Task) (err error) { if task.Index == "bug_optimization_submit" { @@ -333,7 +430,7 @@ func (r *Handle) getUserDingtalkUnionId(ctx context.Context, accessToken, sessio func (r *Handle) getUserDingtalkUnionIdWithUserName(ctx context.Context, accessToken, userName string) (unionId string) { // 获取创建者uid 用户名 -> dingtalk uid - creatorId, err := r.dingtalkContactClient.SearchUserOne(accessToken, userName) + creatorId, err := r.dingtalkContactClient.SearchUserOne(dingtalk.AppKey{AccessToken: accessToken}, userName) if err != nil { log.Warnf("search dingtalk user one failed: %v", err) return diff --git a/internal/biz/do/prompt.go b/internal/biz/do/prompt.go index 231a31c..72ad4ec 100644 --- a/internal/biz/do/prompt.go +++ b/internal/biz/do/prompt.go @@ -138,20 +138,24 @@ func (f *WithDingTalkBot) CreatePrompt(ctx context.Context, rec *entitys.Recogni mes = append(prompt, api.Message{ Role: "system", // 系统角色 Content: rec.SystemPrompt, // 系统提示内容 + // }, api.Message{ // 助手回复无需 + // Role: "assistant", // 助手角色 + // Content: "### 聊天记录:" + pkg.JsonStringIgonErr(rec.ChatHis), // 助手回复内容 }, api.Message{ - Role: "assistant", // 助手角色 - Content: "### 聊天记录:" + pkg.JsonStringIgonErr(rec.ChatHis), // 助手回复内容 + Role: "assistant", // 助手角色 + Content: "用户历史输入:" + pkg.JsonStringIgonErr(rec.ChatHis), // 用户历史输入 }, api.Message{ Role: "user", // 用户角色 Content: content.String(), // 用户输入内容 }) + fmt.Printf("[意图识别]最终prompt:%v", mes) return } func (f *WithDingTalkBot) getUserContent(ctx context.Context, rec *entitys.Recognize) (content strings.Builder, err error) { var hasFile bool - if rec.UserContent.File != nil && len(rec.UserContent.File) > 0 { + if len(rec.UserContent.File) > 0 { hasFile = true } content.WriteString(rec.UserContent.Text) @@ -165,11 +169,10 @@ func (f *WithDingTalkBot) getUserContent(ctx context.Context, rec *entitys.Recog content.WriteString(rec.UserContent.Tag) } - if len(rec.ChatHis.Messages) > 0 { - content.WriteString("\n") - content.WriteString("### 引用历史聊天记录:\n") - content.WriteString(pkg.JsonStringIgonErr(rec.ChatHis)) - } + // if len(rec.ChatHis.Messages) > 0 { + // content.WriteString("### 引用历史聊天记录:\n") + // content.WriteString(pkg.JsonStringIgonErr(rec.ChatHis)) + // } return } diff --git a/internal/biz/group_config.go b/internal/biz/group_config.go index 0c6dbbc..136bdaf 100644 --- a/internal/biz/group_config.go +++ b/internal/biz/group_config.go @@ -7,15 +7,19 @@ import ( "ai_scheduler/internal/data/constants" "ai_scheduler/internal/data/impl" "ai_scheduler/internal/data/model" + "ai_scheduler/internal/domain/tools/common/knowledge_base" "ai_scheduler/internal/domain/workflow/recharge" "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/entitys" + "ai_scheduler/internal/pkg" + "ai_scheduler/internal/pkg/dingtalk" "ai_scheduler/internal/pkg/l_request" "ai_scheduler/internal/pkg/lsxd" "ai_scheduler/internal/pkg/utils_oss" "ai_scheduler/internal/tools" "ai_scheduler/internal/tools/bbxt" "ai_scheduler/utils" + "bufio" "context" "encoding/json" "errors" @@ -26,6 +30,9 @@ import ( "strings" "time" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" + "github.com/alibabacloud-go/dingtalk/card_1_0" + "github.com/alibabacloud-go/tea/tea" "github.com/coze-dev/coze-go" "github.com/gofiber/fiber/v2/log" "xorm.io/builder" @@ -35,6 +42,7 @@ import ( type GroupConfigBiz struct { botGroupConfigImpl *impl.BotGroupConfigImpl reportDailyCacheImpl *impl.ReportDailyCacheImpl + botConfigImpl *impl.BotConfigImpl ossClient *utils_oss.Client workflowManager *runtime.Registry botTools []model.AiBotTool @@ -43,6 +51,7 @@ type GroupConfigBiz struct { rdb *utils.Rdb macro *do.Macro handle *do.Handle + dingtalkCardClient *dingtalk.CardClient } // NewDingTalkBotBiz @@ -50,6 +59,7 @@ func NewGroupConfigBiz( tools *tools_regis.ToolRegis, ossClient *utils_oss.Client, botGroupConfigImpl *impl.BotGroupConfigImpl, + botConfigImpl *impl.BotConfigImpl, workflowManager *runtime.Registry, conf *config.Config, reportDailyCacheImpl *impl.ReportDailyCacheImpl, @@ -57,11 +67,13 @@ func NewGroupConfigBiz( macro *do.Macro, toolManager *tools.Manager, handle *do.Handle, + dingtalkCardClient *dingtalk.CardClient, ) *GroupConfigBiz { return &GroupConfigBiz{ botTools: tools.BootTools, ossClient: ossClient, botGroupConfigImpl: botGroupConfigImpl, + botConfigImpl: botConfigImpl, workflowManager: workflowManager, conf: conf, reportDailyCacheImpl: reportDailyCacheImpl, @@ -69,6 +81,7 @@ func NewGroupConfigBiz( macro: macro, toolManager: toolManager, handle: handle, + dingtalkCardClient: dingtalkCardClient, } } @@ -235,7 +248,7 @@ func (g *GroupConfigBiz) handleReport(ctx context.Context, rec *entitys.Recogniz return nil } -func (g *GroupConfigBiz) handleMatch(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig) (err error) { +func (g *GroupConfigBiz) handleMatch(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) (err error) { if !rec.Match.IsMatch { if len(rec.Match.Chat) != 0 { @@ -269,6 +282,8 @@ func (g *GroupConfigBiz) handleMatch(ctx context.Context, rec *entitys.Recognize return g.handleReport(ctx, rec, pointTask, groupConfig) case constants.TaskTypeCozeWorkflow: return g.handleCozeWorkflow(ctx, rec, pointTask) + case constants.TaskTypeKnowle: // 知识库lightRAG版本 + return g.handleKnowledge(ctx, rec, groupConfig, callback) default: return g.otherTask(ctx, rec) } @@ -426,3 +441,210 @@ func (g *GroupConfigBiz) otherTask(ctx context.Context, rec *entitys.Recognize) entitys.ResText(rec.Ch, "", rec.Match.Reasoning) return } + +func (g *GroupConfigBiz) GetReportCache(ctx context.Context, day time.Time, totalDetail []*bbxt.ResellerLoss, bbxtObj *bbxt.BbxtTools) error { + var ResellerProductRelation map[int32]*bbxt.ResellerLossSumProductRelation + + dayDate := day.Format(time.DateOnly) + cond := builder.NewCond() + cond = cond.And(builder.Eq{"cache_index": bbxt.IndexLossSumDetail}) + cond = cond.And(builder.Eq{"cache_key": dayDate}) + var cache model.AiReportDailyCache + err := g.reportDailyCacheImpl.GetOneBySearchToStrut(&cond, &cache) + if err != nil { + return err + } + if cache.ID == 0 { + ResellerProductRelation, err = bbxtObj.GetResellerLossMannagerAndLossReasonFromApi(ctx, totalDetail) + if err != nil { + return err + } + cache = model.AiReportDailyCache{ + CacheKey: dayDate, + CacheIndex: bbxt.IndexLossSumDetail, + Value: pkg.JsonStringIgonErr(ResellerProductRelation), + } + _, err = g.reportDailyCacheImpl.Add(&cache) + } else { + err = json.Unmarshal([]byte(cache.Value), &ResellerProductRelation) + } + + if err != nil { + return err + } + for _, v := range totalDetail { + if _, ex := ResellerProductRelation[v.ResellerId]; !ex { + continue + } + v.Manager = ResellerProductRelation[v.ResellerId].AfterSaleName + for _, vv := range v.ProductLoss { + if _, ex := ResellerProductRelation[v.ResellerId].Products[vv.ProductId]; !ex { + continue + } + vv.LossReason = ResellerProductRelation[v.ResellerId].Products[vv.ProductId].LossReason + } + } + + return nil +} + +// handleKnowledge 处理知识库V2版本 +func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) (err error) { + // 请求知识库工具 + knowledgeBase := knowledge_base.New(g.conf.KnowledgeConfig) + knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{ + TenantID: constants.KnowledgeTenantIdDefault, // 后续动态接参 + Query: rec.UserContent.Text, + Mode: constants.KnowledgeModeMix, + Stream: true, + Think: false, + OnlyRAG: true, + }) + if err != nil { + return fmt.Errorf("请求知识库工具失败,err: %v", err) + } + + // 读取知识库SSE数据 + isRetrieved, err := g.readKnowledgeSSE(knowledgeResp, rec.Ch, true) + if err != nil { + return + } + + // 未检索到匹配信息,询问是否拉群 + if !isRetrieved { + g.shouldCreateIssueHandlingGroup(ctx, rec, groupConfig, callback) + return nil + } + + return +} + +// 读取知识库 SSE 数据 +func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entitys.Response, useParagraphMode bool) (isRetrieved bool, err error) { + scanner := bufio.NewScanner(resp) + var buffer strings.Builder + + for scanner.Scan() { + line := scanner.Text() + + delta, done, err := knowledge_base.ParseOpenAIStreamData(line) + if err != nil { + return false, fmt.Errorf("解析SSE数据失败: %w", err) + } + if done { + break + } + if delta == nil { + continue + } + + // 知识库未命中 输出提示后中断 + if delta.XRagStatus == constants.KnowledgeRagStatusMiss { + var missContent string = "知识库未检测到匹配信息,即将为您创建群聊解决问题。" + entitys.ResStream(channel, "", missContent) + return false, nil + } + // 推理内容 + if delta.ReasoningContent != "" { + entitys.ResStream(channel, "", delta.ReasoningContent) + continue + } + // 输出内容 - 段落 + if delta.Content != "" && useParagraphMode { + // 存入缓冲区 + buffer.WriteString(delta.Content) + content := buffer.String() + + // 检查是否有换行符,按段落输出 + if idx := strings.LastIndex(content, "\n"); idx != -1 { + // 发送直到最后一个换行符的内容 + toSend := content[:idx+1] + entitys.ResStream(channel, "", toSend) + + // 重置缓冲区,保留剩余部分 + remaining := content[idx+1:] + buffer.Reset() + buffer.WriteString(remaining) + } + } + // 输出内容 - 逐字 + if delta.Content != "" && !useParagraphMode { + entitys.ResStream(channel, "", delta.Content) + } + } + if err := scanner.Err(); err != nil { + return true, fmt.Errorf("读取SSE流中断: %w", err) + } + + // 发送缓冲区剩余内容(仅在段落模式下需要) + if useParagraphMode && buffer.Len() > 0 { + entitys.ResStream(channel, "", buffer.String()) + } + + return true, nil +} + +// 询问是否创建群聊处理问题 +func (g *GroupConfigBiz) shouldCreateIssueHandlingGroup(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) error { + // 获取群问题处理人 + type issueOwnerType struct { + UserId string `json:"userid"` + Name string `json:"name"` + } + var issueOwner []issueOwnerType + if err := json.Unmarshal([]byte(groupConfig.IssueOwner), &issueOwner); err != nil { + return fmt.Errorf("解析群问题处理人失败,err: %v", err) + } + // 合并所有name、Id + userNames := make([]string, 0, len(issueOwner)) + userIds := make([]*string, 0, len(issueOwner)) + for _, owner := range issueOwner { + userNames = append(userNames, "@"+owner.Name) + userIds = append(userIds, tea.String(owner.UserId)) + } + issueOwnerStr := strings.Join(userNames, "、") + + // 获取应用配置 + appKey, err := g.botConfigImpl.GetRobotAppKey(callback.RobotCode) + if err != nil { + return fmt.Errorf("获取机器人配置失败,err: %v", err) + } + + // 构建卡片 OutTrackId + outTrackId := constants.BuildCardOutTrackId(callback.ConversationId, callback.RobotCode) + + // 发送钉钉卡片 + _, err = g.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(g.conf.Dingtalk.Card.Template.CreateGroupApprove), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("STREAM"), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("创建群聊提醒"), + "content": tea.String(fmt.Sprintf("**确认创建群聊?**\n\n将邀请以下成员加入群聊:\n\n%s", issueOwnerStr)), + "remark": tea.String("注:如若无需,忽略即可"), + "button_left": tea.String("创建群聊"), + "button_right": tea.String("忽略"), + "action_id": tea.String("create_group"), + "button_display": tea.String("true"), + "group_scope": tea.String(strings.TrimSpace(rec.UserContent.Text)), + // "_CARD_DEBUG_TOOL_ENTRY": tea.String(g.conf.Dingtalk.Card.DebugToolEntryShow), // 调试字段 + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + callback.ConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(callback.RobotCode), + Recipients: append(userIds, tea.String(callback.SenderStaffId)), + }, + }) + if err != nil { + return fmt.Errorf("发送钉钉卡片失败,err: %v", err) + } + + return nil +} diff --git a/internal/biz/handle/dingtalk/send_card.go b/internal/biz/handle/dingtalk/send_card.go index d2e5cb7..4660f33 100644 --- a/internal/biz/handle/dingtalk/send_card.go +++ b/internal/biz/handle/dingtalk/send_card.go @@ -20,7 +20,7 @@ import ( ) const DefaultInterval = 100 * time.Millisecond -const HeardBeatX = 100 +const HeardBeatX = 1000 type SendCardClient struct { Auth *Auth diff --git a/internal/biz/provider_set.go b/internal/biz/provider_set.go index 6f95898..8fa1da8 100644 --- a/internal/biz/provider_set.go +++ b/internal/biz/provider_set.go @@ -21,4 +21,5 @@ var ProviderSetBiz = wire.NewSet( NewQywxAppBiz, NewGroupConfigBiz, do.NewMacro, + NewCallbackBiz, ) diff --git a/internal/biz/qywx_app_test.go b/internal/biz/qywx_app_test.go index e82f612..6dc5bc9 100644 --- a/internal/biz/qywx_app_test.go +++ b/internal/biz/qywx_app_test.go @@ -10,9 +10,11 @@ import ( "ai_scheduler/internal/domain/repo" "ai_scheduler/internal/domain/workflow" "ai_scheduler/internal/pkg" + "ai_scheduler/internal/pkg/dingtalk" "ai_scheduler/internal/pkg/lsxd" "ai_scheduler/internal/pkg/utils_ollama" "ai_scheduler/internal/pkg/utils_oss" + "ai_scheduler/internal/tools" "ai_scheduler/utils" "context" "testing" @@ -53,6 +55,11 @@ func run() { registry := workflow.NewRegistry(configConfig, client, repos, components) botGroupConfigImpl := impl.NewBotGroupConfigImpl(db) + botConfigImpl := impl.NewBotConfigImpl(db) qywxAppBiz = NewQywxAppBiz(configConfig, botGroupQywxImpl, group, other) - groupConfigBiz = NewGroupConfigBiz(toolRegis, utils_ossClient, botGroupConfigImpl, registry, configConfig) + reportDailyCacheImpl := impl.NewReportDailyCacheImpl(db) + toolManager := tools.NewManager(configConfig, client) + oauth2Client, _ := dingtalk.NewOauth2Client(rdb) + dingtalkCardClient, _ := dingtalk.NewCardClient(oauth2Client) + groupConfigBiz = NewGroupConfigBiz(toolRegis, utils_ossClient, botGroupConfigImpl, botConfigImpl, registry, configConfig, reportDailyCacheImpl, rdb, toolManager, dingtalkCardClient) } diff --git a/internal/config/config.go b/internal/config/config.go index 64857a9..c129ca4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { Oss Oss `mapstructure:"oss"` DefaultPrompt SysPrompt `mapstructure:"default_prompt"` PermissionConfig PermissionConfig `mapstructure:"permissionConfig"` + KnowledgeConfig KnowledgeConfig `mapstructure:"knowledge_config"` LLM LLM `mapstructure:"llm"` Dingtalk DingtalkConfig `mapstructure:"dingtalk"` Qywx QywxConfig `mapstructure:"qywx"` @@ -71,10 +72,12 @@ type LLMCapabilityConfig struct { // DingtalkConfig 钉钉配置 type DingtalkConfig struct { - ApiKey string `mapstructure:"api_key"` - ApiSecret string `mapstructure:"api_secret"` - TableDemand AITableConfig `mapstructure:"table_demand"` - BotGroupID map[string]int `mapstructure:"bot_group_id"` // 机器人群组 + ApiKey string `mapstructure:"api_key"` + ApiSecret string `mapstructure:"api_secret"` + TableDemand AITableConfig `mapstructure:"table_demand"` + BotGroupID map[string]int `mapstructure:"bot_group_id"` // 机器人群组 + Card CardConfig `mapstructure:"card"` // 互动卡片 + SceneGroup SceneGroupConfig `mapstructure:"scene_group"` // 场景群 } // QywxConfig 企业微信配置 @@ -96,6 +99,34 @@ type AITableConfig struct { SheetIdOrName string `mapstructure:"sheet_id_or_name"` } +// CardConfig 互动卡片配置 +type CardConfig struct { + // 卡片回调路由key + CallbackRouteKey string `mapstructure:"callback_route_key"` + // 卡片调试工具 [show:展示 hide:隐藏] + DebugToolEntryShow string `mapstructure:"debug_tool_entry_show"` + // 卡片模板 + Template CardTemplateConfig `mapstructure:"template"` +} + +// CardTemplateConfig 卡片模板配置 +type CardTemplateConfig struct { + // 基础消息卡片(title + content) + BaseMsg string `mapstructure:"base_msg"` + // 内容收集卡片(title + textarea + button) + ContentCollect string `mapstructure:"content_collect"` + // 创建群聊申请(title + content + button) + CreateGroupApprove string `mapstructure:"create_group_approve"` +} + +// SceneGroupConfig 场景群配置 +type SceneGroupConfig struct { + // 问题处理群模板ID + GroupTemplateIDIssueHandling string `mapstructure:"group_template_id_issue_handling"` + // 问题处理群模板机器人ID + GroupTemplateRobotIDIssueHandling string `mapstructure:"group_template_robot_id_issue_handling"` +} + // SysConfig 系统配置 type SysConfig struct { SessionLen int `mapstructure:"session_len"` @@ -253,6 +284,20 @@ type PermissionConfig struct { PermissionURL string `mapstructure:"permission_url"` } +// KnowledgeConfig 知识库配置 +type KnowledgeConfig struct { + // 知识库地址 + BaseURL string `mapstructure:"base_url"` + // 默认租户ID + TenantID string `mapstructure:"tenant_id"` + // 模式 + Mode string `mapstructure:"mode"` + // 是否思考 + Think bool `mapstructure:"think"` + // 是否仅RAG + OnlyRAG bool `mapstructure:"only_rag"` +} + // LoadConfig 加载配置 func LoadConfig(configPath string) (*Config, error) { viper.SetConfigFile(configPath) diff --git a/internal/data/constants/dingtalk.go b/internal/data/constants/dingtalk.go index fbbc7b8..1ec6894 100644 --- a/internal/data/constants/dingtalk.go +++ b/internal/data/constants/dingtalk.go @@ -1,6 +1,11 @@ package constants -import "net/url" +import ( + "net/url" + "strings" + + "github.com/google/uuid" +) const DingTalkBseUrl = "https://oapi.dingtalk.com" @@ -78,3 +83,83 @@ const ( ] }` ) + +// 交互卡片回调 +const ( + // 回调类型 + CardActionCallbackTypeAction string = "actionCallback" // 交互卡片回调事件类型 + + // 回调事件类型 + CardActionTypeCreateGroup string = "create_group" // 创建群聊 +) + +// dingtalk 卡片 OutTrackId 模板 +const CardOutTrackIdTemplate string = "{space_id}:{bot_id}:{uuid}" + +func BuildCardOutTrackId(spaceId string, botId string) (outTrackId string) { + uuid := uuid.New().String() + + outTrackId = strings.ReplaceAll(CardOutTrackIdTemplate, "{space_id}", spaceId) + outTrackId = strings.ReplaceAll(outTrackId, "{bot_id}", botId) + outTrackId = strings.ReplaceAll(outTrackId, "{uuid}", uuid) + + return +} + +func ParseCardOutTrackId(outTrackId string) (spaceId string, botId string) { + parts := strings.Split(outTrackId, ":") + if len(parts) != 3 { + return + } + spaceId, botId, _ = parts[0], parts[1], parts[2] + + return +} + +// 问题处理群机器人 - LLM 提示词 +const IssueHandlingExtractContentPrompt string = `你是一个【问题与答案生成助手】。 + + 你的职责是: + - 分析用户输入的内容 + - 识别其中隐含或明确的问题 + - 基于输入内容本身,生成对应的问题与答案 + + 当用户输入为【多条群聊聊天记录】时: + - 结合问题主题,判断聊天记录中正在讨论或试图解决的问题 + - 一个群聊中可能包含多个相互独立的问题,但它们都围绕着一个主题,一般为用户提出的第一个问题,尽可能总结为一个问题 + - 若确实问题很独立,需要分别识别,对每个问题,整理出清晰、可复用的“问题描述”和“对应答案” + + 生成答案时的原则: + - 答案必须来源于聊天内容中已经给出的信息或共识 + - 不要引入外部知识,不要使用聊天记录中真实人名或敏感信息,适当总结 + - 若聊天中未形成明确答案,应明确标记为“暂无明确结论” + - 若存在多种不同观点,应分别列出,不要擅自合并或裁决 + + 【JSON 输出原则】: + - 你的最终输出必须是**合法的 JSON** + - 不得输出任何额外解释性文字 + - JSON 结构必须严格符合以下约定 + + JSON 结构约定: + { + "items": [ + { + "question": "清晰、独立、可复用的问题描述", + "answer": "基于聊天内容整理出的答案;如无结论则为“暂无明确结论”", + "confidence": "high | medium | low" + } + ] + } + + 字段说明: + - items:问题与答案列表;若未识别到有效问题,则返回空数组 [] + - question:抽象后的标准问题表述,不包含具体聊天语句 + - answer:整理后的答案,不得引入聊天之外的信息 + - confidence:根据聊天中信息的一致性和明确程度给出判断 + + 如果无法从输入中识别出任何有效问题,返回: + { "items": [] } + + 用户输入: + %s + ` diff --git a/internal/data/constants/knowledge.go b/internal/data/constants/knowledge.go index b8b7861..c7f9088 100644 --- a/internal/data/constants/knowledge.go +++ b/internal/data/constants/knowledge.go @@ -20,3 +20,24 @@ func GetKnowledgeId(caller Caller) KnowledgeId { } return CallerKnowledgeIdMap[caller] } + +// 知识库 +const ( + KnowledgeTenantIdDefault = "default" +) + +// 知识库模式 +const ( + KnowledgeModeBypass = "bypass" // 绕过知识库,直接返回用户输入 + KnowledgeModeNaive = "naive" // 简单模式,直接返回知识库答案 + KnowledgeModeLocal = "local" // 本地模式,仅使用本地知识库 + KnowledgeModeGlobal = "global" // 全局模式,使用全局知识库 + KnowledgeModeHybrid = "hybrid" // 混合模式,结合本地和全局知识库 + KnowledgeModeMix = "mix" // 混合模式,结合本地、全局和知识库 +) + +// 知识库命中状态 +const ( + KnowledgeRagStatusHit = "hit" // 知识库命中 + KnowledgeRagStatusMiss = "miss" // 知识库未命中 +) diff --git a/internal/data/impl/bot_config.go b/internal/data/impl/bot_config.go index 2c98ffb..613cbc0 100644 --- a/internal/data/impl/bot_config.go +++ b/internal/data/impl/bot_config.go @@ -2,8 +2,13 @@ package impl import ( "ai_scheduler/internal/data/model" + "ai_scheduler/internal/entitys" + "ai_scheduler/internal/pkg/dingtalk" "ai_scheduler/tmpl/dataTemp" "ai_scheduler/utils" + "encoding/json" + + "xorm.io/builder" ) type BotConfigImpl struct { @@ -15,3 +20,33 @@ func NewBotConfigImpl(db *utils.Db) *BotConfigImpl { DataTemp: *dataTemp.NewDataTemp(db, new(model.AiBotConfig)), } } + +// GetRobotConfig 获取机器人配置 +func (b *BotConfigImpl) GetRobotConfig(robotCode string) (*entitys.DingTalkBot, error) { + // 获取机器人配置 + var botConfig model.AiBotConfig + cond := builder.NewCond().And(builder.Eq{"robot_code": robotCode}).And(builder.Eq{"status": 1}) + err := b.GetOneBySearchToStrut(&cond, &botConfig) + if err != nil { + return nil, err + } + // 解出 config + var config entitys.DingTalkBot + err = json.Unmarshal([]byte(botConfig.BotConfig), &config) + if err != nil { + return nil, err + } + + return &config, nil +} + +// GetRobotAppKey 获取机器人应用ID +func (b *BotConfigImpl) GetRobotAppKey(robotCode string) (dingtalk.AppKey, error) { + // 获取机器人配置 + dingTalkBotConfig, err := b.GetRobotConfig(robotCode) + if err != nil { + return dingtalk.AppKey{}, err + } + + return dingTalkBotConfig.GetAppKey(), nil +} diff --git a/internal/data/impl/bot_group.go b/internal/data/impl/bot_group.go index e0593c4..d8ba58b 100644 --- a/internal/data/impl/bot_group.go +++ b/internal/data/impl/bot_group.go @@ -19,7 +19,7 @@ func NewBotGroupImpl(db *utils.Db) *BotGroupImpl { func (k BotGroupImpl) GetByConversationIdAndRobotCode(staffId string, robotCode string) (*model.AiBotGroup, error) { var data model.AiBotGroup - err := k.Db.Model(k.Model).Where("conversation_id = ? and robot_code = ?", staffId, robotCode).Find(&data).Error + err := k.Db.Model(k.Model).Where("conversation_id = ? and robot_code = ? and status = 1", staffId, robotCode).Find(&data).Error if data.GroupID == 0 { err = sql.ErrNoRows } diff --git a/internal/data/model/ai_bot_group_config.gen.go b/internal/data/model/ai_bot_group_config.gen.go index f839145..8d889a0 100644 --- a/internal/data/model/ai_bot_group_config.gen.go +++ b/internal/data/model/ai_bot_group_config.gen.go @@ -11,6 +11,7 @@ type AiBotGroupConfig struct { ConfigID int32 `gorm:"column:config_id;primaryKey;autoIncrement:true" json:"config_id"` ToolList string `gorm:"column:tool_list;not null" json:"tool_list"` ProductName string `gorm:"column:product_name;not null" json:"product_name"` + IssueOwner string `gorm:"column:issue_owner;comment:群组问题处理人" json:"issue_owner"` // 群组问题处理人 } // TableName AiBotGroupConfig's table name diff --git a/internal/domain/tools/common/knowledge_base/client.go b/internal/domain/tools/common/knowledge_base/client.go new file mode 100644 index 0000000..ae72661 --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/client.go @@ -0,0 +1,112 @@ +package knowledge_base + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "fmt" + "io" + "net/http" + "strings" +) + +type Client struct { + cfg config.KnowledgeConfig +} + +func New(cfg config.KnowledgeConfig) *Client { + return &Client{cfg: cfg} +} + +// 查询知识库 +func (c *Client) Query(req *QueryRequest) (io.ReadCloser, error) { + if req == nil { + return nil, fmt.Errorf("req is nil") + } + if req.TenantID == "" { + return nil, fmt.Errorf("tenantID is empty") + } + if req.Query == "" { + return nil, fmt.Errorf("query is empty") + } + if req.Mode == "" { + req.Mode = c.cfg.Mode + } + if !req.Think { + req.Think = c.cfg.Think + } + if !req.OnlyRAG { + req.OnlyRAG = c.cfg.OnlyRAG + } + + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + + rsp, err := (&l_request.Request{ + Method: "POST", + Url: baseURL + "/query", + Headers: map[string]string{ + "Content-Type": "application/json", + "X-Tenant-ID": req.TenantID, + "Accept": "text/event-stream", + }, + Json: map[string]interface{}{ + "query": req.Query, + "mode": req.Mode, + "stream": req.Stream, + "think": req.Think, + "only_rag": req.OnlyRAG, + }, + }).SendNoParseResponse() + if err != nil { + return nil, err + } + if rsp == nil || rsp.Body == nil { + return nil, fmt.Errorf("empty response") + } + + if rsp.StatusCode != http.StatusOK { + defer rsp.Body.Close() + bodyPreview, _ := io.ReadAll(io.LimitReader(rsp.Body, 4096)) + if len(bodyPreview) > 0 { + return nil, fmt.Errorf("knowledge base returned status %d: %s", rsp.StatusCode, string(bodyPreview)) + } + return nil, fmt.Errorf("knowledge base returned status %d", rsp.StatusCode) + } + + return rsp.Body, nil +} + +// IngestText 向知识库中注入文本 +func (c *Client) IngestText(req *IngestTextRequest) error { + if req == nil { + return fmt.Errorf("req is nil") + } + if req.TenantID == "" { + return fmt.Errorf("tenantID is empty") + } + if req.Text == "" { + return fmt.Errorf("text is empty") + } + + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + + rsp, err := (&l_request.Request{ + Method: "POST", + Url: baseURL + "/ingest/text", + Headers: map[string]string{ + "Content-Type": "application/json", + "X-Tenant-ID": req.TenantID, + }, + Json: map[string]interface{}{ + "text": req.Text, + }, + }).Send() + if err != nil { + return err + } + + if rsp.StatusCode != http.StatusOK { + return fmt.Errorf("knowledge base returned status %d: %s", rsp.StatusCode, rsp.Text) + } + + return nil +} diff --git a/internal/domain/tools/common/knowledge_base/client_test.go b/internal/domain/tools/common/knowledge_base/client_test.go new file mode 100644 index 0000000..ab3091d --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/client_test.go @@ -0,0 +1,63 @@ +package knowledge_base + +import ( + "ai_scheduler/internal/config" + "bufio" + "strings" + "testing" +) + +func TestCall(t *testing.T) { + req := &QueryRequest{ + TenantID: "admin_test_qa", + Query: "lightRAG 的优势?", + Mode: "naive", + Stream: true, + Think: false, + OnlyRAG: true, + } + + client := New(config.KnowledgeConfig{BaseURL: "http://127.0.0.1:9600"}) + resp, err := client.Query(req) + if err != nil { + t.Errorf("Call failed: %v", err) + } + if resp == nil { + t.Error("Response is nil") + } + defer resp.Close() + + scanner := bufio.NewScanner(resp) + var outThinking strings.Builder + var outContent strings.Builder + for scanner.Scan() { + line := scanner.Text() + delta, done, err := ParseOpenAIStreamData(line) + if err != nil { + t.Fatalf("parse openai stream failed: %v", err) + } + if delta == nil { + continue + } + if done { + break + } + + if delta.XRagStatus != "" { + t.Logf("XRagStatus: %s", delta.XRagStatus) + } + if delta.Content != "" { + outContent.WriteString(delta.Content) + } + if delta.ReasoningContent != "" { + outThinking.WriteString(delta.ReasoningContent) + } + + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan failed: %v", err) + } + + t.Logf("Thinking: %s", outThinking.String()) + t.Logf("Content: %s", outContent.String()) +} diff --git a/internal/domain/tools/common/knowledge_base/parse.go b/internal/domain/tools/common/knowledge_base/parse.go new file mode 100644 index 0000000..e591bda --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/parse.go @@ -0,0 +1,75 @@ +package knowledge_base + +import ( + "encoding/json" + "fmt" + "strings" +) + +type openAIChunk struct { + Choices []struct { + Delta *Delta `json:"delta"` + Message *Message `json:"message"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` +} + +type Delta struct { + ReasoningContent string `json:"reasoning_content"` // 推理内容 + Content string `json:"content"` // 内容 + XRagStatus string `json:"x_rag_status"` // rag命中状态 hit|miss +} + +type Message struct { + Role string `json:"role"` // 角色 + Content string `json:"content"` // 内容 + XRagStatus string `json:"x_rag_status"` // rag命中状态 hit|miss +} + +func ParseOpenAIStreamData(dataLine string) (delta *Delta, done bool, err error) { + data := strings.TrimSpace(strings.TrimPrefix(dataLine, "data:")) + if data == "" { + return nil, false, nil + } + + data = strings.TrimSpace(data) + if data == "" { + return nil, false, nil + } + if data == "[DONE]" { + return nil, true, nil + } + + var chunk openAIChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + return nil, false, fmt.Errorf("unmarshal openai stream chunk failed: %w", err) + } + + for _, c := range chunk.Choices { + if c.Delta != nil { + return c.Delta, false, nil // 只输出第一个delta + } + } + + return nil, false, nil +} + +func ParseOpenAIHTTPData(body string) (message *Message, done bool, err error) { + data := strings.TrimSpace(body) + if data == "" { + return nil, false, nil + } + + var resp openAIChunk + if err := json.Unmarshal([]byte(data), &resp); err != nil { + return nil, false, fmt.Errorf("unmarshal openai stream chunk failed: %w", err) + } + + for _, c := range resp.Choices { + if c.Message != nil { + return c.Message, true, nil // 只输出第一个message + } + } + + return nil, false, nil +} diff --git a/internal/domain/tools/common/knowledge_base/type.go b/internal/domain/tools/common/knowledge_base/type.go new file mode 100644 index 0000000..10acf55 --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/type.go @@ -0,0 +1,15 @@ +package knowledge_base + +type QueryRequest struct { + TenantID string // 租户 ID + Query string // 查询内容 + Mode string // 模式,默认 naive 可选:[bypass|naive|local|global|hybrid|mix] + Stream bool // 仅支持流式输出 + Think bool // 是否开启思考模式 + OnlyRAG bool // 是否仅开启 RAG 模式 +} + +type IngestTextRequest struct { + TenantID string // 租户 ID + Text string // 要注入的文本内容 +} diff --git a/internal/domain/tools/registry.go b/internal/domain/tools/registry.go index f6b2193..d1851a4 100644 --- a/internal/domain/tools/registry.go +++ b/internal/domain/tools/registry.go @@ -4,6 +4,7 @@ import ( "ai_scheduler/internal/config" "ai_scheduler/internal/domain/tools/common/excel_generator" "ai_scheduler/internal/domain/tools/common/image_converter" + "ai_scheduler/internal/domain/tools/common/knowledge_base" "ai_scheduler/internal/domain/tools/hyt/goods_add" "ai_scheduler/internal/domain/tools/hyt/goods_brand_search" "ai_scheduler/internal/domain/tools/hyt/goods_category_add" @@ -25,6 +26,7 @@ type Manager struct { type CommonTools struct { ExcelGenerator *excel_generator.Client ImageConverter *image_converter.Client + KnowledgeBase *knowledge_base.Client } type HytTools struct { @@ -60,6 +62,7 @@ func NewManager(cfg *config.Config) *Manager { Common: &CommonTools{ ExcelGenerator: excel_generator.New(), ImageConverter: image_converter.New(cfg.EinoTools.Excel2Pic), + KnowledgeBase: knowledge_base.New(cfg.KnowledgeConfig), }, } } diff --git a/internal/entitys/bot.go b/internal/entitys/bot.go index 03b1a09..d8f2c2f 100644 --- a/internal/entitys/bot.go +++ b/internal/entitys/bot.go @@ -2,6 +2,7 @@ package entitys import ( "ai_scheduler/internal/data/model" + "ai_scheduler/internal/pkg/dingtalk" "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" ) @@ -25,3 +26,10 @@ type DingTalkBot struct { type Task struct { Index string `json:"bot_index"` } + +func (d *DingTalkBot) GetAppKey() dingtalk.AppKey { + return dingtalk.AppKey{ + AppKey: d.ClientId, + AppSecret: d.ClientSecret, + } +} diff --git a/internal/pkg/dingtalk/card_client.go b/internal/pkg/dingtalk/card_client.go new file mode 100644 index 0000000..0862496 --- /dev/null +++ b/internal/pkg/dingtalk/card_client.go @@ -0,0 +1,81 @@ +package dingtalk + +import ( + errorcode "ai_scheduler/internal/data/error" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + card "github.com/alibabacloud-go/dingtalk/card_1_0" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "github.com/alibabacloud-go/tea/tea" +) + +type CardClient struct { + cli *card.Client + oauth2Client *Oauth2Client +} + +func NewCardClient(oauth2Client *Oauth2Client) (*CardClient, error) { + cfg := &openapi.Config{ + Protocol: tea.String("https"), + RegionId: tea.String("central"), + } + c, err := card.NewClient(cfg) + if err != nil { + return nil, err + } + + return &CardClient{cli: c, oauth2Client: oauth2Client}, nil +} + +// 创建并投放卡片 +func (c *CardClient) CreateAndDeliver(appKey AppKey, cardData *card.CreateAndDeliverRequest) (bool, error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return false, err + } + + // 调用API + resp, err := c.cli.CreateAndDeliverWithOptions( + cardData, + &card.CreateAndDeliverHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) + if err != nil { + return false, err + } + + if resp.Body == nil { + return false, errorcode.ParamErrf("empty response body") + } + if !*resp.Body.Success { + return false, errorcode.ParamErrf("create and deliver failed") + } + + return true, nil +} + +// 更新卡片 +func (c *CardClient) UpdateCard(appKey AppKey, cardData *card.UpdateCardRequest) (bool, error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return false, err + } + + // 调用API + resp, err := c.cli.UpdateCardWithOptions( + cardData, + &card.UpdateCardHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) + if err != nil { + return false, err + } + + if resp.Body == nil { + return false, errorcode.ParamErrf("empty response body") + } + + return *resp.Body.Success, nil +} diff --git a/internal/pkg/dingtalk/contact_client.go b/internal/pkg/dingtalk/contact_client.go index ed3e3dd..54a9eb4 100644 --- a/internal/pkg/dingtalk/contact_client.go +++ b/internal/pkg/dingtalk/contact_client.go @@ -1,7 +1,6 @@ package dingtalk import ( - "ai_scheduler/internal/config" errorcode "ai_scheduler/internal/data/error" openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" @@ -11,22 +10,20 @@ import ( ) type ContactClient struct { - config *config.Config - cli *contact.Client + cli *contact.Client + oauth2Client *Oauth2Client } -func NewContactClient(config *config.Config) (*ContactClient, error) { +func NewContactClient(oauth2Client *Oauth2Client) (*ContactClient, error) { cfg := &openapi.Config{ - AccessKeyId: tea.String(config.Tools.DingTalkBot.APIKey), - AccessKeySecret: tea.String(config.Tools.DingTalkBot.APISecret), - Protocol: tea.String("https"), - RegionId: tea.String("central"), + Protocol: tea.String("https"), + RegionId: tea.String("central"), } c, err := contact.NewClient(cfg) if err != nil { return nil, err } - return &ContactClient{config: config, cli: c}, nil + return &ContactClient{cli: c, oauth2Client: oauth2Client}, nil } type SearchUserReq struct { @@ -40,15 +37,23 @@ type SearchUserResp struct { Body interface{} } -func (c *ContactClient) SearchUserOne(accessToken string, name string) (string, error) { - headers := &contact.SearchUserHeaders{} - headers.XAcsDingtalkAccessToken = tea.String(accessToken) - resp, err := c.cli.SearchUserWithOptions(&contact.SearchUserRequest{ - FullMatchField: tea.Int32(1), - QueryWord: tea.String(name), - Offset: tea.Int32(0), - Size: tea.Int32(1), - }, headers, &util.RuntimeOptions{}) +func (c *ContactClient) SearchUserOne(appKey AppKey, name string) (string, error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return "", err + } + + resp, err := c.cli.SearchUserWithOptions( + &contact.SearchUserRequest{ + FullMatchField: tea.Int32(1), + QueryWord: tea.String(name), + Offset: tea.Int32(0), + Size: tea.Int32(1), + }, + &contact.SearchUserHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) if err != nil { return "", err } diff --git a/internal/pkg/dingtalk/im_client.go b/internal/pkg/dingtalk/im_client.go new file mode 100644 index 0000000..72857aa --- /dev/null +++ b/internal/pkg/dingtalk/im_client.go @@ -0,0 +1,78 @@ +package dingtalk + +import ( + errorcode "ai_scheduler/internal/data/error" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + im "github.com/alibabacloud-go/dingtalk/im_1_0" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "github.com/alibabacloud-go/tea/tea" +) + +type ImClient struct { + cli *im.Client + oauth2Client *Oauth2Client +} + +func NewImClient(oauth2Client *Oauth2Client) (*ImClient, error) { + cfg := &openapi.Config{ + Protocol: tea.String("https"), + RegionId: tea.String("central"), + } + c, err := im.NewClient(cfg) + if err != nil { + return nil, err + } + + return &ImClient{cli: c, oauth2Client: oauth2Client}, nil +} + +// 创建并投放卡片 +func (c *ImClient) AddRobotToConversation(appKey AppKey, imData *im.AddRobotToConversationRequest) (string, error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return "", err + } + + // 调用API + resp, err := c.cli.AddRobotToConversationWithOptions( + imData, + &im.AddRobotToConversationHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) + if err != nil { + return "", err + } + + if resp.Body == nil { + return "", errorcode.ParamErrf("empty response body") + } + + return *resp.Body.ChatBotUserId, nil +} + +// 创建场景群 不返回chatid,如果没有获取群聊分享链接的诉求,可以使用该接口 +func (c *ImClient) CreateSceneGroup(appKey AppKey, req *im.CreateSceneGroupConversationRequest) (openConversationId string, err error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return "", err + } + + // 调用API + resp, err := c.cli.CreateSceneGroupConversationWithOptions( + req, + &im.CreateSceneGroupConversationHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) + if err != nil { + return "", err + } + + if resp.Body == nil { + return "", errorcode.ParamErrf("empty response body") + } + + return *resp.Body.OpenConversationId, nil +} diff --git a/internal/pkg/dingtalk/notable_client.go b/internal/pkg/dingtalk/notable_client.go index 885e111..d4fb6b0 100644 --- a/internal/pkg/dingtalk/notable_client.go +++ b/internal/pkg/dingtalk/notable_client.go @@ -1,7 +1,6 @@ package dingtalk import ( - "ai_scheduler/internal/config" errorcode "ai_scheduler/internal/data/error" "encoding/json" "time" @@ -13,22 +12,20 @@ import ( ) type NotableClient struct { - config *config.Config - cli *notable.Client + cli *notable.Client + oauth2Client *Oauth2Client } -func NewNotableClient(config *config.Config) (*NotableClient, error) { +func NewNotableClient(oauth2Client *Oauth2Client) (*NotableClient, error) { cfg := &openapi.Config{ - AccessKeyId: tea.String(config.Tools.DingTalkBot.APIKey), - AccessKeySecret: tea.String(config.Tools.DingTalkBot.APISecret), - Protocol: tea.String("https"), - RegionId: tea.String("central"), + Protocol: tea.String("https"), + RegionId: tea.String("central"), } c, err := notable.NewClient(cfg) if err != nil { return nil, err } - return &NotableClient{config: config, cli: c}, nil + return &NotableClient{cli: c, oauth2Client: oauth2Client}, nil } type UpdateRecordReq struct { @@ -43,9 +40,13 @@ type UpdateRecordsserResp struct { Body interface{} } -func (c *NotableClient) UpdateRecord(accessToken string, req *UpdateRecordReq) (bool, error) { - headers := ¬able.UpdateRecordsHeaders{} - headers.XAcsDingtalkAccessToken = tea.String(accessToken) +func (c *NotableClient) UpdateRecord(appKey AppKey, req *UpdateRecordReq) (bool, error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return false, err + } + resp, err := c.cli.UpdateRecordsWithOptions( tea.String(req.BaseId), tea.String(req.SheetId), @@ -63,7 +64,10 @@ func (c *NotableClient) UpdateRecord(accessToken string, req *UpdateRecordReq) ( Id: tea.String(req.RecordId), }, }, - }, headers, &util.RuntimeOptions{}) + }, + ¬able.UpdateRecordsHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) if err != nil { return false, err } diff --git a/internal/pkg/dingtalk/oauth2_client.go b/internal/pkg/dingtalk/oauth2_client.go new file mode 100644 index 0000000..2b1884c --- /dev/null +++ b/internal/pkg/dingtalk/oauth2_client.go @@ -0,0 +1,74 @@ +package dingtalk + +import ( + errorcode "ai_scheduler/internal/data/error" + "ai_scheduler/utils" + "context" + "fmt" + "time" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + oauth2 "github.com/alibabacloud-go/dingtalk/oauth2_1_0" + "github.com/alibabacloud-go/tea/tea" + "github.com/redis/go-redis/v9" +) + +type Oauth2Client struct { + cli *oauth2.Client + redisCli *redis.Client +} + +func NewOauth2Client(rds *utils.Rdb) (*Oauth2Client, error) { + cfg := &openapi.Config{ + Protocol: tea.String("https"), + RegionId: tea.String("central"), + } + c, err := oauth2.NewClient(cfg) + if err != nil { + return nil, err + } + return &Oauth2Client{cli: c, redisCli: rds.Rdb}, nil +} + +type AppKey struct { + AppKey string `json:"appKey"` + AppSecret string `json:"appSecret"` + AccessToken string `json:"accessToken"` +} + +// GetAccessToken 获取access token +func (c *Oauth2Client) GetAccessToken(req AppKey) (string, error) { + // 兼容直接传入 access token 场景 + if req.AccessToken != "" { + return req.AccessToken, nil + } + + // 取cache + ctx := context.Background() + accessToken, err := c.redisCli.Get(ctx, fmt.Sprintf("dingtalk:oauth2:%s:access_token", req.AppKey)).Result() + if err == nil { + fmt.Println("get access token from cache:", accessToken) + return accessToken, nil + } + if err != redis.Nil { + return "", err + } + + // 调用API + resp, err := c.cli.GetAccessToken(&oauth2.GetAccessTokenRequest{ + AppKey: tea.String(req.AppKey), + AppSecret: tea.String(req.AppSecret), + }) + if err != nil { + return "", err + } + + if resp.Body == nil { + return "", errorcode.ParamErrf("empty response body") + } + + // 缓存token + c.redisCli.Set(ctx, fmt.Sprintf("dingtalk:oauth2:%s:access_token", req.AppKey), *resp.Body.AccessToken, time.Duration(*resp.Body.ExpireIn)*time.Second) + + return *resp.Body.AccessToken, nil +} diff --git a/internal/pkg/dingtalk/old_client.go b/internal/pkg/dingtalk/old_client.go index 7742d3a..def0604 100644 --- a/internal/pkg/dingtalk/old_client.go +++ b/internal/pkg/dingtalk/old_client.go @@ -4,6 +4,7 @@ package dingtalk import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" "bytes" "context" "encoding/json" @@ -12,6 +13,7 @@ import ( "net/http" "net/url" "os" + "strings" "github.com/faabiosr/cachego/file" "github.com/fastwego/dingding" @@ -111,3 +113,141 @@ func (c *OldClient) QueryUserDetailsByMobile(ctx context.Context, mobile string) func (c *OldClient) GetAccessToken() (string, error) { return c.atm.GetAccessToken() } + +// CreateInternalGroupConversation 创建企业内部群聊 +func (c *OldClient) CreateInternalGroupConversation(ctx context.Context, accessToken, groupName string, userIds []string) (chatId, openConversationId string, err error) { + body := struct { + Name string `json:"name"` + Owner string `json:"owner"` + UserIds []string `json:"useridlist"` + ShowHistoryType int `json:"showHistoryType"` + Searchable int `json:"searchable"` + ValidationType int `json:"validationType"` + MentionAllAuthority int `json:"mentionAllAuthority"` + ManagementType int `json:"managementType"` + ChatBannedType int `json:"chatBannedType"` + }{ + Name: groupName, + Owner: userIds[0], + UserIds: userIds, + } + b, _ := json.Marshal(body) + + req := l_request.Request{ + Method: "POST", + JsonByte: b, + Url: "https://oapi.dingtalk.com/chat/create?access_token=" + accessToken, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + res, err := req.Send() + if err != nil { + return + } + + var resp struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` + ChatId string `json:"chatid"` + OpenConversationId string `json:"openConversationId"` + ConversationTag int `json:"conversationTag"` + } + if err = json.Unmarshal(res.Content, &resp); err != nil { + return + } + if resp.Code != 0 { + return "", "", errors.New(resp.Msg) + } + + return resp.ChatId, resp.OpenConversationId, nil +} + +// CreateSceneGroupConversation 创建场景群-基于群模板 +func (c *OldClient) CreateSceneGroupConversation(ctx context.Context, accessToken, groupName string, userIds []string, templateId string) (chatId, openConversationId string, err error) { + body := struct { + Title string `json:"title"` // 群名称 + TemplateId string `json:"template_id"` // 群模板ID + OwnerUserID string `json:"owner_user_id"` // 群主的userid。 + UserIds string `json:"user_ids"` // 群成员userid列表。 + SubAdminIds string `json:"subadmin_ids"` // 群管理员userid列表。 + UUID string `json:"uuid"` // 建群去重的业务ID,由接口调用方指定。 + Icon string `json:"icon"` // 群头像,格式为mediaId。需要调用上传媒体文件接口上传群头像,获取mediaId。 + MentionAllAuthority int `json:"mention_all_authority"` // @all 权限:0(默认):所有人都可以@all 1:仅群主可@all + ShowHistoryType int `json:"show_history_type"` // 新成员是否可查看聊天历史消息:0(默认):不可以查看历史记录 1:可以查看历史记录 + ValidationType int `json:"validation_type"` // 入群是否需要验证:0(默认):不验证入群 1:入群验证 + Searchable int `json:"searchable"` // 群是否可搜索:0(默认):不可搜索 1:可搜索 + ChatBannedType int `json:"chat_banned_type"` // 是否开启群禁言:0(默认):不禁言 1:全员禁言 + ManagementType int `json:"management_type"` // 管理类型:0(默认):所有人可管理 1:仅群主可管理 + OnlyAdminCanDing int `json:"only_admin_can_ding"` // 群内发DING权限:0(默认):所有人可发DING 1:仅群主和管理员可发DING + AllMembersCanCreateMcsConf int `json:"all_members_can_create_mcs_conf"` // 群会议权限:0:仅群主和管理员可发起视频和语音会议 1(默认):所有人可发起视频和语音会议 + AllMembersCanCreateCalendar int `json:"all_members_can_create_calendar"` // 群日历权限:0:仅群主和管理员可创建群日历 1(默认):所有人可创建群日历 + GroupEmailDisabled int `json:"group_email_disabled"` // 群邮件权限:0(默认):群内成员可以对本群发送群邮件 1:群内成员不可对本群发送群邮件 + OnlyAdminCanSetMsgTop int `json:"only_admin_can_set_msg_top"` // 置顶群消息权限:0(默认):所有人可置顶群消息 1:仅群主和管理员可置顶群消息 + AddFriendForbidden int `json:"add_friend_forbidden"` // 群成员私聊权限:0(默认):所有人可私聊 1:普通群成员之间不能够加好友、单聊,且部分功能使用受限(管理员与非管理员之间不受影响) + GroupLiveSwitch int `json:"group_live_switch"` // 群直播权限:0:仅群主与管理员可发起直播 1(默认):群内任意成员可发起群直播 + MembersToAdminChat int `json:"members_to_admin_chat"` // 是否禁止非管理员向管理员发起单聊:0(默认):非管理员可以向管理员发起单聊 1:禁止非管理员向管理员发起单聊 + }{ + Title: groupName, + TemplateId: templateId, + OwnerUserID: userIds[0], + UserIds: strings.Join(userIds, ","), + SubAdminIds: strings.Join(userIds, ","), + } + + b, _ := json.Marshal(body) + + req := l_request.Request{ + Method: "POST", + JsonByte: b, + Url: "https://oapi.dingtalk.com/topapi/im/chat/scenegroup/create?access_token=" + accessToken, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + res, err := req.Send() + if err != nil { + return + } + + var resp struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` + Result struct { + ChatId string `json:"chat_id"` + OpenConversationId string `json:"open_conversation_id"` + } `json:"result"` + } + if err = json.Unmarshal(res.Content, &resp); err != nil { + return + } + if resp.Code != 0 { + return "", "", errors.New(resp.Msg) + } + return resp.Result.ChatId, resp.Result.OpenConversationId, nil +} + +// 获取入群二维码链接 +func (c *OldClient) GetJoinGroupQrcode(ctx context.Context, chatId, userId string) (string, error) { + body := struct { + ChatId string `json:"chatid"` + UserId string `json:"userid"` + }{ChatId: chatId, UserId: userId} + b, _ := json.Marshal(body) + res, err := c.do(ctx, http.MethodPost, "/topapi/chat/qrcode/get", b) + if err != nil { + return "", err + } + var resp struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` + Result string `json:"result"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return "", err + } + if resp.Code != 0 { + return "", errors.New(resp.Msg) + } + return resp.Result, nil +} diff --git a/internal/pkg/dingtalk/robot_client.go b/internal/pkg/dingtalk/robot_client.go new file mode 100644 index 0000000..ccd868a --- /dev/null +++ b/internal/pkg/dingtalk/robot_client.go @@ -0,0 +1,65 @@ +package dingtalk + +import ( + errorcode "ai_scheduler/internal/data/error" + "encoding/json" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + robot "github.com/alibabacloud-go/dingtalk/robot_1_0" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "github.com/alibabacloud-go/tea/tea" +) + +type RobotClient struct { + cli *robot.Client + oauth2Client *Oauth2Client +} + +func NewRobotClient(oauth2Client *Oauth2Client) (*RobotClient, error) { + cfg := &openapi.Config{ + Protocol: tea.String("https"), + RegionId: tea.String("central"), + } + c, err := robot.NewClient(cfg) + if err != nil { + return nil, err + } + return &RobotClient{cli: c, oauth2Client: oauth2Client}, nil +} + +type SendGroupMessagesReq struct { + MsgKey string + MsgParam map[string]any + OpenConversationId string + RobotCode string +} + +func (c *RobotClient) SendGroupMessages(appKey AppKey, req *SendGroupMessagesReq) (string, error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return "", err + } + + msgParamBytes, _ := json.Marshal(req.MsgParam) + msgParamJson := string(msgParamBytes) + resp, err := c.cli.OrgGroupSendWithOptions( + &robot.OrgGroupSendRequest{ + MsgKey: tea.String(req.MsgKey), + MsgParam: tea.String(msgParamJson), + OpenConversationId: tea.String(req.OpenConversationId), + RobotCode: tea.String(req.RobotCode), + }, + &robot.OrgGroupSendHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) + if err != nil { + return "", err + } + + if resp.Body == nil { + return "", errorcode.ParamErrf("empty response body") + } + + return *resp.Body.ProcessQueryKey, nil +} diff --git a/internal/pkg/provider_set.go b/internal/pkg/provider_set.go index 16bef95..25f0c15 100644 --- a/internal/pkg/provider_set.go +++ b/internal/pkg/provider_set.go @@ -21,6 +21,10 @@ var ProviderSetClient = wire.NewSet( dingtalk.NewOldClient, dingtalk.NewContactClient, dingtalk.NewNotableClient, + dingtalk.NewRobotClient, + dingtalk.NewOauth2Client, + dingtalk.NewCardClient, + dingtalk.NewImClient, utils_oss.NewClient, lsxd.NewLogin, diff --git a/internal/pkg/util/json.go b/internal/pkg/util/json.go new file mode 100644 index 0000000..d668e64 --- /dev/null +++ b/internal/pkg/util/json.go @@ -0,0 +1,37 @@ +package util + +import ( + "encoding/json" + "fmt" + "strconv" +) + +type FlexibleType string + +func (ft *FlexibleType) UnmarshalJSON(data []byte) error { + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + switch val := v.(type) { + case string: + *ft = FlexibleType(val) + case float64: + *ft = FlexibleType(strconv.FormatFloat(val, 'f', -1, 64)) + case bool: + *ft = FlexibleType(strconv.FormatBool(val)) + default: + *ft = FlexibleType(fmt.Sprintf("%v", val)) + } + return nil +} + +func (ft FlexibleType) Int() int { + if ft == "" { + return 0 + } + + i, _ := strconv.Atoi(string(ft)) + return i +} diff --git a/internal/pkg/util/safe_pool.go b/internal/pkg/util/safe_pool.go new file mode 100644 index 0000000..3b5dd7b --- /dev/null +++ b/internal/pkg/util/safe_pool.go @@ -0,0 +1,62 @@ +package util + +import ( + "os" + "runtime/debug" + "sync" + "time" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/go-kratos/kratos/v2/log" +) + +var ( + logger *log.Helper + once sync.Once +) + +// getLogger 懒加载获取日志器 +func getLogger() *log.Helper { + once.Do(func() { + // 如果没有手动初始化,使用默认的标准输出日志器 + if logger == nil { + stdLogger := log.With(log.NewStdLogger(os.Stdout), + "ts", log.DefaultTimestamp, + "caller", log.DefaultCaller, + "component", "safe_pool", + ) + logger = log.NewHelper(stdLogger) + } + }) + return logger +} + +// InitSafePool 初始化安全协程池(可选,如果不调用会使用默认日志器) +func InitSafePool(l log.Logger) { + logger = log.NewHelper(l) +} + +// SafeGo 安全执行协程 +// taskName: 协程任务名称,用于日志记录 +// fn: 要执行的函数 +func SafeGo(taskName string, fn func()) { + gopool.Go(func() { + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + getLogger().Errorf("协程 [%s] 发生panic: %v\n堆栈信息:\n%s", taskName, r, string(stack)) + } + }() + + // 记录协程开始执行 + getLogger().Infof("协程 [%s] 开始执行", taskName) + start := time.Now() + + // 执行用户函数 + fn() + + // 记录协程执行完成 + duration := time.Since(start) + getLogger().Infof("协程 [%s] 执行完成,耗时: %v", taskName, duration) + }) +} diff --git a/internal/server/ding_talk_bot.go b/internal/server/ding_talk_bot.go index 2eb31c6..997f761 100644 --- a/internal/server/ding_talk_bot.go +++ b/internal/server/ding_talk_bot.go @@ -7,6 +7,7 @@ import ( "fmt" "sync" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/card" "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/client" "github.com/go-kratos/kratos/v2/log" @@ -15,6 +16,7 @@ import ( type DingBotServiceInterface interface { GetServiceCfg() ([]entitys.DingTalkBot, error) OnChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) (content []byte, err error) + OnCardMessageReceived(ctx context.Context, data *card.CardRequest) (resp *card.CardResponse, err error) } type DingTalkBotServer struct { @@ -38,7 +40,7 @@ func NewDingTalkBotServer( } cli := DingBotServerInit(serviceConf.ClientId, serviceConf.ClientSecret, service) if cli == nil { - log.Info("%s客户端初始失败:%s", serviceConf.BotIndex, err.Error()) + log.Infof("%s客户端初始失败:%s", serviceConf.BotIndex, err.Error()) continue } clients[serviceConf.BotIndex] = cli @@ -52,7 +54,9 @@ func NewDingTalkBotServer( func ProvideAllDingBotServices( dingBotSvc *services.DingBotService, ) []DingBotServiceInterface { - return []DingBotServiceInterface{dingBotSvc} + return []DingBotServiceInterface{ + dingBotSvc, + } } func (d *DingTalkBotServer) Run(ctx context.Context, botIndex string) { @@ -103,5 +107,6 @@ func (d *DingTalkBotServer) Run(ctx context.Context, botIndex string) { func DingBotServerInit(clientId string, clientSecret string, service DingBotServiceInterface) (cli *client.StreamClient) { cli = client.NewStreamClient(client.WithAppCredential(client.NewAppCredentialConfig(clientId, clientSecret))) cli.RegisterChatBotCallbackRouter(service.OnChatBotMessageReceived) + cli.RegisterCardCallbackRouter(service.OnCardMessageReceived) return } diff --git a/internal/services/callback.go b/internal/services/callback.go index 08534f1..1b07006 100644 --- a/internal/services/callback.go +++ b/internal/services/callback.go @@ -1,6 +1,7 @@ package services import ( + "ai_scheduler/internal/biz" "ai_scheduler/internal/biz/handle/qywx/json_callback/wxbizjsonmsgcrypt" "ai_scheduler/internal/config" "ai_scheduler/internal/data/constants" @@ -19,9 +20,10 @@ import ( "strings" "time" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/card" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/log" - "github.com/gofiber/fiber/v2/middleware/proxy" ) // CallbackService 统一回调入口 @@ -31,17 +33,39 @@ type CallbackService struct { dingtalkOldClient *dingtalk.OldClient dingtalkContactClient *dingtalk.ContactClient dingtalkNotableClient *dingtalk.NotableClient + dingtalkCardClient *dingtalk.CardClient callbackManager callback.Manager + dingTalkBotBiz *biz.DingTalkBotBiz + callbackBiz *biz.CallbackBiz + // ollamaClient *utils_ollama.Client + // botConfigImpl *impl.BotConfigImpl } -func NewCallbackService(cfg *config.Config, gateway *gateway.Gateway, dingtalkOldClient *dingtalk.OldClient, dingtalkContactClient *dingtalk.ContactClient, dingtalkNotableClient *dingtalk.NotableClient, callbackManager callback.Manager) *CallbackService { +func NewCallbackService( + cfg *config.Config, + gateway *gateway.Gateway, + dingtalkOldClient *dingtalk.OldClient, + dingtalkContactClient *dingtalk.ContactClient, + dingtalkNotableClient *dingtalk.NotableClient, + dingtalkCardClient *dingtalk.CardClient, + callbackManager callback.Manager, + dingTalkBotBiz *biz.DingTalkBotBiz, + callbackBiz *biz.CallbackBiz, + // ollamaClient *utils_ollama.Client, + // botConfigImpl *impl.BotConfigImpl, +) *CallbackService { return &CallbackService{ cfg: cfg, gateway: gateway, dingtalkOldClient: dingtalkOldClient, dingtalkContactClient: dingtalkContactClient, dingtalkNotableClient: dingtalkNotableClient, + dingtalkCardClient: dingtalkCardClient, callbackManager: callbackManager, + dingTalkBotBiz: dingTalkBotBiz, + callbackBiz: callbackBiz, + // ollamaClient: ollamaClient, + // botConfigImpl: botConfigImpl, } } @@ -271,7 +295,7 @@ func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, // 获取创建者uid accessToken, _ := s.dingtalkOldClient.GetAccessToken() - creatorId, err := s.dingtalkContactClient.SearchUserOne(accessToken, data.Creator) + creatorId, err := s.dingtalkContactClient.SearchUserOne(dingtalk.AppKey{AccessToken: accessToken}, data.Creator) if err != nil { return "", errorcode.ParamErrf("invalid data type: %v", err) } @@ -287,7 +311,7 @@ func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, unionId := userDetails.UnionID // 更新记录 - ok, err := s.dingtalkNotableClient.UpdateRecord(accessToken, &dingtalk.UpdateRecordReq{ + ok, err := s.dingtalkNotableClient.UpdateRecord(dingtalk.AppKey{AccessToken: accessToken}, &dingtalk.UpdateRecordReq{ BaseId: data.BaseId, SheetId: data.SheetId, RecordId: data.RecordId, @@ -370,30 +394,70 @@ func getString(str, endstr string, start int, msg *string) int { // 钉钉 callbackRouteKey: gateway.dev.cdlsxd.cn-dingtalk-robot // 钉钉 apiSecret: aB3dE7fG9hI2jK4L5M6N7O8P9Q0R1S2T func (s *CallbackService) CallbackDingtalkRobot(c *fiber.Ctx) (err error) { - // 代理到本地 - target := "http://192.168.6.94:8090/api/v1/callback/dingtalk-robot" - - if err = proxy.Do(c, target); err != nil { - log.Errorf("proxy failed: %v", err) - return err + // 获取body中的参数 + body := c.Request().Body() + var data chatbot.BotCallbackDataModel + if err := json.Unmarshal(body, &data); err != nil { + return fmt.Errorf("invalid body: %v", err) + } + + // token 校验 ? token 好像没带? + + // 通过机器人ID路由到不同能力 + switch data.RobotCode { + case s.cfg.Dingtalk.SceneGroup.GroupTemplateRobotIDIssueHandling: + // 问题处理群机器人 + // err := s.issueHandling(data) + err := s.callbackBiz.IssueHandlingGroup(data) + if err != nil { + return fmt.Errorf("IssueHandlingGroup failed: %v", err) + } + default: + // 其他机器人 + return nil } - c.Locals("skip_response_wrap", true) return nil } -// CallbackDingtalkCard 钉钉卡片回调 +// CallbackDingtalkCard 处理钉钉卡片回调 // 钉钉 callbackRouteKey: gateway.dev.cdlsxd.cn-dingtalk-card // 钉钉 apiSecret: aB3dE7fG9hI2jK4L5M6N7O8P9Q0R1S2T -func (s *CallbackService) CallbackDingtalkCard(c *fiber.Ctx) (err error) { - // 代理到本地 - target := "http://192.168.6.94:8090/api/v1/callback/dingtalk-card" +func (s *CallbackService) CallbackDingtalkCard(c *fiber.Ctx) error { + // 获取body中的参数 + body := c.Request().Body() - if err = proxy.Do(c, target); err != nil { - log.Errorf("proxy failed: %v", err) - return err + // HTTP 回调结构与SDK结构体不符,包装结构体 + tmp := struct { + card.CardRequest // 嵌入原结构体 + UserIdType util.FlexibleType `json:"userIdType"` // 重写type字段 + }{} + if err := json.Unmarshal(body, &tmp); err != nil { + return fmt.Errorf("invalid body: %v", err) + } + // 异常字段覆盖 + data := tmp.CardRequest + data.UserIdType = tmp.UserIdType.Int() + if err := json.Unmarshal([]byte(data.Content), &data.CardActionData); err != nil { + return fmt.Errorf("invalid content: %v", err) } + // 非回调类型不处理 + if data.Type != constants.CardActionCallbackTypeAction { + return nil + } + + // 处理卡片回调 + var resp *card.CardResponse + for _, actionId := range data.CardActionData.CardPrivateData.ActionIdList { + switch actionId { + case "collect_qa": + // 问题处理群机器人 QA 收集 + resp = s.callbackBiz.IssueHandlingCollectQA(data) + } + } + + // 跳过响应包装 c.Locals("skip_response_wrap", true) - return nil + return c.JSON(resp) } diff --git a/internal/services/dtalk_bot.go b/internal/services/dtalk_bot.go index 177406b..7245f18 100644 --- a/internal/services/dtalk_bot.go +++ b/internal/services/dtalk_bot.go @@ -3,12 +3,14 @@ package services import ( "ai_scheduler/internal/biz" "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" "ai_scheduler/internal/entitys" "context" "log" "sync" "time" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/card" "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" "golang.org/x/sync/errgroup" ) @@ -18,7 +20,10 @@ type DingBotService struct { dingTalkBotBiz *biz.DingTalkBotBiz } -func NewDingBotService(config *config.Config, dingTalkBotBiz *biz.DingTalkBotBiz) *DingBotService { +func NewDingBotService( + config *config.Config, + dingTalkBotBiz *biz.DingTalkBotBiz, +) *DingBotService { return &DingBotService{ config: config, dingTalkBotBiz: dingTalkBotBiz, @@ -140,3 +145,26 @@ func (d *DingBotService) runBackgroundTasks(ctx context.Context, data *chatbot.B return nil } + +// OnCardMessageReceived 处理卡片回调 +func (d *DingBotService) OnCardMessageReceived(ctx context.Context, data *card.CardRequest) (resp *card.CardResponse, err error) { + // 非回调类型暂不接受 + if data.Type != constants.CardActionCallbackTypeAction { + return nil, nil + } + + // action 处理 - 这里先只处理第一个匹配的actionId + for _, actionId := range data.CardActionData.CardPrivateData.ActionIdList { + switch actionId { + case constants.CardActionTypeCreateGroup: + resp, err = d.dingTalkBotBiz.CreateIssueHandlingGroupAndInit(ctx, data) + if err != nil { + return nil, err + } + + return + } + } + + return &card.CardResponse{}, nil +} diff --git a/internal/services/dtalk_bot_test.go b/internal/services/dtalk_bot_test.go index 37c206e..0053382 100644 --- a/internal/services/dtalk_bot_test.go +++ b/internal/services/dtalk_bot_test.go @@ -93,10 +93,14 @@ func run() { ollamaService := llm_service.NewOllamaGenerate(client, utils_vllmClient, configConfig, chatHisImpl) // 初始化工具管理器 manager := tools.NewManager(configConfig, client) + // 初始化钉钉认证客户端 + oauth2Client, _ := dingtalk.NewOauth2Client(rdb) // 初始化钉钉联系人客户端 - contactClient, _ := dingtalk.NewContactClient(configConfig) + contactClient, _ := dingtalk.NewContactClient(oauth2Client) // 初始化钉钉记事本客户端 - notableClient, _ := dingtalk.NewNotableClient(configConfig) + notableClient, _ := dingtalk.NewNotableClient(oauth2Client) + // 初始化钉钉卡片客户端 + cardClient, _ := dingtalk.NewCardClient(oauth2Client) // 初始化工具注册 toolRegis := tools_regis.NewToolsRegis(botToolsImpl) // 初始化机器人聊天历史实现层 @@ -120,8 +124,9 @@ func run() { group := qywx.NewGroup(botGroupQywxImpl, qywxAuth) other := qywx.NewOther(qywxAuth) qywxAppBiz := biz.NewQywxAppBiz(configConfig, botGroupQywxImpl, group, other) - groupConfigBiz := biz.NewGroupConfigBiz(toolRegis, utils_ossClient, botGroupConfigImpl, registry, configConfig, impl.NewReportDailyCacheImpl(db), rdb) - dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, user, botChatHisImpl, impl.NewReportDailyCacheImpl(db), manager, configConfig, sendCardClient, groupConfigBiz) + groupConfigBiz := biz.NewGroupConfigBiz(toolRegis, utils_ossClient, botGroupConfigImpl, botConfigImpl, registry, configConfig, impl.NewReportDailyCacheImpl(db), rdb, manager, cardClient) + macro := do.NewMacro(botGroupImpl, impl.NewReportDailyCacheImpl(db)) + dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, botGroupConfigImpl, user, botChatHisImpl, impl.NewReportDailyCacheImpl(db), manager, configConfig, sendCardClient, groupConfigBiz, macro, oauth2Client, oldClient, cardClient) // 初始化钉钉机器人服务 cronService = NewCronService(configConfig, dingTalkBotBiz, qywxAppBiz, groupConfigBiz) }