diff --git a/pkg/channels/wecom/aibot_ws.go b/pkg/channels/wecom/aibot_ws.go index e75ff9e50..069d36a4e 100644 --- a/pkg/channels/wecom/aibot_ws.go +++ b/pkg/channels/wecom/aibot_ws.go @@ -136,6 +136,15 @@ type wsSendMsgBody struct { ChatType uint32 `json:"chat_type,omitempty"` MsgType string `json:"msgtype"` Markdown *wsMarkdownContent `json:"markdown,omitempty"` + Image *wsMediaIDContent `json:"image,omitempty"` + Voice *wsMediaIDContent `json:"voice,omitempty"` + Video *wsMediaIDContent `json:"video,omitempty"` + File *wsMediaIDContent `json:"file,omitempty"` +} + +// wsMediaIDContent is used by aibot_send_msg for media types (image/voice/video/file). +type wsMediaIDContent struct { + MediaID string `json:"media_id"` } // wsRespondMsgBody is the body for aibot_respond_msg / aibot_respond_welcome_msg. @@ -359,6 +368,97 @@ func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) return nil } +// SendMedia implements channels.MediaSender. +// Each media part is uploaded via the WS long-connection upload API and then +// delivered as a proactive message push (aibot_send_msg) to the originating chat. +func (c *WeComAIBotWSChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + // msg.ChatID is the req_id set by dispatchWSAgentTask. + // Resolve the real WeCom chatID and chat_type from the req state. + // If no state is found (e.g. cron/scheduler push), treat msg.ChatID as the + // real chatID directly (chat_type defaults to 0 = single chat). + var realChatID string + var chatType uint32 + _, route, ok := c.getReqState(msg.ChatID) + if ok { + realChatID = route.ChatID + chatType = route.ChatType + } else { + realChatID = msg.ChatID + } + + if realChatID == "" { + return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue // skip this part, try others + } + + // Map bus part type to WeCom media type. + var wsMediaType string + switch part.Type { + case "image": + wsMediaType = "image" + case "audio": + wsMediaType = "voice" + case "video": + wsMediaType = "video" + default: + wsMediaType = "file" + } + + filename := part.Filename + if filename == "" { + filename = filepath.Base(localPath) + } + + mediaID, err := c.UploadWSMedia(ctx, localPath, filename, wsMediaType) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to upload media", map[string]any{ + "type": wsMediaType, + "path": localPath, + "error": err.Error(), + }) + // Fallback: send caption as text if upload fails. + if part.Caption != "" { + _ = c.wsSendActivePush(realChatID, chatType, part.Caption) + } + continue + } + + if err := c.wsSendActivePushMedia(realChatID, chatType, wsMediaType, mediaID); err != nil { + logger.ErrorCF("wecom_aibot", "Failed to send media message", map[string]any{ + "type": wsMediaType, + "media_id": mediaID, + "error": err.Error(), + }) + return fmt.Errorf("websocket media delivery failed: %w", channels.ErrSendFailed) + } + + logger.InfoCF("wecom_aibot", "Media sent via proactive push", map[string]any{ + "type": wsMediaType, + "filename": filename, + "chat_id": realChatID, + }) + } + return nil +} + // ---- Connection management ---- // wsBackoffResetDuration is the minimum duration a WebSocket connection must @@ -1046,6 +1146,36 @@ func (c *WeComAIBotWSChannel) wsSendActivePush(chatID string, chatType uint32, c return nil } +// wsSendActivePushMedia sends a single media asset (identified by media_id) as a +// proactive push message using aibot_send_msg. +// wsMediaType must be one of "image", "voice", "video", "file". +func (c *WeComAIBotWSChannel) wsSendActivePushMedia(chatID string, chatType uint32, wsMediaType, mediaID string) error { + if chatID == "" { + return fmt.Errorf("chatid is empty") + } + body := wsSendMsgBody{ + ChatID: chatID, + ChatType: chatType, + MsgType: wsMediaType, + } + mc := &wsMediaIDContent{MediaID: mediaID} + switch wsMediaType { + case "image": + body.Image = mc + case "voice": + body.Voice = mc + case "video": + body.Video = mc + default: // "file" + body.File = mc + } + return c.writeWSAndWait(wsCommand{ + Cmd: "aibot_send_msg", + Headers: wsHeaders{ReqID: wsGenerateID()}, + Body: body, + }, wsSendMsgTimeout) +} + // writeWSAndWait writes cmd to the active connection and validates the command response. // It is a thin wrapper around callWSCommand that discards the response body. func (c *WeComAIBotWSChannel) writeWSAndWait(cmd wsCommand, timeout time.Duration) error { diff --git a/pkg/channels/wecom/aibot_ws_upload.go b/pkg/channels/wecom/aibot_ws_upload.go index 8dab028cd..ec3b70786 100644 --- a/pkg/channels/wecom/aibot_ws_upload.go +++ b/pkg/channels/wecom/aibot_ws_upload.go @@ -60,7 +60,7 @@ type wsUploadFinishBody struct { type wsUploadFinishResponse struct { Type string `json:"type"` MediaID string `json:"media_id"` - CreatedAt string `json:"created_at"` + CreatedAt int64 `json:"created_at"` // Unix timestamp (seconds) } // ---- Public API ---- diff --git a/pkg/channels/wecom/aibot_ws_upload_test.go b/pkg/channels/wecom/aibot_ws_upload_test.go index 90f1961e0..9a5847a24 100644 --- a/pkg/channels/wecom/aibot_ws_upload_test.go +++ b/pkg/channels/wecom/aibot_ws_upload_test.go @@ -237,7 +237,7 @@ func TestWsUploadChunkBodyBase64(t *testing.T) { // TestWsUploadFinishResponseJSON checks that the finish response unmarshals // the fields WeCom returns. func TestWsUploadFinishResponseJSON(t *testing.T) { - raw := `{"type":"file","media_id":"MEDIAID_ABCDE","created_at":"1680000000"}` + raw := `{"type":"file","media_id":"MEDIAID_ABCDE","created_at":1680000000}` var resp wsUploadFinishResponse if err := json.Unmarshal([]byte(raw), &resp); err != nil { t.Fatalf("unmarshal: %v", err) @@ -248,8 +248,8 @@ func TestWsUploadFinishResponseJSON(t *testing.T) { if resp.Type != "file" { t.Errorf("type: got %q, want %q", resp.Type, "file") } - if resp.CreatedAt != "1680000000" { - t.Errorf("created_at: got %q, want %q", resp.CreatedAt, "1680000000") + if resp.CreatedAt != 1680000000 { + t.Errorf("created_at: got %d, want %d", resp.CreatedAt, int64(1680000000)) } }