package feishu import ( "context" "encoding/json" "fmt" "strings" "sync" "time" "github.com/google/uuid" lark "github.com/larksuite/oapi-sdk-go/v3" "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" larkcardkit "github.com/larksuite/oapi-sdk-go/v3/service/cardkit/v1" larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "github.com/chaitin/panda-wiki/domain" "github.com/chaitin/panda-wiki/log" "github.com/chaitin/panda-wiki/pkg/bot" ) type FeishuBotLogger struct { logger *log.Logger } func (l *FeishuBotLogger) Info(ctx context.Context, args ...interface{}) { l.logger.Info("feishu bot", log.Any("args", args)) } func (l *FeishuBotLogger) Error(ctx context.Context, args ...interface{}) { l.logger.Error("feishu bot", log.Any("args", args)) } func (l *FeishuBotLogger) Debug(ctx context.Context, args ...interface{}) { l.logger.Debug("feishu bot", log.Any("args", args)) } func (l *FeishuBotLogger) Warn(ctx context.Context, args ...interface{}) { l.logger.Warn("feishu bot", log.Any("args", args)) } type FeishuClient struct { ctx context.Context cancel context.CancelFunc clientID string clientSecret string logger *log.Logger client *lark.Client msgMap sync.Map getQA bot.GetQAFun } func NewFeishuClient(ctx context.Context, cancel context.CancelFunc, clientID, clientSecret string, logger *log.Logger, getQA bot.GetQAFun) *FeishuClient { client := lark.NewClient(clientID, clientSecret, lark.WithLogger(&FeishuBotLogger{logger: logger})) c := &FeishuClient{ ctx: ctx, cancel: cancel, clientID: clientID, clientSecret: clientSecret, client: client, logger: logger, getQA: getQA, } go func() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-c.ctx.Done(): return case <-ticker.C: c.msgMap.Range(func(key, value any) bool { // remove messageId if it is older than 5 minutes if time.Now().Unix()-value.(int64) > 5*60 { c.msgMap.Delete(key) } return true }) } } }() return c } var cardDataTemplate = `{"schema":"2.0","header":{"title":{"content":"%s","tag":"plain_text"}},"config":{"streaming_mode":true,"summary":{"content":""}},"body":{"elements":[{"tag":"markdown","content":"%s","element_id":"markdown_1"}]}}` func (c *FeishuClient) sendQACard(ctx context.Context, receiveIdType string, receiveId string, question string, additionalInfo string) { // create card cardData := fmt.Sprintf(cardDataTemplate, question, "稍等,让我想一想...") req := larkcardkit.NewCreateCardReqBuilder(). Body(larkcardkit.NewCreateCardReqBodyBuilder(). Type(`card_json`). Data(cardData). Build()). Build() resp, err := c.client.Cardkit.V1.Card.Create(ctx, req) if err != nil { c.logger.Error("failed to create card", log.Error(err)) return } if !resp.Success() { c.logger.Error("failed to create card", log.String("request_id", resp.RequestId()), log.Any("code_error", resp.CodeError)) return } content, err := json.Marshal(map[string]any{ "type": "card", "data": map[string]string{ "card_id": *resp.Data.CardId, }, }) if err != nil { c.logger.Error("failed to marshal alarm card", log.Error(err)) return } // send card to user or group res, err := c.client.Im.Message.Create(ctx, larkim.NewCreateMessageReqBuilder(). ReceiveIdType(receiveIdType). Body(larkim.NewCreateMessageReqBodyBuilder(). MsgType("interactive"). ReceiveId(receiveId). Content(string(content)). Build()). Build()) if err != nil { c.logger.Error("failed to create message", log.Error(err)) return } if !res.Success() { c.logger.Error("failed to create message", log.Int("code", res.Code), log.String("msg", res.Msg), log.String("request_id", res.RequestId())) return } // 打印日志 c.logger.Info("send QA card to user or group", log.String("receive_id_type", receiveIdType), log.String("receive_id", receiveId), log.String("question", question), log.String("additional_info(chat:user_openid/p2p:chat_id)", additionalInfo)) // start processing QA convInfo := domain.ConversationInfo{ UserInfo: domain.UserInfo{ From: domain.MessageFromPrivate, // 默认是私聊 }, } if receiveIdType == "open_id" { // 获取用户的信息,只需要获取p2p的对话的类型的用户信息 - p2p对话 userinfo, err := c.GetUserInfo(receiveId) if err != nil { c.logger.Error("get user info failed", log.Error(err)) } else { if userinfo.UserId != nil { convInfo.UserInfo.UserID = *userinfo.UserId } if userinfo.Name != nil { convInfo.UserInfo.NickName = *userinfo.Name } if userinfo.Avatar != nil && userinfo.Avatar.AvatarOrigin != nil { convInfo.UserInfo.Avatar = *userinfo.Avatar.AvatarOrigin } c.logger.Info("get user info success", log.Any("user_info", userinfo)) } convInfo.UserInfo.From = domain.MessageFromPrivate // 私聊 } else { // chat_id 中的userid // 获取群聊的消息,用户如果是在群聊中@机器人,那么就获取的是群聊的消息 userinfo, err := c.GetUserInfo(additionalInfo) if err != nil { c.logger.Error("get chat info failed", log.Error(err)) } else { if userinfo.UserId != nil { convInfo.UserInfo.UserID = *userinfo.UserId } if userinfo.Name != nil { convInfo.UserInfo.NickName = *userinfo.Name } if userinfo.Avatar != nil && userinfo.Avatar.AvatarOrigin != nil { convInfo.UserInfo.Avatar = *userinfo.Avatar.AvatarOrigin } c.logger.Info("get chat user info success", log.Any("user_info", userinfo)) } convInfo.UserInfo.From = domain.MessageFromGroup // 群聊 } answerCh, err := c.getQA(ctx, question, convInfo, "") if err != nil { c.logger.Error("get QA failed", log.Error(err)) return } answer := "" seq := 1 for chunk := range answerCh { seq += 1 answer += chunk // 部分模型存在输出为空的情况导致飞书报错 if strings.TrimSpace(chunk) == "" { continue } // update card content streaming updateReq := larkcardkit.NewContentCardElementReqBuilder(). CardId(*resp.Data.CardId). ElementId(`markdown_1`). Body(larkcardkit.NewContentCardElementReqBodyBuilder(). Uuid(uuid.New().String()). Content(answer). Sequence(seq). Build()). Build() updateResp, err := c.client.Cardkit.V1.CardElement.Content(ctx, updateReq) if err != nil { c.logger.Error("failed to update card", log.Error(err)) return } if !updateResp.Success() { c.logger.Error("failed to update card", log.String("request_id", updateResp.RequestId()), log.Any("code_error", updateResp.CodeError)) return } } c.logger.Info("start processing QA", log.String("message_id", *res.Data.MessageId)) } type Message struct { Text string `json:"text"` } func (c *FeishuClient) Start() error { eventHandler := dispatcher.NewEventDispatcher("", ""). OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { // ignore duplicate message if *event.Event.Message.MessageId == "" { return nil } messageId := *event.Event.Message.MessageId if _, ok := c.msgMap.Load(messageId); ok { return nil } c.msgMap.Store(messageId, time.Now().Unix()) c.logger.Info("received message from feishu bot", log.String("message_id", messageId)) // only handle text type if *event.Event.Message.MessageType != "text" { return nil } switch *event.Event.Message.ChatType { case "group": var message Message if err := json.Unmarshal([]byte(*event.Event.Message.Content), &message); err != nil { c.logger.Error("failed to unmarshal message", log.Error(err)) return nil } c.sendQACard(ctx, "chat_id", *event.Event.Message.ChatId, message.Text, *event.Event.Sender.SenderId.OpenId) case "p2p": var message Message if err := json.Unmarshal([]byte(*event.Event.Message.Content), &message); err != nil { c.logger.Error("failed to unmarshal message", log.Error(err)) return nil } c.sendQACard(ctx, "open_id", *event.Event.Sender.SenderId.OpenId, message.Text, *event.Event.Message.ChatId) default: c.logger.Warn("unsupported chat type", log.String("chat_type", *event.Event.Message.ChatType)) } return nil }) cli := larkws.NewClient(c.clientID, c.clientSecret, larkws.WithEventHandler(eventHandler), larkws.WithLogger(&FeishuBotLogger{logger: c.logger}), ) // FIXME: goroutine leak in larkws.Start err := cli.Start(c.ctx) if err != nil { return fmt.Errorf("failed to start feishu client: %w", err) } return nil } // 下面功能都是需要开启飞书对应的权限才可以获取到用户信息 -- 应用权限(否则获取不到对话用户的信息) // 飞书机器人获取用户信息,只是适用于单个用户 func (c *FeishuClient) GetUserInfo(UserOpenId string) (*larkcontact.User, error) { // 获取用户信息,根据用户的id req := larkcontact.NewGetUserReqBuilder().UserId(UserOpenId). UserIdType(`open_id`).DepartmentIdType(`open_department_id`).Build() // 发起请求,获取用户消息 resp, err := c.client.Contact.User.Get(context.Background(), req) if err != nil { c.logger.Error("failed to get user info", log.Error(err)) return nil, err } // 失败 if !resp.Success() { c.logger.Error("failed to get user info, response status not success", log.Any("errcode:", resp.Code)) return nil, fmt.Errorf("failed to get user info, response data not success") } return resp.Data.User, nil } func (c *FeishuClient) Stop() { c.cancel() }