From dd5e347436ba796f9bd84ec54e653fbdada1c60f Mon Sep 17 00:00:00 2001 From: iamatulsingh Date: Sat, 21 Feb 2026 21:12:14 +0100 Subject: [PATCH] feat: whatsapp-mcp-go --- LICENSE | 22 + README.md | 263 ++ screenshots/chat_example.png | Bin 0 -> 65313 bytes screenshots/connector_claude.png | Bin 0 -> 67520 bytes whatsapp-bridge/.dockerignore | 7 + whatsapp-bridge/.gitignore | 1 + whatsapp-bridge/.idea/.gitignore | 10 + whatsapp-bridge/.idea/dataSources.xml | 25 + whatsapp-bridge/.idea/data_source_mapping.xml | 6 + whatsapp-bridge/.idea/db-forest-config.xml | 6 + whatsapp-bridge/.idea/go.imports.xml | 11 + whatsapp-bridge/.idea/misc.xml | 6 + whatsapp-bridge/.idea/modules.xml | 8 + whatsapp-bridge/.idea/sqldialects.xml | 6 + whatsapp-bridge/.idea/vcs.xml | 6 + whatsapp-bridge/.idea/whatsapp-bridge.iml | 10 + whatsapp-bridge/Dockerfile | 30 + whatsapp-bridge/Makefile | 24 + whatsapp-bridge/docker-compose.yaml | 30 + whatsapp-bridge/go.mod | 33 + whatsapp-bridge/go.sum | 116 + whatsapp-bridge/main.go | 2491 +++++++++++++++++ whatsapp-mcp-server/.idea/.gitignore | 10 + whatsapp-mcp-server/.idea/go.imports.xml | 11 + whatsapp-mcp-server/.idea/misc.xml | 7 + whatsapp-mcp-server/.idea/modules.xml | 8 + whatsapp-mcp-server/.idea/vcs.xml | 6 + .../.idea/whatsapp-mcp-server-golang.iml | 9 + whatsapp-mcp-server/go.mod | 17 + whatsapp-mcp-server/go.sum | 22 + whatsapp-mcp-server/helpers/audio.go | 92 + whatsapp-mcp-server/helpers/mcp_tool.go | 554 ++++ whatsapp-mcp-server/helpers/tools.go | 51 + whatsapp-mcp-server/helpers/whatsapp.go | 254 ++ whatsapp-mcp-server/main.go | 7 + 35 files changed, 4159 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 screenshots/chat_example.png create mode 100644 screenshots/connector_claude.png create mode 100644 whatsapp-bridge/.dockerignore create mode 100644 whatsapp-bridge/.gitignore create mode 100644 whatsapp-bridge/.idea/.gitignore create mode 100644 whatsapp-bridge/.idea/dataSources.xml create mode 100644 whatsapp-bridge/.idea/data_source_mapping.xml create mode 100644 whatsapp-bridge/.idea/db-forest-config.xml create mode 100644 whatsapp-bridge/.idea/go.imports.xml create mode 100644 whatsapp-bridge/.idea/misc.xml create mode 100644 whatsapp-bridge/.idea/modules.xml create mode 100644 whatsapp-bridge/.idea/sqldialects.xml create mode 100644 whatsapp-bridge/.idea/vcs.xml create mode 100644 whatsapp-bridge/.idea/whatsapp-bridge.iml create mode 100644 whatsapp-bridge/Dockerfile create mode 100644 whatsapp-bridge/Makefile create mode 100644 whatsapp-bridge/docker-compose.yaml create mode 100644 whatsapp-bridge/go.mod create mode 100644 whatsapp-bridge/go.sum create mode 100644 whatsapp-bridge/main.go create mode 100644 whatsapp-mcp-server/.idea/.gitignore create mode 100644 whatsapp-mcp-server/.idea/go.imports.xml create mode 100644 whatsapp-mcp-server/.idea/misc.xml create mode 100644 whatsapp-mcp-server/.idea/modules.xml create mode 100644 whatsapp-mcp-server/.idea/vcs.xml create mode 100644 whatsapp-mcp-server/.idea/whatsapp-mcp-server-golang.iml create mode 100644 whatsapp-mcp-server/go.mod create mode 100644 whatsapp-mcp-server/go.sum create mode 100644 whatsapp-mcp-server/helpers/audio.go create mode 100644 whatsapp-mcp-server/helpers/mcp_tool.go create mode 100644 whatsapp-mcp-server/helpers/tools.go create mode 100644 whatsapp-mcp-server/helpers/whatsapp.go create mode 100644 whatsapp-mcp-server/main.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..579e9b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Luke Harries +Copyright (c) 2026 Atul Singh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba3219f --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +# WhatsApp‑MCP (Go Edition) + +A lightweight, WhatsApp MCP server and bridge — rewritten entirely in Go for simplicity, portability, and real‑world automation workflows. + +This project is a re‑imagining of the original **[whatsapp-mcp](https://github.com/lharries/whatsapp-mcp)** by **lharries**, who created the first WhatsApp MCP bridge using a Python MCP server and a Go WhatsApp client powered by **WhatsMeow**. Their work demonstrated how Claude Desktop could interact with WhatsApp through the MCP protocol, and this project would not exist without that foundation. + +Start `whatsapp-bridge` -> then run `whatsapp-mcp-server` in your preferred mode (STDIO or SSE). + +| Connector | Chat | +|------------|----------| +| ![Connector](./screenshots/connector_claude.png) | ![Chat](./screenshots/chat_example.png) | + +## Installation + +### Prerequisites + +- Go +- Anthropic Claude Desktop app (or Cursor) or `n8n` workflow +- FFmpeg (_optional_) - Only needed for audio messages. If you want to send audio files as playable WhatsApp voice messages, they must be in `.ogg` Opus format. With FFmpeg installed, the MCP server will automatically convert non-Opus audio files. Without FFmpeg, you can still send raw audio files using the `send_file` tool. + +### Steps +1. **Clone this repository** + + ```bash + git clone https://github.com/iamatulsingh/whatsapp-mcp.git + cd whatsapp-mcp + ``` + +2. **Run the WhatsApp bridge** + Navigate to the whatsapp-bridge directory and run the Go application: + + ```bash + cd whatsapp-bridge + go run main.go + ``` + + The first time you run it, you will be prompted to scan a QR code. Scan the QR code with your WhatsApp mobile app to authenticate. + After approximately 20 days, you will might need to re-authenticate. + +3. **Connect to the MCP server** + ```bash + cd whatsapp-mcp-server + go build -o whatsapp-mcp + ``` + Copy the below json with the appropriate {{PATH}} values: + ```json + { + "mcpServers": { + "whatsapp-mcp": { + "command": "{{PROJECT_BASE_PATH}}/whatsapp-mcp-server/whatsapp-mcp" + } + }, + "preferences": { + "sidebarMode": "chat", + "coworkScheduledTasksEnabled": false + } + } + ``` + + For **Claude**, save this as `claude_desktop_config.json` in your Claude Desktop configuration directory at: + + ``` + ~/Library/Application Support/Claude/claude_desktop_config.json + ``` + + For **Cursor**, save this as `mcp.json` in your Cursor configuration directory at: + + ``` + ~/.cursor/mcp.json + ``` + +### Windows Compatibility + +If you're running this project on Windows, be aware that `go-sqlite3` requires **CGO to be enabled** in order to compile and work properly. By default, **CGO is disabled on Windows**, so you need to explicitly enable it and have a C compiler installed. + +#### Steps to get it working: + +1. **Install a C compiler** + We recommend using [MSYS2](https://www.msys2.org/) to install a C compiler for Windows. After installing MSYS2, make sure to add the `ucrt64\bin` folder to your `PATH`. + → A step-by-step guide is available [here](https://code.visualstudio.com/docs/cpp/config-mingw). + +2. **Enable CGO and run the app** + + ```bash + cd whatsapp-bridge + go env -w CGO_ENABLED=1 + go run main.go + ``` + +OR + +Simply use dokcer compose to do all the job for you +```bash +docker compose up +``` + + +## Architecture Overview + +This application consists of two main components: + +1. **WhatsApp Bridge** (`whatsapp-bridge/`): A Go application that connects to WhatsApp's web API, handles authentication via QR code, and stores message history in SQLite. It serves as the bridge between WhatsApp and the MCP server. + +2. **MCP Server** (`whatsapp-mcp-server/`): A Go implemention of the Model Context Protocol (MCP), which provides standardized tools for Claude to interact with WhatsApp data and send/receive messages. + +### Data Storage + +- All message history is stored in `postgres` by default and you'll need to create a database name `whatsapp` OR a SQLite database within the `whatsapp-bridge/store/` directory +- The database maintains tables for chats and messages +- Messages are indexed for efficient searching and retrieval + +### MCP Tools + +Claude can access the following tools to interact with WhatsApp: + +- **search_contacts**: Search for contacts by name or phone number +- **list_messages**: Retrieve messages with optional filters and context +- **list_chats**: List available chats with metadata +- **get_chat**: Get information about a specific chat +- **get_direct_chat_by_contact**: Find a direct chat with a specific contact +- **get_contact_chats**: List all chats involving a specific contact +- **get_last_interaction**: Get the most recent message with a contact +- **get_message_context**: Retrieve context around a specific message +- **send_message**: Send a WhatsApp message to a specified phone number or group JID +- **send_file**: Send a file (image, video, raw audio, document) to a specified recipient +- **send_audio_message**: Send an audio file as a WhatsApp voice message (requires the file to be an .ogg opus file or ffmpeg must be installed) +- **download_media**: Download media from a WhatsApp message and get the local file path + +### Media Handling Features + +The MCP server supports both sending and receiving various media types: + +#### Media Sending + +You can send various media types to your WhatsApp contacts: + +- **Images, Videos, Documents**: Use the `send_file` tool to share any supported media type. +- **Voice Messages**: Use the `send_audio_message` tool to send audio files as playable WhatsApp voice messages. + - For optimal compatibility, audio files should be in `.ogg` Opus format. + - With FFmpeg installed, the system will automatically convert other audio formats (MP3, WAV, etc.) to the required format. + - Without FFmpeg, you can still send raw audio files using the `send_file` tool, but they won't appear as playable voice messages. + +#### Media Downloading + +By default, just the metadata of the media is stored in the local database. The message will indicate that media was sent. To access this media you need to use the download_media tool which takes the `message_id` and `chat_jid` (which are shown when printing messages containing the meda), this downloads the media and then returns the file path which can be then opened or passed to another tool. + + +## Technical Details + +1. Claude sends requests to the MCP server +2. The MCP server queries the Go bridge for WhatsApp data or directly to the SQLite database +3. The Bridge accesses the WhatsApp API and keeps the SQLite or Postgres database up to date +4. Data flows back through the chain to Claude +5. When sending messages, the request flows from Claude through the MCP server to the bridge and to WhatsApp + +## Troubleshooting + +- Make sure both the Bridge application and the MCP server are running for the integration to work properly. +- In case of the Client outdated (405) error in the Bridge Go server, + ```bash + 06:13:28.434 [Client INFO] Starting WhatsApp client... + 2025/07/29 06:13:28 Connecting to postgres + 06:13:30.402 [Client ERROR] Client outdated (405) connect failure (client version: 2.3000.1021018791) + 06:13:30.403 [Client/Socket ERROR] Error reading from websocket: websocket: close 1006 (abnormal closure): unexpecte + 06:13:30.675 [Client ERROR] Failed to establish stable connection + ``` + + Run below command and fix any error in your code after upgrade and the bridge code will automatic update the whatsapp client version. + + ```bash + go get -u go.mau.fi/whatsmeow@latest + ``` + + OR Update all packages + + ```bash + go get -u + ``` + +### Authentication Issues + +- **QR Code Not Displaying**: If the QR code doesn't appear, try restarting the authentication script. If issues persist, check if your terminal supports displaying QR codes. +- **WhatsApp Already Logged In**: If your session is already active, the Go bridge will automatically reconnect without showing a QR code. +- **Device Limit Reached**: WhatsApp limits the number of linked devices. If you reach this limit, you'll need to remove an existing device from WhatsApp on your phone (Settings > Linked Devices). +- **No Messages Loading**: After initial authentication, it can take several minutes for your message history to load, especially if you have many chats. +- **WhatsApp Out of Sync**: If your WhatsApp messages get out of sync with the bridge, delete both database files (`whatsapp-bridge/store/messages.db` and `whatsapp-bridge/store/whatsapp.db`) and restart the bridge to re-authenticate. + +This fork takes the core idea and rebuilds it with a different philosophy: +**one language, one binary, clean architecture, and flexible deployment.** + +--- + +## Why This Project Exists + +The original project is excellent, but it was designed specifically for **Claude Desktop**, and its architecture had a few constraints that made extending or deploying it more difficult. This fork was created to solve those pain points. + +* ***Single Language: Everything in Go** +The original design split responsibilities: This dramatically simplifies development and distribution. + +* **Clean Architecture: No Mixed Database Logic** +Moving all database logic into the bridge + +* **SQLite or PostgreSQL — Your Choice** +The original project only supported SQLite. + +* **Two Communication Modes: STDIO + SSE** The original project was built only for Claude Desktop (STDIO MCP). This makes the project usable far beyond Claude Desktop. + +* **Docker Support** This makes deployment trivial on: +- servers +- containers +- orchestrators +- local development + +--- + +## Features + +- Full WhatsApp client using **WhatsMeow** +- Pure Go MCP server +- Clean API boundary between MCP and bridge +- SQLite or PostgreSQL support +- STDIO + SSE modes +- Lightweight Docker image +- Easy deployment with Docker Compose +- No Python dependencies +- No mixed database logic +- Production‑ready architecture + +--- + +## Credits + +This project stands on the shoulders of two major contributors: + +### **Original Author: lharries** +The initial idea, architecture, and implementation of WhatsApp‑MCP came from +**https://github.com/lharries/whatsapp-mcp** + +Their work demonstrated how MCP could be used to bridge WhatsApp and Claude Desktop. +This fork would not exist without their project. + +### **WhatsMeow** +The WhatsApp client functionality is powered by the incredible Go library: +**https://github.com/tulir/whatsmeow** + +Without WhatsMeow, none of this would be possible. + +--- + +## Why You Might Use This Fork + +Choose this version if you want: + +- a cleaner architecture +- a single‑language codebase +- a portable binary +- Docker support +- PostgreSQL support +- SSE support for automation workflows +- a more maintainable and extensible project + +The goal is not to replace the original project but to offer an alternative that fits different needs — especially for developers who prefer Go or want to deploy MCP‑based WhatsApp automation in production environments. + diff --git a/screenshots/chat_example.png b/screenshots/chat_example.png new file mode 100644 index 0000000000000000000000000000000000000000..3bb9ff1a51624a70b56210f3e9aab0cc8ef451fd GIT binary patch literal 65313 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 literal 0 HcmV?d00001 diff --git a/screenshots/connector_claude.png b/screenshots/connector_claude.png new file mode 100644 index 0000000000000000000000000000000000000000..32cf1865e24ee528131d664e69dddb4976a7b010 GIT binary patch literal 67520 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 LXfXNf6F>fMNom60 literal 0 HcmV?d00001 diff --git a/whatsapp-bridge/.dockerignore b/whatsapp-bridge/.dockerignore new file mode 100644 index 0000000..e2180ba --- /dev/null +++ b/whatsapp-bridge/.dockerignore @@ -0,0 +1,7 @@ +.idea +.vscode +manifests +Dockerfile +README.md +Makefile +store \ No newline at end of file diff --git a/whatsapp-bridge/.gitignore b/whatsapp-bridge/.gitignore new file mode 100644 index 0000000..3454702 --- /dev/null +++ b/whatsapp-bridge/.gitignore @@ -0,0 +1 @@ +media diff --git a/whatsapp-bridge/.idea/.gitignore b/whatsapp-bridge/.idea/.gitignore new file mode 100644 index 0000000..7bc07ec --- /dev/null +++ b/whatsapp-bridge/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Environment-dependent path to Maven home directory +/mavenHomeManager.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/whatsapp-bridge/.idea/dataSources.xml b/whatsapp-bridge/.idea/dataSources.xml new file mode 100644 index 0000000..27cabb3 --- /dev/null +++ b/whatsapp-bridge/.idea/dataSources.xml @@ -0,0 +1,25 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/store/whatsapp.db + + + + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/store/messages.db + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/data_source_mapping.xml b/whatsapp-bridge/.idea/data_source_mapping.xml new file mode 100644 index 0000000..1a66341 --- /dev/null +++ b/whatsapp-bridge/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/db-forest-config.xml b/whatsapp-bridge/.idea/db-forest-config.xml new file mode 100644 index 0000000..a4a030b --- /dev/null +++ b/whatsapp-bridge/.idea/db-forest-config.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/go.imports.xml b/whatsapp-bridge/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/whatsapp-bridge/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/misc.xml b/whatsapp-bridge/.idea/misc.xml new file mode 100644 index 0000000..f03c948 --- /dev/null +++ b/whatsapp-bridge/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/modules.xml b/whatsapp-bridge/.idea/modules.xml new file mode 100644 index 0000000..2dc8bd7 --- /dev/null +++ b/whatsapp-bridge/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/sqldialects.xml b/whatsapp-bridge/.idea/sqldialects.xml new file mode 100644 index 0000000..0928188 --- /dev/null +++ b/whatsapp-bridge/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/vcs.xml b/whatsapp-bridge/.idea/vcs.xml new file mode 100644 index 0000000..4fce1d8 --- /dev/null +++ b/whatsapp-bridge/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/.idea/whatsapp-bridge.iml b/whatsapp-bridge/.idea/whatsapp-bridge.iml new file mode 100644 index 0000000..25ed3f6 --- /dev/null +++ b/whatsapp-bridge/.idea/whatsapp-bridge.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/whatsapp-bridge/Dockerfile b/whatsapp-bridge/Dockerfile new file mode 100644 index 0000000..1821fbb --- /dev/null +++ b/whatsapp-bridge/Dockerfile @@ -0,0 +1,30 @@ +# Build the manager binary +FROM golang:1.25.7 AS builder + +ARG TARGETARCH +ARG TARGETOS + +WORKDIR /project + +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum +# cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +RUN go mod download + +# Copy the go source +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GO111MODULE=on go build -a -o whatsapp_backup main.go + +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /project +COPY --from=builder /project/whatsapp_backup . + +USER 65532:65532 + +ENTRYPOINT ["/project/whatsapp_backup"] \ No newline at end of file diff --git a/whatsapp-bridge/Makefile b/whatsapp-bridge/Makefile new file mode 100644 index 0000000..2ce2366 --- /dev/null +++ b/whatsapp-bridge/Makefile @@ -0,0 +1,24 @@ +VERSION ?= 0.0.1 + +all: install build + +install: + go get ./... + +build: + go build + +run: + go run main.go + +tidy: + go mod tidy + +test: + go test ./... + +image: + docker buildx build --platform linux/amd64,linux/arm64 -t venomdarklord/wa:v$(VERSION) --push . + +deployment: + kubectl apply -k ./manifests diff --git a/whatsapp-bridge/docker-compose.yaml b/whatsapp-bridge/docker-compose.yaml new file mode 100644 index 0000000..d3954d8 --- /dev/null +++ b/whatsapp-bridge/docker-compose.yaml @@ -0,0 +1,30 @@ +version: "3.9" + +services: + wa: + build: + context: . + dockerfile: Dockerfile + container_name: wa + restart: always + environment: + IS_POSTGRES: "true" + POSTGRES_USER: "test" + POSTGRES_PASS: "test" + POSTGRES_HOST: "192.168.1.100" + POSTGRES_PORT: "5432" + ports: + - "8080:8080" + volumes: + - media:/project/store + deploy: + resources: + limits: + cpus: "1" + memory: "128M" + reservations: + cpus: "0.5" + memory: "64M" + +volumes: + media: diff --git a/whatsapp-bridge/go.mod b/whatsapp-bridge/go.mod new file mode 100644 index 0000000..6b76d41 --- /dev/null +++ b/whatsapp-bridge/go.mod @@ -0,0 +1,33 @@ +module whatsapp-bridge + +go 1.25.0 + +require ( + github.com/lib/pq v1.11.2 + github.com/mattn/go-sqlite3 v1.14.34 + github.com/mdp/qrterminal v1.0.1 + go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 + google.golang.org/protobuf v1.36.11 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/vektah/gqlparser/v2 v2.5.32 // indirect + go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/util v0.9.6 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/whatsapp-bridge/go.sum b/whatsapp-bridge/go.sum new file mode 100644 index 0000000..00d904c --- /dev/null +++ b/whatsapp-bridge/go.sum @@ -0,0 +1,116 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= +github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= +github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= +github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c= +github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ= +github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= +github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0= +github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k= +github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= +github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +go.mau.fi/libsignal v0.2.0 h1:oRXj3OHhEJq51BFEM8/50UZblmWiTYH93hsNTPcbk90= +go.mau.fi/libsignal v0.2.0/go.mod h1:tvjoDsMejgT38CXTXwqaYu8itBiY8O2Mb6biWvZBb9k= +go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= +go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= +go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c= +go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c= +go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso= +go.mau.fi/util v0.9.4/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/whatsmeow v0.0.0-20250723174453-937d77661333 h1:wZnqbIembr69yyUeWWEjxNxeofhYg3PDEx1BRRvHdWM= +go.mau.fi/whatsmeow v0.0.0-20250723174453-937d77661333/go.mod h1:ltDTXUgOAT7LcFKp11H+5S7UY7+xHBMGzNJcv3dLHGk= +go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32 h1:NeE9eEYY4kEJVCfCXaAU27LgAPugPHRHJdC9IpXFPzI= +go.mau.fi/whatsmeow v0.0.0-20251217143725-11cf47c62d32/go.mod h1:S4OWR9+hTx+54+jRzl+NfRBXnGpPm5IRPyhXB7haSd0= +go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= +go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= +golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go new file mode 100644 index 0000000..276b0f0 --- /dev/null +++ b/whatsapp-bridge/main.go @@ -0,0 +1,2491 @@ +package main + +import ( + "context" + "database/sql" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "math" + "math/rand" + "net/http" + "os" + "os/signal" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "go.mau.fi/whatsmeow/proto/waCompanionReg" + "go.mau.fi/whatsmeow/socket" + + _ "github.com/lib/pq" + _ "github.com/mattn/go-sqlite3" + "github.com/mdp/qrterminal" + + "bytes" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" +) + +type MessageInteraction struct { + Timestamp time.Time `json:"timestamp"` + Sender string `json:"sender"` + Content string `json:"content"` + IsFromMe bool `json:"is_from_me"` + ChatJID string `json:"chat_jid"` + ID string `json:"id"` + ChatName string `json:"chat_name,omitempty"` + MediaType string `json:"media_type,omitempty"` +} + +type Chat struct { + JID string `json:"jid"` + Name string `json:"name,omitempty"` + LastMessageTime time.Time `json:"last_message_time,omitempty"` + LastMessage string `json:"last_message,omitempty"` + LastSender string `json:"last_sender,omitempty"` + LastIsFromMe bool `json:"last_is_from_me,omitempty"` +} + +func (c *Chat) IsGroup() bool { + return strings.HasSuffix(c.JID, "@g.us") +} + +type Contact struct { + PhoneNumber string `json:"phone_number"` + Name string `json:"name,omitempty"` + JID string `json:"jid"` +} + +type MessageContext struct { + Message MessageInteraction `json:"message"` + Before []MessageInteraction `json:"before"` + After []MessageInteraction `json:"after"` +} + +type ListMessagesParams struct { + After, Before string + SenderPhoneNumber *string + ChatJid *string + Query *string + Limit, Page int + IncludeContext bool + ContextBefore int + ContextAfter int +} + +type Message struct { + Time time.Time + Sender string + Content string + IsFromMe bool + MediaType string + Filename string +} + +type MessageStore struct { + db *sql.DB +} + +type dbConfig struct { + User string + Pass string + Host string + Port string + IsPostgres bool +} + +var isPostgres = false + +func getEnv() (*dbConfig, error) { + user, ok := os.LookupEnv("POSTGRES_USER") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_USER") + } + pass, ok := os.LookupEnv("POSTGRES_PASS") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_PASS") + } + host, ok := os.LookupEnv("POSTGRES_HOST") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_HOST") + } + port, ok := os.LookupEnv("POSTGRES_PORT") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_PORT") + } + IsPostgres, ok := os.LookupEnv("IS_POSTGRES") + if !ok { + IsPostgres = "false" + } + + isPostgres = IsPostgres == "true" + + return &dbConfig{ + User: user, + Pass: pass, + Host: host, + Port: port, + IsPostgres: isPostgres, + }, nil +} + +func openDatabase(dbName string) (*sql.DB, error) { + if val, ok := os.LookupEnv("IS_POSTGRES"); ok && strings.ToLower(val) == "true" { + config, err := getEnv() + if err != nil { + return nil, fmt.Errorf("missing environment variable") + } + + connStr := fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", config.User, config.Pass, config.Host, config.Port, dbName) + log.Println("Connecting to postgres") + return sql.Open("postgres", connStr) + } + + // Fallback to SQLite + log.Println("Connecting to sqlite3") + return sql.Open("sqlite3", "file:store/messages.db?_foreign_keys=on") +} + +// NewMessageStore Initialize message store +func NewMessageStore() (*MessageStore, error) { + if err := os.MkdirAll("store", 0755); err != nil { + return nil, fmt.Errorf("failed to create store directory: %v", err) + } + + db, err := openDatabase("whatsapp") + if err != nil { + return nil, fmt.Errorf("failed to open message database: %v", err) + } + + val, ok := os.LookupEnv("IS_POSTGRES") + + var blobType string + if ok && strings.ToLower(val) == "true" { + blobType = "BYTEA" + } else { + blobType = "BLOB" + } + + _, err = db.Exec(fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS chats ( + jid TEXT PRIMARY KEY, + name TEXT, + last_message_time TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT, + chat_jid TEXT, + sender TEXT, + content TEXT, + timestamp TIMESTAMP, + is_from_me BOOLEAN, + media_type TEXT, + filename TEXT, + url TEXT, + media_key %s, + file_sha256 %s, + file_enc_sha256 %s, + file_length INTEGER, + PRIMARY KEY (id, chat_jid), + FOREIGN KEY (chat_jid) REFERENCES chats(jid) + ); + `, blobType, blobType, blobType)) + if err != nil { + db.Close() + return nil, fmt.Errorf("failed to create tables: %v", err) + } + + return &MessageStore{db: db}, nil +} + +// Close the database connection +func (store *MessageStore) Close() error { + return store.db.Close() +} + +// StoreChat Store a chat in the database +func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error { + if isPostgres { + _, err := store.db.Exec( + `INSERT INTO chats (jid, name, last_message_time) + VALUES ($1, $2, $3) + ON CONFLICT (jid) DO UPDATE SET + name = EXCLUDED.name, + last_message_time = EXCLUDED.last_message_time`, + jid, name, lastMessageTime, + ) + return err + } + _, err := store.db.Exec( + "INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)", + jid, name, lastMessageTime, + ) + return err +} + +// StoreMessage Store a message in the database +func (store *MessageStore) StoreMessage(id, chatJID, sender, content string, timestamp time.Time, isFromMe bool, + mediaType, filename, url string, mediaKey, fileSHA256, fileEncSHA256 []byte, fileLength uint64) error { + if content == "" && mediaType == "" { + return nil + } + + if !isPostgres { + _, err := store.db.Exec( + `INSERT OR REPLACE INTO messages + (id, chat_jid, sender, content, timestamp, is_from_me, media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, chatJID, sender, content, timestamp, isFromMe, mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, + ) + return err + } + _, err := store.db.Exec( + `INSERT INTO messages + (id, chat_jid, sender, content, timestamp, is_from_me, media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT(id, chat_jid) DO UPDATE SET + chat_jid = EXCLUDED.chat_jid, + sender = EXCLUDED.sender, + content = EXCLUDED.content, + timestamp = EXCLUDED.timestamp, + is_from_me = EXCLUDED.is_from_me, + media_type = EXCLUDED.media_type, + filename = EXCLUDED.filename, + url = EXCLUDED.url, + media_key = EXCLUDED.media_key, + file_sha256 = EXCLUDED.file_sha256, + file_enc_sha256 = EXCLUDED.file_enc_sha256, + file_length = EXCLUDED.file_length`, + id, chatJID, sender, content, timestamp, isFromMe, mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, + ) + + return err +} + +// GetMessages Get messages from a chat +func (store *MessageStore) GetMessages(chatJID string, limit int) ([]Message, error) { + var rows *sql.Rows + var err error + + if isPostgres { + rows, err = store.db.Query( + `SELECT sender, content, timestamp, is_from_me, media_type, filename + FROM messages + WHERE chat_jid = $1 + ORDER BY timestamp DESC + LIMIT $2`, + chatJID, limit, + ) + } else { + rows, err = store.db.Query( + "SELECT sender, content, timestamp, is_from_me, media_type, filename FROM messages WHERE chat_jid = ? ORDER BY timestamp DESC LIMIT ?", + chatJID, limit, + ) + } + + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []Message + for rows.Next() { + var msg Message + var timestamp time.Time + err := rows.Scan(&msg.Sender, &msg.Content, ×tamp, &msg.IsFromMe, &msg.MediaType, &msg.Filename) + if err != nil { + return nil, err + } + msg.Time = timestamp + messages = append(messages, msg) + } + + return messages, nil +} + +// GetChats Get all chats +func (store *MessageStore) GetChats() (map[string]time.Time, error) { + rows, err := store.db.Query("SELECT jid, last_message_time FROM chats ORDER BY last_message_time DESC") + if err != nil { + return nil, err + } + defer rows.Close() + + chats := make(map[string]time.Time) + for rows.Next() { + var jid string + var lastMessageTime time.Time + err := rows.Scan(&jid, &lastMessageTime) + if err != nil { + return nil, err + } + chats[jid] = lastMessageTime + } + + return chats, nil +} + +// Extract text content from a message +func extractTextContent(msg *waE2E.Message) string { + if msg == nil { + return "" + } + + // Try to get text content + if text := msg.GetConversation(); text != "" { + return text + } else if extendedText := msg.GetExtendedTextMessage(); extendedText != nil { + return extendedText.GetText() + } + + return "" +} + +// SendMessageResponse represents the response for the send message API +type SendMessageResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// SendMessageRequest represents the request body for the send message API +type SendMessageRequest struct { + Recipient string `json:"recipient"` + Message string `json:"message"` + MediaPath string `json:"media_path,omitempty"` +} + +var clientVersionRegex = regexp.MustCompile(`"client_revision":(\d+),`) + +func CustomGetLatestVersion(ctx context.Context, httpClient *http.Client) (*store.WAVersionContainer, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, socket.Origin, nil) + if err != nil { + return nil, fmt.Errorf("failed to prepare request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-User", "?1") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + if httpClient == nil { + httpClient = http.DefaultClient + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + data, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } else if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected response with status %d: %s", resp.StatusCode, data) + } else if match := clientVersionRegex.FindSubmatch(data); len(match) == 0 { + return nil, fmt.Errorf("version number not found") + } else if parsedVer, err := strconv.ParseInt(string(match[1]), 10, 64); err != nil { + return nil, fmt.Errorf("failed to parse version number: %w", err) + } else { + return &store.WAVersionContainer{2, 3000, uint32(parsedVer)}, nil + } +} + +// Function to send a WhatsApp message +func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message string, mediaPath string) (bool, string) { + if !client.IsConnected() { + return false, "Not connected to WhatsApp" + } + + var recipientJID types.JID + var err error + + isJID := strings.Contains(recipient, "@") + + if isJID { + recipientJID, err = types.ParseJID(recipient) + if err != nil { + return false, fmt.Sprintf("Error parsing JID: %v", err) + } + } else { + recipientJID = types.JID{ + User: recipient, + Server: "s.whatsapp.net", // For personal chats + } + } + + msg := &waE2E.Message{} + + if mediaPath != "" { + mediaData, err := os.ReadFile(mediaPath) + if err != nil { + return false, fmt.Sprintf("Error reading media file: %v", err) + } + + fileExt := strings.ToLower(mediaPath[strings.LastIndex(mediaPath, ".")+1:]) + var mediaType whatsmeow.MediaType + var mimeType string + + switch fileExt { + case "jpg", "jpeg": + mediaType = whatsmeow.MediaImage + mimeType = "image/jpeg" + case "png": + mediaType = whatsmeow.MediaImage + mimeType = "image/png" + case "gif": + mediaType = whatsmeow.MediaImage + mimeType = "image/gif" + case "webp": + mediaType = whatsmeow.MediaImage + mimeType = "image/webp" + + case "ogg": + mediaType = whatsmeow.MediaAudio + mimeType = "audio/ogg; codecs=opus" + + case "mp4": + mediaType = whatsmeow.MediaVideo + mimeType = "video/mp4" + case "avi": + mediaType = whatsmeow.MediaVideo + mimeType = "video/avi" + case "mov": + mediaType = whatsmeow.MediaVideo + mimeType = "video/quicktime" + + default: + mediaType = whatsmeow.MediaDocument + mimeType = "application/octet-stream" + } + + resp, err := client.Upload(context.Background(), mediaData, mediaType) + if err != nil { + return false, fmt.Sprintf("Error uploading media: %v", err) + } + + fmt.Println("Media uploaded", resp) + + switch mediaType { + case whatsmeow.MediaImage: + msg.ImageMessage = &waE2E.ImageMessage{ + Caption: proto.String(message), + Mimetype: proto.String(mimeType), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + } + case whatsmeow.MediaAudio: + var seconds uint32 = 30 + var waveform []byte = nil + + if strings.Contains(mimeType, "ogg") { + analyzedSeconds, analyzedWaveform, err := analyzeOggOpus(mediaData) + if err == nil { + seconds = analyzedSeconds + waveform = analyzedWaveform + } else { + return false, fmt.Sprintf("Failed to analyze Ogg Opus file: %v", err) + } + } else { + fmt.Printf("Not an Ogg Opus file: %s\n", mimeType) + } + + msg.AudioMessage = &waE2E.AudioMessage{ + Mimetype: proto.String(mimeType), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + Seconds: proto.Uint32(seconds), + PTT: proto.Bool(true), + Waveform: waveform, + } + case whatsmeow.MediaVideo: + msg.VideoMessage = &waE2E.VideoMessage{ + Caption: proto.String(message), + Mimetype: proto.String(mimeType), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + } + case whatsmeow.MediaDocument: + msg.DocumentMessage = &waE2E.DocumentMessage{ + Title: proto.String(mediaPath[strings.LastIndex(mediaPath, "/")+1:]), + Caption: proto.String(message), + Mimetype: proto.String(mimeType), + URL: &resp.URL, + DirectPath: &resp.DirectPath, + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: &resp.FileLength, + } + } + } else { + msg.Conversation = proto.String(message) + } + + _, err = client.SendMessage(context.Background(), recipientJID, msg) + + if err != nil { + return false, fmt.Sprintf("Error sending message: %v", err) + } + + return true, fmt.Sprintf("Message sent to %s", recipient) +} + +// Extract media info from a message +func extractMediaInfo(msg *waE2E.Message) (mediaType string, filename string, url string, mediaKey []byte, fileSHA256 []byte, fileEncSHA256 []byte, fileLength uint64) { + if msg == nil { + return "", "", "", nil, nil, nil, 0 + } + + if img := msg.GetImageMessage(); img != nil { + return "image", "image_" + time.Now().Format("20060102_150405") + ".jpg", + img.GetURL(), img.GetMediaKey(), img.GetFileSHA256(), img.GetFileEncSHA256(), img.GetFileLength() + } + + if vid := msg.GetVideoMessage(); vid != nil { + return "video", "video_" + time.Now().Format("20060102_150405") + ".mp4", + vid.GetURL(), vid.GetMediaKey(), vid.GetFileSHA256(), vid.GetFileEncSHA256(), vid.GetFileLength() + } + + if aud := msg.GetAudioMessage(); aud != nil { + return "audio", "audio_" + time.Now().Format("20060102_150405") + ".ogg", + aud.GetURL(), aud.GetMediaKey(), aud.GetFileSHA256(), aud.GetFileEncSHA256(), aud.GetFileLength() + } + + if doc := msg.GetDocumentMessage(); doc != nil { + filename := doc.GetFileName() + if filename == "" { + filename = "document_" + time.Now().Format("20060102_150405") + } + return "document", filename, + doc.GetURL(), doc.GetMediaKey(), doc.GetFileSHA256(), doc.GetFileEncSHA256(), doc.GetFileLength() + } + + return "", "", "", nil, nil, nil, 0 +} + +// Handle regular incoming messages with media support +func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *events.Message, logger waLog.Logger) { + chatJID := msg.Info.Chat.String() + sender := msg.Info.Sender.User + + name := GetChatName(client, messageStore, msg.Info.Chat, chatJID, nil, sender, logger) + + err := messageStore.StoreChat(chatJID, name, msg.Info.Timestamp) + if err != nil { + logger.Warnf("Failed to store chat: %v", err) + } + + content := extractTextContent(msg.Message) + + mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength := extractMediaInfo(msg.Message) + + if content == "" && mediaType == "" { + return + } + + err = messageStore.StoreMessage( + msg.Info.ID, + chatJID, + sender, + content, + msg.Info.Timestamp, + msg.Info.IsFromMe, + mediaType, + filename, + url, + mediaKey, + fileSHA256, + fileEncSHA256, + fileLength, + ) + + if err != nil { + logger.Warnf("Failed to store message: %v", err) + } else { + timestamp := msg.Info.Timestamp.Format("2006-01-02 15:04:05") + direction := "←" + if msg.Info.IsFromMe { + direction = "→" + } + + if mediaType != "" { + fmt.Printf("[%s] %s %s: [%s: %s] %s\n", timestamp, direction, sender, mediaType, filename, content) + } else if content != "" { + fmt.Printf("[%s] %s %s: %s\n", timestamp, direction, sender, content) + } + } +} + +// DownloadMediaRequest represents the request body for the download media API +type DownloadMediaRequest struct { + MessageID string `json:"message_id"` + ChatJID string `json:"chat_jid"` +} + +// DownloadMediaResponse represents the response for the download media API +type DownloadMediaResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Filename string `json:"filename,omitempty"` + Path string `json:"path,omitempty"` +} + +// StoreMediaInfo Store additional media info in the database +func (store *MessageStore) StoreMediaInfo(id, chatJID, url string, mediaKey, fileSHA256, fileEncSHA256 []byte, fileLength uint64) error { + if isPostgres { + _, err := store.db.Exec( + `UPDATE messages + SET url = $1, + media_key = $2, + file_sha256 = $3, + file_enc_sha256 = $4, + file_length = $5 + WHERE id = $6 AND chat_jid = $7`, + url, mediaKey, fileSHA256, fileEncSHA256, fileLength, id, chatJID, + ) + return err + } + + _, err := store.db.Exec( + "UPDATE messages SET url = ?, media_key = ?, file_sha256 = ?, file_enc_sha256 = ?, file_length = ? WHERE id = ? AND chat_jid = ?", + url, mediaKey, fileSHA256, fileEncSHA256, fileLength, id, chatJID, + ) + return err +} + +// GetMediaInfo Get media info from the database +func (store *MessageStore) GetMediaInfo(id, chatJID string) (string, string, string, []byte, []byte, []byte, uint64, error) { + var mediaType, filename, url string + var mediaKey, fileSHA256, fileEncSHA256 []byte + var fileLength uint64 + var err error + + if isPostgres { + err = store.db.QueryRow( + `SELECT media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length + FROM messages + WHERE id = $1 AND chat_jid = $2`, + id, chatJID, + ).Scan(&mediaType, &filename, &url, &mediaKey, &fileSHA256, &fileEncSHA256, &fileLength) + } else { + err = store.db.QueryRow( + "SELECT media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length FROM messages WHERE id = ? AND chat_jid = ?", + id, chatJID, + ).Scan(&mediaType, &filename, &url, &mediaKey, &fileSHA256, &fileEncSHA256, &fileLength) + } + + return mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, err +} + +// MediaDownloader implements the whatsmeow.DownloadableMessage interface +type MediaDownloader struct { + URL string + DirectPath string + MediaKey []byte + FileLength uint64 + FileSHA256 []byte + FileEncSHA256 []byte + MediaType whatsmeow.MediaType +} + +// GetDirectPath implements the DownloadableMessage interface +func (d *MediaDownloader) GetDirectPath() string { + return d.DirectPath +} + +// GetURL implements the DownloadableMessage interface +func (d *MediaDownloader) GetURL() string { + return d.URL +} + +// GetMediaKey implements the DownloadableMessage interface +func (d *MediaDownloader) GetMediaKey() []byte { + return d.MediaKey +} + +// GetFileLength implements the DownloadableMessage interface +func (d *MediaDownloader) GetFileLength() uint64 { + return d.FileLength +} + +// GetFileSHA256 implements the DownloadableMessage interface +func (d *MediaDownloader) GetFileSHA256() []byte { + return d.FileSHA256 +} + +// GetFileEncSHA256 implements the DownloadableMessage interface +func (d *MediaDownloader) GetFileEncSHA256() []byte { + return d.FileEncSHA256 +} + +// GetMediaType implements the DownloadableMessage interface +func (d *MediaDownloader) GetMediaType() whatsmeow.MediaType { + return d.MediaType +} + +// Function to download media from a message +func downloadMedia(client *whatsmeow.Client, messageStore *MessageStore, messageID, chatJID string) (bool, string, string, string, error) { + var mediaType, filename, url string + var mediaKey, fileSHA256, fileEncSHA256 []byte + var fileLength uint64 + var err error + + chatDir := fmt.Sprintf("store/%s", strings.ReplaceAll(chatJID, ":", "_")) + localPath := "" + + mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, err = messageStore.GetMediaInfo(messageID, chatJID) + + if err != nil { + if isPostgres { + err = messageStore.db.QueryRow( + `SELECT media_type, filename FROM messages WHERE id = $1 AND chat_jid = $2`, + messageID, chatJID, + ).Scan(&mediaType, &filename) + } else { + err = messageStore.db.QueryRow( + "SELECT media_type, filename FROM messages WHERE id = ? AND chat_jid = ?", + messageID, chatJID, + ).Scan(&mediaType, &filename) + } + + if err != nil { + return false, "", "", "", fmt.Errorf("failed to find message: %v", err) + } + } + + if mediaType == "" { + return false, "", "", "", fmt.Errorf("not a media message") + } + + if err := os.MkdirAll(chatDir, 0755); err != nil { + return false, "", "", "", fmt.Errorf("failed to create chat directory: %v", err) + } + + localPath = fmt.Sprintf("%s/%s", chatDir, filename) + + absPath, err := filepath.Abs(localPath) + if err != nil { + return false, "", "", "", fmt.Errorf("failed to get absolute path: %v", err) + } + + if _, err := os.Stat(localPath); err == nil { + return true, mediaType, filename, absPath, nil + } + + if url == "" || len(mediaKey) == 0 || len(fileSHA256) == 0 || len(fileEncSHA256) == 0 || fileLength == 0 { + return false, "", "", "", fmt.Errorf("incomplete media information for download") + } + + fmt.Printf("Attempting to download media for message %s in chat %s...\n", messageID, chatJID) + + directPath := extractDirectPathFromURL(url) + + var waMediaType whatsmeow.MediaType + switch mediaType { + case "image": + waMediaType = whatsmeow.MediaImage + case "video": + waMediaType = whatsmeow.MediaVideo + case "audio": + waMediaType = whatsmeow.MediaAudio + case "document": + waMediaType = whatsmeow.MediaDocument + default: + return false, "", "", "", fmt.Errorf("unsupported media type: %s", mediaType) + } + + downloader := &MediaDownloader{ + URL: url, + DirectPath: directPath, + MediaKey: mediaKey, + FileLength: fileLength, + FileSHA256: fileSHA256, + FileEncSHA256: fileEncSHA256, + MediaType: waMediaType, + } + + mediaData, err := client.Download(context.Background(), downloader) + if err != nil { + return false, "", "", "", fmt.Errorf("failed to download media: %v", err) + } + + if err := os.WriteFile(localPath, mediaData, 0644); err != nil { + return false, "", "", "", fmt.Errorf("failed to save media file: %v", err) + } + + fmt.Printf("Successfully downloaded %s media to %s (%d bytes)\n", mediaType, absPath, len(mediaData)) + return true, mediaType, filename, absPath, nil +} + +func extractDirectPathFromURL(url string) string { + // The direct path is typically in the URL, we need to extract it + // Example URL: https://mmg.whatsapp.net/v/t62.7118-24/13812002_698058036224062_3424455886509161511_n.enc?ccb=11-4&oh=... + + parts := strings.SplitN(url, ".net/", 2) + if len(parts) < 2 { + return url // Return original URL if parsing fails + } + + pathPart := parts[1] + + pathPart = strings.SplitN(pathPart, "?", 2)[0] + + return "/" + pathPart +} + +// Start a REST API server to expose the WhatsApp client functionality +func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port int) { + http.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req SendMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request format", http.StatusBadRequest) + return + } + + if req.Recipient == "" { + http.Error(w, "Recipient is required", http.StatusBadRequest) + return + } + + if req.Message == "" && req.MediaPath == "" { + http.Error(w, "Message or media path is required", http.StatusBadRequest) + return + } + + fmt.Println("Received request to send message", req.Message, req.MediaPath) + + success, message := sendWhatsAppMessage(client, req.Recipient, req.Message, req.MediaPath) + fmt.Println("Message sent", success, message) + w.Header().Set("Content-Type", "application/json") + + if !success { + w.WriteHeader(http.StatusInternalServerError) + } + + json.NewEncoder(w).Encode(SendMessageResponse{ + Success: success, + Message: message, + }) + }) + + // Handler for downloading media + http.HandleFunc("/api/download", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req DownloadMediaRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request format", http.StatusBadRequest) + return + } + + if req.MessageID == "" || req.ChatJID == "" { + http.Error(w, "Message ID and Chat JID are required", http.StatusBadRequest) + return + } + + success, mediaType, filename, path, err := downloadMedia(client, messageStore, req.MessageID, req.ChatJID) + + w.Header().Set("Content-Type", "application/json") + + if !success || err != nil { + errMsg := "Unknown error" + if err != nil { + errMsg = err.Error() + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(DownloadMediaResponse{ + Success: false, + Message: fmt.Sprintf("Failed to download media: %s", errMsg), + }) + return + } + + json.NewEncoder(w).Encode(DownloadMediaResponse{ + Success: true, + Message: fmt.Sprintf("Successfully downloaded %s media", mediaType), + Filename: filename, + Path: path, + }) + }) + + // List recent chats + http.HandleFunc("/api/chats", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + q := r.URL.Query().Get("q") + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + sortBy := r.URL.Query().Get("sort") + + if limit < 1 { + limit = 30 + } + if page < 0 { + page = 0 + } + + var queryPtr *string + if q != "" { + queryPtr = &q + } + + chats, err := messageStore.ListChats(queryPtr, limit, page, true, sortBy) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "chats": chats, + "count": len(chats), + }) + }) + + // Get single chat + http.HandleFunc("/api/chats/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + jid := strings.TrimPrefix(r.URL.Path, "/api/chats/") + if jid == "" { + http.Error(w, "Missing chat JID", http.StatusBadRequest) + return + } + + chat, err := messageStore.GetChat(jid, true) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + if chat == nil { + http.Error(w, "Chat not found", http.StatusNotFound) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "chat": chat, + }) + }) + + // List messages (very flexible) + http.HandleFunc("/api/messages", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + params := ListMessagesParams{ + Limit: 50, + Page: 0, + IncludeContext: false, + ContextBefore: 5, + ContextAfter: 5, + } + + q := r.URL.Query() + if after := q.Get("after"); after != "" { + params.After = after + } + if before := q.Get("before"); before != "" { + params.Before = before + } + if chat := q.Get("chat"); chat != "" { + params.ChatJid = &chat + } + if sender := q.Get("sender"); sender != "" { + params.SenderPhoneNumber = &sender + } + if search := q.Get("search"); search != "" { + params.Query = &search + } + if lim := q.Get("limit"); lim != "" { + if v, err := strconv.Atoi(lim); err == nil && v > 0 { + params.Limit = v + } + } + if pg := q.Get("page"); pg != "" { + if v, err := strconv.Atoi(pg); err == nil && v >= 0 { + params.Page = v + } + } + if ctx := q.Get("context"); ctx == "true" { + params.IncludeContext = true + } + + result, err := messageStore.ListMessages(params) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "result": result, + }) + }) + + // Get message + context + http.HandleFunc("/api/messages/context/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + messageID := strings.TrimPrefix(r.URL.Path, "/api/messages/context/") + if messageID == "" { + http.Error(w, "Missing message ID", http.StatusBadRequest) + return + } + + before, _ := strconv.Atoi(r.URL.Query().Get("before")) + after, _ := strconv.Atoi(r.URL.Query().Get("after")) + if before == 0 { + before = 6 + } + if after == 0 { + after = 6 + } + + ctx, err := messageStore.GetMessageContext(messageID, before, after) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + + respondJSON(w, http.StatusOK, ctx) + }) + + // Search contacts + http.HandleFunc("/api/contacts/search", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query().Get("q") + if query == "" { + http.Error(w, "Missing search query (?q=...)", http.StatusBadRequest) + return + } + + contacts, err := messageStore.SearchContacts(query) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "contacts": contacts, + }) + }) + + // GET /api/contacts/:phone/chat + // Find the 1:1 (direct) chat for a given phone number + http.HandleFunc("/api/direct-contacts/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/api/direct-contacts/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[1] != "chat" { + http.Error(w, "Invalid path. Use /api/direct-contacts/{phone}/chat", http.StatusBadRequest) + return + } + + phone := parts[0] + if phone == "" { + http.Error(w, "Phone number is required", http.StatusBadRequest) + return + } + + chat, err := messageStore.GetDirectChatByContact(phone) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + if chat == nil { + respondJSON(w, http.StatusNotFound, map[string]string{ + "error": "No direct chat found for this phone number", + }) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "chat": chat, + }) + }) + + // GET /api/contacts/:jid/chats + // List all chats where this contact (by JID) appears as sender or in group + http.HandleFunc("/api/contacts/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/api/contacts/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[1] != "chats" { + // Skip if not this endpoint (previous handler already took /chat) + return + } + + jid := parts[0] + if jid == "" { + http.Error(w, "JID is required", http.StatusBadRequest) + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if limit <= 0 { + limit = 20 + } + if page < 0 { + page = 0 + } + + chats, err := messageStore.GetContactChats(jid, limit, page) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "chats": chats, + "count": len(chats), + }) + }) + + serverAddr := fmt.Sprintf(":%d", port) + fmt.Printf("Starting REST API server on %s...\n", serverAddr) + + go func() { + if err := http.ListenAndServe(serverAddr, nil); err != nil { + fmt.Printf("REST API server error: %v\n", err) + } + }() +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{ + "error": msg, + }) +} + +// GetChatName determines the appropriate name for a chat based on JID and other info +func GetChatName(client *whatsmeow.Client, messageStore *MessageStore, jid types.JID, chatJID string, conversation interface{}, sender string, logger waLog.Logger) string { + var existingName string + err := messageStore.db.QueryRow("SELECT name FROM chats WHERE jid = ?", chatJID).Scan(&existingName) + if err == nil && existingName != "" { + logger.Infof("Using existing chat name for %s: %s", chatJID, existingName) + return existingName + } + + var name string + + if jid.Server == "g.us" { + logger.Infof("Getting name for group: %s", chatJID) + + if conversation != nil { + var displayName, convName *string + v := reflect.ValueOf(conversation) + if v.Kind() == reflect.Ptr && !v.IsNil() { + v = v.Elem() + + if displayNameField := v.FieldByName("DisplayName"); displayNameField.IsValid() && displayNameField.Kind() == reflect.Ptr && !displayNameField.IsNil() { + dn := displayNameField.Elem().String() + displayName = &dn + } + + if nameField := v.FieldByName("Name"); nameField.IsValid() && nameField.Kind() == reflect.Ptr && !nameField.IsNil() { + n := nameField.Elem().String() + convName = &n + } + } + + if displayName != nil && *displayName != "" { + name = *displayName + } else if convName != nil && *convName != "" { + name = *convName + } + } + + if name == "" { + groupInfo, err := client.GetGroupInfo(context.Background(), jid) + if err == nil && groupInfo.Name != "" { + name = groupInfo.Name + } else { + name = fmt.Sprintf("Group %s", jid.User) + } + } + + logger.Infof("Using group name: %s", name) + } else { + logger.Infof("Getting name for contact: %s", chatJID) + + contact, err := client.Store.Contacts.GetContact(context.Background(), jid) + if err == nil && contact.FullName != "" { + name = contact.FullName + } else if sender != "" { + name = sender + } else { + name = jid.User + } + + logger.Infof("Using contact name: %s", name) + } + + return name +} + +// Handle history sync events +func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, historySync *events.HistorySync, logger waLog.Logger) { + fmt.Printf("Received history sync event with %d conversations\n", len(historySync.Data.Conversations)) + + syncedCount := 0 + for _, conversation := range historySync.Data.Conversations { + if conversation.ID == nil { + continue + } + + chatJID := *conversation.ID + + jid, err := types.ParseJID(chatJID) + if err != nil { + logger.Warnf("Failed to parse JID %s: %v", chatJID, err) + continue + } + + name := GetChatName(client, messageStore, jid, chatJID, conversation, "", logger) + + messages := conversation.Messages + if len(messages) > 0 { + latestMsg := messages[0] + if latestMsg == nil || latestMsg.Message == nil { + continue + } + + timestamp := time.Time{} + if ts := latestMsg.Message.GetMessageTimestamp(); ts != 0 { + timestamp = time.Unix(int64(ts), 0) + } else { + continue + } + + messageStore.StoreChat(chatJID, name, timestamp) + + for _, msg := range messages { + if msg == nil || msg.Message == nil { + continue + } + + var content string + if msg.Message.Message != nil { + if conv := msg.Message.Message.GetConversation(); conv != "" { + content = conv + } else if ext := msg.Message.Message.GetExtendedTextMessage(); ext != nil { + content = ext.GetText() + } + } + + var mediaType, filename, url string + var mediaKey, fileSHA256, fileEncSHA256 []byte + var fileLength uint64 + + if msg.Message.Message != nil { + mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength = extractMediaInfo(msg.Message.Message) + } + + logger.Infof("Message content: %v, Media Type: %v", content, mediaType) + + if content == "" && mediaType == "" { + continue + } + + var sender string + isFromMe := false + if msg.Message.Key != nil { + if msg.Message.Key.FromMe != nil { + isFromMe = *msg.Message.Key.FromMe + } + if !isFromMe && msg.Message.Key.Participant != nil && *msg.Message.Key.Participant != "" { + sender = *msg.Message.Key.Participant + } else if isFromMe { + sender = client.Store.ID.User + } else { + sender = jid.User + } + } else { + sender = jid.User + } + + msgID := "" + if msg.Message.Key != nil && msg.Message.Key.ID != nil { + msgID = *msg.Message.Key.ID + } + + timestamp := time.Time{} + if ts := msg.Message.GetMessageTimestamp(); ts != 0 { + timestamp = time.Unix(int64(ts), 0) + } else { + continue + } + + err = messageStore.StoreMessage( + msgID, + chatJID, + sender, + content, + timestamp, + isFromMe, + mediaType, + filename, + url, + mediaKey, + fileSHA256, + fileEncSHA256, + fileLength, + ) + if err != nil { + logger.Warnf("Failed to store history message: %v", err) + } else { + syncedCount++ + if mediaType != "" { + logger.Infof("Stored message: [%s] %s -> %s: [%s: %s] %s", + timestamp.Format("2006-01-02 15:04:05"), sender, chatJID, mediaType, filename, content) + } else { + logger.Infof("Stored message: [%s] %s -> %s: %s", + timestamp.Format("2006-01-02 15:04:05"), sender, chatJID, content) + } + } + } + } + } + + fmt.Printf("History sync complete. Stored %d messages.\n", syncedCount) +} + +// Request history sync from the server +func requestHistorySync(client *whatsmeow.Client) { + if client == nil { + fmt.Println("Client is not initialized. Cannot request history sync.") + return + } + + if !client.IsConnected() { + fmt.Println("Client is not connected. Please ensure you are connected to WhatsApp first.") + return + } + + if client.Store.ID == nil { + fmt.Println("Client is not logged in. Please scan the QR code first.") + return + } + + historyMsg := client.BuildHistorySyncRequest(nil, 100) + if historyMsg == nil { + fmt.Println("Failed to build history sync request.") + return + } + + _, err := client.SendMessage(context.Background(), types.JID{ + Server: "s.whatsapp.net", + User: "status", + }, historyMsg) + + if err != nil { + fmt.Printf("Failed to request history sync: %v\n", err) + } else { + fmt.Println("History sync requested. Waiting for server response...") + } +} + +// analyzeOggOpus tries to extract duration and generate a simple waveform from an Ogg Opus file +func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) { + // Try to detect if this is a valid Ogg file by checking for the "OggS" signature + // at the beginning of the file + if len(data) < 4 || string(data[0:4]) != "OggS" { + return 0, nil, fmt.Errorf("not a valid Ogg file (missing OggS signature)") + } + + // Parse Ogg pages to find the last page with a valid granule position + var lastGranule uint64 + var sampleRate uint32 = 48000 // Default Opus sample rate + var preSkip uint16 = 0 + var foundOpusHead bool + + for i := 0; i < len(data); { + if i+27 >= len(data) { + break + } + + if string(data[i:i+4]) != "OggS" { + i++ + continue + } + + granulePos := binary.LittleEndian.Uint64(data[i+6 : i+14]) + pageSeqNum := binary.LittleEndian.Uint32(data[i+18 : i+22]) + numSegments := int(data[i+26]) + + if i+27+numSegments >= len(data) { + break + } + segmentTable := data[i+27 : i+27+numSegments] + + pageSize := 27 + numSegments + for _, segLen := range segmentTable { + pageSize += int(segLen) + } + + if !foundOpusHead && pageSeqNum <= 1 { + pageData := data[i : i+pageSize] + headPos := bytes.Index(pageData, []byte("OpusHead")) + if headPos >= 0 && headPos+12 < len(pageData) { + headPos += 8 + if headPos+12 <= len(pageData) { + preSkip = binary.LittleEndian.Uint16(pageData[headPos+10 : headPos+12]) + sampleRate = binary.LittleEndian.Uint32(pageData[headPos+12 : headPos+16]) + foundOpusHead = true + fmt.Printf("Found OpusHead: sampleRate=%d, preSkip=%d\n", sampleRate, preSkip) + } + } + } + + if granulePos != 0 { + lastGranule = granulePos + } + + i += pageSize + } + + if !foundOpusHead { + fmt.Println("Warning: OpusHead not found, using default values") + } + + if lastGranule > 0 { + durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate) + duration = uint32(math.Ceil(durationSeconds)) + fmt.Printf("Calculated Opus duration from granule: %f seconds (lastGranule=%d)\n", + durationSeconds, lastGranule) + } else { + fmt.Println("Warning: No valid granule position found, using estimation") + durationEstimate := float64(len(data)) / 2000.0 + duration = uint32(durationEstimate) + } + + if duration < 1 { + duration = 1 + } else if duration > 300 { + duration = 300 + } + + waveform = placeholderWaveform(duration) + + fmt.Printf("Ogg Opus analysis: size=%d bytes, calculated duration=%d sec, waveform=%d bytes\n", + len(data), duration, len(waveform)) + + return duration, waveform, nil +} + +func placeholderWaveform(duration uint32) []byte { + const waveformLength = 64 + waveform := make([]byte, waveformLength) + + source := rand.NewSource(int64(duration)) + rng := rand.New(source) + + baseAmplitude := 35.0 + frequencyFactor := float64(min(int(duration), 120)) / 30.0 + + for i := range waveform { + pos := float64(i) / float64(waveformLength) + + val := baseAmplitude * math.Sin(pos*math.Pi*frequencyFactor*8) + val += (baseAmplitude / 2) * math.Sin(pos*math.Pi*frequencyFactor*16) + + val += (rng.Float64() - 0.5) * 15 + + fadeInOut := math.Sin(pos * math.Pi) + val = val * (0.7 + 0.3*fadeInOut) + + val = val + 50 + + if val < 0 { + val = 0 + } else if val > 100 { + val = 100 + } + + waveform[i] = byte(val) + } + + return waveform +} + +func (store *MessageStore) GetSenderName(senderJID string) string { + var name string + var err error + + if isPostgres { + err = store.db.QueryRow( + "SELECT name FROM chats WHERE jid = $1 LIMIT 1", + senderJID, + ).Scan(&name) + + if err == nil && name != "" { + return name + } + + phonePart := senderJID + if idx := strings.Index(senderJID, "@"); idx > 0 { + phonePart = senderJID[:idx] + } + + err = store.db.QueryRow( + "SELECT name FROM chats WHERE jid LIKE $1 LIMIT 1", + "%"+phonePart+"%", + ).Scan(&name) + + if err == nil && name != "" { + return name + } + + return senderJID + } + + err = store.db.QueryRow( + "SELECT name FROM chats WHERE jid = ? LIMIT 1", + senderJID, + ).Scan(&name) + + if err == nil && name != "" { + return name + } + + phonePart := senderJID + if idx := strings.Index(senderJID, "@"); idx > 0 { + phonePart = senderJID[:idx] + } + + err = store.db.QueryRow( + "SELECT name FROM chats WHERE jid LIKE ? LIMIT 1", + "%"+phonePart+"%", + ).Scan(&name) + + if err == nil && name != "" { + return name + } + + return senderJID +} + +func (store *MessageStore) FormatMessage(msg MessageInteraction, showChatInfo bool) string { + var sb strings.Builder + + ts := msg.Timestamp.Format("2006-01-02 15:04:05") + + if showChatInfo && msg.ChatName != "" { + sb.WriteString(fmt.Sprintf("[%s] Chat: %s ", ts, msg.ChatName)) + } else { + sb.WriteString(fmt.Sprintf("[%s] ", ts)) + } + + prefix := "" + if msg.MediaType != "" { + prefix = fmt.Sprintf("[%s - Message ID: %s - Chat JID: %s] ", msg.MediaType, msg.ID, msg.ChatJID) + } + + senderName := "Me" + if !msg.IsFromMe { + senderName = store.GetSenderName(msg.Sender) + } + + sb.WriteString(fmt.Sprintf("From: %s: %s%s\n", senderName, prefix, msg.Content)) + return sb.String() +} + +func (store *MessageStore) FormatMessagesList(messages []MessageInteraction, showChatInfo bool) string { + if len(messages) == 0 { + return "No messages to display.\n" + } + var sb strings.Builder + for _, m := range messages { + sb.WriteString(store.FormatMessage(m, showChatInfo)) + } + return sb.String() +} + +func (store *MessageStore) ListMessages(s ListMessagesParams) (string, error) { + var args []any + var where []string + + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + q := ` + SELECT + m.timestamp, m.sender, c.name, m.content, m.is_from_me, + c.jid, m.id, m.media_type + FROM messages m + JOIN chats c ON m.chat_jid = c.jid + ` + + if s.After != "" { + t, err := time.Parse(time.RFC3339, s.After) + if err != nil { + return "", fmt.Errorf("invalid after format: %w", err) + } + where = append(where, "m.timestamp > "+placeholder(len(args)+1)) + args = append(args, t) + } + + if s.Before != "" { + t, err := time.Parse(time.RFC3339, s.Before) + if err != nil { + return "", fmt.Errorf("invalid before format: %w", err) + } + where = append(where, "m.timestamp < "+placeholder(len(args)+1)) + args = append(args, t) + } + + if s.SenderPhoneNumber != nil && *s.SenderPhoneNumber != "" { + where = append(where, "m.sender = "+placeholder(len(args)+1)) + args = append(args, *s.SenderPhoneNumber) + } + + if s.ChatJid != nil && *s.ChatJid != "" { + where = append(where, "m.chat_jid = "+placeholder(len(args)+1)) + args = append(args, *s.ChatJid) + } + + if s.Query != nil && *s.Query != "" { + where = append(where, "LOWER(m.content) LIKE LOWER("+placeholder(len(args)+1)+")") + args = append(args, "%"+*s.Query+"%") + } + + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + + q += " ORDER BY m.timestamp DESC" + q += " LIMIT " + placeholder(len(args)+1) + args = append(args, s.Limit) + + q += " OFFSET " + placeholder(len(args)+1) + args = append(args, s.Page*s.Limit) + + rows, err := store.db.Query(q, args...) + if err != nil { + return "", err + } + defer rows.Close() + + var msgs []MessageInteraction + for rows.Next() { + var ( + ts time.Time + sender string + chatName sql.NullString + content string + isFromMe bool + cJid string + id string + mediaType sql.NullString + ) + + if err := rows.Scan(&ts, &sender, &chatName, &content, &isFromMe, &cJid, &id, &mediaType); err != nil { + log.Printf("scan error: %v", err) + continue + } + + m := MessageInteraction{ + Timestamp: ts, + Sender: sender, + Content: content, + IsFromMe: isFromMe, + ChatJID: cJid, + ID: id, + } + + if chatName.Valid { + m.ChatName = chatName.String + } + if mediaType.Valid { + m.MediaType = mediaType.String + } + + msgs = append(msgs, m) + } + + if s.IncludeContext && len(msgs) > 0 { + var all []MessageInteraction + for _, m := range msgs { + ctx, err := store.GetMessageContext(m.ID, s.ContextBefore, s.ContextAfter) + if err != nil { + log.Printf("context error for %s: %v", m.ID, err) + continue + } + all = append(all, ctx.Before...) + all = append(all, ctx.Message) + all = append(all, ctx.After...) + } + return store.FormatMessagesList(all, true), nil + } + + return store.FormatMessagesList(msgs, true), nil +} + +func (store *MessageStore) GetMessageContext(messageID string, before, after int) (MessageContext, error) { + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + // --- Fetch the target message --- + q := ` + SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, + c.jid, m.id, m.media_type, m.chat_jid + FROM messages m + JOIN chats c ON m.chat_jid = c.jid + WHERE m.id = ` + placeholder(1) + + var ( + ts time.Time + sender string + chatName sql.NullString + content string + isFromMe bool + chatJid string + id string + media sql.NullString + chatJid2 string + ) + + row := store.db.QueryRow(q, messageID) + if err := row.Scan(&ts, &sender, &chatName, &content, &isFromMe, &chatJid, &id, &media, &chatJid2); err != nil { + if err == sql.ErrNoRows { + return MessageContext{}, fmt.Errorf("message not found: %s", messageID) + } + return MessageContext{}, err + } + + target := MessageInteraction{ + Timestamp: ts, + Sender: sender, + Content: content, + IsFromMe: isFromMe, + ChatJID: chatJid, + ID: id, + } + if chatName.Valid { + target.ChatName = chatName.String + } + if media.Valid { + target.MediaType = media.String + } + + qBefore := ` + SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, + c.jid, m.id, m.media_type + FROM messages m + JOIN chats c ON m.chat_jid = c.jid + WHERE m.chat_jid = ` + placeholder(1) + ` + AND m.timestamp < ` + placeholder(2) + ` + ORDER BY m.timestamp DESC + LIMIT ` + placeholder(3) + + bRows, err := store.db.Query(qBefore, chatJid2, ts, before) + if err != nil { + return MessageContext{}, err + } + defer bRows.Close() + + var beforeMsgs []MessageInteraction + for bRows.Next() { + var ( + bts time.Time + bsender sql.NullString + bname sql.NullString + bcontent string + bif bool + bjid string + bid string + bmedia sql.NullString + ) + + if err := bRows.Scan(&bts, &bsender, &bname, &bcontent, &bif, &bjid, &bid, &bmedia); err != nil { + continue + } + + m := MessageInteraction{ + Timestamp: bts, + Sender: bsender.String, + Content: bcontent, + IsFromMe: bif, + ChatJID: bjid, + ID: bid, + } + if bname.Valid { + m.ChatName = bname.String + } + if bmedia.Valid { + m.MediaType = bmedia.String + } + beforeMsgs = append(beforeMsgs, m) + } + + qAfter := ` + SELECT m.timestamp, m.sender, c.name, m.content, m.is_from_me, + c.jid, m.id, m.media_type + FROM messages m + JOIN chats c ON m.chat_jid = c.jid + WHERE m.chat_jid = ` + placeholder(1) + ` + AND m.timestamp > ` + placeholder(2) + ` + ORDER BY m.timestamp ASC + LIMIT ` + placeholder(3) + + aRows, err := store.db.Query(qAfter, chatJid2, ts, after) + if err != nil { + return MessageContext{}, err + } + defer aRows.Close() + + var afterMsgs []MessageInteraction + for aRows.Next() { + var ( + ats time.Time + asender sql.NullString + aname sql.NullString + acontent string + aif bool + ajid string + aid string + amedia sql.NullString + ) + + if err := aRows.Scan(&ats, &asender, &aname, &acontent, &aif, &ajid, &aid, &amedia); err != nil { + continue + } + + m := MessageInteraction{ + Timestamp: ats, + Sender: asender.String, + Content: acontent, + IsFromMe: aif, + ChatJID: ajid, + ID: aid, + } + if aname.Valid { + m.ChatName = aname.String + } + if amedia.Valid { + m.MediaType = amedia.String + } + afterMsgs = append(afterMsgs, m) + } + + return MessageContext{ + Message: target, + Before: beforeMsgs, + After: afterMsgs, + }, nil +} + +func (store *MessageStore) ListChats( + query *string, + limit, page int, + includeLastMessage bool, + sortBy string, +) ([]Chat, error) { + + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + q := ` + SELECT + c.jid, c.name, c.last_message_time, + m.content AS last_message, + m.sender AS last_sender, + m.is_from_me AS last_is_from_me + FROM chats c + ` + + if includeLastMessage { + q += ` + LEFT JOIN messages m + ON c.jid = m.chat_jid + AND c.last_message_time = m.timestamp + ` + } + + var args []any + var where []string + + if query != nil && *query != "" { + if isPostgres { + where = append(where, "(LOWER(c.name) LIKE LOWER("+placeholder(len(args)+1)+") OR c.jid LIKE "+placeholder(len(args)+2)+")") + } else { + where = append(where, "(LOWER(c.name) LIKE LOWER(?) OR c.jid LIKE ?)") + } + args = append(args, "%"+*query+"%", "%"+*query+"%") + } + + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + + order := "c.last_message_time DESC" + if sortBy == "name" { + order = "c.name ASC" + } + q += " ORDER BY " + order + + q += " LIMIT " + placeholder(len(args)+1) + args = append(args, limit) + + q += " OFFSET " + placeholder(len(args)+2) + args = append(args, page*limit) + + rows, err := store.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var chats []Chat + for rows.Next() { + var ( + jid string + name sql.NullString + lastMsgTime sql.NullTime + lastMsg sql.NullString + lastSender sql.NullString + lastFromMe sql.NullBool + ) + + if err := rows.Scan(&jid, &name, &lastMsgTime, &lastMsg, &lastSender, &lastFromMe); err != nil { + log.Printf("list_chats scan error: %v", err) + continue + } + + c := Chat{JID: jid} + + if name.Valid { + c.Name = name.String + } + if lastMsgTime.Valid { + c.LastMessageTime = lastMsgTime.Time + } + if lastMsg.Valid { + c.LastMessage = lastMsg.String + } + if lastSender.Valid { + c.LastSender = lastSender.String + } + if lastFromMe.Valid { + c.LastIsFromMe = lastFromMe.Bool + } + + chats = append(chats, c) + } + + return chats, nil +} + +func (store *MessageStore) SearchContacts(query string) ([]Contact, error) { + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + q := ` + SELECT DISTINCT their_jid, first_name + FROM whatsmeow_contacts + WHERE (LOWER(first_name) LIKE LOWER(` + placeholder(1) + `) + OR LOWER(their_jid) LIKE LOWER(` + placeholder(2) + `)) + AND their_jid NOT LIKE '%@g.us' + ORDER BY first_name, their_jid + LIMIT 50 + ` + + args := []any{"%" + query + "%", "%" + query + "%"} + + rows, err := store.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []Contact + + for rows.Next() { + var jid, name sql.NullString + + if err := rows.Scan(&jid, &name); err != nil { + continue + } + if !jid.Valid { + continue + } + + phone := strings.Split(jid.String, "@")[0] + + c := Contact{ + PhoneNumber: phone, + JID: jid.String, + } + if name.Valid { + c.Name = name.String + } + + contacts = append(contacts, c) + } + + return contacts, nil +} + +func (store *MessageStore) GetContactChats(jid string, limit, page int) ([]Chat, error) { + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + q := ` + SELECT DISTINCT + c.jid, c.name, c.last_message_time, + m.content AS last_message, + m.sender AS last_sender, + m.is_from_me AS last_is_from_me + FROM chats c + JOIN messages m ON c.jid = m.chat_jid + WHERE m.sender = ` + placeholder(1) + ` + OR c.jid = ` + placeholder(2) + ` + ORDER BY c.last_message_time DESC + LIMIT ` + placeholder(3) + ` + OFFSET ` + placeholder(4) + + args := []any{jid, jid, limit, page * limit} + + rows, err := store.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var chats []Chat + + for rows.Next() { + var ( + cjid string + name sql.NullString + lmt sql.NullTime + lmsg sql.NullString + lsender sql.NullString + lfromme sql.NullBool + ) + + if err := rows.Scan(&cjid, &name, &lmt, &lmsg, &lsender, &lfromme); err != nil { + continue + } + + c := Chat{JID: cjid} + + if name.Valid { + c.Name = name.String + } + if lmt.Valid { + c.LastMessageTime = lmt.Time + } + if lmsg.Valid { + c.LastMessage = lmsg.String + } + if lsender.Valid { + c.LastSender = lsender.String + } + if lfromme.Valid { + c.LastIsFromMe = lfromme.Bool + } + + chats = append(chats, c) + } + + return chats, nil +} + +func (store *MessageStore) GetLastInteraction(jid string) (string, error) { + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + q := ` + SELECT + m.timestamp, m.sender, c.name, m.content, m.is_from_me, + c.jid, m.id, m.media_type + FROM messages m + JOIN chats c ON m.chat_jid = c.jid + WHERE m.sender = ` + placeholder(1) + ` + OR c.jid = ` + placeholder(2) + ` + ORDER BY m.timestamp DESC + LIMIT 1 + ` + + row := store.db.QueryRow(q, jid, jid) + + var ( + ts time.Time + sender string + chatName sql.NullString + content string + isFromMe bool + chatJid string + id string + mediaType sql.NullString + ) + + if err := row.Scan(&ts, &sender, &chatName, &content, &isFromMe, &chatJid, &id, &mediaType); err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", err + } + + msg := MessageInteraction{ + Timestamp: ts, + Sender: sender, + Content: content, + IsFromMe: isFromMe, + ChatJID: chatJid, + ID: id, + } + + if chatName.Valid { + msg.ChatName = chatName.String + } + if mediaType.Valid { + msg.MediaType = mediaType.String + } + + return store.FormatMessage(msg, true), nil +} + +func (store *MessageStore) GetChat(chatJID string, includeLastMessage bool) (*Chat, error) { + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + q := ` + SELECT + c.jid, c.name, c.last_message_time, + m.content AS last_message, + m.sender AS last_sender, + m.is_from_me AS last_is_from_me + FROM chats c + ` + + if includeLastMessage { + q += ` + LEFT JOIN messages m + ON c.jid = m.chat_jid + AND c.last_message_time = m.timestamp + ` + } + + q += " WHERE c.jid = " + placeholder(1) + + row := store.db.QueryRow(q, chatJID) + + var ( + jid string + name sql.NullString + lmt sql.NullTime + lmsg sql.NullString + lsender sql.NullString + lfromme sql.NullBool + ) + + if err := row.Scan(&jid, &name, &lmt, &lmsg, &lsender, &lfromme); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + c := Chat{JID: jid} + + if name.Valid { + c.Name = name.String + } + if lmt.Valid { + c.LastMessageTime = lmt.Time + } + if lmsg.Valid { + c.LastMessage = lmsg.String + } + if lsender.Valid { + c.LastSender = lsender.String + } + if lfromme.Valid { + c.LastIsFromMe = lfromme.Bool + } + + return &c, nil +} + +func (store *MessageStore) GetDirectChatByContact(phone string) (*Chat, error) { + placeholder := func(n int) string { + if isPostgres { + return fmt.Sprintf("$%d", n) + } + return "?" + } + + q := ` + SELECT + c.jid, c.name, c.last_message_time, + m.content AS last_message, + m.sender AS last_sender, + m.is_from_me AS last_is_from_me + FROM chats c + LEFT JOIN messages m + ON c.jid = m.chat_jid + AND c.last_message_time = m.timestamp + WHERE c.jid LIKE ` + placeholder(1) + ` + AND c.jid NOT LIKE '%@g.us' + LIMIT 1 + ` + + arg := "%" + phone + "%" + + row := store.db.QueryRow(q, arg) + + var ( + jid string + name sql.NullString + lmt sql.NullTime + lmsg sql.NullString + lsender sql.NullString + lfromme sql.NullBool + ) + + if err := row.Scan(&jid, &name, &lmt, &lmsg, &lsender, &lfromme); err != nil { + return nil, err + } + + return &Chat{ + JID: jid, + Name: name.String, + LastMessageTime: lmt.Time, + LastMessage: lmsg.String, + LastSender: lsender.String, + LastIsFromMe: lfromme.Bool, + }, nil +} + +func main() { + logger := waLog.Stdout("Client", "INFO", true) + logger.Infof("Starting WhatsApp client...") + + dbLog := waLog.Stdout("Database", "INFO", true) + + if err := os.MkdirAll("store", 0755); err != nil { + logger.Errorf("Failed to create store directory: %v", err) + return + } + + config, err := getEnv() + if err != nil { + return + } + + dialect := "sqlite3" + connStr := "file:store/whatsapp.db?_foreign_keys=on" + + if config.IsPostgres { + dialect = "postgres" + connStr = fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", config.User, + config.Pass, config.Host, config.Port, "whatsapp") + } + + container, err := sqlstore.New(context.Background(), dialect, connStr, dbLog) + if err != nil { + logger.Errorf("Failed to connect to database: %v", err) + return + } + + deviceStore, err := container.GetFirstDevice(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + deviceStore = container.NewDevice() + logger.Infof("Created new device") + } else { + logger.Errorf("Failed to get device: %v", err) + return + } + } + + version, err := CustomGetLatestVersion(context.Background(), nil) + if err != nil { + logger.Errorf("Failed to retrieve current WhatsApp Web client Version") + } else { + store.SetWAVersion(*version) + logger.Infof("WhatsApp Web Client Version: %d.%d.%d\n", version[0], version[1], version[2]) + } + client := whatsmeow.NewClient(deviceStore, logger) + if client == nil { + logger.Errorf("Failed to create WhatsApp client") + return + } + + store.SetOSInfo("Linux", store.GetWAVersion()) + store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_CHROME.Enum() + + messageStore, err := NewMessageStore() + if err != nil { + logger.Errorf("Failed to initialize message store: %v", err) + return + } + defer messageStore.Close() + + client.AddEventHandler(func(evt interface{}) { + switch v := evt.(type) { + case *events.Message: + handleMessage(client, messageStore, v, logger) + + case *events.HistorySync: + handleHistorySync(client, messageStore, v, logger) + + case *events.Connected: + logger.Infof("Connected to WhatsApp") + + case *events.LoggedOut: + logger.Warnf("Device logged out, please scan QR code to log in again") + } + }) + + connected := make(chan bool, 1) + + if client.Store.ID == nil { + qrChan, _ := client.GetQRChannel(context.Background()) + err = client.Connect() + if err != nil { + logger.Errorf("Failed to connect: %v", err) + return + } + + for evt := range qrChan { + if evt.Event == "code" { + fmt.Println("\nScan this QR code with your WhatsApp app:") + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + } else if evt.Event == "success" { + connected <- true + break + } + } + + select { + case <-connected: + fmt.Println("\nSuccessfully connected and authenticated!") + case <-time.After(3 * time.Minute): + logger.Errorf("Timeout waiting for QR code scan") + return + } + } else { + err = client.Connect() + if err != nil { + logger.Errorf("Failed to connect: %v", err) + return + } + connected <- true + } + + time.Sleep(2 * time.Second) + + if !client.IsConnected() { + logger.Errorf("Failed to establish stable connection") + return + } + + fmt.Println("\n✓ Connected to WhatsApp! Type 'help' for commands.") + + startRESTServer(client, messageStore, 8080) + + exitChan := make(chan os.Signal, 1) + signal.Notify(exitChan, syscall.SIGINT, syscall.SIGTERM) + + fmt.Println("REST server is running. Press Ctrl+C to disconnect and exit.") + + <-exitChan + + fmt.Println("Disconnecting...") + client.Disconnect() +} diff --git a/whatsapp-mcp-server/.idea/.gitignore b/whatsapp-mcp-server/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/whatsapp-mcp-server/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/whatsapp-mcp-server/.idea/go.imports.xml b/whatsapp-mcp-server/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/whatsapp-mcp-server/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/whatsapp-mcp-server/.idea/misc.xml b/whatsapp-mcp-server/.idea/misc.xml new file mode 100644 index 0000000..11a695e --- /dev/null +++ b/whatsapp-mcp-server/.idea/misc.xml @@ -0,0 +1,7 @@ + + + {} + + + + \ No newline at end of file diff --git a/whatsapp-mcp-server/.idea/modules.xml b/whatsapp-mcp-server/.idea/modules.xml new file mode 100644 index 0000000..bcd7a4f --- /dev/null +++ b/whatsapp-mcp-server/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/whatsapp-mcp-server/.idea/vcs.xml b/whatsapp-mcp-server/.idea/vcs.xml new file mode 100644 index 0000000..4fce1d8 --- /dev/null +++ b/whatsapp-mcp-server/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/whatsapp-mcp-server/.idea/whatsapp-mcp-server-golang.iml b/whatsapp-mcp-server/.idea/whatsapp-mcp-server-golang.iml new file mode 100644 index 0000000..eacc75a --- /dev/null +++ b/whatsapp-mcp-server/.idea/whatsapp-mcp-server-golang.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/whatsapp-mcp-server/go.mod b/whatsapp-mcp-server/go.mod new file mode 100644 index 0000000..169a1de --- /dev/null +++ b/whatsapp-mcp-server/go.mod @@ -0,0 +1,17 @@ +module whatsapp-mcp-server + +go 1.25.0 + +require ( + github.com/mattn/go-sqlite3 v1.14.34 + github.com/modelcontextprotocol/go-sdk v1.3.1 +) + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/whatsapp-mcp-server/go.sum b/whatsapp-mcp-server/go.sum new file mode 100644 index 0000000..126c038 --- /dev/null +++ b/whatsapp-mcp-server/go.sum @@ -0,0 +1,22 @@ +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/whatsapp-mcp-server/helpers/audio.go b/whatsapp-mcp-server/helpers/audio.go new file mode 100644 index 0000000..a350d9c --- /dev/null +++ b/whatsapp-mcp-server/helpers/audio.go @@ -0,0 +1,92 @@ +package helpers + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ConvertToOpusOggTemp – creates a temporary .ogg file in Opus format +// Uses reasonable defaults for WhatsApp voice messages +func ConvertToOpusOggTemp(inputFile string) (string, error) { + // WhatsApp voice messages usually use: + // bitrate → 24k–32k is very common and good quality/size balance + // sample rate → 48000 Hz (Opus native) + const defaultBitrate = "32k" + const defaultSampleRate = 48000 + + return ConvertToOpusOggTempWithParams(inputFile, defaultBitrate, defaultSampleRate) +} + +// ConvertToOpusOgg converts an audio file to Opus format in an Ogg container. +func ConvertToOpusOgg(inputFile string, outputFile string, bitrate string, sampleRate int) (string, error) { + // Check if input file exists + if _, err := os.Stat(inputFile); os.IsNotExist(err) { + return "", fmt.Errorf("input file not found: %s", inputFile) + } + + // If no output file is specified, replace extension with .ogg + if outputFile == "" { + ext := filepath.Ext(inputFile) + outputFile = strings.TrimSuffix(inputFile, ext) + ".ogg" + } + + // Ensure output directory exists + outputDir := filepath.Dir(outputFile) + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + } + + // Build the ffmpeg command + cmd := exec.Command("ffmpeg", + "-i", inputFile, + "-c:a", "libopus", + "-b:a", bitrate, + "-ar", fmt.Sprintf("%d", sampleRate), + "-application", "voip", + "-vbr", "on", + "-compression_level", "10", + "-frame_duration", "60", + "-y", + outputFile, + ) + + // Run command and capture output for error reporting + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to convert audio. ffmpeg error: %s (%w)", string(output), err) + } + + return outputFile, nil +} + +// ConvertToOpusOggTempWithParams converts audio to a temporary .ogg file. +func ConvertToOpusOggTempWithParams(inputFile string, bitrate string, sampleRate int) (string, error) { + // Create a temporary file + tempFile, err := os.CreateTemp("", "audio-*.ogg") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + tempPath := tempFile.Name() + err = tempFile.Close() + if err != nil { + return "", err + } // Close it so ffmpeg can write to the path + + // Convert the audio + result, err := ConvertToOpusOgg(inputFile, tempPath, bitrate, sampleRate) + if err != nil { + // Clean up on failure + err := os.Remove(tempPath) + if err != nil { + return "", err + } + return "", err + } + + return result, nil +} diff --git a/whatsapp-mcp-server/helpers/mcp_tool.go b/whatsapp-mcp-server/helpers/mcp_tool.go new file mode 100644 index 0000000..3aeb8cc --- /dev/null +++ b/whatsapp-mcp-server/helpers/mcp_tool.go @@ -0,0 +1,554 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "path/filepath" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// InitMcpTool initializes MCP tool for the MCP server +func InitMcpTool() { + server := mcp.NewServer(&mcp.Implementation{ + Name: "whatsapp-mcp", + Version: "v1.0.0", + }, nil) + + mcp.AddTool[searchContactsInput, any](server, &mcp.Tool{ + Name: "search_contacts", + Description: "Search WhatsApp contacts by name or phone number.", + }, searchContactsHandler) + + mcp.AddTool[listMessagesInput, any](server, &mcp.Tool{ + Name: "list_messages", + Description: "Get WhatsApp messages matching specified criteria with optional context around matches.", + }, listMessagesHandler) + + mcp.AddTool[getMessageContextInput, any](server, &mcp.Tool{ + Name: "get_message_context", + Description: "Get surrounding messages (context) around a specific WhatsApp message.", + }, getMessageContextHandler) + + mcp.AddTool[listChatsInput, any](server, &mcp.Tool{ + Name: "list_chats", + Description: "Get list of WhatsApp chats, optionally filtered and sorted.", + }, listChatsHandler) + + mcp.AddTool[getChatInput, any](server, &mcp.Tool{ + Name: "get_chat", + Description: "Get metadata of a specific WhatsApp chat by JID.", + }, getChatHandler) + + mcp.AddTool[getDirectChatByContactInput, any](server, &mcp.Tool{ + Name: "get_direct_chat_by_contact", + Description: "Get direct (1:1) chat metadata by contact phone number.", + }, getDirectChatByContactHandler) + + mcp.AddTool[getContactChatsInput, any](server, &mcp.Tool{ + Name: "get_contact_chats", + Description: "Get all chats involving a specific contact (JID).", + }, getContactChatsHandler) + + mcp.AddTool[getLastInteractionInput, any](server, &mcp.Tool{ + Name: "get_last_interaction", + Description: "Get most recent WhatsApp message involving the contact.", + }, getLastInteractionHandler) + + mcp.AddTool[sendMessageInput, map[string]any](server, &mcp.Tool{ + Name: "send_message", + Description: "Send a text message to a person or group on WhatsApp. For groups use the group JID.", + }, sendMessageHandler) + + mcp.AddTool[sendFileInput, map[string]any](server, &mcp.Tool{ + Name: "send_file", + Description: "Send image, video, document or any file via WhatsApp.", + }, sendFileHandler) + + mcp.AddTool[sendAudioMessageInput, map[string]any](server, &mcp.Tool{ + Name: "send_audio_message", + Description: "Send audio/voice message (converted to Opus .ogg if needed).", + }, sendAudioMessageHandler) + + mcp.AddTool[downloadMediaInput, map[string]any](server, &mcp.Tool{ + Name: "download_media", + Description: "Download media from a WhatsApp message and return local file path.", + }, downloadMediaHandler) + + isSSE := strings.ToLower(ReadEnv("IS_SSE", "false")) == "true" || + strings.ToLower(ReadEnv("IS_SSE", "0")) == "1" + + ctx := context.Background() + + if isSSE { + addr := ReadEnv("SSE_BASE_URL", "0.0.0.0:5777") + slog.Info("Starting WhatsApp MCP HTTP server (SSE)", "addr", addr) + + handler := mcp.NewSSEHandler(func(request *http.Request) *mcp.Server { + // A logic can be added here to return different servers based on URL path + // For now, it returns the same WhatsApp server for the root/default path + return server + }, nil) + + if err := http.ListenAndServe(addr, handler); err != nil { + log.Fatalf("http server failed: %v", err) + } + } else { + slog.Info("Starting WhatsApp MCP server in stdio mode") + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { + log.Fatalf("stdio failed: %v", err) + } + } +} + +type searchContactsInput struct { + Query string `json:"query" mcp:"description:Search term to match against contact names or phone numbers"` +} + +type listMessagesInput struct { + After *string `mcp:"description:ISO-8601 formatted string"` + Before *string `json:"before,omitempty" jsonschema:"description:ISO-8601 formatted string"` + SenderPhoneNumber *string `json:"sender_phone_number,omitempty"` + ChatJid *string `json:"chat_jid,omitempty"` + Query *string `json:"query,omitempty" jsonschema:"description:Search term in message content"` + Limit int `json:"limit" jsonschema:"default:20"` + Page int `json:"page" jsonschema:"default:0"` + IncludeContext bool `json:"include_context" jsonschema:"default:true"` + ContextBefore int `json:"context_before" jsonschema:"default:1"` + ContextAfter int `json:"context_after" jsonschema:"default:1"` +} + +type getMessageContextInput struct { + MessageID string `json:"message_id" jsonschema:"description:The ID of the message"` + Before int `json:"before" jsonschema:"default:5"` + After int `json:"after" jsonschema:"default:5"` +} + +type listChatsInput struct { + Query *string `json:"query,omitempty"` + Limit int `json:"limit" jsonschema:"default:20"` + Page int `json:"page" jsonschema:"default:0"` + IncludeLastMessage bool `json:"include_last_message" jsonschema:"default:true"` + SortBy string `json:"sort_by" jsonschema:"default:last_active,enum:last_active|name"` +} + +type getChatInput struct { + ChatJid string `json:"chat_jid" jsonschema:"description:The JID of the chat"` + IncludeLastMessage bool `json:"include_last_message" jsonschema:"default:true"` +} + +type getDirectChatByContactInput struct { + SenderPhoneNumber string `json:"sender_phone_number" jsonschema:"description:Phone number with country code"` +} + +type getContactChatsInput struct { + Jid string `json:"jid"` + Limit int `json:"limit" jsonschema:"default:20"` + Page int `json:"page" jsonschema:"default:0"` +} + +type getLastInteractionInput struct { + Jid string `json:"jid" jsonschema:"description:The contact's JID"` +} + +type sendMessageInput struct { + Recipient string `json:"recipient" jsonschema:"description:Phone number (no +) or group JID like 123@g.us"` + Message string `json:"message"` +} + +type sendFileInput struct { + Recipient string `json:"recipient"` + MediaPath string `json:"media_path" jsonschema:"description:Absolute path to the file"` +} + +type sendAudioMessageInput struct { + Recipient string `json:"recipient"` + MediaPath string `json:"media_path" jsonschema:"description:Absolute path to audio file"` +} + +type downloadMediaInput struct { + MessageID string `json:"message_id"` + ChatJid string `json:"chat_jid"` +} + +func callAPI(method, path string, body any) ([]byte, error) { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, apiBaseURL+path, reqBody) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: apiTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(data)) + } + + return data, nil +} + +func sendMessageHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in sendMessageInput, +) (*mcp.CallToolResult, map[string]any, error) { + if in.Recipient == "" || in.Message == "" { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: "recipient and message are required"}, + }, + }, map[string]any{ + "success": false, + "error": "recipient and message are required", + }, nil + } + + payload := map[string]any{ + "recipient": in.Recipient, + "message": in.Message, + } + + data, err := callAPI(http.MethodPost, "/send", payload) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: err.Error()}, + }, + }, map[string]any{ + "success": false, + "error": err.Error(), + }, nil + } + + var resp map[string]any + if err := json.Unmarshal(data, &resp); err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: "failed to parse API response"}, + }, + }, map[string]any{ + "success": false, + "error": "failed to parse API response", + }, nil + } + + return &mcp.CallToolResult{}, resp, nil +} + +func searchContactsHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in searchContactsInput, +) (*mcp.CallToolResult, any, error) { + if in.Query == "" { + return ErrResult("query is required"), nil, nil + } + + data, err := callAPI(http.MethodGet, "/contacts/search?q="+in.Query, nil) + if err != nil { + return ErrResult(err.Error()), nil, nil + } + + var result struct { + Contacts []map[string]any `json:"contacts"` + } + if err := json.Unmarshal(data, &result); err != nil { + return ErrResult("invalid response format"), nil, nil + } + + return OkResult(result.Contacts), nil, nil +} + +// list_messages (similar for all read/list tools that return slices/maps) +func listMessagesHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in listMessagesInput, +) (*mcp.CallToolResult, any, error) { + q := "" + if in.After != nil { + q += "&after=" + *in.After + } + if in.Before != nil { + q += "&before=" + *in.Before + } + if in.SenderPhoneNumber != nil { + q += "&sender=" + *in.SenderPhoneNumber + } + if in.ChatJid != nil { + q += "&chat=" + *in.ChatJid + } + if in.Query != nil { + q += "&search=" + *in.Query + } + q += fmt.Sprintf("&limit=%d&page=%d", in.Limit, in.Page) + if in.IncludeContext { + q += "&context=true" + } + + data, err := callAPI(http.MethodGet, "/messages?"+strings.TrimPrefix(q, "&"), nil) + if err != nil { + return ErrResult(err.Error()), nil, nil + } + + // The /messages endpoint returns plain text → we return it as string + return OkResult(string(data)), nil, nil +} + +// download_media (same idea) +func downloadMediaHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in downloadMediaInput, +) (*mcp.CallToolResult, map[string]any, error) { + path, err := DownloadMedia(in.MessageID, in.ChatJid) + if err != nil || path == "" { + msg := "failed to download media" + if err != nil { + msg += ": " + err.Error() + } + return &mcp.CallToolResult{}, map[string]any{ + "success": false, + "message": msg, + }, nil + } + return &mcp.CallToolResult{}, map[string]any{ + "success": true, + "message": "Media downloaded successfully", + "file_path": path, + }, nil +} + +func getMessageContextHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in getMessageContextInput, +) (*mcp.CallToolResult, any, error) { + if in.MessageID == "" { + return ErrResult("message_id is required"), nil, nil + } + + path := fmt.Sprintf("/messages/context/%s?before=%d&after=%d", + in.MessageID, in.Before, in.After) + + data, err := callAPI(http.MethodGet, path, nil) + if err != nil { + return ErrResult(err.Error()), nil, nil + } + + var ctxData map[string]any + if err := json.Unmarshal(data, &ctxData); err != nil { + return ErrResult("invalid context response"), nil, nil + } + + return OkResult(ctxData), nil, nil +} + +func listChatsHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in listChatsInput, +) (*mcp.CallToolResult, any, error) { + q := fmt.Sprintf("?limit=%d&page=%d", in.Limit, in.Page) + if in.Query != nil && *in.Query != "" { + q += "&q=" + *in.Query + } + if in.SortBy != "" { + q += "&sort=" + in.SortBy + } + + data, err := callAPI(http.MethodGet, "/chats"+q, nil) + if err != nil { + return ErrResult(err.Error()), nil, nil + } + + var result struct { + Chats []map[string]any `json:"chats"` + Count int `json:"count"` + } + _ = json.Unmarshal(data, &result) // best effort + + return OkResult(result.Chats), nil, nil +} + +func getChatHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in getChatInput, +) (*mcp.CallToolResult, any, error) { + if in.ChatJid == "" { + return ErrResult("chat_jid is required"), nil, nil + } + + data, err := callAPI(http.MethodGet, "/chats/"+in.ChatJid, nil) + if err != nil { + return ErrResult(err.Error()), nil, nil + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return ErrResult("invalid chat response"), nil, nil + } + + return OkResult(result["chat"]), nil, nil +} + +func getDirectChatByContactHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in getDirectChatByContactInput, +) (*mcp.CallToolResult, any, error) { + if in.SenderPhoneNumber == "" { + return ErrResult("sender_phone_number is required"), nil, nil + } + + // GET /api/direct-contacts/{phone}/chat + path := fmt.Sprintf("/direct-contacts/%s/chat", in.SenderPhoneNumber) + + data, err := callAPI(http.MethodGet, path, nil) + if err != nil { + if strings.Contains(err.Error(), "404") { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: "No direct (1:1) chat found for this phone number"}, + }, + }, nil, nil + } + return ErrResult(err.Error()), nil, nil + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return ErrResult("failed to parse chat response"), nil, nil + } + + chat, ok := result["chat"] + if !ok { + return ErrResult("chat object missing in response"), nil, nil + } + + return &mcp.CallToolResult{}, chat, nil +} + +func getContactChatsHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in getContactChatsInput, +) (*mcp.CallToolResult, any, error) { + if in.Jid == "" { + return ErrResult("jid is required"), nil, nil + } + + limit := in.Limit + if limit <= 0 { + limit = 20 + } + page := in.Page + if page < 0 { + page = 0 + } + + // GET /api/contacts/{jid}/chats?limit=...&page=... + path := fmt.Sprintf("/contacts/%s/chats?limit=%d&page=%d", in.Jid, limit, page) + + data, err := callAPI(http.MethodGet, path, nil) + if err != nil { + return ErrResult(err.Error()), nil, nil + } + + var result struct { + Chats []map[string]any `json:"chats"` + Count int `json:"count"` + } + if err := json.Unmarshal(data, &result); err != nil { + return ErrResult("failed to parse chats response"), nil, nil + } + + return &mcp.CallToolResult{}, result.Chats, nil +} + +func getLastInteractionHandler( + ctx context.Context, + req *mcp.CallToolRequest, + in getLastInteractionInput, +) (*mcp.CallToolResult, any, error) { + if in.Jid == "" { + return ErrResult("jid is required"), nil, nil + } + + // We simulate it by asking for 1 message from that sender + data, err := callAPI(http.MethodGet, "/messages?sender="+in.Jid+"&limit=1", nil) + if err != nil { + return ErrResult(err.Error()), nil, nil + } + + return OkResult(string(data)), nil, nil +} + +func sendFileHandler(ctx context.Context, + req *mcp.CallToolRequest, + in sendFileInput) (*mcp.CallToolResult, map[string]any, error) { + + if in.MediaPath == "" { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: "media_path is required"}}, + }, nil, nil + } + + absPath, err := filepath.Abs(in.MediaPath) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid path: %v", err)}}, + }, nil, nil + } + + success, msg := SendFile(in.Recipient, absPath) + + resultData := map[string]any{"success": success, "message": msg} + + return &mcp.CallToolResult{IsError: !success}, resultData, nil +} + +func sendAudioMessageHandler(ctx context.Context, + req *mcp.CallToolRequest, + in sendAudioMessageInput) (*mcp.CallToolResult, map[string]any, error) { + + success, msg := SendAudioVoiceMessage(in.Recipient, in.MediaPath) + + resultData := map[string]any{"success": success, "message": msg} + + return &mcp.CallToolResult{IsError: !success}, resultData, nil +} diff --git a/whatsapp-mcp-server/helpers/tools.go b/whatsapp-mcp-server/helpers/tools.go new file mode 100644 index 0000000..608414b --- /dev/null +++ b/whatsapp-mcp-server/helpers/tools.go @@ -0,0 +1,51 @@ +package helpers + +import ( + "encoding/json" + "os" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var apiBaseURL = ReadEnv("API_BASE_URL", "http://192.168.178.119:30015/api") + +const apiTimeout = 25 * time.Second + +// OkResult return proper ok result for mcp tool +func OkResult(v any) *mcp.CallToolResult { + b, err := json.Marshal(v) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: "Failed to encode JSON: " + err.Error()}, + }, + } + } + + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{ + &mcp.TextContent{Text: string(b)}, + }, + } +} + +// ErrResult return error result for mcp tool +func ErrResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: msg}, + }, + } +} + +// ReadEnv read return value for an env +func ReadEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} diff --git a/whatsapp-mcp-server/helpers/whatsapp.go b/whatsapp-mcp-server/helpers/whatsapp.go new file mode 100644 index 0000000..b7bdf4f --- /dev/null +++ b/whatsapp-mcp-server/helpers/whatsapp.go @@ -0,0 +1,254 @@ +package helpers + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type Message struct { + Timestamp time.Time `json:"timestamp"` + Sender string `json:"sender"` + Content string `json:"content"` + IsFromMe bool `json:"is_from_me"` + ChatJID string `json:"chat_jid"` + ID string `json:"id"` + ChatName string `json:"chat_name,omitempty"` + MediaType string `json:"media_type,omitempty"` +} + +type Chat struct { + JID string `json:"jid"` + Name string `json:"name,omitempty"` + LastMessageTime time.Time `json:"last_message_time,omitempty"` + LastMessage string `json:"last_message,omitempty"` + LastSender string `json:"last_sender,omitempty"` + LastIsFromMe bool `json:"last_is_from_me,omitempty"` +} + +func (c *Chat) IsGroup() bool { + return strings.HasSuffix(c.JID, "@g.us") +} + +type Contact struct { + PhoneNumber string `json:"phone_number"` + Name string `json:"name,omitempty"` + JID string `json:"jid"` +} + +type MessageContext struct { + Message Message `json:"message"` + Before []Message `json:"before"` + After []Message `json:"after"` +} + +type ListMessagesParams struct { + After, Before string + SenderPhoneNumber *string + ChatJid *string + Query *string + Limit, Page int + IncludeContext bool + ContextBefore int + ContextAfter int +} + +func SendMessage(recipient, message string) (bool, string) { + if recipient == "" { + return false, "Recipient must be provided" + } + + payload := map[string]string{ + "recipient": recipient, + "message": message, + } + + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", apiBaseURL+"/send", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return false, "Request error: " + err.Error() + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return false, fmt.Sprintf("HTTP %d - %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, "Failed to parse response" + } + + success, _ := result["success"].(bool) + msg, _ := result["message"].(string) + if msg == "" { + msg = "Unknown response" + } + + return success, msg +} + +func SendFile(recipient, mediaPath string) (bool, string) { + if recipient == "" { + return false, "Recipient must be provided" + } + if mediaPath == "" { + return false, "Media path must be provided" + } + if _, err := os.Stat(mediaPath); os.IsNotExist(err) { + return false, "Media file not found: " + mediaPath + } + + payload := map[string]string{ + "recipient": recipient, + "media_path": mediaPath, + } + + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", apiBaseURL+"/send", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return false, "Request error: " + err.Error() + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return false, fmt.Sprintf("HTTP %d - %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, "Failed to parse response" + } + + success, _ := result["success"].(bool) + msg, _ := result["message"].(string) + if msg == "" { + msg = "Unknown response" + } + + return success, msg +} + +func SendAudioVoiceMessage(recipient, mediaPath string) (bool, string) { + if recipient == "" { + return false, "Recipient must be provided" + } + if mediaPath == "" { + return false, "Media path must be provided" + } + if _, err := os.Stat(mediaPath); os.IsNotExist(err) { + return false, "Media file not found: " + mediaPath + } + + finalPath := mediaPath + if !strings.HasSuffix(strings.ToLower(mediaPath), ".ogg") { + converted, err := ConvertToOpusOggTemp(mediaPath) + if err != nil { + return false, "Audio conversion failed (ffmpeg required?): " + err.Error() + } + finalPath = converted + defer func(name string) { + err := os.Remove(name) + if err != nil { + + } + }(finalPath) + } + + payload := map[string]string{ + "recipient": recipient, + "media_path": finalPath, + } + + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", apiBaseURL+"/send", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return false, "Request error: " + err.Error() + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return false, fmt.Sprintf("HTTP %d - %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, "Failed to parse response" + } + + success, _ := result["success"].(bool) + msg, _ := result["message"].(string) + if msg == "" { + msg = "Unknown response" + } + + return success, msg +} + +func DownloadMedia(messageID, chatJID string) (string, error) { + payload := map[string]string{ + "message_id": messageID, + "chat_jid": chatJID, + } + + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", apiBaseURL+"/download", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d - %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if success, ok := result["success"].(bool); !ok || !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "Unknown error" + } + return "", errors.New(msg) + } + + path, ok := result["path"].(string) + if !ok || path == "" { + return "", errors.New("no path returned") + } + + log.Printf("Media downloaded: %s", path) + return path, nil +} diff --git a/whatsapp-mcp-server/main.go b/whatsapp-mcp-server/main.go new file mode 100644 index 0000000..40f18a6 --- /dev/null +++ b/whatsapp-mcp-server/main.go @@ -0,0 +1,7 @@ +package main + +import "whatsapp-mcp-server/helpers" + +func main() { + helpers.InitMcpTool() +}