866 lines
27 KiB
Go
866 lines
27 KiB
Go
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"
|
||
errorcode "ai_scheduler/internal/data/error"
|
||
"ai_scheduler/internal/data/impl"
|
||
"ai_scheduler/internal/domain/component/callback"
|
||
"ai_scheduler/internal/domain/tools/common/knowledge_base"
|
||
"ai_scheduler/internal/entitys"
|
||
"ai_scheduler/internal/gateway"
|
||
"ai_scheduler/internal/pkg"
|
||
"ai_scheduler/internal/pkg/dingtalk"
|
||
"ai_scheduler/internal/pkg/util"
|
||
"ai_scheduler/internal/pkg/utils_ollama"
|
||
"ai_scheduler/internal/tool_callback"
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/url"
|
||
"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/alibabacloud-go/dingtalk/card_1_0"
|
||
"github.com/alibabacloud-go/tea/tea"
|
||
"github.com/gofiber/fiber/v2"
|
||
"github.com/gofiber/fiber/v2/log"
|
||
"github.com/ollama/ollama/api"
|
||
)
|
||
|
||
// CallbackService 统一回调入口
|
||
type CallbackService struct {
|
||
cfg *config.Config
|
||
gateway *gateway.Gateway
|
||
dingtalkOldClient *dingtalk.OldClient
|
||
dingtalkContactClient *dingtalk.ContactClient
|
||
dingtalkNotableClient *dingtalk.NotableClient
|
||
dingtalkCardClient *dingtalk.CardClient
|
||
callbackManager callback.Manager
|
||
dingTalkBotBiz *biz.DingTalkBotBiz
|
||
ollamaClient *utils_ollama.Client
|
||
botConfigImpl *impl.BotConfigImpl
|
||
}
|
||
|
||
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,
|
||
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,
|
||
ollamaClient: ollamaClient,
|
||
botConfigImpl: botConfigImpl,
|
||
}
|
||
}
|
||
|
||
// Envelope 回调统一请求体
|
||
type Envelope struct {
|
||
Action string `json:"action"`
|
||
TaskID string `json:"task_id"`
|
||
Data json.RawMessage `json:"data"`
|
||
}
|
||
|
||
// bug_optimization_submit 工单回调
|
||
const (
|
||
ActionBugOptimizationSubmitProcess = "bug_optimization_submit_process" // 工单过程回调
|
||
ActionBugOptimizationSubmitDone = "bug_optimization_submit_done" // 工单完成回调
|
||
ActionBugOptimizationSubmitUpdate = "bug_optimization_submit_update" // 工单更新回调
|
||
)
|
||
|
||
// BugOptimizationSubmitDoneData 工单完成回调数据
|
||
type BugOptimizationSubmitDoneData struct {
|
||
Receivers []string `json:"receivers"`
|
||
DetailPage string `json:"detail_page"`
|
||
Msg string `json:"msg"`
|
||
}
|
||
|
||
// BugOptimizationSubmitUpdateData 工单更新回调数据
|
||
type BugOptimizationSubmitUpdateData struct {
|
||
BaseId string `json:"base_id"` // 表格ID
|
||
SheetId string `json:"sheet_id"` // 表单ID
|
||
RecordId string `json:"record_id"` // 记录ID
|
||
UnionId string `json:"union_id"` // 钉钉用户 UnionID
|
||
Creator string `json:"creator"` // 钉钉用户名称
|
||
}
|
||
|
||
// Callback 统一回调处理
|
||
// 头部:X-Source-Key / X-Timestamp
|
||
func (s *CallbackService) Callback(c *fiber.Ctx) error {
|
||
// 读取头
|
||
sourceKey := strings.TrimSpace(c.Get("X-Source-Key"))
|
||
ts := strings.TrimSpace(c.Get("X-Timestamp"))
|
||
|
||
// 时间窗口(如果提供了 ts 则校验,否则跳过),窗口 5 分钟
|
||
// if ts != "" && !validateTimestamp(ts, 5*time.Minute) {
|
||
if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) {
|
||
return errorcode.AuthNotFound
|
||
}
|
||
|
||
// 解析 Envelope
|
||
var env Envelope
|
||
if err := json.Unmarshal(c.Body(), &env); err != nil {
|
||
return errorcode.ParamErrf("invalid json: %v", err)
|
||
}
|
||
if env.Action == "" || env.TaskID == "" {
|
||
return errorcode.ParamErrf("missing action/task_id")
|
||
}
|
||
if env.Data == nil {
|
||
return errorcode.ParamErrf("missing data")
|
||
}
|
||
|
||
switch sourceKey {
|
||
case "dingtalk":
|
||
return s.handleDingTalkCallback(c, env)
|
||
default:
|
||
return errorcode.AuthNotFound
|
||
}
|
||
}
|
||
|
||
func (s *CallbackService) CallbackQr(c *fiber.Ctx) error {
|
||
// 读取头
|
||
sourceKey := strings.TrimSpace(c.Get("X-Source-Key"))
|
||
ts := strings.TrimSpace(c.Get("X-Timestamp"))
|
||
|
||
// 时间窗口(如果提供了 ts 则校验,否则跳过),窗口 5 分钟
|
||
// if ts != "" && !validateTimestamp(ts, 5*time.Minute) {
|
||
if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) {
|
||
return errorcode.AuthNotFound
|
||
}
|
||
|
||
// 解析 Envelope
|
||
var env Envelope
|
||
if err := json.Unmarshal(c.Body(), &env); err != nil {
|
||
return errorcode.ParamErrf("invalid json: %v", err)
|
||
}
|
||
if env.Action == "" || env.TaskID == "" {
|
||
return errorcode.ParamErrf("missing action/task_id")
|
||
}
|
||
if env.Data == nil {
|
||
return errorcode.ParamErrf("missing data")
|
||
}
|
||
|
||
switch sourceKey {
|
||
case "dingtalk":
|
||
return s.handleDingTalkCallback(c, env)
|
||
default:
|
||
return errorcode.AuthNotFound
|
||
}
|
||
}
|
||
|
||
func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) error {
|
||
// 校验taskId
|
||
ctx := c.Context()
|
||
sessionID, err := s.callbackManager.GetSession(ctx, env.TaskID)
|
||
if err != nil {
|
||
return errorcode.ParamErrf("failed to get session for task_id: %s, err: %v", env.TaskID, err)
|
||
}
|
||
if sessionID == "" {
|
||
return errorcode.ParamErrf("missing session_id for task_id: %s", env.TaskID)
|
||
}
|
||
|
||
switch env.Action {
|
||
case ActionBugOptimizationSubmitUpdate:
|
||
// 业务处理
|
||
msg, businessErr := s.handleBugOptimizationSubmitUpdate(ctx, env.Data)
|
||
if businessErr != nil {
|
||
return businessErr
|
||
}
|
||
|
||
s.sendStreamLog(sessionID, msg)
|
||
|
||
return c.JSON(fiber.Map{"code": 0, "message": "ok"})
|
||
case ActionBugOptimizationSubmitDone:
|
||
// 业务处理
|
||
msg, businessErr := s.handleBugOptimizationSubmitDone(ctx, env.Data)
|
||
if businessErr != nil {
|
||
return businessErr
|
||
}
|
||
|
||
// 发送日志
|
||
s.sendStreamTxt(sessionID, msg)
|
||
|
||
// 通知等待者
|
||
if err := s.callbackManager.Notify(ctx, env.TaskID, msg); err != nil {
|
||
// 记录错误但继续
|
||
}
|
||
|
||
return c.JSON(fiber.Map{"code": 0, "message": "ok"})
|
||
case ActionBugOptimizationSubmitProcess:
|
||
type processData struct {
|
||
Process string `json:"process"`
|
||
}
|
||
var data processData
|
||
if err := json.Unmarshal(env.Data, &data); err != nil {
|
||
return errorcode.ParamErrf("invalid json: %v", err)
|
||
}
|
||
|
||
s.sendStreamLoading(sessionID, data.Process)
|
||
|
||
return c.JSON(fiber.Map{"code": 0, "message": "ok"})
|
||
default:
|
||
return errorcode.ParamErrf("unknown action: %s", env.Action)
|
||
}
|
||
}
|
||
|
||
// getDingtalkReceivers 解析接收者字符串为 DingTalk 用户 ID 列表
|
||
func (s *CallbackService) getDingtalkReceivers(ctx context.Context, receiverIds []string) string {
|
||
var receiverNames []string
|
||
for _, receiverId := range receiverIds {
|
||
userDetails, err := s.dingtalkOldClient.QueryUserDetails(ctx, receiverId)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
if userDetails == nil {
|
||
return ""
|
||
}
|
||
receiverNames = append(receiverNames, "@"+userDetails.Name)
|
||
}
|
||
|
||
receivers := strings.Join(receiverNames, " ")
|
||
|
||
return receivers
|
||
}
|
||
|
||
// sendStreamLog 发送流式日志
|
||
func (s *CallbackService) sendStreamLog(sessionID string, content string) {
|
||
if content == "" {
|
||
return
|
||
}
|
||
|
||
streamLog := entitys.Response{
|
||
Index: string(constants.BotToolsBugOptimizationSubmit),
|
||
Content: content,
|
||
Type: entitys.ResponseLog,
|
||
}
|
||
streamLogBytes := pkg.JsonByteIgonErr(streamLog)
|
||
s.gateway.SendToUid(sessionID, streamLogBytes)
|
||
}
|
||
|
||
// sendStreamTxt 发送流式文本
|
||
func (s *CallbackService) sendStreamTxt(sessionID string, content string) {
|
||
if content == "" {
|
||
return
|
||
}
|
||
|
||
streamLog := entitys.Response{
|
||
Index: string(constants.BotToolsBugOptimizationSubmit),
|
||
Content: content,
|
||
Type: entitys.ResponseText,
|
||
}
|
||
streamLogBytes := pkg.JsonByteIgonErr(streamLog)
|
||
s.gateway.SendToUid(sessionID, streamLogBytes)
|
||
}
|
||
|
||
// sendStreamLoading 发送流式加载过程
|
||
func (s *CallbackService) sendStreamLoading(sessionID string, content string) {
|
||
if content == "" {
|
||
return
|
||
}
|
||
|
||
streamLog := entitys.Response{
|
||
Index: string(constants.BotToolsBugOptimizationSubmit),
|
||
Content: content,
|
||
Type: entitys.ResponseLoading,
|
||
}
|
||
streamLogBytes := pkg.JsonByteIgonErr(streamLog)
|
||
s.gateway.SendToUid(sessionID, streamLogBytes)
|
||
}
|
||
|
||
// handleBugOptimizationSubmitUpdate 处理 bug 优化提交更新回调
|
||
func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) {
|
||
var data BugOptimizationSubmitUpdateData
|
||
if err := json.Unmarshal(taskData, &data); err != nil {
|
||
return "", errorcode.ParamErrf("invalid data type: %v", err)
|
||
}
|
||
|
||
if data.Creator == "" {
|
||
return "", errorcode.ParamErrf("empty creator")
|
||
}
|
||
|
||
// 获取创建者uid
|
||
accessToken, _ := s.dingtalkOldClient.GetAccessToken()
|
||
creatorId, err := s.dingtalkContactClient.SearchUserOne(dingtalk.AppKey{AccessToken: accessToken}, data.Creator)
|
||
if err != nil {
|
||
return "", errorcode.ParamErrf("invalid data type: %v", err)
|
||
}
|
||
|
||
// 获取用户详情
|
||
userDetails, err := s.dingtalkOldClient.QueryUserDetails(ctx, creatorId)
|
||
if err != nil {
|
||
return "", errorcode.ParamErrf("invalid data type: %v", err)
|
||
}
|
||
if userDetails == nil {
|
||
return "", errorcode.ParamErrf("user details not found")
|
||
}
|
||
unionId := userDetails.UnionID
|
||
|
||
// 更新记录
|
||
ok, err := s.dingtalkNotableClient.UpdateRecord(dingtalk.AppKey{AccessToken: accessToken}, &dingtalk.UpdateRecordReq{
|
||
BaseId: data.BaseId,
|
||
SheetId: data.SheetId,
|
||
RecordId: data.RecordId,
|
||
OperatorId: tool_callback.BotBugOptimizationSubmitAdminUnionId,
|
||
CreatorUnionId: unionId,
|
||
})
|
||
if err != nil {
|
||
return "", errorcode.ParamErrf("invalid data type: %v", err)
|
||
}
|
||
if !ok {
|
||
return "", errorcode.ParamErrf("update record failed")
|
||
}
|
||
|
||
return "问题记录即将完成", nil
|
||
}
|
||
|
||
// handleBugOptimizationSubmitDone 处理 bug 优化提交完成回调
|
||
func (s *CallbackService) handleBugOptimizationSubmitDone(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) {
|
||
var data BugOptimizationSubmitDoneData
|
||
if err := json.Unmarshal(taskData, &data); err != nil {
|
||
return "", errorcode.ParamErrf("invalid data type: %v", err)
|
||
}
|
||
|
||
if len(data.Receivers) == 0 {
|
||
return "", errorcode.ParamErrf("empty receivers")
|
||
}
|
||
// 构建接收者
|
||
receivers := s.getDingtalkReceivers(ctx, data.Receivers)
|
||
if receivers == "" {
|
||
return "", errorcode.ParamErrf("invalid receivers")
|
||
}
|
||
|
||
// 构建跳转链接
|
||
var detailPage string
|
||
if data.DetailPage != "" {
|
||
detailPage = util.BuildJumpLink(data.DetailPage, "去查看")
|
||
}
|
||
|
||
msg := data.Msg
|
||
msg = util.ReplacePlaceholder(msg, "receivers", receivers)
|
||
msg = util.ReplacePlaceholder(msg, "detail_page", detailPage)
|
||
|
||
return msg, nil
|
||
}
|
||
|
||
func (s *CallbackService) QywxCallback(c *fiber.Ctx) (err error) {
|
||
// 读取头
|
||
httpstr := string(c.Request().URI().QueryString())
|
||
|
||
start := strings.Index(httpstr, "msg_signature=")
|
||
start += len("msg_signature=")
|
||
var msgSignature string
|
||
next := getString(httpstr, "×tamp=", start, &msgSignature)
|
||
var timestamp string
|
||
next = getString(httpstr, "&nonce=", next, ×tamp)
|
||
var nonce string
|
||
next = getString(httpstr, "&echostr=", next, &nonce)
|
||
echostr := httpstr[next:len(httpstr)]
|
||
echostr, _ = url.QueryUnescape(echostr)
|
||
fmt.Println(httpstr, msgSignature, timestamp, nonce, echostr)
|
||
wxcpt := wxbizjsonmsgcrypt.NewWXBizMsgCrypt(s.cfg.Qywx.Token, s.cfg.Qywx.AES_KEY, s.cfg.Qywx.CorpId, wxbizjsonmsgcrypt.JsonType)
|
||
echoStr, cryptErr := wxcpt.VerifyURL(msgSignature, timestamp, nonce, echostr)
|
||
if nil != cryptErr {
|
||
log.Errorf("%v", cryptErr)
|
||
return fmt.Errorf("%v", cryptErr)
|
||
}
|
||
fmt.Println("verifyUrl success echoStr", string(echoStr))
|
||
err = c.Send(echoStr)
|
||
return err
|
||
|
||
}
|
||
|
||
func getString(str, endstr string, start int, msg *string) int {
|
||
end := strings.Index(str, endstr)
|
||
*msg = str[start:end]
|
||
return end + len(endstr)
|
||
}
|
||
|
||
// CallbackDingtalkRobot 钉钉机器人回调
|
||
// 钉钉 callbackRouteKey: gateway.dev.cdlsxd.cn-dingtalk-robot
|
||
// 钉钉 apiSecret: aB3dE7fG9hI2jK4L5M6N7O8P9Q0R1S2T
|
||
func (s *CallbackService) CallbackDingtalkRobot(c *fiber.Ctx) (err error) {
|
||
// 获取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 constants.GroupTemplateRobotIdIssueHandling:
|
||
// 问题处理群机器人
|
||
err := s.issueHandling(c, data)
|
||
if err != nil {
|
||
return fmt.Errorf("issueHandling failed: %v", err)
|
||
}
|
||
default:
|
||
// 其他机器人
|
||
return nil
|
||
}
|
||
|
||
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||
// defer cancel()
|
||
|
||
// 统一初始化请求参数
|
||
// requireData, err := s.dingTalkBotBiz.InitRequire(ctx, &data)
|
||
// if err != nil {
|
||
// return fmt.Errorf("初始化请求参数失败: %v", err)
|
||
// }
|
||
|
||
// 这里需要再实现一套HTTP形式的回调,用于处理钉钉群模板机器人的回调
|
||
// 主程等待处理结果
|
||
// resChan := make(chan string, 10)
|
||
// defer close(resChan)
|
||
|
||
return nil
|
||
}
|
||
|
||
// issueHandling 问题处理群机器人回调
|
||
// 能力1: 通过[内容提取] 宏,分析用户QA问题,调出QA表单卡片
|
||
// 能力2: 通过[QA收集] 宏,收集用户反馈,写入知识库
|
||
// 能力3: 通过[知识库查询] 宏,查询知识库,返回答案
|
||
func (s *CallbackService) issueHandling(c *fiber.Ctx, data chatbot.BotCallbackDataModel) error {
|
||
// 能力1、2:分析用户QA问题,写入知识库
|
||
if strings.Contains(data.Text.Content, "[内容提取]") || strings.Contains(data.Text.Content, "[QA收集]") {
|
||
s.issueHandlingExtractContent(data)
|
||
}
|
||
// 能力3:查询知识库,返回答案
|
||
if strings.Contains(data.Text.Content, "[知识库查询]") {
|
||
s.issueHandlingQueryKnowledgeBase(data)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// 问题处理群机器人内容提取
|
||
func (s *CallbackService) issueHandlingExtractContent(data chatbot.BotCallbackDataModel) {
|
||
systemPrompt := `你是一个【问题与答案生成助手】。
|
||
|
||
你的职责是:
|
||
- 分析用户输入的内容
|
||
- 识别其中隐含或明确的问题
|
||
- 基于输入内容本身,生成对应的问题与答案
|
||
|
||
当用户输入为【多条群聊聊天记录】时:
|
||
- 结合问题主题,判断聊天记录中正在讨论或试图解决的问题
|
||
- 一个群聊中可能包含多个相互独立的问题,但它们都围绕着一个主题,尽可能总结为一个问题
|
||
- 若确实问题很独立,需要分别识别,对每个问题,整理出清晰、可复用的“问题描述”和“对应答案”
|
||
|
||
生成答案时的原则:
|
||
- 答案必须来源于聊天内容中已经给出的信息或共识
|
||
- 不要引入外部知识,不要使用聊天记录中真实人名或敏感信息,适当总结
|
||
- 若聊天中未形成明确答案,应明确标记为“暂无明确结论”
|
||
- 若存在多种不同观点,应分别列出,不要擅自合并或裁决
|
||
|
||
【JSON 输出原则】:
|
||
- 你的最终输出必须是**合法的 JSON**
|
||
- 不得输出任何额外解释性文字
|
||
- JSON 结构必须严格符合以下约定
|
||
|
||
JSON 结构约定:
|
||
{
|
||
"items": [
|
||
{
|
||
"question": "清晰、独立、可复用的问题描述",
|
||
"answer": "基于聊天内容整理出的答案;如无结论则为“暂无明确结论”",
|
||
"confidence": "high | medium | low"
|
||
}
|
||
]
|
||
}
|
||
|
||
字段说明:
|
||
- items:问题与答案列表;若未识别到有效问题,则返回空数组 []
|
||
- question:抽象后的标准问题表述,不包含具体聊天语句
|
||
- answer:整理后的答案,不得引入聊天之外的信息
|
||
- confidence:根据聊天中信息的一致性和明确程度给出判断
|
||
|
||
如果无法从输入中识别出任何有效问题,返回:
|
||
{ "items": [] }
|
||
|
||
问题主题:
|
||
%s
|
||
|
||
用户输入:
|
||
%s
|
||
`
|
||
|
||
prompt := fmt.Sprintf(systemPrompt, "紧急加款,提示当前账户为离线账户,请输入银行流水号", data.Text.Content)
|
||
|
||
fmt.Println("prompt:", prompt)
|
||
|
||
generateResp, err := s.ollamaClient.Generation(context.Background(), &api.GenerateRequest{
|
||
Model: s.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
|
||
}
|
||
|
||
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")
|
||
|
||
// 调用卡片
|
||
// 构建卡片 OutTrackId
|
||
outTrackId := constants.BuildCardOutTrackId(data.ConversationId, data.RobotCode)
|
||
|
||
_, err = s.dingtalkCardClient.CreateAndDeliver(
|
||
dingtalk.AppKey{
|
||
AppKey: "ding5wwvnf9hxeyjau7t",
|
||
AppSecret: "FxXVlTzxrKXvJ8h-9uK0s5TjaBfOJSXumpmrHal-NmQAtku9wOPxcss0Af6WHoAK",
|
||
},
|
||
&card_1_0.CreateAndDeliverRequest{
|
||
CardTemplateId: tea.String("3a447814-6a3e-4a02-b48a-92c57b349d77.schema"),
|
||
OutTrackId: tea.String(outTrackId),
|
||
CallbackType: tea.String("HTTP"),
|
||
CallbackRouteKey: tea.String("gateway.dev.cdlsxd.cn-dingtalk-card"),
|
||
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(constants.CardDebugToolEntryShow), // 调试字段
|
||
},
|
||
},
|
||
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(constants.GroupTemplateRobotIdIssueHandling),
|
||
Recipients: []*string{
|
||
tea.String(data.SenderStaffId),
|
||
},
|
||
},
|
||
},
|
||
)
|
||
|
||
}
|
||
|
||
// 问题处理群机器人查询知识库
|
||
func (s *CallbackService) issueHandlingQueryKnowledgeBase(data chatbot.BotCallbackDataModel) {
|
||
// 获取应用主机器人
|
||
mainRobotCode := data.RobotCode
|
||
if robotCode, ok := constants.GroupTemplateRobotIdMap[data.RobotCode]; ok {
|
||
mainRobotCode = robotCode
|
||
}
|
||
// 获取应用机器人配置
|
||
robotConfig, err := s.botConfigImpl.GetRobotConfig(mainRobotCode)
|
||
if err != nil {
|
||
log.Errorf("应用机器人配置不存在: %s, err: %v", mainRobotCode, err)
|
||
return
|
||
}
|
||
// 创建卡片
|
||
outTrackId := constants.BuildCardOutTrackId(data.ConversationId, mainRobotCode)
|
||
_, err = s.dingtalkCardClient.CreateAndDeliver(
|
||
dingtalk.AppKey{
|
||
AppKey: robotConfig.ClientId,
|
||
AppSecret: robotConfig.ClientSecret,
|
||
},
|
||
&card_1_0.CreateAndDeliverRequest{
|
||
CardTemplateId: tea.String(constants.DingtalkCardTplBaseMsg),
|
||
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),
|
||
Recipients: []*string{
|
||
tea.String(data.SenderStaffId),
|
||
},
|
||
},
|
||
},
|
||
)
|
||
|
||
// 查询知识库
|
||
knowledgeBase := knowledge_base.New(s.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 = s.dingtalkCardClient.UpdateCard(
|
||
dingtalk.AppKey{
|
||
AppKey: robotConfig.ClientId,
|
||
AppSecret: robotConfig.ClientSecret,
|
||
},
|
||
&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
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// 读取知识库 SSE 数据
|
||
func (s *CallbackService) readKnowledgeSSE(resp io.ReadCloser, channel chan string) (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 = "知识库未检测到匹配信息,即将为您创建群聊解决问题。"
|
||
channel <- missContent
|
||
return false, nil
|
||
}
|
||
// 推理内容
|
||
if delta.ReasoningContent != "" {
|
||
channel <- delta.ReasoningContent
|
||
continue
|
||
}
|
||
// 输出内容 - 段落
|
||
// 存入缓冲区
|
||
buffer.WriteString(delta.Content)
|
||
content := buffer.String()
|
||
|
||
// 检查是否有换行符,按段落输出
|
||
if idx := strings.LastIndex(content, "\n"); idx != -1 {
|
||
// 发送直到最后一个换行符的内容
|
||
toSend := content[:idx+1]
|
||
channel <- toSend
|
||
|
||
// 重置缓冲区,保留剩余部分
|
||
remaining := content[idx+1:]
|
||
buffer.Reset()
|
||
buffer.WriteString(remaining)
|
||
}
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return true, fmt.Errorf("读取SSE流中断: %w", err)
|
||
}
|
||
|
||
// 发送缓冲区剩余内容(仅在段落模式下需要)
|
||
if buffer.Len() > 0 {
|
||
channel <- buffer.String()
|
||
}
|
||
|
||
return true, nil
|
||
}
|
||
|
||
// CallbackDingtalkCard 处理钉钉卡片回调
|
||
// 钉钉 callbackRouteKey: gateway.dev.cdlsxd.cn-dingtalk-card
|
||
// 钉钉 apiSecret: aB3dE7fG9hI2jK4L5M6N7O8P9Q0R1S2T
|
||
func (s *CallbackService) CallbackDingtalkCard(c *fiber.Ctx) error {
|
||
// 获取body中的参数
|
||
body := c.Request().Body()
|
||
|
||
// 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.issueHandlingCollectQA(data)
|
||
}
|
||
}
|
||
|
||
// 跳过响应包装
|
||
c.Locals("skip_response_wrap", true)
|
||
return c.JSON(resp)
|
||
}
|
||
|
||
// 问题处理群机器人 QA 收集
|
||
func (s *CallbackService) 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(s.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)
|
||
|
||
// 获取主应用机器人(这里可能是群模板机器人)
|
||
mainRobotId := robotCode
|
||
if robotCode, ok := constants.GroupTemplateRobotIdMap[robotCode]; ok {
|
||
mainRobotId = robotCode
|
||
}
|
||
|
||
// 获取 robot 配置
|
||
robotConfig, err := s.botConfigImpl.GetRobotConfig(mainRobotId)
|
||
if err != nil {
|
||
log.Errorf("获取 robot 配置失败: %v", err)
|
||
return
|
||
}
|
||
|
||
// 发送卡片通知用户注入成功
|
||
outTrackId := constants.BuildCardOutTrackId(conversationId, robotCode)
|
||
s.dingtalkCardClient.CreateAndDeliver(
|
||
dingtalk.AppKey{
|
||
AppKey: robotConfig.ClientId,
|
||
AppSecret: robotConfig.ClientSecret,
|
||
},
|
||
&card_1_0.CreateAndDeliverRequest{
|
||
CardTemplateId: tea.String(constants.DingtalkCardTplBaseMsg),
|
||
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),
|
||
Recipients: []*string{
|
||
tea.String(data.UserId),
|
||
},
|
||
},
|
||
},
|
||
)
|
||
})
|
||
}
|
||
|
||
// 取消提交,禁用输入框
|
||
resp := &card.CardResponse{
|
||
CardUpdateOptions: &card.CardUpdateOptions{
|
||
UpdateCardDataByKey: true,
|
||
},
|
||
CardData: &card.CardDataDto{
|
||
CardParamMap: map[string]string{
|
||
"textarea_display": "disabled",
|
||
},
|
||
},
|
||
}
|
||
|
||
return resp
|
||
}
|