diff --git a/API.md b/API.md index 26463826..8d094918 100644 --- a/API.md +++ b/API.md @@ -131,6 +131,42 @@ Response: --- +## Serve Local Media + +Serves media files stored locally when `media_delivery` is set to `local`. Requires the user's API token as a query parameter for authentication. The webhook payload includes a `mediaURL` with the token already appended. + +Endpoint: _/media/{userid}/{filename}_ + +Method: **GET** + +| Parameter | Type | Description | +|-----------|--------|-----------------------------------------------| +| userid | string | User directory identifier (e.g. `user_1`) | +| filename | string | Media filename (message ID + extension) | +| token | string | User API token (query parameter, required) | + +**curl example:** + +```bash +curl "http://localhost:8080/media/user_1/3EB0A1B2C3D4E5F6.jpg?token=1234ABCD" --output image.jpg +``` + +**Responses:** + +| Code | Description | +|------|------------------------| +| 200 | Media file content | +| 400 | Invalid filename/userid| +| 401 | Missing or invalid token| +| 404 | File not found | + +**Environment variables:** + +- `WUZAPI_BASE_URL`: Override the base URL used in webhook `mediaURL` fields (useful behind proxy/ngrok). Default: `http://{address}:{port}`. +- `WEBHOOK_TIMEOUT`: Webhook HTTP client timeout in seconds. Default: `30`. + +--- + ## Webhook The following _webhook_ endpoints are used to get or set the webhook that will be called whenever a message or event is received. Available event types are: @@ -864,6 +900,276 @@ curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{" --- +## List Chats + +Returns a list of chats from message history for the authenticated user, enriched with contact names. Results are ordered by most recent message. + +endpoint: _/chat/list_ + +method: **GET** + +**Query Parameters:** +- `limit` (optional): Maximum number of chats to return (default: 100) + +``` +curl -s -X GET -H 'Token: 1234ABCD' http://localhost:8080/chat/list?limit=50 +``` + +Response: + +```json +{ + "code": 200, + "data": [ + { + "chat_jid": "5491155553934@s.whatsapp.net", + "last_message_time": "2024-12-25T10:30:00Z", + "message_count": 42, + "name": "John Doe" + }, + { + "chat_jid": "120362023605733675@g.us", + "last_message_time": "2024-12-24T18:00:00Z", + "message_count": 150, + "name": "Super Group" + } + ], + "success": true +} +``` + +--- + +## Archive/Unarchive chat + +Archives or unarchives a chat in WhatsApp. + +endpoint: _/chat/archive_ + +method: **POST** + +| Param | Required | Description | +|---------|----------|-------------| +| jid | Yes | Chat JID to archive/unarchive | +| archive | Yes | `true` to archive, `false` to unarchive | + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"jid":"5491155553934@s.whatsapp.net","archive":true}' http://localhost:8080/chat/archive +``` + +Response: + +```json +{ + "code": 200, + "data": { + "success": true, + "message": "Chat archived" + }, + "success": true +} +``` + +--- + +## Mark chat as unread + +Marks a chat as unread in WhatsApp. + +endpoint: _/chat/markunread_ + +method: **POST** + +| Param | Required | Description | +|-------|----------|-------------| +| jid | Yes | Chat JID to mark as unread | + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"jid":"5491155553934@s.whatsapp.net"}' http://localhost:8080/chat/markunread +``` + +Response: + +```json +{ + "code": 200, + "data": { + "success": true, + "message": "Chat marked as unread" + }, + "success": true +} +``` + +--- + +## Pin/Unpin chat + +Pins or unpins a chat in WhatsApp. + +endpoint: _/chat/pin_ + +method: **POST** + +| Param | Required | Description | +|-------|----------|-------------| +| jid | Yes | Chat JID to pin/unpin | +| pin | Yes | `true` to pin, `false` to unpin | + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"jid":"5491155553934@s.whatsapp.net","pin":true}' http://localhost:8080/chat/pin +``` + +Response: + +```json +{ + "code": 200, + "data": { + "success": true, + "message": "Chat pinned" + }, + "success": true +} +``` + +--- + +## Labels + +The following _label_ endpoints allow you to manage WhatsApp labels (available on WhatsApp Business). + +## Create/Edit label + +Creates a new label or edits an existing one. + +endpoint: _/label/edit_ + +method: **POST** + +| Param | Required | Description | +|-------------|----------|-------------| +| label_id | Yes | Label identifier | +| label_name | No | Display name for the label | +| label_color | No | Color index for the label | +| deleted | No | Set to `true` to delete the label | + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"label_id":"1","label_name":"Important","label_color":0,"deleted":false}' http://localhost:8080/label/edit +``` + +Response: + +```json +{ + "code": 200, + "data": { + "success": true, + "message": "Label updated" + }, + "success": true +} +``` + +--- + +## Assign/Remove label from chat + +Assigns a label to a chat or removes it. + +endpoint: _/label/chat_ + +method: **POST** + +| Param | Required | Description | +|----------|----------|-------------| +| jid | Yes | Chat JID | +| label_id | Yes | Label identifier | +| labeled | Yes | `true` to assign, `false` to remove | + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"jid":"5491155553934@s.whatsapp.net","label_id":"1","labeled":true}' http://localhost:8080/label/chat +``` + +Response: + +```json +{ + "code": 200, + "data": { + "success": true, + "message": "Label assigned to chat" + }, + "success": true +} +``` + +--- + +## Assign/Remove label from message + +Assigns a label to a specific message or removes it. + +endpoint: _/label/message_ + +method: **POST** + +| Param | Required | Description | +|------------|----------|-------------| +| jid | Yes | Chat JID where the message is | +| label_id | Yes | Label identifier | +| message_id | Yes | Message identifier | +| labeled | Yes | `true` to assign, `false` to remove | + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"jid":"5491155553934@s.whatsapp.net","label_id":"1","message_id":"3EB06F9067F80BAB89FF","labeled":true}' http://localhost:8080/label/message +``` + +Response: + +```json +{ + "code": 200, + "data": { + "success": true, + "message": "Label assigned to message" + }, + "success": true +} +``` + +--- + +## Set Status Message + +Updates the current user's status text, which is shown in the "About" section in the user profile. + +endpoint: _/status/set/text_ + +method: **POST** + +| Param | Required | Description | +|-------|----------|-------------| +| Body | Yes | Status text to set | + +``` +curl -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"Body":"Hello World"}' http://localhost:8080/status/set/text +``` + +Response: + +```json +{ + "code": 200, + "data": { + "Details": "Set" + }, + "success": true +} +``` + +--- + ## React to messages Sends a reaction for an existing message. Id is the message Id to react to, if its your own message, prefix the Id with the string 'me:' @@ -1324,7 +1630,7 @@ Configure S3 storage settings for the authenticated user. - `secret_key`: S3 secret access key - `path_style`: Use path-style URLs (required for MinIO) - `public_url`: Custom public URL for accessing files (optional) -- `media_delivery`: Delivery method - "base64", "s3", or "both" +- `media_delivery`: Delivery method - "base64", "s3", "both", or "local" - `retention_days`: Days to retain files (0 for no expiration) ### Get S3 Configuration diff --git a/handlers.go b/handlers.go index 427c4ecf..9d26239b 100644 --- a/handlers.go +++ b/handlers.go @@ -1556,6 +1556,7 @@ func (s *server) SendVideo() http.HandlerFunc { var uploaded whatsmeow.UploadResponse var filedata []byte + var detectedMimeType string if t.Video[0:4] == "data" { var dataURL, err = dataurl.DecodeString(t.Video) @@ -1564,31 +1565,30 @@ func (s *server) SendVideo() http.HandlerFunc { return } else { filedata = dataURL.Data - + detectedMimeType = dataURL.MediaType.ContentType() } } else if isHTTPURL(t.Video) { data, ct, err := fetchURLBytes(r.Context(), t.Video, openGraphImageMaxBytes) if err != nil { - s.Respond(w, r, http.StatusBadRequest, errors.New(fmt.Sprintf("failed to fetch image from url: %v", err))) - return - } - mimeType := ct - if !strings.HasPrefix(strings.ToLower(mimeType), "video/") { - mimeType = "video/mpeg" - } - imgDataURL := dataurl.New(data, mimeType) - parsed, err := dataurl.DecodeString(imgDataURL.String()) - if err != nil { - s.Respond(w, r, http.StatusInternalServerError, errors.New("could not re-encode video to base64")) + s.Respond(w, r, http.StatusBadRequest, errors.New(fmt.Sprintf("failed to fetch video from url: %v", err))) return } - filedata = parsed.Data - + filedata = data + detectedMimeType = ct } else { s.Respond(w, r, http.StatusBadRequest, errors.New("data should start with \"data:mime/type;base64,\"")) return } + // Determine MIME type: explicit field > data URL / HTTP header > fallback to video/mp4 + videoMimeType := t.MimeType + if videoMimeType == "" { + videoMimeType = detectedMimeType + } + if videoMimeType == "" || !strings.HasPrefix(strings.ToLower(videoMimeType), "video/") { + videoMimeType = "video/mp4" + } + uploaded, err = clientManager.GetWhatsmeowClient(txtid).Upload(context.Background(), filedata, whatsmeow.MediaVideo) if err != nil { s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("failed to upload file: %v", err))) @@ -1596,16 +1596,11 @@ func (s *server) SendVideo() http.HandlerFunc { } msg := &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ - Caption: proto.String(t.Caption), - URL: proto.String(uploaded.URL), - DirectPath: proto.String(uploaded.DirectPath), - MediaKey: uploaded.MediaKey, - Mimetype: proto.String(func() string { - if t.MimeType != "" { - return t.MimeType - } - return http.DetectContentType(filedata) - }()), + Caption: proto.String(t.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(videoMimeType), FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(filedata))), @@ -5699,6 +5694,20 @@ func (s *server) Respond(w http.ResponseWriter, r *http.Request, status int, dat } } +// RespondData writes a success JSON response with the given data, avoiding double marshal +func (s *server) RespondData(w http.ResponseWriter, r *http.Request, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + envelope := map[string]interface{}{ + "code": status, + "success": true, + "data": data, + } + if err := json.NewEncoder(w).Encode(envelope); err != nil { + panic("respond: " + err.Error()) + } +} + // Validate message fields func validateMessageFields(phone string, stanzaid *string, participant *string) (types.JID, error) { @@ -5907,13 +5916,13 @@ func (s *server) ConfigureS3() http.HandlerFunc { } // Validate media_delivery - if t.MediaDelivery != "" && t.MediaDelivery != "base64" && t.MediaDelivery != "s3" && t.MediaDelivery != "both" { - s.Respond(w, r, http.StatusBadRequest, errors.New("media_delivery must be 'base64', 's3', or 'both'")) + if t.MediaDelivery != "" && t.MediaDelivery != "base64" && t.MediaDelivery != "s3" && t.MediaDelivery != "both" && t.MediaDelivery != "local" { + s.Respond(w, r, http.StatusBadRequest, errors.New("media_delivery must be 'base64', 's3', 'both', or 'local'")) return } if t.MediaDelivery == "" { - t.MediaDelivery = "base64" + t.MediaDelivery = "local" } // Update database @@ -6148,7 +6157,7 @@ func (s *server) DeleteS3Config() http.HandlerFunc { s3_secret_key = '', s3_path_style = true, s3_public_url = '', - media_delivery = 'base64', + media_delivery = 'local', s3_retention_days = 30 WHERE id = $1`, txtid) @@ -7070,3 +7079,432 @@ func (s *server) publishSentMessageEvent(token, userID, txtid string, recipient // Publish directly to RabbitMQ (bypassing subscription check for sent messages) go sendToGlobalRabbit(jsonData, token, userID) } + +// ListChats returns a list of chats from message_history for the authenticated user +func (s *server) ListChats() http.HandlerFunc { + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + client := clientManager.GetWhatsmeowClient(txtid) + + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + limit := 100 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { + limit = parsed + } + } + + var query string + if s.db.DriverName() == "postgres" { + query = ` + SELECT chat_jid, MAX(timestamp) as last_message_time, COUNT(*) as message_count + FROM message_history + WHERE user_id = $1 + GROUP BY chat_jid + ORDER BY last_message_time DESC + LIMIT $2` + } else { + query = ` + SELECT chat_jid, MAX(timestamp) as last_message_time, COUNT(*) as message_count + FROM message_history + WHERE user_id = ? + GROUP BY chat_jid + ORDER BY last_message_time DESC + LIMIT ?` + } + + type ChatEntry struct { + ChatJID string `json:"chat_jid" db:"chat_jid"` + LastMessageTime string `json:"last_message_time" db:"last_message_time"` + MessageCount int `json:"message_count" db:"message_count"` + Name string `json:"name,omitempty"` + } + + var chats []ChatEntry + err := s.db.Select(&chats, query, txtid, limit) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, fmt.Errorf("failed to get chats: %w", err)) + return + } + + // Enrich with contact names + contacts, cerr := client.Store.Contacts.GetAllContacts(context.Background()) + if cerr == nil { + for i, chat := range chats { + jid, perr := types.ParseJID(chat.ChatJID) + if perr == nil { + if contact, ok := contacts[jid]; ok { + if contact.FullName != "" { + chats[i].Name = contact.FullName + } else if contact.PushName != "" { + chats[i].Name = contact.PushName + } else if contact.BusinessName != "" { + chats[i].Name = contact.BusinessName + } + } + } + } + } + + // Format timestamps to RFC3339 + timeFormats := []string{ + time.RFC3339Nano, + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05-07:00", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + } + for i, chat := range chats { + parsed := false + for _, layout := range timeFormats { + if t, err := time.Parse(layout, chat.LastMessageTime); err == nil { + chats[i].LastMessageTime = t.Format(time.RFC3339) + parsed = true + break + } + } + if !parsed { + // Strip Go monotonic clock suffix if present + if idx := strings.Index(chat.LastMessageTime, " m="); idx != -1 { + chats[i].LastMessageTime = chat.LastMessageTime[:idx] + } + } + } + + s.RespondData(w, r, http.StatusOK, chats) + } +} + +// MarkUnread marks a chat as unread +func (s *server) MarkUnread() http.HandlerFunc { + + type requestMarkUnreadStruct struct { + Jid string `json:"jid"` + } + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + client := clientManager.GetWhatsmeowClient(txtid) + + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + decoder := json.NewDecoder(r.Body) + var t requestMarkUnreadStruct + err := decoder.Decode(&t) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if t.Jid == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing jid in Payload")) + return + } + + chatJID, err := types.ParseJID(t.Jid) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("invalid Chat JID format")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = client.SendAppState(ctx, appstate.BuildMarkChatAsRead(chatJID, false, time.Time{}, nil)) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("failed to mark chat as unread: %s", err))) + return + } + + s.RespondData(w, r, http.StatusOK, map[string]interface{}{ + "message": "Chat marked as unread", + }) + } +} + +// PinChat pins or unpins a chat +func (s *server) PinChat() http.HandlerFunc { + + type requestPinStruct struct { + Jid string `json:"jid"` + Pin bool `json:"pin"` + } + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + client := clientManager.GetWhatsmeowClient(txtid) + + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + decoder := json.NewDecoder(r.Body) + var t requestPinStruct + err := decoder.Decode(&t) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if t.Jid == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing jid in Payload")) + return + } + + chatJID, err := types.ParseJID(t.Jid) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("invalid Chat JID format")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = client.SendAppState(ctx, appstate.BuildPin(chatJID, t.Pin)) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("failed to pin chat: %s", err))) + return + } + + statusText := "Chat pinned" + if !t.Pin { + statusText = "Chat unpinned" + } + s.RespondData(w, r, http.StatusOK, map[string]interface{}{ + "message": statusText, + }) + } +} + +// LabelEdit creates, updates, or deletes a label +func (s *server) LabelEdit() http.HandlerFunc { + + type requestLabelEditStruct struct { + LabelID string `json:"label_id"` + LabelName string `json:"label_name"` + LabelColor int32 `json:"label_color"` + Deleted bool `json:"deleted"` + } + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + client := clientManager.GetWhatsmeowClient(txtid) + + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + decoder := json.NewDecoder(r.Body) + var t requestLabelEditStruct + err := decoder.Decode(&t) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if t.LabelID == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing label_id in Payload")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = client.SendAppState(ctx, appstate.BuildLabelEdit(t.LabelID, t.LabelName, t.LabelColor, t.Deleted)) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("failed to edit label: %s", err))) + return + } + + s.RespondData(w, r, http.StatusOK, map[string]interface{}{ + "message": "Label updated", + }) + } +} + +// LabelChat assigns or removes a label from a chat +func (s *server) LabelChat() http.HandlerFunc { + + type requestLabelChatStruct struct { + Jid string `json:"jid"` + LabelID string `json:"label_id"` + Labeled bool `json:"labeled"` + } + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + client := clientManager.GetWhatsmeowClient(txtid) + + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + decoder := json.NewDecoder(r.Body) + var t requestLabelChatStruct + err := decoder.Decode(&t) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if t.Jid == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing jid in Payload")) + return + } + if t.LabelID == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing label_id in Payload")) + return + } + + chatJID, err := types.ParseJID(t.Jid) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("invalid Chat JID format")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = client.SendAppState(ctx, appstate.BuildLabelChat(chatJID, t.LabelID, t.Labeled)) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("failed to label chat: %s", err))) + return + } + + statusText := "Label assigned to chat" + if !t.Labeled { + statusText = "Label removed from chat" + } + s.RespondData(w, r, http.StatusOK, map[string]interface{}{ + "message": statusText, + }) + } +} + +// LabelMessage assigns or removes a label from a specific message +func (s *server) LabelMessage() http.HandlerFunc { + + type requestLabelMessageStruct struct { + Jid string `json:"jid"` + LabelID string `json:"label_id"` + MessageID string `json:"message_id"` + Labeled bool `json:"labeled"` + } + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + client := clientManager.GetWhatsmeowClient(txtid) + + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + decoder := json.NewDecoder(r.Body) + var t requestLabelMessageStruct + err := decoder.Decode(&t) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if t.Jid == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing jid in Payload")) + return + } + if t.LabelID == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing label_id in Payload")) + return + } + if t.MessageID == "" { + s.Respond(w, r, http.StatusBadRequest, errors.New("missing message_id in Payload")) + return + } + + chatJID, err := types.ParseJID(t.Jid) + if err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("invalid Chat JID format")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = client.SendAppState(ctx, appstate.BuildLabelMessage(chatJID, t.LabelID, t.MessageID, t.Labeled)) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New(fmt.Sprintf("failed to label message: %s", err))) + return + } + + statusText := "Label assigned to message" + if !t.Labeled { + statusText = "Label removed from message" + } + s.RespondData(w, r, http.StatusOK, map[string]interface{}{ + "message": statusText, + }) + } +} + +// ServeMedia serves media files from the dedicated media directory +func (s *server) ServeMedia() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userid := vars["userid"] + filename := vars["filename"] + + // Validate against path traversal + if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + http.Error(w, "Invalid filename", http.StatusBadRequest) + return + } + if strings.Contains(userid, "..") || strings.Contains(userid, "/") || strings.Contains(userid, "\\") { + http.Error(w, "Invalid userid", http.StatusBadRequest) + return + } + + // Validate token from query parameter + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Verify that the token matches the user + var count int + userIDStr := strings.TrimPrefix(userid, "user_") + err := s.db.Get(&count, "SELECT COUNT(*) FROM users WHERE id=$1 AND token=$2", userIDStr, token) + if err != nil || count == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + mediaRoot := filepath.Join(s.exPath, "media") + filePath := filepath.Join(mediaRoot, userid, filename) + + // Verify the file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + http.ServeFile(w, r, filePath) + } +} diff --git a/handlers_new_test.go b/handlers_new_test.go new file mode 100644 index 00000000..2953abc0 --- /dev/null +++ b/handlers_new_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "testing" +) + +func TestChatListRouting(t *testing.T) { + s := makeTestServer(t) + + // Create a user first + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "ListUser", + "token": "list-token", + }).toJSON(t) + executeRequest(t, s, addRequest) + + // Try to list chats (will fail because no WhatsApp session, but tests routing) + listRequest := newRequest("2", "chat.list", map[string]interface{}{ + "token": "list-token", + }).toJSON(t) + listResponse := executeRequest(t, s, listRequest) + + expected := map[string]interface{}{ + "jsonrpc": "2.0", + "id": "2", + } + if diff := compareJSON(expected, listResponse); diff != "" { + t.Errorf("Response mismatch:\n%s", diff) + } + + // Either result or error should be present + if listResponse["result"] == nil && listResponse["error"] == nil { + t.Errorf("Expected either result or error field") + } +} + +func TestChatMarkUnreadRouting(t *testing.T) { + s := makeTestServer(t) + + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "UnreadUser", + "token": "unread-token", + }).toJSON(t) + executeRequest(t, s, addRequest) + + // Try to mark unread (no session, tests routing) + unreadRequest := newRequest("2", "chat.markunread", map[string]interface{}{ + "token": "unread-token", + "jid": "5511999@s.whatsapp.net", + }).toJSON(t) + unreadResponse := executeRequest(t, s, unreadRequest) + + assertJSONRPC20Error(t, unreadResponse, "2", 500) // "no session" +} + +func TestChatMarkUnreadMissingJid(t *testing.T) { + s := makeTestServer(t) + + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "UnreadUser2", + "token": "unread-token-2", + }).toJSON(t) + executeRequest(t, s, addRequest) + + // Missing jid should return 400 - but no session so 500 first + unreadRequest := newRequest("2", "chat.markunread", map[string]interface{}{ + "token": "unread-token-2", + }).toJSON(t) + unreadResponse := executeRequest(t, s, unreadRequest) + + // Should get error (either no session 500 or missing jid 400) + if unreadResponse["error"] == nil { + t.Errorf("Expected error response") + } +} + +func TestChatPinRouting(t *testing.T) { + s := makeTestServer(t) + + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "PinUser", + "token": "pin-token", + }).toJSON(t) + executeRequest(t, s, addRequest) + + pinRequest := newRequest("2", "chat.pin", map[string]interface{}{ + "token": "pin-token", + "jid": "5511999@s.whatsapp.net", + "pin": true, + }).toJSON(t) + pinResponse := executeRequest(t, s, pinRequest) + + assertJSONRPC20Error(t, pinResponse, "2", 500) // "no session" +} + +func TestLabelEditRouting(t *testing.T) { + s := makeTestServer(t) + + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "LabelUser", + "token": "label-token", + }).toJSON(t) + executeRequest(t, s, addRequest) + + labelRequest := newRequest("2", "label.edit", map[string]interface{}{ + "token": "label-token", + "label_id": "1", + "label_name": "Important", + "label_color": 0, + "deleted": false, + }).toJSON(t) + labelResponse := executeRequest(t, s, labelRequest) + + assertJSONRPC20Error(t, labelResponse, "2", 500) // "no session" +} + +func TestLabelEditMissingLabelId(t *testing.T) { + s := makeTestServer(t) + + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "LabelUser2", + "token": "label-token-2", + }).toJSON(t) + executeRequest(t, s, addRequest) + + // Missing label_id + labelRequest := newRequest("2", "label.edit", map[string]interface{}{ + "token": "label-token-2", + "label_name": "Important", + }).toJSON(t) + labelResponse := executeRequest(t, s, labelRequest) + + // Should get error (no session 500 comes before validation) + if labelResponse["error"] == nil { + t.Errorf("Expected error response") + } +} + +func TestLabelChatRouting(t *testing.T) { + s := makeTestServer(t) + + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "LabelChatUser", + "token": "labelchat-token", + }).toJSON(t) + executeRequest(t, s, addRequest) + + labelChatRequest := newRequest("2", "label.chat", map[string]interface{}{ + "token": "labelchat-token", + "jid": "5511999@s.whatsapp.net", + "label_id": "1", + "labeled": true, + }).toJSON(t) + labelChatResponse := executeRequest(t, s, labelChatRequest) + + assertJSONRPC20Error(t, labelChatResponse, "2", 500) // "no session" +} + +func TestLabelMessageRouting(t *testing.T) { + s := makeTestServer(t) + + addRequest := newRequest("1", "admin.users.add", map[string]interface{}{ + "adminToken": "test-admin-token", + "name": "LabelMsgUser", + "token": "labelmsg-token", + }).toJSON(t) + executeRequest(t, s, addRequest) + + labelMsgRequest := newRequest("2", "label.message", map[string]interface{}{ + "token": "labelmsg-token", + "jid": "5511999@s.whatsapp.net", + "label_id": "1", + "message_id": "ABC123", + "labeled": true, + }).toJSON(t) + labelMsgResponse := executeRequest(t, s, labelMsgRequest) + + assertJSONRPC20Error(t, labelMsgResponse, "2", 500) // "no session" +} + +func TestUnknownMethodReturns404(t *testing.T) { + s := makeTestServer(t) + + request := newRequest("1", "nonexistent.method", nil).toJSON(t) + response := executeRequest(t, s, request) + + assertJSONRPC20Error(t, response, "1", 404) +} diff --git a/helpers.go b/helpers.go index 83c4c628..9a2383b5 100644 --- a/helpers.go +++ b/helpers.go @@ -533,7 +533,7 @@ func ProcessOutgoingMedia(userID string, contactJID string, messageID string, da if err != nil { log.Error().Err(err).Msg("Failed to get S3 config") s3Config.Enabled = false - s3Config.MediaDelivery = "base64" + s3Config.MediaDelivery = "local" } // Process S3 upload if enabled diff --git a/migrations.go b/migrations.go index 46f2a3a0..3acac74b 100644 --- a/migrations.go +++ b/migrations.go @@ -159,7 +159,7 @@ BEGIN END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'media_delivery') THEN - ALTER TABLE users ADD COLUMN media_delivery TEXT DEFAULT 'base64'; + ALTER TABLE users ADD COLUMN media_delivery TEXT DEFAULT 'local'; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_retention_days') THEN @@ -392,7 +392,7 @@ func applyMigration(db *sqlx.DB, migration Migration) error { err = addColumnIfNotExistsSQLite(tx, "users", "s3_public_url", "TEXT DEFAULT ''") } if err == nil { - err = addColumnIfNotExistsSQLite(tx, "users", "media_delivery", "TEXT DEFAULT 'base64'") + err = addColumnIfNotExistsSQLite(tx, "users", "media_delivery", "TEXT DEFAULT 'local'") } if err == nil { err = addColumnIfNotExistsSQLite(tx, "users", "s3_retention_days", "INTEGER DEFAULT 30") diff --git a/routes.go b/routes.go index 19c96b26..e75c8f95 100644 --- a/routes.go +++ b/routes.go @@ -135,6 +135,9 @@ func (s *server) routes() { s.router.Handle("/chat/presence", c.Then(s.ChatPresence())).Methods("POST") s.router.Handle("/chat/markread", c.Then(s.MarkRead())).Methods("POST") + s.router.Handle("/chat/list", c.Then(s.ListChats())).Methods("GET") + s.router.Handle("/chat/markunread", c.Then(s.MarkUnread())).Methods("POST") + s.router.Handle("/chat/pin", c.Then(s.PinChat())).Methods("POST") s.router.Handle("/chat/downloadimage", c.Then(s.DownloadImage())).Methods("POST") s.router.Handle("/chat/downloadvideo", c.Then(s.DownloadVideo())).Methods("POST") s.router.Handle("/chat/downloadaudio", c.Then(s.DownloadAudio())).Methods("POST") @@ -157,7 +160,13 @@ func (s *server) routes() { s.router.Handle("/group/inviteinfo", c.Then(s.GetGroupInviteInfo())).Methods("POST") s.router.Handle("/group/updateparticipants", c.Then(s.UpdateGroupParticipants())).Methods("POST") + s.router.Handle("/label/edit", c.Then(s.LabelEdit())).Methods("POST") + s.router.Handle("/label/chat", c.Then(s.LabelChat())).Methods("POST") + s.router.Handle("/label/message", c.Then(s.LabelMessage())).Methods("POST") + s.router.Handle("/newsletter/list", c.Then(s.ListNewsletter())).Methods("GET") + s.router.Handle("/media/{userid}/{filename}", s.ServeMedia()).Methods("GET") + s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(exPath + "/static/"))) } diff --git a/static/api/spec.yml b/static/api/spec.yml index afd8eb17..81bf5bbc 100644 --- a/static/api/spec.yml +++ b/static/api/spec.yml @@ -87,6 +87,50 @@ paths: required: - status - timestamp + /media/{userid}/{filename}: + get: + tags: + - System + summary: Serve local media file + description: | + Serves media files stored locally when `media_delivery` is set to `local`. + The webhook payload includes a `mediaURL` with the authentication token already appended. + Requires the user's API token as a query parameter. + parameters: + - name: userid + in: path + required: true + schema: + type: string + description: "User directory identifier (e.g. user_1)" + example: "user_1" + - name: filename + in: path + required: true + schema: + type: string + description: "Media filename (message ID + extension)" + example: "3EB0A1B2C3D4E5F6.jpg" + - name: token + in: query + required: true + schema: + type: string + description: "User API token for authentication" + responses: + 200: + description: Media file content + content: + application/octet-stream: + schema: + type: string + format: binary + 400: + description: Invalid filename or userid + 401: + description: Missing or invalid token + 404: + description: File not found /admin/users: get: tags: @@ -841,7 +885,7 @@ paths: application/json: schema: example: { "code": 200, "data": { "5491122223333@s.whatsapp.net": { "BusinessName": "", "FirstName": "", "Found": true, "FullName": "", "PushName": "FOP2" }, "549113334444@s.whatsapp.net": { "BusinessName": "", "FirstName": "", "Found": true, "FullName": "", "PushName": "Asternic" } } } - /user/lid/{phone}: + /user/lid/{jid}: get: tags: - User @@ -850,7 +894,7 @@ paths: Returns the **JID** (WhatsApp ID) and **LID** (Linked Device ID) associated with a given phone number.
Use this endpoint to identify contacts within WhatsApp. parameters: - - name: phone + - name: jid in: path required: true description: Phone number in international format (without the + sign) @@ -925,6 +969,99 @@ paths: application/json: schema: example: { "code": 200, "data": { "Details": "Message(s) marked as read" }, "success": true } + /chat/list: + get: + tags: + - Chat + summary: List chats + description: Returns a list of chats from message history for the authenticated user, enriched with contact names. Results are ordered by most recent message. + security: + - ApiKeyAuth: [] + parameters: + - name: limit + in: query + description: Maximum number of chats to return + required: false + schema: + type: integer + default: 100 + responses: + 200: + description: Response + content: + application/json: + schema: + example: + code: 200 + data: + - chat_jid: "5491155553934@s.whatsapp.net" + last_message_time: "2024-12-25T10:30:00Z" + message_count: 42 + name: "John Doe" + success: true + /chat/archive: + post: + tags: + - Chat + summary: Archive or unarchive a chat + description: Archives or unarchives a chat in WhatsApp + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/ArchiveChat' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "success": true, "message": "Chat archived" }, "success": true } + /chat/markunread: + post: + tags: + - Chat + summary: Mark chat as unread + description: Marks a chat as unread in WhatsApp + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/MarkUnread' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "success": true, "message": "Chat marked as unread" }, "success": true } + /chat/pin: + post: + tags: + - Chat + summary: Pin or unpin a chat + description: Pins or unpins a chat in WhatsApp + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PinChat' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "success": true, "message": "Chat pinned" }, "success": true } /chat/react: post: tags: @@ -1986,6 +2123,70 @@ paths: schema: example: { "code": 200, "data": { "Details": "Participants updated successfully" }, "success": true } + /label/edit: + post: + tags: + - Label + summary: Create or edit a label + description: Creates a new label, updates an existing one, or deletes it (WhatsApp Business feature) + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/LabelEdit' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "success": true, "message": "Label updated" }, "success": true } + /label/chat: + post: + tags: + - Label + summary: Assign or remove a label from a chat + description: Assigns a label to a chat or removes it (WhatsApp Business feature) + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/LabelChat' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "success": true, "message": "Label assigned to chat" }, "success": true } + /label/message: + post: + tags: + - Label + summary: Assign or remove a label from a message + description: Assigns a label to a specific message or removes it (WhatsApp Business feature) + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/LabelMessage' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "success": true, "message": "Label assigned to message" }, "success": true } + definitions: ContextInfo: type: object @@ -3033,13 +3234,115 @@ definitions: media_delivery: type: string description: Media delivery method - enum: ["base64", "s3", "both"] + enum: ["base64", "s3", "both", "local"] example: "both" retention_days: type: integer description: Number of days to retain files (0 for no expiration) example: 30 + ArchiveChat: + type: object + required: + - jid + - archive + properties: + jid: + type: string + example: "5491155553934@s.whatsapp.net" + description: "Chat JID to archive/unarchive" + archive: + type: boolean + example: true + description: "true to archive, false to unarchive" + MarkUnread: + type: object + required: + - jid + properties: + jid: + type: string + example: "5491155553934@s.whatsapp.net" + description: "Chat JID to mark as unread" + PinChat: + type: object + required: + - jid + - pin + properties: + jid: + type: string + example: "5491155553934@s.whatsapp.net" + description: "Chat JID to pin/unpin" + pin: + type: boolean + example: true + description: "true to pin, false to unpin" + LabelEdit: + type: object + required: + - label_id + properties: + label_id: + type: string + example: "1" + description: "Label identifier" + label_name: + type: string + example: "Important" + description: "Display name for the label" + label_color: + type: integer + example: 0 + description: "Color index for the label" + deleted: + type: boolean + example: false + description: "Set to true to delete the label" + LabelChat: + type: object + required: + - jid + - label_id + - labeled + properties: + jid: + type: string + example: "5491155553934@s.whatsapp.net" + description: "Chat JID" + label_id: + type: string + example: "1" + description: "Label identifier" + labeled: + type: boolean + example: true + description: "true to assign label, false to remove" + LabelMessage: + type: object + required: + - jid + - label_id + - message_id + - labeled + properties: + jid: + type: string + example: "5491155553934@s.whatsapp.net" + description: "Chat JID where the message is" + label_id: + type: string + example: "1" + description: "Label identifier" + message_id: + type: string + example: "3EB06F9067F80BAB89FF" + description: "Message identifier" + labeled: + type: boolean + example: true + description: "true to assign label, false to remove" + components: securitySchemes: ApiKeyAuth: diff --git a/stdio.go b/stdio.go index 429c33a7..70d379d3 100644 --- a/stdio.go +++ b/stdio.go @@ -460,6 +460,28 @@ func (ss *stdioServer) routeRequest(req *jsonRpcRequest) { httpMethod = "DELETE" httpPath = "/webhook" + // Chat management + case "chat.list": + httpMethod = "GET" + httpPath = "/chat/list" + case "chat.markunread": + httpMethod = "POST" + httpPath = "/chat/markunread" + case "chat.pin": + httpMethod = "POST" + httpPath = "/chat/pin" + + // Labels + case "label.edit": + httpMethod = "POST" + httpPath = "/label/edit" + case "label.chat": + httpMethod = "POST" + httpPath = "/label/chat" + case "label.message": + httpMethod = "POST" + httpPath = "/label/message" + default: ss.sendError(req.ID, 404, fmt.Sprintf("unknown method: %s", req.Method)) return diff --git a/wmiau.go b/wmiau.go index 302c2dda..43c24992 100644 --- a/wmiau.go +++ b/wmiau.go @@ -442,7 +442,13 @@ func (s *server) startClient(userID string, textjid string, token string, subscr if *waDebug == "DEBUG" { httpClient.SetDebug(true) } - httpClient.SetTimeout(30 * time.Second) + webhookTimeout := 30 * time.Second + if t := os.Getenv("WEBHOOK_TIMEOUT"); t != "" { + if parsed, err := strconv.Atoi(t); err == nil && parsed > 0 { + webhookTimeout = time.Duration(parsed) * time.Second + } + } + httpClient.SetTimeout(webhookTimeout) httpClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) httpClient.OnError(func(req *resty.Request, err error) { if v, ok := err.(*resty.ResponseError); ok { @@ -834,7 +840,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { if err != nil { log.Error().Err(err).Msg("onMessage Failed to get S3 config from DB as it was not on cache") s3Config.Enabled = "false" - s3Config.MediaDelivery = "base64" + s3Config.MediaDelivery = "local" } } else { s3Config.Enabled = myuserinfo.(Values).Get("S3Enabled") @@ -869,7 +875,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { img := evt.Message.GetImageMessage() if img != nil { // Create a temporary directory in /tmp - tmpDirectory := filepath.Join("/tmp", "user_"+txtid) + tmpDirectory := filepath.Join(mycli.s.exPath, "media", "user_"+txtid) errDir := os.MkdirAll(tmpDirectory, 0751) if errDir != nil { log.Error().Err(errDir).Msg("Could not create temporary directory") @@ -936,15 +942,28 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["fileName"] = filepath.Base(tmpPath) } + // Serve media via local URL if configured + if s3Config.MediaDelivery == "local" { + baseURL := os.Getenv("WUZAPI_BASE_URL") + if baseURL == "" { + baseURL = "http://" + *address + ":" + *port + } + postmap["mediaURL"] = baseURL + "/media/user_" + txtid + "/" + filepath.Base(tmpPath) + "?token=" + url.QueryEscape(mycli.token) + postmap["mimeType"] = img.GetMimetype() + postmap["fileName"] = filepath.Base(tmpPath) + } + // Log the successful conversion log.Info().Str("path", tmpPath).Msg("Image processed") - // Delete the temporary file - err = os.Remove(tmpPath) - if err != nil { - log.Error().Err(err).Msg("Failed to delete temporary file") - } else { - log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + // Delete the temporary file (skip if local delivery to keep serving) + if s3Config.MediaDelivery != "local" { + err = os.Remove(tmpPath) + if err != nil { + log.Error().Err(err).Msg("Failed to delete temporary file") + } else { + log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + } } } @@ -952,7 +971,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { audio := evt.Message.GetAudioMessage() if audio != nil { // Create a temporary directory in /tmp - tmpDirectory := filepath.Join("/tmp", "user_"+txtid) + tmpDirectory := filepath.Join(mycli.s.exPath, "media", "user_"+txtid) errDir := os.MkdirAll(tmpDirectory, 0751) if errDir != nil { log.Error().Err(errDir).Msg("Could not create temporary directory") @@ -1025,15 +1044,28 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["fileName"] = filepath.Base(tmpPath) } + // Serve media via local URL if configured + if s3Config.MediaDelivery == "local" { + baseURL := os.Getenv("WUZAPI_BASE_URL") + if baseURL == "" { + baseURL = "http://" + *address + ":" + *port + } + postmap["mediaURL"] = baseURL + "/media/user_" + txtid + "/" + filepath.Base(tmpPath) + "?token=" + url.QueryEscape(mycli.token) + postmap["mimeType"] = audio.GetMimetype() + postmap["fileName"] = filepath.Base(tmpPath) + } + // Log the successful conversion log.Info().Str("path", tmpPath).Msg("Audio processed") - // Delete the temporary file - err = os.Remove(tmpPath) - if err != nil { - log.Error().Err(err).Msg("Failed to delete temporary file") - } else { - log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + // Delete the temporary file (skip if local delivery to keep serving) + if s3Config.MediaDelivery != "local" { + err = os.Remove(tmpPath) + if err != nil { + log.Error().Err(err).Msg("Failed to delete temporary file") + } else { + log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + } } } @@ -1041,7 +1073,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { document := evt.Message.GetDocumentMessage() if document != nil { // Create a temporary directory in /tmp - tmpDirectory := filepath.Join("/tmp", "user_"+txtid) + tmpDirectory := filepath.Join(mycli.s.exPath, "media", "user_"+txtid) errDir := os.MkdirAll(tmpDirectory, 0751) if errDir != nil { log.Error().Err(errDir).Msg("Could not create temporary directory") @@ -1119,15 +1151,28 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["fileName"] = filepath.Base(tmpPath) } + // Serve media via local URL if configured + if s3Config.MediaDelivery == "local" { + baseURL := os.Getenv("WUZAPI_BASE_URL") + if baseURL == "" { + baseURL = "http://" + *address + ":" + *port + } + postmap["mediaURL"] = baseURL + "/media/user_" + txtid + "/" + filepath.Base(tmpPath) + "?token=" + url.QueryEscape(mycli.token) + postmap["mimeType"] = document.GetMimetype() + postmap["fileName"] = filepath.Base(tmpPath) + } + // Log the successful conversion log.Info().Str("path", tmpPath).Msg("Document processed") - // Delete the temporary file - err = os.Remove(tmpPath) - if err != nil { - log.Error().Err(err).Msg("Failed to delete temporary file") - } else { - log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + // Delete the temporary file (skip if local delivery to keep serving) + if s3Config.MediaDelivery != "local" { + err = os.Remove(tmpPath) + if err != nil { + log.Error().Err(err).Msg("Failed to delete temporary file") + } else { + log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + } } } @@ -1135,7 +1180,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { video := evt.Message.GetVideoMessage() if video != nil { // Create a temporary directory in /tmp - tmpDirectory := filepath.Join("/tmp", "user_"+txtid) + tmpDirectory := filepath.Join(mycli.s.exPath, "media", "user_"+txtid) errDir := os.MkdirAll(tmpDirectory, 0751) if errDir != nil { log.Error().Err(errDir).Msg("Could not create temporary directory") @@ -1202,21 +1247,34 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["fileName"] = filepath.Base(tmpPath) } + // Serve media via local URL if configured + if s3Config.MediaDelivery == "local" { + baseURL := os.Getenv("WUZAPI_BASE_URL") + if baseURL == "" { + baseURL = "http://" + *address + ":" + *port + } + postmap["mediaURL"] = baseURL + "/media/user_" + txtid + "/" + filepath.Base(tmpPath) + "?token=" + url.QueryEscape(mycli.token) + postmap["mimeType"] = video.GetMimetype() + postmap["fileName"] = filepath.Base(tmpPath) + } + // Log the successful conversion log.Info().Str("path", tmpPath).Msg("Video processed") - // Delete the temporary file - err = os.Remove(tmpPath) - if err != nil { - log.Error().Err(err).Msg("Failed to delete temporary file") - } else { - log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + // Delete the temporary file (skip if local delivery to keep serving) + if s3Config.MediaDelivery != "local" { + err = os.Remove(tmpPath) + if err != nil { + log.Error().Err(err).Msg("Failed to delete temporary file") + } else { + log.Info().Str("path", tmpPath).Msg("Temporary file deleted") + } } } sticker := evt.Message.GetStickerMessage() if sticker != nil { - tmpDirectory := filepath.Join("/tmp", "user_"+txtid) + tmpDirectory := filepath.Join(mycli.s.exPath, "media", "user_"+txtid) errDir := os.MkdirAll(tmpDirectory, 0751) if errDir != nil { log.Error().Err(errDir).Msg("Could not create temporary directory") @@ -1280,12 +1338,26 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["fileName"] = filepath.Base(tmpPath) } + // Serve media via local URL if configured + if s3Config.MediaDelivery == "local" { + baseURL := os.Getenv("WUZAPI_BASE_URL") + if baseURL == "" { + baseURL = "http://" + *address + ":" + *port + } + postmap["mediaURL"] = baseURL + "/media/user_" + txtid + "/" + filepath.Base(tmpPath) + "?token=" + url.QueryEscape(mycli.token) + postmap["mimeType"] = sticker.GetMimetype() + postmap["fileName"] = filepath.Base(tmpPath) + } + // useful metadata (optional, but handy) postmap["isSticker"] = true postmap["stickerAnimated"] = sticker.GetIsAnimated() - if err := os.Remove(tmpPath); err != nil { - log.Error().Err(err).Msg("Failed to delete temporary file") + // Delete the temporary file (skip if local delivery to keep serving) + if s3Config.MediaDelivery != "local" { + if err := os.Remove(tmpPath); err != nil { + log.Error().Err(err).Msg("Failed to delete temporary file") + } } }