commit dd5e347436ba796f9bd84ec54e653fbdada1c60f Author: iamatulsingh Date: Sat Feb 21 21:12:14 2026 +0100 feat: whatsapp-mcp-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 0000000..3bb9ff1 Binary files /dev/null and b/screenshots/chat_example.png differ diff --git a/screenshots/connector_claude.png b/screenshots/connector_claude.png new file mode 100644 index 0000000..32cf186 Binary files /dev/null and b/screenshots/connector_claude.png differ 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() +}