card.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. package dingtalk
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "time"
  7. "unicode/utf8"
  8. dingtalkcard_1_0 "github.com/alibabacloud-go/dingtalk/card_1_0"
  9. util "github.com/alibabacloud-go/tea-utils/v2/service"
  10. "github.com/alibabacloud-go/tea/tea"
  11. "github.com/google/uuid"
  12. )
  13. // 会话类型常量(来自钉钉 stream sdk 回调中的 conversationType)
  14. const (
  15. ConvTypeSingle = "1" // 单聊
  16. ConvTypeGroup = "2" // 群聊
  17. )
  18. // CardContentKey 模板里绑定的流式 markdown 变量名,需与卡片模板保持一致
  19. const CardContentKey = "content"
  20. // streamingMarkdownMaxContentBytes 钉钉 StreamingUpdate 单次 content 建议不超过约 1K(见官方文档)
  21. const streamingMarkdownMaxContentBytes = 1000
  22. // CardService 卡片服务(基于钉钉官方 card_1.0 SDK)
  23. type CardService struct {
  24. dtClient *Client
  25. }
  26. func NewCardService(dtClient *Client) *CardService {
  27. return &CardService{dtClient: dtClient}
  28. }
  29. // DeliverParams 投放卡片所需参数(不同会话类型走不同分支)
  30. type DeliverParams struct {
  31. OutTrackId string // 外部卡片实例ID(业务侧每条消息生成一个新的)
  32. CardTemplateId string // 模板ID
  33. RobotCode string // 机器人 robotCode(一般等同于 ClientID/AppKey)
  34. UserID string // 接收人 staffId(单聊必填)
  35. ConversationID string // 钉钉会话ID(群聊必填)
  36. ConversationType string // "1"=单聊 "2"=群聊
  37. InitTitle string // 卡片初始标题,可选
  38. InitContent string // 卡片初始内容,可选
  39. }
  40. // CreateAndDeliverCard 创建并投放 AI 卡片
  41. func (c *CardService) CreateAndDeliverCard(p *DeliverParams) error {
  42. if p == nil {
  43. return fmt.Errorf("DeliverParams 不能为空")
  44. }
  45. if p.OutTrackId == "" || p.CardTemplateId == "" {
  46. return fmt.Errorf("OutTrackId/CardTemplateId 不能为空")
  47. }
  48. token, err := c.dtClient.GetAccessToken()
  49. if err != nil {
  50. return fmt.Errorf("获取钉钉 access_token 失败: %w", err)
  51. }
  52. headers := &dingtalkcard_1_0.CreateAndDeliverHeaders{
  53. XAcsDingtalkAccessToken: tea.String(token),
  54. }
  55. cardParamMap := map[string]*string{
  56. "title": tea.String(orDefault(p.InitTitle, "AI助理回复中")),
  57. CardContentKey: tea.String(orDefault(p.InitContent, "正在思考中...")),
  58. }
  59. cardData := &dingtalkcard_1_0.CreateAndDeliverRequestCardData{
  60. CardParamMap: cardParamMap,
  61. }
  62. req := &dingtalkcard_1_0.CreateAndDeliverRequest{
  63. OutTrackId: tea.String(p.OutTrackId),
  64. CardTemplateId: tea.String(p.CardTemplateId),
  65. CardData: cardData,
  66. CallbackType: tea.String("STREAM"),
  67. UserIdType: tea.Int32(1), // 1: staffId
  68. }
  69. switch p.ConversationType {
  70. case ConvTypeGroup:
  71. if p.ConversationID == "" {
  72. return fmt.Errorf("群聊投放缺少 ConversationID")
  73. }
  74. req.SetOpenSpaceId(fmt.Sprintf("dtv1.card//IM_GROUP.%s", p.ConversationID))
  75. req.SetImGroupOpenDeliverModel(
  76. (&dingtalkcard_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{}).
  77. SetRobotCode(p.RobotCode),
  78. )
  79. req.SetImGroupOpenSpaceModel(
  80. (&dingtalkcard_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{}).
  81. SetSupportForward(tea.BoolValue(tea.Bool(true))),
  82. )
  83. default:
  84. if p.UserID == "" {
  85. return fmt.Errorf("单聊投放缺少 UserID")
  86. }
  87. req.SetUserId(p.UserID)
  88. req.SetOpenSpaceId(fmt.Sprintf("dtv1.card//IM_ROBOT.%s", p.UserID))
  89. req.SetImRobotOpenDeliverModel(
  90. (&dingtalkcard_1_0.CreateAndDeliverRequestImRobotOpenDeliverModel{}).
  91. SetRobotCode(p.RobotCode).
  92. SetSpaceType("IM_ROBOT"),
  93. )
  94. req.SetImRobotOpenSpaceModel(
  95. (&dingtalkcard_1_0.CreateAndDeliverRequestImRobotOpenSpaceModel{}).
  96. SetSupportForward(tea.BoolValue(tea.Bool(true))),
  97. )
  98. }
  99. resp, err := c.dtClient.cardClient.CreateAndDeliverWithOptions(req, headers, &util.RuntimeOptions{})
  100. if err != nil {
  101. if isAuthError(err) {
  102. c.dtClient.InvalidateAccessToken(context.Background())
  103. token2, err2 := c.dtClient.GetAccessToken()
  104. if err2 != nil {
  105. return fmt.Errorf("刷新 access_token 失败: %w", err2)
  106. }
  107. headers.XAcsDingtalkAccessToken = tea.String(token2)
  108. resp, err = c.dtClient.cardClient.CreateAndDeliverWithOptions(req, headers, &util.RuntimeOptions{})
  109. if err != nil {
  110. return fmt.Errorf("CreateAndDeliver 失败(重试): %w", err)
  111. }
  112. } else {
  113. return fmt.Errorf("CreateAndDeliver 失败: %w", err)
  114. }
  115. }
  116. if resp == nil || resp.Body == nil || !tea.BoolValue(resp.Body.Success) {
  117. return fmt.Errorf("CreateAndDeliver 返回失败: %+v", resp)
  118. }
  119. return nil
  120. }
  121. // StreamingUpdate 流式更新卡片内容
  122. func (c *CardService) StreamingUpdate(outTrackId, key, content string, isFinalize, isError bool) error {
  123. if outTrackId == "" || key == "" {
  124. return fmt.Errorf("outTrackId/key 不能为空")
  125. }
  126. contentToSend := truncateUTF8ToMaxBytes(content, streamingMarkdownMaxContentBytes)
  127. if isFinalize && !isError && len(contentToSend) != len(content) {
  128. suffix := "\n\n> *(钉钉单次展示约 1KB 限制,此处为截断;完整内容见 RAGFlow 会话)*"
  129. contentToSend = truncateUTF8ToMaxBytes(contentToSend+suffix, streamingMarkdownMaxContentBytes)
  130. }
  131. var lastErr error
  132. for attempt := 0; attempt < 2; attempt++ {
  133. token, err := c.dtClient.GetAccessToken()
  134. if err != nil {
  135. lastErr = fmt.Errorf("获取钉钉 access_token 失败: %w", err)
  136. time.Sleep(200 * time.Millisecond)
  137. continue
  138. }
  139. headers := &dingtalkcard_1_0.StreamingUpdateHeaders{
  140. XAcsDingtalkAccessToken: tea.String(token),
  141. }
  142. req := &dingtalkcard_1_0.StreamingUpdateRequest{
  143. OutTrackId: tea.String(outTrackId),
  144. Guid: tea.String(uuid.NewString()),
  145. Key: tea.String(key),
  146. Content: tea.String(contentToSend),
  147. IsFull: tea.Bool(true),
  148. IsFinalize: tea.Bool(isFinalize),
  149. IsError: tea.Bool(isError),
  150. }
  151. if _, err := c.dtClient.cardClient.StreamingUpdateWithOptions(req, headers, &util.RuntimeOptions{}); err != nil {
  152. lastErr = err
  153. if isAuthError(err) {
  154. c.dtClient.InvalidateAccessToken(context.Background())
  155. continue
  156. }
  157. time.Sleep(300 * time.Millisecond)
  158. continue
  159. }
  160. return nil
  161. }
  162. return fmt.Errorf("StreamingUpdate 失败: %w", lastErr)
  163. }
  164. func isAuthError(err error) bool {
  165. if err == nil {
  166. return false
  167. }
  168. msg := err.Error()
  169. return strings.Contains(msg, "40014") ||
  170. strings.Contains(msg, "42001") ||
  171. strings.Contains(msg, "InvalidAuthentication") ||
  172. strings.Contains(msg, "AccessTokenExpired") ||
  173. strings.Contains(msg, "invalid access_token")
  174. }
  175. func orDefault(s, def string) string {
  176. if s == "" {
  177. return def
  178. }
  179. return s
  180. }
  181. func truncateUTF8ToMaxBytes(s string, maxBytes int) string {
  182. if maxBytes <= 0 {
  183. return ""
  184. }
  185. b := []byte(s)
  186. if len(b) <= maxBytes {
  187. return s
  188. }
  189. b = b[:maxBytes]
  190. for len(b) > 0 && !utf8.RuneStart(b[len(b)-1]) {
  191. b = b[:len(b)-1]
  192. }
  193. return string(b)
  194. }