├── whatsapp-mcp-server ├── .python-version ├── .gitignore ├── pyproject.toml ├── audio.py ├── main.py ├── whatsapp.py └── uv.lock ├── .gitignore ├── example-use.png ├── whatsapp-bridge ├── go.mod ├── go.sum └── main.go ├── LICENSE └── README.md /whatsapp-mcp-server/.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | claude_desktop_config.json 3 | whatsapp.log -------------------------------------------------------------------------------- /example-use.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lharries/whatsapp-mcp/HEAD/example-use.png -------------------------------------------------------------------------------- /whatsapp-mcp-server/.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | -------------------------------------------------------------------------------- /whatsapp-mcp-server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "whatsapp-mcp-server" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "httpx>=0.28.1", 9 | "mcp[cli]>=1.6.0", 10 | "requests>=2.32.3", 11 | ] 12 | -------------------------------------------------------------------------------- /whatsapp-bridge/go.mod: -------------------------------------------------------------------------------- 1 | module whatsapp-client 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/mattn/go-sqlite3 v1.14.24 7 | go.mau.fi/whatsmeow v0.0.0-20250318233852-06705625cf82 8 | ) 9 | 10 | require ( 11 | filippo.io/edwards25519 v1.1.0 // indirect 12 | github.com/google/uuid v1.6.0 // indirect 13 | github.com/gorilla/websocket v1.5.0 // indirect 14 | github.com/mattn/go-colorable v0.1.13 // indirect 15 | github.com/mattn/go-isatty v0.0.19 // indirect 16 | github.com/mdp/qrterminal v1.0.1 // indirect 17 | github.com/rs/zerolog v1.33.0 // indirect 18 | go.mau.fi/libsignal v0.1.2 // indirect 19 | go.mau.fi/util v0.8.6 // indirect 20 | golang.org/x/crypto v0.36.0 // indirect 21 | golang.org/x/net v0.37.0 // indirect 22 | golang.org/x/sys v0.31.0 // indirect 23 | google.golang.org/protobuf v1.36.5 // indirect 24 | rsc.io/qr v0.2.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Luke Harries 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /whatsapp-mcp-server/audio.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | 5 | def convert_to_opus_ogg(input_file, output_file=None, bitrate="32k", sample_rate=24000): 6 | """ 7 | Convert an audio file to Opus format in an Ogg container. 8 | 9 | Args: 10 | input_file (str): Path to the input audio file 11 | output_file (str, optional): Path to save the output file. If None, replaces the 12 | extension of input_file with .ogg 13 | bitrate (str, optional): Target bitrate for Opus encoding (default: "32k") 14 | sample_rate (int, optional): Sample rate for output (default: 24000) 15 | 16 | Returns: 17 | str: Path to the converted file 18 | 19 | Raises: 20 | FileNotFoundError: If the input file doesn't exist 21 | RuntimeError: If the ffmpeg conversion fails 22 | """ 23 | if not os.path.isfile(input_file): 24 | raise FileNotFoundError(f"Input file not found: {input_file}") 25 | 26 | # If no output file is specified, replace the extension with .ogg 27 | if output_file is None: 28 | output_file = os.path.splitext(input_file)[0] + ".ogg" 29 | 30 | # Ensure the output directory exists 31 | output_dir = os.path.dirname(output_file) 32 | if output_dir and not os.path.exists(output_dir): 33 | os.makedirs(output_dir) 34 | 35 | # Build the ffmpeg command 36 | cmd = [ 37 | "ffmpeg", 38 | "-i", input_file, 39 | "-c:a", "libopus", 40 | "-b:a", bitrate, 41 | "-ar", str(sample_rate), 42 | "-application", "voip", # Optimize for voice 43 | "-vbr", "on", # Variable bitrate 44 | "-compression_level", "10", # Maximum compression 45 | "-frame_duration", "60", # 60ms frames (good for voice) 46 | "-y", # Overwrite output file if it exists 47 | output_file 48 | ] 49 | 50 | try: 51 | # Run the ffmpeg command and capture output 52 | process = subprocess.run( 53 | cmd, 54 | stdout=subprocess.PIPE, 55 | stderr=subprocess.PIPE, 56 | text=True, 57 | check=True 58 | ) 59 | return output_file 60 | except subprocess.CalledProcessError as e: 61 | raise RuntimeError(f"Failed to convert audio. You likely need to install ffmpeg {e.stderr}") 62 | 63 | 64 | def convert_to_opus_ogg_temp(input_file, bitrate="32k", sample_rate=24000): 65 | """ 66 | Convert an audio file to Opus format in an Ogg container and store in a temporary file. 67 | 68 | Args: 69 | input_file (str): Path to the input audio file 70 | bitrate (str, optional): Target bitrate for Opus encoding (default: "32k") 71 | sample_rate (int, optional): Sample rate for output (default: 24000) 72 | 73 | Returns: 74 | str: Path to the temporary file with the converted audio 75 | 76 | Raises: 77 | FileNotFoundError: If the input file doesn't exist 78 | RuntimeError: If the ffmpeg conversion fails 79 | """ 80 | # Create a temporary file with .ogg extension 81 | temp_file = tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) 82 | temp_file.close() 83 | 84 | try: 85 | # Convert the audio 86 | convert_to_opus_ogg(input_file, temp_file.name, bitrate, sample_rate) 87 | return temp_file.name 88 | except Exception as e: 89 | # Clean up the temporary file if conversion fails 90 | if os.path.exists(temp_file.name): 91 | os.unlink(temp_file.name) 92 | raise e 93 | 94 | 95 | if __name__ == "__main__": 96 | # Example usage 97 | import sys 98 | 99 | if len(sys.argv) < 2: 100 | print("Usage: python audio.py input_file [output_file]") 101 | sys.exit(1) 102 | 103 | input_file = sys.argv[1] 104 | 105 | try: 106 | result = convert_to_opus_ogg_temp(input_file) 107 | print(f"Successfully converted to: {result}") 108 | except Exception as e: 109 | print(f"Error: {e}") 110 | sys.exit(1) 111 | -------------------------------------------------------------------------------- /whatsapp-bridge/go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 7 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 8 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 12 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 13 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 14 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 15 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 16 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 17 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 18 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 19 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 20 | github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c= 21 | github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ= 22 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 26 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 27 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 28 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 29 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 30 | go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0= 31 | go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE= 32 | go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= 33 | go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= 34 | go.mau.fi/whatsmeow v0.0.0-20250318233852-06705625cf82 h1:AZlDkXHgoQNW4gd2hnTCvPH7hYznmwc3gPaYqGZ5w8A= 35 | go.mau.fi/whatsmeow v0.0.0-20250318233852-06705625cf82/go.mod h1:WNhj4JeQ6YR6dUOEiCXKqmE4LavSFkwRoKmu4atRrRs= 36 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 37 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 38 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 39 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 40 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 44 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 45 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 46 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 47 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 48 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 49 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 50 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 51 | rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= 52 | rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WhatsApp MCP Server 2 | 3 | This is a Model Context Protocol (MCP) server for WhatsApp. 4 | 5 | With this you can search and read your personal Whatsapp messages (including images, videos, documents, and audio messages), search your contacts and send messages to either individuals or groups. You can also send media files including images, videos, documents, and audio messages. 6 | 7 | It connects to your **personal WhatsApp account** directly via the Whatsapp web multidevice API (using the [whatsmeow](https://github.com/tulir/whatsmeow) library). All your messages are stored locally in a SQLite database and only sent to an LLM (such as Claude) when the agent accesses them through tools (which you control). 8 | 9 | Here's an example of what you can do when it's connected to Claude. 10 | 11 | ![WhatsApp MCP](./example-use.png) 12 | 13 | > To get updates on this and other projects I work on [enter your email here](https://docs.google.com/forms/d/1rTF9wMBTN0vPfzWuQa2BjfGKdKIpTbyeKxhPMcEzgyI/preview) 14 | 15 | > *Caution:* as with many MCP servers, the WhatsApp MCP is subject to [the lethal trifecta](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/). This means that project injection could lead to private data exfiltration. 16 | 17 | ## Installation 18 | 19 | ### Prerequisites 20 | 21 | - Go 22 | - Python 3.6+ 23 | - Anthropic Claude Desktop app (or Cursor) 24 | - UV (Python package manager), install with `curl -LsSf https://astral.sh/uv/install.sh | sh` 25 | - 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. 26 | 27 | ### Steps 28 | 29 | 1. **Clone this repository** 30 | 31 | ```bash 32 | git clone https://github.com/lharries/whatsapp-mcp.git 33 | cd whatsapp-mcp 34 | ``` 35 | 36 | 2. **Run the WhatsApp bridge** 37 | 38 | Navigate to the whatsapp-bridge directory and run the Go application: 39 | 40 | ```bash 41 | cd whatsapp-bridge 42 | go run main.go 43 | ``` 44 | 45 | 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. 46 | 47 | After approximately 20 days, you will might need to re-authenticate. 48 | 49 | 3. **Connect to the MCP server** 50 | 51 | Copy the below json with the appropriate {{PATH}} values: 52 | 53 | ```json 54 | { 55 | "mcpServers": { 56 | "whatsapp": { 57 | "command": "{{PATH_TO_UV}}", // Run `which uv` and place the output here 58 | "args": [ 59 | "--directory", 60 | "{{PATH_TO_SRC}}/whatsapp-mcp/whatsapp-mcp-server", // cd into the repo, run `pwd` and enter the output here + "/whatsapp-mcp-server" 61 | "run", 62 | "main.py" 63 | ] 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | For **Claude**, save this as `claude_desktop_config.json` in your Claude Desktop configuration directory at: 70 | 71 | ``` 72 | ~/Library/Application Support/Claude/claude_desktop_config.json 73 | ``` 74 | 75 | For **Cursor**, save this as `mcp.json` in your Cursor configuration directory at: 76 | 77 | ``` 78 | ~/.cursor/mcp.json 79 | ``` 80 | 81 | 4. **Restart Claude Desktop / Cursor** 82 | 83 | Open Claude Desktop and you should now see WhatsApp as an available integration. 84 | 85 | Or restart Cursor. 86 | 87 | ### Windows Compatibility 88 | 89 | 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. 90 | 91 | #### Steps to get it working: 92 | 93 | 1. **Install a C compiler** 94 | 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`. 95 | → A step-by-step guide is available [here](https://code.visualstudio.com/docs/cpp/config-mingw). 96 | 97 | 2. **Enable CGO and run the app** 98 | 99 | ```bash 100 | cd whatsapp-bridge 101 | go env -w CGO_ENABLED=1 102 | go run main.go 103 | ``` 104 | 105 | Without this setup, you'll likely run into errors like: 106 | 107 | > `Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work.` 108 | 109 | ## Architecture Overview 110 | 111 | This application consists of two main components: 112 | 113 | 1. **Go 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. 114 | 115 | 2. **Python MCP Server** (`whatsapp-mcp-server/`): A Python server implementing the Model Context Protocol (MCP), which provides standardized tools for Claude to interact with WhatsApp data and send/receive messages. 116 | 117 | ### Data Storage 118 | 119 | - All message history is stored in a SQLite database within the `whatsapp-bridge/store/` directory 120 | - The database maintains tables for chats and messages 121 | - Messages are indexed for efficient searching and retrieval 122 | 123 | ## Usage 124 | 125 | Once connected, you can interact with your WhatsApp contacts through Claude, leveraging Claude's AI capabilities in your WhatsApp conversations. 126 | 127 | ### MCP Tools 128 | 129 | Claude can access the following tools to interact with WhatsApp: 130 | 131 | - **search_contacts**: Search for contacts by name or phone number 132 | - **list_messages**: Retrieve messages with optional filters and context 133 | - **list_chats**: List available chats with metadata 134 | - **get_chat**: Get information about a specific chat 135 | - **get_direct_chat_by_contact**: Find a direct chat with a specific contact 136 | - **get_contact_chats**: List all chats involving a specific contact 137 | - **get_last_interaction**: Get the most recent message with a contact 138 | - **get_message_context**: Retrieve context around a specific message 139 | - **send_message**: Send a WhatsApp message to a specified phone number or group JID 140 | - **send_file**: Send a file (image, video, raw audio, document) to a specified recipient 141 | - **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) 142 | - **download_media**: Download media from a WhatsApp message and get the local file path 143 | 144 | ### Media Handling Features 145 | 146 | The MCP server supports both sending and receiving various media types: 147 | 148 | #### Media Sending 149 | 150 | You can send various media types to your WhatsApp contacts: 151 | 152 | - **Images, Videos, Documents**: Use the `send_file` tool to share any supported media type. 153 | - **Voice Messages**: Use the `send_audio_message` tool to send audio files as playable WhatsApp voice messages. 154 | - For optimal compatibility, audio files should be in `.ogg` Opus format. 155 | - With FFmpeg installed, the system will automatically convert other audio formats (MP3, WAV, etc.) to the required format. 156 | - Without FFmpeg, you can still send raw audio files using the `send_file` tool, but they won't appear as playable voice messages. 157 | 158 | #### Media Downloading 159 | 160 | 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. 161 | 162 | ## Technical Details 163 | 164 | 1. Claude sends requests to the Python MCP server 165 | 2. The MCP server queries the Go bridge for WhatsApp data or directly to the SQLite database 166 | 3. The Go accesses the WhatsApp API and keeps the SQLite database up to date 167 | 4. Data flows back through the chain to Claude 168 | 5. When sending messages, the request flows from Claude through the MCP server to the Go bridge and to WhatsApp 169 | 170 | ## Troubleshooting 171 | 172 | - If you encounter permission issues when running uv, you may need to add it to your PATH or use the full path to the executable. 173 | - Make sure both the Go application and the Python server are running for the integration to work properly. 174 | 175 | ### Authentication Issues 176 | 177 | - **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. 178 | - **WhatsApp Already Logged In**: If your session is already active, the Go bridge will automatically reconnect without showing a QR code. 179 | - **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). 180 | - **No Messages Loading**: After initial authentication, it can take several minutes for your message history to load, especially if you have many chats. 181 | - **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. 182 | 183 | For additional Claude Desktop integration troubleshooting, see the [MCP documentation](https://modelcontextprotocol.io/quickstart/server#claude-for-desktop-integration-issues). The documentation includes helpful tips for checking logs and resolving common issues. 184 | -------------------------------------------------------------------------------- /whatsapp-mcp-server/main.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any, Optional 2 | from mcp.server.fastmcp import FastMCP 3 | from whatsapp import ( 4 | search_contacts as whatsapp_search_contacts, 5 | list_messages as whatsapp_list_messages, 6 | list_chats as whatsapp_list_chats, 7 | get_chat as whatsapp_get_chat, 8 | get_direct_chat_by_contact as whatsapp_get_direct_chat_by_contact, 9 | get_contact_chats as whatsapp_get_contact_chats, 10 | get_last_interaction as whatsapp_get_last_interaction, 11 | get_message_context as whatsapp_get_message_context, 12 | send_message as whatsapp_send_message, 13 | send_file as whatsapp_send_file, 14 | send_audio_message as whatsapp_audio_voice_message, 15 | download_media as whatsapp_download_media 16 | ) 17 | 18 | # Initialize FastMCP server 19 | mcp = FastMCP("whatsapp") 20 | 21 | @mcp.tool() 22 | def search_contacts(query: str) -> List[Dict[str, Any]]: 23 | """Search WhatsApp contacts by name or phone number. 24 | 25 | Args: 26 | query: Search term to match against contact names or phone numbers 27 | """ 28 | contacts = whatsapp_search_contacts(query) 29 | return contacts 30 | 31 | @mcp.tool() 32 | def list_messages( 33 | after: Optional[str] = None, 34 | before: Optional[str] = None, 35 | sender_phone_number: Optional[str] = None, 36 | chat_jid: Optional[str] = None, 37 | query: Optional[str] = None, 38 | limit: int = 20, 39 | page: int = 0, 40 | include_context: bool = True, 41 | context_before: int = 1, 42 | context_after: int = 1 43 | ) -> List[Dict[str, Any]]: 44 | """Get WhatsApp messages matching specified criteria with optional context. 45 | 46 | Args: 47 | after: Optional ISO-8601 formatted string to only return messages after this date 48 | before: Optional ISO-8601 formatted string to only return messages before this date 49 | sender_phone_number: Optional phone number to filter messages by sender 50 | chat_jid: Optional chat JID to filter messages by chat 51 | query: Optional search term to filter messages by content 52 | limit: Maximum number of messages to return (default 20) 53 | page: Page number for pagination (default 0) 54 | include_context: Whether to include messages before and after matches (default True) 55 | context_before: Number of messages to include before each match (default 1) 56 | context_after: Number of messages to include after each match (default 1) 57 | """ 58 | messages = whatsapp_list_messages( 59 | after=after, 60 | before=before, 61 | sender_phone_number=sender_phone_number, 62 | chat_jid=chat_jid, 63 | query=query, 64 | limit=limit, 65 | page=page, 66 | include_context=include_context, 67 | context_before=context_before, 68 | context_after=context_after 69 | ) 70 | return messages 71 | 72 | @mcp.tool() 73 | def list_chats( 74 | query: Optional[str] = None, 75 | limit: int = 20, 76 | page: int = 0, 77 | include_last_message: bool = True, 78 | sort_by: str = "last_active" 79 | ) -> List[Dict[str, Any]]: 80 | """Get WhatsApp chats matching specified criteria. 81 | 82 | Args: 83 | query: Optional search term to filter chats by name or JID 84 | limit: Maximum number of chats to return (default 20) 85 | page: Page number for pagination (default 0) 86 | include_last_message: Whether to include the last message in each chat (default True) 87 | sort_by: Field to sort results by, either "last_active" or "name" (default "last_active") 88 | """ 89 | chats = whatsapp_list_chats( 90 | query=query, 91 | limit=limit, 92 | page=page, 93 | include_last_message=include_last_message, 94 | sort_by=sort_by 95 | ) 96 | return chats 97 | 98 | @mcp.tool() 99 | def get_chat(chat_jid: str, include_last_message: bool = True) -> Dict[str, Any]: 100 | """Get WhatsApp chat metadata by JID. 101 | 102 | Args: 103 | chat_jid: The JID of the chat to retrieve 104 | include_last_message: Whether to include the last message (default True) 105 | """ 106 | chat = whatsapp_get_chat(chat_jid, include_last_message) 107 | return chat 108 | 109 | @mcp.tool() 110 | def get_direct_chat_by_contact(sender_phone_number: str) -> Dict[str, Any]: 111 | """Get WhatsApp chat metadata by sender phone number. 112 | 113 | Args: 114 | sender_phone_number: The phone number to search for 115 | """ 116 | chat = whatsapp_get_direct_chat_by_contact(sender_phone_number) 117 | return chat 118 | 119 | @mcp.tool() 120 | def get_contact_chats(jid: str, limit: int = 20, page: int = 0) -> List[Dict[str, Any]]: 121 | """Get all WhatsApp chats involving the contact. 122 | 123 | Args: 124 | jid: The contact's JID to search for 125 | limit: Maximum number of chats to return (default 20) 126 | page: Page number for pagination (default 0) 127 | """ 128 | chats = whatsapp_get_contact_chats(jid, limit, page) 129 | return chats 130 | 131 | @mcp.tool() 132 | def get_last_interaction(jid: str) -> str: 133 | """Get most recent WhatsApp message involving the contact. 134 | 135 | Args: 136 | jid: The JID of the contact to search for 137 | """ 138 | message = whatsapp_get_last_interaction(jid) 139 | return message 140 | 141 | @mcp.tool() 142 | def get_message_context( 143 | message_id: str, 144 | before: int = 5, 145 | after: int = 5 146 | ) -> Dict[str, Any]: 147 | """Get context around a specific WhatsApp message. 148 | 149 | Args: 150 | message_id: The ID of the message to get context for 151 | before: Number of messages to include before the target message (default 5) 152 | after: Number of messages to include after the target message (default 5) 153 | """ 154 | context = whatsapp_get_message_context(message_id, before, after) 155 | return context 156 | 157 | @mcp.tool() 158 | def send_message( 159 | recipient: str, 160 | message: str 161 | ) -> Dict[str, Any]: 162 | """Send a WhatsApp message to a person or group. For group chats use the JID. 163 | 164 | Args: 165 | recipient: The recipient - either a phone number with country code but no + or other symbols, 166 | or a JID (e.g., "123456789@s.whatsapp.net" or a group JID like "123456789@g.us") 167 | message: The message text to send 168 | 169 | Returns: 170 | A dictionary containing success status and a status message 171 | """ 172 | # Validate input 173 | if not recipient: 174 | return { 175 | "success": False, 176 | "message": "Recipient must be provided" 177 | } 178 | 179 | # Call the whatsapp_send_message function with the unified recipient parameter 180 | success, status_message = whatsapp_send_message(recipient, message) 181 | return { 182 | "success": success, 183 | "message": status_message 184 | } 185 | 186 | @mcp.tool() 187 | def send_file(recipient: str, media_path: str) -> Dict[str, Any]: 188 | """Send a file such as a picture, raw audio, video or document via WhatsApp to the specified recipient. For group messages use the JID. 189 | 190 | Args: 191 | recipient: The recipient - either a phone number with country code but no + or other symbols, 192 | or a JID (e.g., "123456789@s.whatsapp.net" or a group JID like "123456789@g.us") 193 | media_path: The absolute path to the media file to send (image, video, document) 194 | 195 | Returns: 196 | A dictionary containing success status and a status message 197 | """ 198 | 199 | # Call the whatsapp_send_file function 200 | success, status_message = whatsapp_send_file(recipient, media_path) 201 | return { 202 | "success": success, 203 | "message": status_message 204 | } 205 | 206 | @mcp.tool() 207 | def send_audio_message(recipient: str, media_path: str) -> Dict[str, Any]: 208 | """Send any audio file as a WhatsApp audio message to the specified recipient. For group messages use the JID. If it errors due to ffmpeg not being installed, use send_file instead. 209 | 210 | Args: 211 | recipient: The recipient - either a phone number with country code but no + or other symbols, 212 | or a JID (e.g., "123456789@s.whatsapp.net" or a group JID like "123456789@g.us") 213 | media_path: The absolute path to the audio file to send (will be converted to Opus .ogg if it's not a .ogg file) 214 | 215 | Returns: 216 | A dictionary containing success status and a status message 217 | """ 218 | success, status_message = whatsapp_audio_voice_message(recipient, media_path) 219 | return { 220 | "success": success, 221 | "message": status_message 222 | } 223 | 224 | @mcp.tool() 225 | def download_media(message_id: str, chat_jid: str) -> Dict[str, Any]: 226 | """Download media from a WhatsApp message and get the local file path. 227 | 228 | Args: 229 | message_id: The ID of the message containing the media 230 | chat_jid: The JID of the chat containing the message 231 | 232 | Returns: 233 | A dictionary containing success status, a status message, and the file path if successful 234 | """ 235 | file_path = whatsapp_download_media(message_id, chat_jid) 236 | 237 | if file_path: 238 | return { 239 | "success": True, 240 | "message": "Media downloaded successfully", 241 | "file_path": file_path 242 | } 243 | else: 244 | return { 245 | "success": False, 246 | "message": "Failed to download media" 247 | } 248 | 249 | if __name__ == "__main__": 250 | # Initialize and run the server 251 | mcp.run(transport='stdio') -------------------------------------------------------------------------------- /whatsapp-mcp-server/whatsapp.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from datetime import datetime 3 | from dataclasses import dataclass 4 | from typing import Optional, List, Tuple 5 | import os.path 6 | import requests 7 | import json 8 | import audio 9 | 10 | MESSAGES_DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'whatsapp-bridge', 'store', 'messages.db') 11 | WHATSAPP_API_BASE_URL = "http://localhost:8080/api" 12 | 13 | @dataclass 14 | class Message: 15 | timestamp: datetime 16 | sender: str 17 | content: str 18 | is_from_me: bool 19 | chat_jid: str 20 | id: str 21 | chat_name: Optional[str] = None 22 | media_type: Optional[str] = None 23 | 24 | @dataclass 25 | class Chat: 26 | jid: str 27 | name: Optional[str] 28 | last_message_time: Optional[datetime] 29 | last_message: Optional[str] = None 30 | last_sender: Optional[str] = None 31 | last_is_from_me: Optional[bool] = None 32 | 33 | @property 34 | def is_group(self) -> bool: 35 | """Determine if chat is a group based on JID pattern.""" 36 | return self.jid.endswith("@g.us") 37 | 38 | @dataclass 39 | class Contact: 40 | phone_number: str 41 | name: Optional[str] 42 | jid: str 43 | 44 | @dataclass 45 | class MessageContext: 46 | message: Message 47 | before: List[Message] 48 | after: List[Message] 49 | 50 | def get_sender_name(sender_jid: str) -> str: 51 | try: 52 | conn = sqlite3.connect(MESSAGES_DB_PATH) 53 | cursor = conn.cursor() 54 | 55 | # First try matching by exact JID 56 | cursor.execute(""" 57 | SELECT name 58 | FROM chats 59 | WHERE jid = ? 60 | LIMIT 1 61 | """, (sender_jid,)) 62 | 63 | result = cursor.fetchone() 64 | 65 | # If no result, try looking for the number within JIDs 66 | if not result: 67 | # Extract the phone number part if it's a JID 68 | if '@' in sender_jid: 69 | phone_part = sender_jid.split('@')[0] 70 | else: 71 | phone_part = sender_jid 72 | 73 | cursor.execute(""" 74 | SELECT name 75 | FROM chats 76 | WHERE jid LIKE ? 77 | LIMIT 1 78 | """, (f"%{phone_part}%",)) 79 | 80 | result = cursor.fetchone() 81 | 82 | if result and result[0]: 83 | return result[0] 84 | else: 85 | return sender_jid 86 | 87 | except sqlite3.Error as e: 88 | print(f"Database error while getting sender name: {e}") 89 | return sender_jid 90 | finally: 91 | if 'conn' in locals(): 92 | conn.close() 93 | 94 | def format_message(message: Message, show_chat_info: bool = True) -> None: 95 | """Print a single message with consistent formatting.""" 96 | output = "" 97 | 98 | if show_chat_info and message.chat_name: 99 | output += f"[{message.timestamp:%Y-%m-%d %H:%M:%S}] Chat: {message.chat_name} " 100 | else: 101 | output += f"[{message.timestamp:%Y-%m-%d %H:%M:%S}] " 102 | 103 | content_prefix = "" 104 | if hasattr(message, 'media_type') and message.media_type: 105 | content_prefix = f"[{message.media_type} - Message ID: {message.id} - Chat JID: {message.chat_jid}] " 106 | 107 | try: 108 | sender_name = get_sender_name(message.sender) if not message.is_from_me else "Me" 109 | output += f"From: {sender_name}: {content_prefix}{message.content}\n" 110 | except Exception as e: 111 | print(f"Error formatting message: {e}") 112 | return output 113 | 114 | def format_messages_list(messages: List[Message], show_chat_info: bool = True) -> None: 115 | output = "" 116 | if not messages: 117 | output += "No messages to display." 118 | return output 119 | 120 | for message in messages: 121 | output += format_message(message, show_chat_info) 122 | return output 123 | 124 | def list_messages( 125 | after: Optional[str] = None, 126 | before: Optional[str] = None, 127 | sender_phone_number: Optional[str] = None, 128 | chat_jid: Optional[str] = None, 129 | query: Optional[str] = None, 130 | limit: int = 20, 131 | page: int = 0, 132 | include_context: bool = True, 133 | context_before: int = 1, 134 | context_after: int = 1 135 | ) -> List[Message]: 136 | """Get messages matching the specified criteria with optional context.""" 137 | try: 138 | conn = sqlite3.connect(MESSAGES_DB_PATH) 139 | cursor = conn.cursor() 140 | 141 | # Build base query 142 | query_parts = ["SELECT messages.timestamp, messages.sender, chats.name, messages.content, messages.is_from_me, chats.jid, messages.id, messages.media_type FROM messages"] 143 | query_parts.append("JOIN chats ON messages.chat_jid = chats.jid") 144 | where_clauses = [] 145 | params = [] 146 | 147 | # Add filters 148 | if after: 149 | try: 150 | after = datetime.fromisoformat(after) 151 | except ValueError: 152 | raise ValueError(f"Invalid date format for 'after': {after}. Please use ISO-8601 format.") 153 | 154 | where_clauses.append("messages.timestamp > ?") 155 | params.append(after) 156 | 157 | if before: 158 | try: 159 | before = datetime.fromisoformat(before) 160 | except ValueError: 161 | raise ValueError(f"Invalid date format for 'before': {before}. Please use ISO-8601 format.") 162 | 163 | where_clauses.append("messages.timestamp < ?") 164 | params.append(before) 165 | 166 | if sender_phone_number: 167 | where_clauses.append("messages.sender = ?") 168 | params.append(sender_phone_number) 169 | 170 | if chat_jid: 171 | where_clauses.append("messages.chat_jid = ?") 172 | params.append(chat_jid) 173 | 174 | if query: 175 | where_clauses.append("LOWER(messages.content) LIKE LOWER(?)") 176 | params.append(f"%{query}%") 177 | 178 | if where_clauses: 179 | query_parts.append("WHERE " + " AND ".join(where_clauses)) 180 | 181 | # Add pagination 182 | offset = page * limit 183 | query_parts.append("ORDER BY messages.timestamp DESC") 184 | query_parts.append("LIMIT ? OFFSET ?") 185 | params.extend([limit, offset]) 186 | 187 | cursor.execute(" ".join(query_parts), tuple(params)) 188 | messages = cursor.fetchall() 189 | 190 | result = [] 191 | for msg in messages: 192 | message = Message( 193 | timestamp=datetime.fromisoformat(msg[0]), 194 | sender=msg[1], 195 | chat_name=msg[2], 196 | content=msg[3], 197 | is_from_me=msg[4], 198 | chat_jid=msg[5], 199 | id=msg[6], 200 | media_type=msg[7] 201 | ) 202 | result.append(message) 203 | 204 | if include_context and result: 205 | # Add context for each message 206 | messages_with_context = [] 207 | for msg in result: 208 | context = get_message_context(msg.id, context_before, context_after) 209 | messages_with_context.extend(context.before) 210 | messages_with_context.append(context.message) 211 | messages_with_context.extend(context.after) 212 | 213 | return format_messages_list(messages_with_context, show_chat_info=True) 214 | 215 | # Format and display messages without context 216 | return format_messages_list(result, show_chat_info=True) 217 | 218 | except sqlite3.Error as e: 219 | print(f"Database error: {e}") 220 | return [] 221 | finally: 222 | if 'conn' in locals(): 223 | conn.close() 224 | 225 | 226 | def get_message_context( 227 | message_id: str, 228 | before: int = 5, 229 | after: int = 5 230 | ) -> MessageContext: 231 | """Get context around a specific message.""" 232 | try: 233 | conn = sqlite3.connect(MESSAGES_DB_PATH) 234 | cursor = conn.cursor() 235 | 236 | # Get the target message first 237 | cursor.execute(""" 238 | SELECT messages.timestamp, messages.sender, chats.name, messages.content, messages.is_from_me, chats.jid, messages.id, messages.chat_jid, messages.media_type 239 | FROM messages 240 | JOIN chats ON messages.chat_jid = chats.jid 241 | WHERE messages.id = ? 242 | """, (message_id,)) 243 | msg_data = cursor.fetchone() 244 | 245 | if not msg_data: 246 | raise ValueError(f"Message with ID {message_id} not found") 247 | 248 | target_message = Message( 249 | timestamp=datetime.fromisoformat(msg_data[0]), 250 | sender=msg_data[1], 251 | chat_name=msg_data[2], 252 | content=msg_data[3], 253 | is_from_me=msg_data[4], 254 | chat_jid=msg_data[5], 255 | id=msg_data[6], 256 | media_type=msg_data[8] 257 | ) 258 | 259 | # Get messages before 260 | cursor.execute(""" 261 | SELECT messages.timestamp, messages.sender, chats.name, messages.content, messages.is_from_me, chats.jid, messages.id, messages.media_type 262 | FROM messages 263 | JOIN chats ON messages.chat_jid = chats.jid 264 | WHERE messages.chat_jid = ? AND messages.timestamp < ? 265 | ORDER BY messages.timestamp DESC 266 | LIMIT ? 267 | """, (msg_data[7], msg_data[0], before)) 268 | 269 | before_messages = [] 270 | for msg in cursor.fetchall(): 271 | before_messages.append(Message( 272 | timestamp=datetime.fromisoformat(msg[0]), 273 | sender=msg[1], 274 | chat_name=msg[2], 275 | content=msg[3], 276 | is_from_me=msg[4], 277 | chat_jid=msg[5], 278 | id=msg[6], 279 | media_type=msg[7] 280 | )) 281 | 282 | # Get messages after 283 | cursor.execute(""" 284 | SELECT messages.timestamp, messages.sender, chats.name, messages.content, messages.is_from_me, chats.jid, messages.id, messages.media_type 285 | FROM messages 286 | JOIN chats ON messages.chat_jid = chats.jid 287 | WHERE messages.chat_jid = ? AND messages.timestamp > ? 288 | ORDER BY messages.timestamp ASC 289 | LIMIT ? 290 | """, (msg_data[7], msg_data[0], after)) 291 | 292 | after_messages = [] 293 | for msg in cursor.fetchall(): 294 | after_messages.append(Message( 295 | timestamp=datetime.fromisoformat(msg[0]), 296 | sender=msg[1], 297 | chat_name=msg[2], 298 | content=msg[3], 299 | is_from_me=msg[4], 300 | chat_jid=msg[5], 301 | id=msg[6], 302 | media_type=msg[7] 303 | )) 304 | 305 | return MessageContext( 306 | message=target_message, 307 | before=before_messages, 308 | after=after_messages 309 | ) 310 | 311 | except sqlite3.Error as e: 312 | print(f"Database error: {e}") 313 | raise 314 | finally: 315 | if 'conn' in locals(): 316 | conn.close() 317 | 318 | 319 | def list_chats( 320 | query: Optional[str] = None, 321 | limit: int = 20, 322 | page: int = 0, 323 | include_last_message: bool = True, 324 | sort_by: str = "last_active" 325 | ) -> List[Chat]: 326 | """Get chats matching the specified criteria.""" 327 | try: 328 | conn = sqlite3.connect(MESSAGES_DB_PATH) 329 | cursor = conn.cursor() 330 | 331 | # Build base query 332 | query_parts = [""" 333 | SELECT 334 | chats.jid, 335 | chats.name, 336 | chats.last_message_time, 337 | messages.content as last_message, 338 | messages.sender as last_sender, 339 | messages.is_from_me as last_is_from_me 340 | FROM chats 341 | """] 342 | 343 | if include_last_message: 344 | query_parts.append(""" 345 | LEFT JOIN messages ON chats.jid = messages.chat_jid 346 | AND chats.last_message_time = messages.timestamp 347 | """) 348 | 349 | where_clauses = [] 350 | params = [] 351 | 352 | if query: 353 | where_clauses.append("(LOWER(chats.name) LIKE LOWER(?) OR chats.jid LIKE ?)") 354 | params.extend([f"%{query}%", f"%{query}%"]) 355 | 356 | if where_clauses: 357 | query_parts.append("WHERE " + " AND ".join(where_clauses)) 358 | 359 | # Add sorting 360 | order_by = "chats.last_message_time DESC" if sort_by == "last_active" else "chats.name" 361 | query_parts.append(f"ORDER BY {order_by}") 362 | 363 | # Add pagination 364 | offset = (page ) * limit 365 | query_parts.append("LIMIT ? OFFSET ?") 366 | params.extend([limit, offset]) 367 | 368 | cursor.execute(" ".join(query_parts), tuple(params)) 369 | chats = cursor.fetchall() 370 | 371 | result = [] 372 | for chat_data in chats: 373 | chat = Chat( 374 | jid=chat_data[0], 375 | name=chat_data[1], 376 | last_message_time=datetime.fromisoformat(chat_data[2]) if chat_data[2] else None, 377 | last_message=chat_data[3], 378 | last_sender=chat_data[4], 379 | last_is_from_me=chat_data[5] 380 | ) 381 | result.append(chat) 382 | 383 | return result 384 | 385 | except sqlite3.Error as e: 386 | print(f"Database error: {e}") 387 | return [] 388 | finally: 389 | if 'conn' in locals(): 390 | conn.close() 391 | 392 | 393 | def search_contacts(query: str) -> List[Contact]: 394 | """Search contacts by name or phone number.""" 395 | try: 396 | conn = sqlite3.connect(MESSAGES_DB_PATH) 397 | cursor = conn.cursor() 398 | 399 | # Split query into characters to support partial matching 400 | search_pattern = '%' +query + '%' 401 | 402 | cursor.execute(""" 403 | SELECT DISTINCT 404 | jid, 405 | name 406 | FROM chats 407 | WHERE 408 | (LOWER(name) LIKE LOWER(?) OR LOWER(jid) LIKE LOWER(?)) 409 | AND jid NOT LIKE '%@g.us' 410 | ORDER BY name, jid 411 | LIMIT 50 412 | """, (search_pattern, search_pattern)) 413 | 414 | contacts = cursor.fetchall() 415 | 416 | result = [] 417 | for contact_data in contacts: 418 | contact = Contact( 419 | phone_number=contact_data[0].split('@')[0], 420 | name=contact_data[1], 421 | jid=contact_data[0] 422 | ) 423 | result.append(contact) 424 | 425 | return result 426 | 427 | except sqlite3.Error as e: 428 | print(f"Database error: {e}") 429 | return [] 430 | finally: 431 | if 'conn' in locals(): 432 | conn.close() 433 | 434 | 435 | def get_contact_chats(jid: str, limit: int = 20, page: int = 0) -> List[Chat]: 436 | """Get all chats involving the contact. 437 | 438 | Args: 439 | jid: The contact's JID to search for 440 | limit: Maximum number of chats to return (default 20) 441 | page: Page number for pagination (default 0) 442 | """ 443 | try: 444 | conn = sqlite3.connect(MESSAGES_DB_PATH) 445 | cursor = conn.cursor() 446 | 447 | cursor.execute(""" 448 | SELECT DISTINCT 449 | c.jid, 450 | c.name, 451 | c.last_message_time, 452 | m.content as last_message, 453 | m.sender as last_sender, 454 | m.is_from_me as last_is_from_me 455 | FROM chats c 456 | JOIN messages m ON c.jid = m.chat_jid 457 | WHERE m.sender = ? OR c.jid = ? 458 | ORDER BY c.last_message_time DESC 459 | LIMIT ? OFFSET ? 460 | """, (jid, jid, limit, page * limit)) 461 | 462 | chats = cursor.fetchall() 463 | 464 | result = [] 465 | for chat_data in chats: 466 | chat = Chat( 467 | jid=chat_data[0], 468 | name=chat_data[1], 469 | last_message_time=datetime.fromisoformat(chat_data[2]) if chat_data[2] else None, 470 | last_message=chat_data[3], 471 | last_sender=chat_data[4], 472 | last_is_from_me=chat_data[5] 473 | ) 474 | result.append(chat) 475 | 476 | return result 477 | 478 | except sqlite3.Error as e: 479 | print(f"Database error: {e}") 480 | return [] 481 | finally: 482 | if 'conn' in locals(): 483 | conn.close() 484 | 485 | 486 | def get_last_interaction(jid: str) -> str: 487 | """Get most recent message involving the contact.""" 488 | try: 489 | conn = sqlite3.connect(MESSAGES_DB_PATH) 490 | cursor = conn.cursor() 491 | 492 | cursor.execute(""" 493 | SELECT 494 | m.timestamp, 495 | m.sender, 496 | c.name, 497 | m.content, 498 | m.is_from_me, 499 | c.jid, 500 | m.id, 501 | m.media_type 502 | FROM messages m 503 | JOIN chats c ON m.chat_jid = c.jid 504 | WHERE m.sender = ? OR c.jid = ? 505 | ORDER BY m.timestamp DESC 506 | LIMIT 1 507 | """, (jid, jid)) 508 | 509 | msg_data = cursor.fetchone() 510 | 511 | if not msg_data: 512 | return None 513 | 514 | message = Message( 515 | timestamp=datetime.fromisoformat(msg_data[0]), 516 | sender=msg_data[1], 517 | chat_name=msg_data[2], 518 | content=msg_data[3], 519 | is_from_me=msg_data[4], 520 | chat_jid=msg_data[5], 521 | id=msg_data[6], 522 | media_type=msg_data[7] 523 | ) 524 | 525 | return format_message(message) 526 | 527 | except sqlite3.Error as e: 528 | print(f"Database error: {e}") 529 | return None 530 | finally: 531 | if 'conn' in locals(): 532 | conn.close() 533 | 534 | 535 | def get_chat(chat_jid: str, include_last_message: bool = True) -> Optional[Chat]: 536 | """Get chat metadata by JID.""" 537 | try: 538 | conn = sqlite3.connect(MESSAGES_DB_PATH) 539 | cursor = conn.cursor() 540 | 541 | query = """ 542 | SELECT 543 | c.jid, 544 | c.name, 545 | c.last_message_time, 546 | m.content as last_message, 547 | m.sender as last_sender, 548 | m.is_from_me as last_is_from_me 549 | FROM chats c 550 | """ 551 | 552 | if include_last_message: 553 | query += """ 554 | LEFT JOIN messages m ON c.jid = m.chat_jid 555 | AND c.last_message_time = m.timestamp 556 | """ 557 | 558 | query += " WHERE c.jid = ?" 559 | 560 | cursor.execute(query, (chat_jid,)) 561 | chat_data = cursor.fetchone() 562 | 563 | if not chat_data: 564 | return None 565 | 566 | return Chat( 567 | jid=chat_data[0], 568 | name=chat_data[1], 569 | last_message_time=datetime.fromisoformat(chat_data[2]) if chat_data[2] else None, 570 | last_message=chat_data[3], 571 | last_sender=chat_data[4], 572 | last_is_from_me=chat_data[5] 573 | ) 574 | 575 | except sqlite3.Error as e: 576 | print(f"Database error: {e}") 577 | return None 578 | finally: 579 | if 'conn' in locals(): 580 | conn.close() 581 | 582 | 583 | def get_direct_chat_by_contact(sender_phone_number: str) -> Optional[Chat]: 584 | """Get chat metadata by sender phone number.""" 585 | try: 586 | conn = sqlite3.connect(MESSAGES_DB_PATH) 587 | cursor = conn.cursor() 588 | 589 | cursor.execute(""" 590 | SELECT 591 | c.jid, 592 | c.name, 593 | c.last_message_time, 594 | m.content as last_message, 595 | m.sender as last_sender, 596 | m.is_from_me as last_is_from_me 597 | FROM chats c 598 | LEFT JOIN messages m ON c.jid = m.chat_jid 599 | AND c.last_message_time = m.timestamp 600 | WHERE c.jid LIKE ? AND c.jid NOT LIKE '%@g.us' 601 | LIMIT 1 602 | """, (f"%{sender_phone_number}%",)) 603 | 604 | chat_data = cursor.fetchone() 605 | 606 | if not chat_data: 607 | return None 608 | 609 | return Chat( 610 | jid=chat_data[0], 611 | name=chat_data[1], 612 | last_message_time=datetime.fromisoformat(chat_data[2]) if chat_data[2] else None, 613 | last_message=chat_data[3], 614 | last_sender=chat_data[4], 615 | last_is_from_me=chat_data[5] 616 | ) 617 | 618 | except sqlite3.Error as e: 619 | print(f"Database error: {e}") 620 | return None 621 | finally: 622 | if 'conn' in locals(): 623 | conn.close() 624 | 625 | def send_message(recipient: str, message: str) -> Tuple[bool, str]: 626 | try: 627 | # Validate input 628 | if not recipient: 629 | return False, "Recipient must be provided" 630 | 631 | url = f"{WHATSAPP_API_BASE_URL}/send" 632 | payload = { 633 | "recipient": recipient, 634 | "message": message, 635 | } 636 | 637 | response = requests.post(url, json=payload) 638 | 639 | # Check if the request was successful 640 | if response.status_code == 200: 641 | result = response.json() 642 | return result.get("success", False), result.get("message", "Unknown response") 643 | else: 644 | return False, f"Error: HTTP {response.status_code} - {response.text}" 645 | 646 | except requests.RequestException as e: 647 | return False, f"Request error: {str(e)}" 648 | except json.JSONDecodeError: 649 | return False, f"Error parsing response: {response.text}" 650 | except Exception as e: 651 | return False, f"Unexpected error: {str(e)}" 652 | 653 | def send_file(recipient: str, media_path: str) -> Tuple[bool, str]: 654 | try: 655 | # Validate input 656 | if not recipient: 657 | return False, "Recipient must be provided" 658 | 659 | if not media_path: 660 | return False, "Media path must be provided" 661 | 662 | if not os.path.isfile(media_path): 663 | return False, f"Media file not found: {media_path}" 664 | 665 | url = f"{WHATSAPP_API_BASE_URL}/send" 666 | payload = { 667 | "recipient": recipient, 668 | "media_path": media_path 669 | } 670 | 671 | response = requests.post(url, json=payload) 672 | 673 | # Check if the request was successful 674 | if response.status_code == 200: 675 | result = response.json() 676 | return result.get("success", False), result.get("message", "Unknown response") 677 | else: 678 | return False, f"Error: HTTP {response.status_code} - {response.text}" 679 | 680 | except requests.RequestException as e: 681 | return False, f"Request error: {str(e)}" 682 | except json.JSONDecodeError: 683 | return False, f"Error parsing response: {response.text}" 684 | except Exception as e: 685 | return False, f"Unexpected error: {str(e)}" 686 | 687 | def send_audio_message(recipient: str, media_path: str) -> Tuple[bool, str]: 688 | try: 689 | # Validate input 690 | if not recipient: 691 | return False, "Recipient must be provided" 692 | 693 | if not media_path: 694 | return False, "Media path must be provided" 695 | 696 | if not os.path.isfile(media_path): 697 | return False, f"Media file not found: {media_path}" 698 | 699 | if not media_path.endswith(".ogg"): 700 | try: 701 | media_path = audio.convert_to_opus_ogg_temp(media_path) 702 | except Exception as e: 703 | return False, f"Error converting file to opus ogg. You likely need to install ffmpeg: {str(e)}" 704 | 705 | url = f"{WHATSAPP_API_BASE_URL}/send" 706 | payload = { 707 | "recipient": recipient, 708 | "media_path": media_path 709 | } 710 | 711 | response = requests.post(url, json=payload) 712 | 713 | # Check if the request was successful 714 | if response.status_code == 200: 715 | result = response.json() 716 | return result.get("success", False), result.get("message", "Unknown response") 717 | else: 718 | return False, f"Error: HTTP {response.status_code} - {response.text}" 719 | 720 | except requests.RequestException as e: 721 | return False, f"Request error: {str(e)}" 722 | except json.JSONDecodeError: 723 | return False, f"Error parsing response: {response.text}" 724 | except Exception as e: 725 | return False, f"Unexpected error: {str(e)}" 726 | 727 | def download_media(message_id: str, chat_jid: str) -> Optional[str]: 728 | """Download media from a message and return the local file path. 729 | 730 | Args: 731 | message_id: The ID of the message containing the media 732 | chat_jid: The JID of the chat containing the message 733 | 734 | Returns: 735 | The local file path if download was successful, None otherwise 736 | """ 737 | try: 738 | url = f"{WHATSAPP_API_BASE_URL}/download" 739 | payload = { 740 | "message_id": message_id, 741 | "chat_jid": chat_jid 742 | } 743 | 744 | response = requests.post(url, json=payload) 745 | 746 | if response.status_code == 200: 747 | result = response.json() 748 | if result.get("success", False): 749 | path = result.get("path") 750 | print(f"Media downloaded successfully: {path}") 751 | return path 752 | else: 753 | print(f"Download failed: {result.get('message', 'Unknown error')}") 754 | return None 755 | else: 756 | print(f"Error: HTTP {response.status_code} - {response.text}") 757 | return None 758 | 759 | except requests.RequestException as e: 760 | print(f"Request error: {str(e)}") 761 | return None 762 | except json.JSONDecodeError: 763 | print(f"Error parsing response: {response.text}") 764 | return None 765 | except Exception as e: 766 | print(f"Unexpected error: {str(e)}") 767 | return None 768 | -------------------------------------------------------------------------------- /whatsapp-bridge/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/binary" 7 | "encoding/json" 8 | "fmt" 9 | "math" 10 | "math/rand" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "path/filepath" 15 | "reflect" 16 | "strings" 17 | "syscall" 18 | "time" 19 | 20 | _ "github.com/mattn/go-sqlite3" 21 | "github.com/mdp/qrterminal" 22 | 23 | "bytes" 24 | 25 | "go.mau.fi/whatsmeow" 26 | waProto "go.mau.fi/whatsmeow/binary/proto" 27 | "go.mau.fi/whatsmeow/store/sqlstore" 28 | "go.mau.fi/whatsmeow/types" 29 | "go.mau.fi/whatsmeow/types/events" 30 | waLog "go.mau.fi/whatsmeow/util/log" 31 | "google.golang.org/protobuf/proto" 32 | ) 33 | 34 | // Message represents a chat message for our client 35 | type Message struct { 36 | Time time.Time 37 | Sender string 38 | Content string 39 | IsFromMe bool 40 | MediaType string 41 | Filename string 42 | } 43 | 44 | // Database handler for storing message history 45 | type MessageStore struct { 46 | db *sql.DB 47 | } 48 | 49 | // Initialize message store 50 | func NewMessageStore() (*MessageStore, error) { 51 | // Create directory for database if it doesn't exist 52 | if err := os.MkdirAll("store", 0755); err != nil { 53 | return nil, fmt.Errorf("failed to create store directory: %v", err) 54 | } 55 | 56 | // Open SQLite database for messages 57 | db, err := sql.Open("sqlite3", "file:store/messages.db?_foreign_keys=on") 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to open message database: %v", err) 60 | } 61 | 62 | // Create tables if they don't exist 63 | _, err = db.Exec(` 64 | CREATE TABLE IF NOT EXISTS chats ( 65 | jid TEXT PRIMARY KEY, 66 | name TEXT, 67 | last_message_time TIMESTAMP 68 | ); 69 | 70 | CREATE TABLE IF NOT EXISTS messages ( 71 | id TEXT, 72 | chat_jid TEXT, 73 | sender TEXT, 74 | content TEXT, 75 | timestamp TIMESTAMP, 76 | is_from_me BOOLEAN, 77 | media_type TEXT, 78 | filename TEXT, 79 | url TEXT, 80 | media_key BLOB, 81 | file_sha256 BLOB, 82 | file_enc_sha256 BLOB, 83 | file_length INTEGER, 84 | PRIMARY KEY (id, chat_jid), 85 | FOREIGN KEY (chat_jid) REFERENCES chats(jid) 86 | ); 87 | `) 88 | if err != nil { 89 | db.Close() 90 | return nil, fmt.Errorf("failed to create tables: %v", err) 91 | } 92 | 93 | return &MessageStore{db: db}, nil 94 | } 95 | 96 | // Close the database connection 97 | func (store *MessageStore) Close() error { 98 | return store.db.Close() 99 | } 100 | 101 | // Store a chat in the database 102 | func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error { 103 | _, err := store.db.Exec( 104 | "INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)", 105 | jid, name, lastMessageTime, 106 | ) 107 | return err 108 | } 109 | 110 | // Store a message in the database 111 | func (store *MessageStore) StoreMessage(id, chatJID, sender, content string, timestamp time.Time, isFromMe bool, 112 | mediaType, filename, url string, mediaKey, fileSHA256, fileEncSHA256 []byte, fileLength uint64) error { 113 | // Only store if there's actual content or media 114 | if content == "" && mediaType == "" { 115 | return nil 116 | } 117 | 118 | _, err := store.db.Exec( 119 | `INSERT OR REPLACE INTO messages 120 | (id, chat_jid, sender, content, timestamp, is_from_me, media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length) 121 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 122 | id, chatJID, sender, content, timestamp, isFromMe, mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, 123 | ) 124 | return err 125 | } 126 | 127 | // Get messages from a chat 128 | func (store *MessageStore) GetMessages(chatJID string, limit int) ([]Message, error) { 129 | rows, err := store.db.Query( 130 | "SELECT sender, content, timestamp, is_from_me, media_type, filename FROM messages WHERE chat_jid = ? ORDER BY timestamp DESC LIMIT ?", 131 | chatJID, limit, 132 | ) 133 | if err != nil { 134 | return nil, err 135 | } 136 | defer rows.Close() 137 | 138 | var messages []Message 139 | for rows.Next() { 140 | var msg Message 141 | var timestamp time.Time 142 | err := rows.Scan(&msg.Sender, &msg.Content, ×tamp, &msg.IsFromMe, &msg.MediaType, &msg.Filename) 143 | if err != nil { 144 | return nil, err 145 | } 146 | msg.Time = timestamp 147 | messages = append(messages, msg) 148 | } 149 | 150 | return messages, nil 151 | } 152 | 153 | // Get all chats 154 | func (store *MessageStore) GetChats() (map[string]time.Time, error) { 155 | rows, err := store.db.Query("SELECT jid, last_message_time FROM chats ORDER BY last_message_time DESC") 156 | if err != nil { 157 | return nil, err 158 | } 159 | defer rows.Close() 160 | 161 | chats := make(map[string]time.Time) 162 | for rows.Next() { 163 | var jid string 164 | var lastMessageTime time.Time 165 | err := rows.Scan(&jid, &lastMessageTime) 166 | if err != nil { 167 | return nil, err 168 | } 169 | chats[jid] = lastMessageTime 170 | } 171 | 172 | return chats, nil 173 | } 174 | 175 | // Extract text content from a message 176 | func extractTextContent(msg *waProto.Message) string { 177 | if msg == nil { 178 | return "" 179 | } 180 | 181 | // Try to get text content 182 | if text := msg.GetConversation(); text != "" { 183 | return text 184 | } else if extendedText := msg.GetExtendedTextMessage(); extendedText != nil { 185 | return extendedText.GetText() 186 | } 187 | 188 | // For now, we're ignoring non-text messages 189 | return "" 190 | } 191 | 192 | // SendMessageResponse represents the response for the send message API 193 | type SendMessageResponse struct { 194 | Success bool `json:"success"` 195 | Message string `json:"message"` 196 | } 197 | 198 | // SendMessageRequest represents the request body for the send message API 199 | type SendMessageRequest struct { 200 | Recipient string `json:"recipient"` 201 | Message string `json:"message"` 202 | MediaPath string `json:"media_path,omitempty"` 203 | } 204 | 205 | // Function to send a WhatsApp message 206 | func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message string, mediaPath string) (bool, string) { 207 | if !client.IsConnected() { 208 | return false, "Not connected to WhatsApp" 209 | } 210 | 211 | // Create JID for recipient 212 | var recipientJID types.JID 213 | var err error 214 | 215 | // Check if recipient is a JID 216 | isJID := strings.Contains(recipient, "@") 217 | 218 | if isJID { 219 | // Parse the JID string 220 | recipientJID, err = types.ParseJID(recipient) 221 | if err != nil { 222 | return false, fmt.Sprintf("Error parsing JID: %v", err) 223 | } 224 | } else { 225 | // Create JID from phone number 226 | recipientJID = types.JID{ 227 | User: recipient, 228 | Server: "s.whatsapp.net", // For personal chats 229 | } 230 | } 231 | 232 | msg := &waProto.Message{} 233 | 234 | // Check if we have media to send 235 | if mediaPath != "" { 236 | // Read media file 237 | mediaData, err := os.ReadFile(mediaPath) 238 | if err != nil { 239 | return false, fmt.Sprintf("Error reading media file: %v", err) 240 | } 241 | 242 | // Determine media type and mime type based on file extension 243 | fileExt := strings.ToLower(mediaPath[strings.LastIndex(mediaPath, ".")+1:]) 244 | var mediaType whatsmeow.MediaType 245 | var mimeType string 246 | 247 | // Handle different media types 248 | switch fileExt { 249 | // Image types 250 | case "jpg", "jpeg": 251 | mediaType = whatsmeow.MediaImage 252 | mimeType = "image/jpeg" 253 | case "png": 254 | mediaType = whatsmeow.MediaImage 255 | mimeType = "image/png" 256 | case "gif": 257 | mediaType = whatsmeow.MediaImage 258 | mimeType = "image/gif" 259 | case "webp": 260 | mediaType = whatsmeow.MediaImage 261 | mimeType = "image/webp" 262 | 263 | // Audio types 264 | case "ogg": 265 | mediaType = whatsmeow.MediaAudio 266 | mimeType = "audio/ogg; codecs=opus" 267 | 268 | // Video types 269 | case "mp4": 270 | mediaType = whatsmeow.MediaVideo 271 | mimeType = "video/mp4" 272 | case "avi": 273 | mediaType = whatsmeow.MediaVideo 274 | mimeType = "video/avi" 275 | case "mov": 276 | mediaType = whatsmeow.MediaVideo 277 | mimeType = "video/quicktime" 278 | 279 | // Document types (for any other file type) 280 | default: 281 | mediaType = whatsmeow.MediaDocument 282 | mimeType = "application/octet-stream" 283 | } 284 | 285 | // Upload media to WhatsApp servers 286 | resp, err := client.Upload(context.Background(), mediaData, mediaType) 287 | if err != nil { 288 | return false, fmt.Sprintf("Error uploading media: %v", err) 289 | } 290 | 291 | fmt.Println("Media uploaded", resp) 292 | 293 | // Create the appropriate message type based on media type 294 | switch mediaType { 295 | case whatsmeow.MediaImage: 296 | msg.ImageMessage = &waProto.ImageMessage{ 297 | Caption: proto.String(message), 298 | Mimetype: proto.String(mimeType), 299 | URL: &resp.URL, 300 | DirectPath: &resp.DirectPath, 301 | MediaKey: resp.MediaKey, 302 | FileEncSHA256: resp.FileEncSHA256, 303 | FileSHA256: resp.FileSHA256, 304 | FileLength: &resp.FileLength, 305 | } 306 | case whatsmeow.MediaAudio: 307 | // Handle ogg audio files 308 | var seconds uint32 = 30 // Default fallback 309 | var waveform []byte = nil 310 | 311 | // Try to analyze the ogg file 312 | if strings.Contains(mimeType, "ogg") { 313 | analyzedSeconds, analyzedWaveform, err := analyzeOggOpus(mediaData) 314 | if err == nil { 315 | seconds = analyzedSeconds 316 | waveform = analyzedWaveform 317 | } else { 318 | return false, fmt.Sprintf("Failed to analyze Ogg Opus file: %v", err) 319 | } 320 | } else { 321 | fmt.Printf("Not an Ogg Opus file: %s\n", mimeType) 322 | } 323 | 324 | msg.AudioMessage = &waProto.AudioMessage{ 325 | Mimetype: proto.String(mimeType), 326 | URL: &resp.URL, 327 | DirectPath: &resp.DirectPath, 328 | MediaKey: resp.MediaKey, 329 | FileEncSHA256: resp.FileEncSHA256, 330 | FileSHA256: resp.FileSHA256, 331 | FileLength: &resp.FileLength, 332 | Seconds: proto.Uint32(seconds), 333 | PTT: proto.Bool(true), 334 | Waveform: waveform, 335 | } 336 | case whatsmeow.MediaVideo: 337 | msg.VideoMessage = &waProto.VideoMessage{ 338 | Caption: proto.String(message), 339 | Mimetype: proto.String(mimeType), 340 | URL: &resp.URL, 341 | DirectPath: &resp.DirectPath, 342 | MediaKey: resp.MediaKey, 343 | FileEncSHA256: resp.FileEncSHA256, 344 | FileSHA256: resp.FileSHA256, 345 | FileLength: &resp.FileLength, 346 | } 347 | case whatsmeow.MediaDocument: 348 | msg.DocumentMessage = &waProto.DocumentMessage{ 349 | Title: proto.String(mediaPath[strings.LastIndex(mediaPath, "/")+1:]), 350 | Caption: proto.String(message), 351 | Mimetype: proto.String(mimeType), 352 | URL: &resp.URL, 353 | DirectPath: &resp.DirectPath, 354 | MediaKey: resp.MediaKey, 355 | FileEncSHA256: resp.FileEncSHA256, 356 | FileSHA256: resp.FileSHA256, 357 | FileLength: &resp.FileLength, 358 | } 359 | } 360 | } else { 361 | msg.Conversation = proto.String(message) 362 | } 363 | 364 | // Send message 365 | _, err = client.SendMessage(context.Background(), recipientJID, msg) 366 | 367 | if err != nil { 368 | return false, fmt.Sprintf("Error sending message: %v", err) 369 | } 370 | 371 | return true, fmt.Sprintf("Message sent to %s", recipient) 372 | } 373 | 374 | // Extract media info from a message 375 | func extractMediaInfo(msg *waProto.Message) (mediaType string, filename string, url string, mediaKey []byte, fileSHA256 []byte, fileEncSHA256 []byte, fileLength uint64) { 376 | if msg == nil { 377 | return "", "", "", nil, nil, nil, 0 378 | } 379 | 380 | // Check for image message 381 | if img := msg.GetImageMessage(); img != nil { 382 | return "image", "image_" + time.Now().Format("20060102_150405") + ".jpg", 383 | img.GetURL(), img.GetMediaKey(), img.GetFileSHA256(), img.GetFileEncSHA256(), img.GetFileLength() 384 | } 385 | 386 | // Check for video message 387 | if vid := msg.GetVideoMessage(); vid != nil { 388 | return "video", "video_" + time.Now().Format("20060102_150405") + ".mp4", 389 | vid.GetURL(), vid.GetMediaKey(), vid.GetFileSHA256(), vid.GetFileEncSHA256(), vid.GetFileLength() 390 | } 391 | 392 | // Check for audio message 393 | if aud := msg.GetAudioMessage(); aud != nil { 394 | return "audio", "audio_" + time.Now().Format("20060102_150405") + ".ogg", 395 | aud.GetURL(), aud.GetMediaKey(), aud.GetFileSHA256(), aud.GetFileEncSHA256(), aud.GetFileLength() 396 | } 397 | 398 | // Check for document message 399 | if doc := msg.GetDocumentMessage(); doc != nil { 400 | filename := doc.GetFileName() 401 | if filename == "" { 402 | filename = "document_" + time.Now().Format("20060102_150405") 403 | } 404 | return "document", filename, 405 | doc.GetURL(), doc.GetMediaKey(), doc.GetFileSHA256(), doc.GetFileEncSHA256(), doc.GetFileLength() 406 | } 407 | 408 | return "", "", "", nil, nil, nil, 0 409 | } 410 | 411 | // Handle regular incoming messages with media support 412 | func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *events.Message, logger waLog.Logger) { 413 | // Save message to database 414 | chatJID := msg.Info.Chat.String() 415 | sender := msg.Info.Sender.User 416 | 417 | // Get appropriate chat name (pass nil for conversation since we don't have one for regular messages) 418 | name := GetChatName(client, messageStore, msg.Info.Chat, chatJID, nil, sender, logger) 419 | 420 | // Update chat in database with the message timestamp (keeps last message time updated) 421 | err := messageStore.StoreChat(chatJID, name, msg.Info.Timestamp) 422 | if err != nil { 423 | logger.Warnf("Failed to store chat: %v", err) 424 | } 425 | 426 | // Extract text content 427 | content := extractTextContent(msg.Message) 428 | 429 | // Extract media info 430 | mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength := extractMediaInfo(msg.Message) 431 | 432 | // Skip if there's no content and no media 433 | if content == "" && mediaType == "" { 434 | return 435 | } 436 | 437 | // Store message in database 438 | err = messageStore.StoreMessage( 439 | msg.Info.ID, 440 | chatJID, 441 | sender, 442 | content, 443 | msg.Info.Timestamp, 444 | msg.Info.IsFromMe, 445 | mediaType, 446 | filename, 447 | url, 448 | mediaKey, 449 | fileSHA256, 450 | fileEncSHA256, 451 | fileLength, 452 | ) 453 | 454 | if err != nil { 455 | logger.Warnf("Failed to store message: %v", err) 456 | } else { 457 | // Log message reception 458 | timestamp := msg.Info.Timestamp.Format("2006-01-02 15:04:05") 459 | direction := "←" 460 | if msg.Info.IsFromMe { 461 | direction = "→" 462 | } 463 | 464 | // Log based on message type 465 | if mediaType != "" { 466 | fmt.Printf("[%s] %s %s: [%s: %s] %s\n", timestamp, direction, sender, mediaType, filename, content) 467 | } else if content != "" { 468 | fmt.Printf("[%s] %s %s: %s\n", timestamp, direction, sender, content) 469 | } 470 | } 471 | } 472 | 473 | // DownloadMediaRequest represents the request body for the download media API 474 | type DownloadMediaRequest struct { 475 | MessageID string `json:"message_id"` 476 | ChatJID string `json:"chat_jid"` 477 | } 478 | 479 | // DownloadMediaResponse represents the response for the download media API 480 | type DownloadMediaResponse struct { 481 | Success bool `json:"success"` 482 | Message string `json:"message"` 483 | Filename string `json:"filename,omitempty"` 484 | Path string `json:"path,omitempty"` 485 | } 486 | 487 | // Store additional media info in the database 488 | func (store *MessageStore) StoreMediaInfo(id, chatJID, url string, mediaKey, fileSHA256, fileEncSHA256 []byte, fileLength uint64) error { 489 | _, err := store.db.Exec( 490 | "UPDATE messages SET url = ?, media_key = ?, file_sha256 = ?, file_enc_sha256 = ?, file_length = ? WHERE id = ? AND chat_jid = ?", 491 | url, mediaKey, fileSHA256, fileEncSHA256, fileLength, id, chatJID, 492 | ) 493 | return err 494 | } 495 | 496 | // Get media info from the database 497 | func (store *MessageStore) GetMediaInfo(id, chatJID string) (string, string, string, []byte, []byte, []byte, uint64, error) { 498 | var mediaType, filename, url string 499 | var mediaKey, fileSHA256, fileEncSHA256 []byte 500 | var fileLength uint64 501 | 502 | err := store.db.QueryRow( 503 | "SELECT media_type, filename, url, media_key, file_sha256, file_enc_sha256, file_length FROM messages WHERE id = ? AND chat_jid = ?", 504 | id, chatJID, 505 | ).Scan(&mediaType, &filename, &url, &mediaKey, &fileSHA256, &fileEncSHA256, &fileLength) 506 | 507 | return mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, err 508 | } 509 | 510 | // MediaDownloader implements the whatsmeow.DownloadableMessage interface 511 | type MediaDownloader struct { 512 | URL string 513 | DirectPath string 514 | MediaKey []byte 515 | FileLength uint64 516 | FileSHA256 []byte 517 | FileEncSHA256 []byte 518 | MediaType whatsmeow.MediaType 519 | } 520 | 521 | // GetDirectPath implements the DownloadableMessage interface 522 | func (d *MediaDownloader) GetDirectPath() string { 523 | return d.DirectPath 524 | } 525 | 526 | // GetURL implements the DownloadableMessage interface 527 | func (d *MediaDownloader) GetURL() string { 528 | return d.URL 529 | } 530 | 531 | // GetMediaKey implements the DownloadableMessage interface 532 | func (d *MediaDownloader) GetMediaKey() []byte { 533 | return d.MediaKey 534 | } 535 | 536 | // GetFileLength implements the DownloadableMessage interface 537 | func (d *MediaDownloader) GetFileLength() uint64 { 538 | return d.FileLength 539 | } 540 | 541 | // GetFileSHA256 implements the DownloadableMessage interface 542 | func (d *MediaDownloader) GetFileSHA256() []byte { 543 | return d.FileSHA256 544 | } 545 | 546 | // GetFileEncSHA256 implements the DownloadableMessage interface 547 | func (d *MediaDownloader) GetFileEncSHA256() []byte { 548 | return d.FileEncSHA256 549 | } 550 | 551 | // GetMediaType implements the DownloadableMessage interface 552 | func (d *MediaDownloader) GetMediaType() whatsmeow.MediaType { 553 | return d.MediaType 554 | } 555 | 556 | // Function to download media from a message 557 | func downloadMedia(client *whatsmeow.Client, messageStore *MessageStore, messageID, chatJID string) (bool, string, string, string, error) { 558 | // Query the database for the message 559 | var mediaType, filename, url string 560 | var mediaKey, fileSHA256, fileEncSHA256 []byte 561 | var fileLength uint64 562 | var err error 563 | 564 | // First, check if we already have this file 565 | chatDir := fmt.Sprintf("store/%s", strings.ReplaceAll(chatJID, ":", "_")) 566 | localPath := "" 567 | 568 | // Get media info from the database 569 | mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength, err = messageStore.GetMediaInfo(messageID, chatJID) 570 | 571 | if err != nil { 572 | // Try to get basic info if extended info isn't available 573 | err = messageStore.db.QueryRow( 574 | "SELECT media_type, filename FROM messages WHERE id = ? AND chat_jid = ?", 575 | messageID, chatJID, 576 | ).Scan(&mediaType, &filename) 577 | 578 | if err != nil { 579 | return false, "", "", "", fmt.Errorf("failed to find message: %v", err) 580 | } 581 | } 582 | 583 | // Check if this is a media message 584 | if mediaType == "" { 585 | return false, "", "", "", fmt.Errorf("not a media message") 586 | } 587 | 588 | // Create directory for the chat if it doesn't exist 589 | if err := os.MkdirAll(chatDir, 0755); err != nil { 590 | return false, "", "", "", fmt.Errorf("failed to create chat directory: %v", err) 591 | } 592 | 593 | // Generate a local path for the file 594 | localPath = fmt.Sprintf("%s/%s", chatDir, filename) 595 | 596 | // Get absolute path 597 | absPath, err := filepath.Abs(localPath) 598 | if err != nil { 599 | return false, "", "", "", fmt.Errorf("failed to get absolute path: %v", err) 600 | } 601 | 602 | // Check if file already exists 603 | if _, err := os.Stat(localPath); err == nil { 604 | // File exists, return it 605 | return true, mediaType, filename, absPath, nil 606 | } 607 | 608 | // If we don't have all the media info we need, we can't download 609 | if url == "" || len(mediaKey) == 0 || len(fileSHA256) == 0 || len(fileEncSHA256) == 0 || fileLength == 0 { 610 | return false, "", "", "", fmt.Errorf("incomplete media information for download") 611 | } 612 | 613 | fmt.Printf("Attempting to download media for message %s in chat %s...\n", messageID, chatJID) 614 | 615 | // Extract direct path from URL 616 | directPath := extractDirectPathFromURL(url) 617 | 618 | // Create a downloader that implements DownloadableMessage 619 | var waMediaType whatsmeow.MediaType 620 | switch mediaType { 621 | case "image": 622 | waMediaType = whatsmeow.MediaImage 623 | case "video": 624 | waMediaType = whatsmeow.MediaVideo 625 | case "audio": 626 | waMediaType = whatsmeow.MediaAudio 627 | case "document": 628 | waMediaType = whatsmeow.MediaDocument 629 | default: 630 | return false, "", "", "", fmt.Errorf("unsupported media type: %s", mediaType) 631 | } 632 | 633 | downloader := &MediaDownloader{ 634 | URL: url, 635 | DirectPath: directPath, 636 | MediaKey: mediaKey, 637 | FileLength: fileLength, 638 | FileSHA256: fileSHA256, 639 | FileEncSHA256: fileEncSHA256, 640 | MediaType: waMediaType, 641 | } 642 | 643 | // Download the media using whatsmeow client 644 | mediaData, err := client.Download(downloader) 645 | if err != nil { 646 | return false, "", "", "", fmt.Errorf("failed to download media: %v", err) 647 | } 648 | 649 | // Save the downloaded media to file 650 | if err := os.WriteFile(localPath, mediaData, 0644); err != nil { 651 | return false, "", "", "", fmt.Errorf("failed to save media file: %v", err) 652 | } 653 | 654 | fmt.Printf("Successfully downloaded %s media to %s (%d bytes)\n", mediaType, absPath, len(mediaData)) 655 | return true, mediaType, filename, absPath, nil 656 | } 657 | 658 | // Extract direct path from a WhatsApp media URL 659 | func extractDirectPathFromURL(url string) string { 660 | // The direct path is typically in the URL, we need to extract it 661 | // Example URL: https://mmg.whatsapp.net/v/t62.7118-24/13812002_698058036224062_3424455886509161511_n.enc?ccb=11-4&oh=... 662 | 663 | // Find the path part after the domain 664 | parts := strings.SplitN(url, ".net/", 2) 665 | if len(parts) < 2 { 666 | return url // Return original URL if parsing fails 667 | } 668 | 669 | pathPart := parts[1] 670 | 671 | // Remove query parameters 672 | pathPart = strings.SplitN(pathPart, "?", 2)[0] 673 | 674 | // Create proper direct path format 675 | return "/" + pathPart 676 | } 677 | 678 | // Start a REST API server to expose the WhatsApp client functionality 679 | func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port int) { 680 | // Handler for sending messages 681 | http.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) { 682 | // Only allow POST requests 683 | if r.Method != http.MethodPost { 684 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 685 | return 686 | } 687 | 688 | // Parse the request body 689 | var req SendMessageRequest 690 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 691 | http.Error(w, "Invalid request format", http.StatusBadRequest) 692 | return 693 | } 694 | 695 | // Validate request 696 | if req.Recipient == "" { 697 | http.Error(w, "Recipient is required", http.StatusBadRequest) 698 | return 699 | } 700 | 701 | if req.Message == "" && req.MediaPath == "" { 702 | http.Error(w, "Message or media path is required", http.StatusBadRequest) 703 | return 704 | } 705 | 706 | fmt.Println("Received request to send message", req.Message, req.MediaPath) 707 | 708 | // Send the message 709 | success, message := sendWhatsAppMessage(client, req.Recipient, req.Message, req.MediaPath) 710 | fmt.Println("Message sent", success, message) 711 | // Set response headers 712 | w.Header().Set("Content-Type", "application/json") 713 | 714 | // Set appropriate status code 715 | if !success { 716 | w.WriteHeader(http.StatusInternalServerError) 717 | } 718 | 719 | // Send response 720 | json.NewEncoder(w).Encode(SendMessageResponse{ 721 | Success: success, 722 | Message: message, 723 | }) 724 | }) 725 | 726 | // Handler for downloading media 727 | http.HandleFunc("/api/download", func(w http.ResponseWriter, r *http.Request) { 728 | // Only allow POST requests 729 | if r.Method != http.MethodPost { 730 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 731 | return 732 | } 733 | 734 | // Parse the request body 735 | var req DownloadMediaRequest 736 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 737 | http.Error(w, "Invalid request format", http.StatusBadRequest) 738 | return 739 | } 740 | 741 | // Validate request 742 | if req.MessageID == "" || req.ChatJID == "" { 743 | http.Error(w, "Message ID and Chat JID are required", http.StatusBadRequest) 744 | return 745 | } 746 | 747 | // Download the media 748 | success, mediaType, filename, path, err := downloadMedia(client, messageStore, req.MessageID, req.ChatJID) 749 | 750 | // Set response headers 751 | w.Header().Set("Content-Type", "application/json") 752 | 753 | // Handle download result 754 | if !success || err != nil { 755 | errMsg := "Unknown error" 756 | if err != nil { 757 | errMsg = err.Error() 758 | } 759 | 760 | w.WriteHeader(http.StatusInternalServerError) 761 | json.NewEncoder(w).Encode(DownloadMediaResponse{ 762 | Success: false, 763 | Message: fmt.Sprintf("Failed to download media: %s", errMsg), 764 | }) 765 | return 766 | } 767 | 768 | // Send successful response 769 | json.NewEncoder(w).Encode(DownloadMediaResponse{ 770 | Success: true, 771 | Message: fmt.Sprintf("Successfully downloaded %s media", mediaType), 772 | Filename: filename, 773 | Path: path, 774 | }) 775 | }) 776 | 777 | // Start the server 778 | serverAddr := fmt.Sprintf(":%d", port) 779 | fmt.Printf("Starting REST API server on %s...\n", serverAddr) 780 | 781 | // Run server in a goroutine so it doesn't block 782 | go func() { 783 | if err := http.ListenAndServe(serverAddr, nil); err != nil { 784 | fmt.Printf("REST API server error: %v\n", err) 785 | } 786 | }() 787 | } 788 | 789 | func main() { 790 | // Set up logger 791 | logger := waLog.Stdout("Client", "INFO", true) 792 | logger.Infof("Starting WhatsApp client...") 793 | 794 | // Create database connection for storing session data 795 | dbLog := waLog.Stdout("Database", "INFO", true) 796 | 797 | // Create directory for database if it doesn't exist 798 | if err := os.MkdirAll("store", 0755); err != nil { 799 | logger.Errorf("Failed to create store directory: %v", err) 800 | return 801 | } 802 | 803 | container, err := sqlstore.New("sqlite3", "file:store/whatsapp.db?_foreign_keys=on", dbLog) 804 | if err != nil { 805 | logger.Errorf("Failed to connect to database: %v", err) 806 | return 807 | } 808 | 809 | // Get device store - This contains session information 810 | deviceStore, err := container.GetFirstDevice() 811 | if err != nil { 812 | if err == sql.ErrNoRows { 813 | // No device exists, create one 814 | deviceStore = container.NewDevice() 815 | logger.Infof("Created new device") 816 | } else { 817 | logger.Errorf("Failed to get device: %v", err) 818 | return 819 | } 820 | } 821 | 822 | // Create client instance 823 | client := whatsmeow.NewClient(deviceStore, logger) 824 | if client == nil { 825 | logger.Errorf("Failed to create WhatsApp client") 826 | return 827 | } 828 | 829 | // Initialize message store 830 | messageStore, err := NewMessageStore() 831 | if err != nil { 832 | logger.Errorf("Failed to initialize message store: %v", err) 833 | return 834 | } 835 | defer messageStore.Close() 836 | 837 | // Setup event handling for messages and history sync 838 | client.AddEventHandler(func(evt interface{}) { 839 | switch v := evt.(type) { 840 | case *events.Message: 841 | // Process regular messages 842 | handleMessage(client, messageStore, v, logger) 843 | 844 | case *events.HistorySync: 845 | // Process history sync events 846 | handleHistorySync(client, messageStore, v, logger) 847 | 848 | case *events.Connected: 849 | logger.Infof("Connected to WhatsApp") 850 | 851 | case *events.LoggedOut: 852 | logger.Warnf("Device logged out, please scan QR code to log in again") 853 | } 854 | }) 855 | 856 | // Create channel to track connection success 857 | connected := make(chan bool, 1) 858 | 859 | // Connect to WhatsApp 860 | if client.Store.ID == nil { 861 | // No ID stored, this is a new client, need to pair with phone 862 | qrChan, _ := client.GetQRChannel(context.Background()) 863 | err = client.Connect() 864 | if err != nil { 865 | logger.Errorf("Failed to connect: %v", err) 866 | return 867 | } 868 | 869 | // Print QR code for pairing with phone 870 | for evt := range qrChan { 871 | if evt.Event == "code" { 872 | fmt.Println("\nScan this QR code with your WhatsApp app:") 873 | qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) 874 | } else if evt.Event == "success" { 875 | connected <- true 876 | break 877 | } 878 | } 879 | 880 | // Wait for connection 881 | select { 882 | case <-connected: 883 | fmt.Println("\nSuccessfully connected and authenticated!") 884 | case <-time.After(3 * time.Minute): 885 | logger.Errorf("Timeout waiting for QR code scan") 886 | return 887 | } 888 | } else { 889 | // Already logged in, just connect 890 | err = client.Connect() 891 | if err != nil { 892 | logger.Errorf("Failed to connect: %v", err) 893 | return 894 | } 895 | connected <- true 896 | } 897 | 898 | // Wait a moment for connection to stabilize 899 | time.Sleep(2 * time.Second) 900 | 901 | if !client.IsConnected() { 902 | logger.Errorf("Failed to establish stable connection") 903 | return 904 | } 905 | 906 | fmt.Println("\n✓ Connected to WhatsApp! Type 'help' for commands.") 907 | 908 | // Start REST API server 909 | startRESTServer(client, messageStore, 8080) 910 | 911 | // Create a channel to keep the main goroutine alive 912 | exitChan := make(chan os.Signal, 1) 913 | signal.Notify(exitChan, syscall.SIGINT, syscall.SIGTERM) 914 | 915 | fmt.Println("REST server is running. Press Ctrl+C to disconnect and exit.") 916 | 917 | // Wait for termination signal 918 | <-exitChan 919 | 920 | fmt.Println("Disconnecting...") 921 | // Disconnect client 922 | client.Disconnect() 923 | } 924 | 925 | // GetChatName determines the appropriate name for a chat based on JID and other info 926 | func GetChatName(client *whatsmeow.Client, messageStore *MessageStore, jid types.JID, chatJID string, conversation interface{}, sender string, logger waLog.Logger) string { 927 | // First, check if chat already exists in database with a name 928 | var existingName string 929 | err := messageStore.db.QueryRow("SELECT name FROM chats WHERE jid = ?", chatJID).Scan(&existingName) 930 | if err == nil && existingName != "" { 931 | // Chat exists with a name, use that 932 | logger.Infof("Using existing chat name for %s: %s", chatJID, existingName) 933 | return existingName 934 | } 935 | 936 | // Need to determine chat name 937 | var name string 938 | 939 | if jid.Server == "g.us" { 940 | // This is a group chat 941 | logger.Infof("Getting name for group: %s", chatJID) 942 | 943 | // Use conversation data if provided (from history sync) 944 | if conversation != nil { 945 | // Extract name from conversation if available 946 | // This uses type assertions to handle different possible types 947 | var displayName, convName *string 948 | // Try to extract the fields we care about regardless of the exact type 949 | v := reflect.ValueOf(conversation) 950 | if v.Kind() == reflect.Ptr && !v.IsNil() { 951 | v = v.Elem() 952 | 953 | // Try to find DisplayName field 954 | if displayNameField := v.FieldByName("DisplayName"); displayNameField.IsValid() && displayNameField.Kind() == reflect.Ptr && !displayNameField.IsNil() { 955 | dn := displayNameField.Elem().String() 956 | displayName = &dn 957 | } 958 | 959 | // Try to find Name field 960 | if nameField := v.FieldByName("Name"); nameField.IsValid() && nameField.Kind() == reflect.Ptr && !nameField.IsNil() { 961 | n := nameField.Elem().String() 962 | convName = &n 963 | } 964 | } 965 | 966 | // Use the name we found 967 | if displayName != nil && *displayName != "" { 968 | name = *displayName 969 | } else if convName != nil && *convName != "" { 970 | name = *convName 971 | } 972 | } 973 | 974 | // If we didn't get a name, try group info 975 | if name == "" { 976 | groupInfo, err := client.GetGroupInfo(jid) 977 | if err == nil && groupInfo.Name != "" { 978 | name = groupInfo.Name 979 | } else { 980 | // Fallback name for groups 981 | name = fmt.Sprintf("Group %s", jid.User) 982 | } 983 | } 984 | 985 | logger.Infof("Using group name: %s", name) 986 | } else { 987 | // This is an individual contact 988 | logger.Infof("Getting name for contact: %s", chatJID) 989 | 990 | // Just use contact info (full name) 991 | contact, err := client.Store.Contacts.GetContact(jid) 992 | if err == nil && contact.FullName != "" { 993 | name = contact.FullName 994 | } else if sender != "" { 995 | // Fallback to sender 996 | name = sender 997 | } else { 998 | // Last fallback to JID 999 | name = jid.User 1000 | } 1001 | 1002 | logger.Infof("Using contact name: %s", name) 1003 | } 1004 | 1005 | return name 1006 | } 1007 | 1008 | // Handle history sync events 1009 | func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, historySync *events.HistorySync, logger waLog.Logger) { 1010 | fmt.Printf("Received history sync event with %d conversations\n", len(historySync.Data.Conversations)) 1011 | 1012 | syncedCount := 0 1013 | for _, conversation := range historySync.Data.Conversations { 1014 | // Parse JID from the conversation 1015 | if conversation.ID == nil { 1016 | continue 1017 | } 1018 | 1019 | chatJID := *conversation.ID 1020 | 1021 | // Try to parse the JID 1022 | jid, err := types.ParseJID(chatJID) 1023 | if err != nil { 1024 | logger.Warnf("Failed to parse JID %s: %v", chatJID, err) 1025 | continue 1026 | } 1027 | 1028 | // Get appropriate chat name by passing the history sync conversation directly 1029 | name := GetChatName(client, messageStore, jid, chatJID, conversation, "", logger) 1030 | 1031 | // Process messages 1032 | messages := conversation.Messages 1033 | if len(messages) > 0 { 1034 | // Update chat with latest message timestamp 1035 | latestMsg := messages[0] 1036 | if latestMsg == nil || latestMsg.Message == nil { 1037 | continue 1038 | } 1039 | 1040 | // Get timestamp from message info 1041 | timestamp := time.Time{} 1042 | if ts := latestMsg.Message.GetMessageTimestamp(); ts != 0 { 1043 | timestamp = time.Unix(int64(ts), 0) 1044 | } else { 1045 | continue 1046 | } 1047 | 1048 | messageStore.StoreChat(chatJID, name, timestamp) 1049 | 1050 | // Store messages 1051 | for _, msg := range messages { 1052 | if msg == nil || msg.Message == nil { 1053 | continue 1054 | } 1055 | 1056 | // Extract text content 1057 | var content string 1058 | if msg.Message.Message != nil { 1059 | if conv := msg.Message.Message.GetConversation(); conv != "" { 1060 | content = conv 1061 | } else if ext := msg.Message.Message.GetExtendedTextMessage(); ext != nil { 1062 | content = ext.GetText() 1063 | } 1064 | } 1065 | 1066 | // Extract media info 1067 | var mediaType, filename, url string 1068 | var mediaKey, fileSHA256, fileEncSHA256 []byte 1069 | var fileLength uint64 1070 | 1071 | if msg.Message.Message != nil { 1072 | mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength = extractMediaInfo(msg.Message.Message) 1073 | } 1074 | 1075 | // Log the message content for debugging 1076 | logger.Infof("Message content: %v, Media Type: %v", content, mediaType) 1077 | 1078 | // Skip messages with no content and no media 1079 | if content == "" && mediaType == "" { 1080 | continue 1081 | } 1082 | 1083 | // Determine sender 1084 | var sender string 1085 | isFromMe := false 1086 | if msg.Message.Key != nil { 1087 | if msg.Message.Key.FromMe != nil { 1088 | isFromMe = *msg.Message.Key.FromMe 1089 | } 1090 | if !isFromMe && msg.Message.Key.Participant != nil && *msg.Message.Key.Participant != "" { 1091 | sender = *msg.Message.Key.Participant 1092 | } else if isFromMe { 1093 | sender = client.Store.ID.User 1094 | } else { 1095 | sender = jid.User 1096 | } 1097 | } else { 1098 | sender = jid.User 1099 | } 1100 | 1101 | // Store message 1102 | msgID := "" 1103 | if msg.Message.Key != nil && msg.Message.Key.ID != nil { 1104 | msgID = *msg.Message.Key.ID 1105 | } 1106 | 1107 | // Get message timestamp 1108 | timestamp := time.Time{} 1109 | if ts := msg.Message.GetMessageTimestamp(); ts != 0 { 1110 | timestamp = time.Unix(int64(ts), 0) 1111 | } else { 1112 | continue 1113 | } 1114 | 1115 | err = messageStore.StoreMessage( 1116 | msgID, 1117 | chatJID, 1118 | sender, 1119 | content, 1120 | timestamp, 1121 | isFromMe, 1122 | mediaType, 1123 | filename, 1124 | url, 1125 | mediaKey, 1126 | fileSHA256, 1127 | fileEncSHA256, 1128 | fileLength, 1129 | ) 1130 | if err != nil { 1131 | logger.Warnf("Failed to store history message: %v", err) 1132 | } else { 1133 | syncedCount++ 1134 | // Log successful message storage 1135 | if mediaType != "" { 1136 | logger.Infof("Stored message: [%s] %s -> %s: [%s: %s] %s", 1137 | timestamp.Format("2006-01-02 15:04:05"), sender, chatJID, mediaType, filename, content) 1138 | } else { 1139 | logger.Infof("Stored message: [%s] %s -> %s: %s", 1140 | timestamp.Format("2006-01-02 15:04:05"), sender, chatJID, content) 1141 | } 1142 | } 1143 | } 1144 | } 1145 | } 1146 | 1147 | fmt.Printf("History sync complete. Stored %d messages.\n", syncedCount) 1148 | } 1149 | 1150 | // Request history sync from the server 1151 | func requestHistorySync(client *whatsmeow.Client) { 1152 | if client == nil { 1153 | fmt.Println("Client is not initialized. Cannot request history sync.") 1154 | return 1155 | } 1156 | 1157 | if !client.IsConnected() { 1158 | fmt.Println("Client is not connected. Please ensure you are connected to WhatsApp first.") 1159 | return 1160 | } 1161 | 1162 | if client.Store.ID == nil { 1163 | fmt.Println("Client is not logged in. Please scan the QR code first.") 1164 | return 1165 | } 1166 | 1167 | // Build and send a history sync request 1168 | historyMsg := client.BuildHistorySyncRequest(nil, 100) 1169 | if historyMsg == nil { 1170 | fmt.Println("Failed to build history sync request.") 1171 | return 1172 | } 1173 | 1174 | _, err := client.SendMessage(context.Background(), types.JID{ 1175 | Server: "s.whatsapp.net", 1176 | User: "status", 1177 | }, historyMsg) 1178 | 1179 | if err != nil { 1180 | fmt.Printf("Failed to request history sync: %v\n", err) 1181 | } else { 1182 | fmt.Println("History sync requested. Waiting for server response...") 1183 | } 1184 | } 1185 | 1186 | // analyzeOggOpus tries to extract duration and generate a simple waveform from an Ogg Opus file 1187 | func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) { 1188 | // Try to detect if this is a valid Ogg file by checking for the "OggS" signature 1189 | // at the beginning of the file 1190 | if len(data) < 4 || string(data[0:4]) != "OggS" { 1191 | return 0, nil, fmt.Errorf("not a valid Ogg file (missing OggS signature)") 1192 | } 1193 | 1194 | // Parse Ogg pages to find the last page with a valid granule position 1195 | var lastGranule uint64 1196 | var sampleRate uint32 = 48000 // Default Opus sample rate 1197 | var preSkip uint16 = 0 1198 | var foundOpusHead bool 1199 | 1200 | // Scan through the file looking for Ogg pages 1201 | for i := 0; i < len(data); { 1202 | // Check if we have enough data to read Ogg page header 1203 | if i+27 >= len(data) { 1204 | break 1205 | } 1206 | 1207 | // Verify Ogg page signature 1208 | if string(data[i:i+4]) != "OggS" { 1209 | // Skip until next potential page 1210 | i++ 1211 | continue 1212 | } 1213 | 1214 | // Extract header fields 1215 | granulePos := binary.LittleEndian.Uint64(data[i+6 : i+14]) 1216 | pageSeqNum := binary.LittleEndian.Uint32(data[i+18 : i+22]) 1217 | numSegments := int(data[i+26]) 1218 | 1219 | // Extract segment table 1220 | if i+27+numSegments >= len(data) { 1221 | break 1222 | } 1223 | segmentTable := data[i+27 : i+27+numSegments] 1224 | 1225 | // Calculate page size 1226 | pageSize := 27 + numSegments 1227 | for _, segLen := range segmentTable { 1228 | pageSize += int(segLen) 1229 | } 1230 | 1231 | // Check if we're looking at an OpusHead packet (should be in first few pages) 1232 | if !foundOpusHead && pageSeqNum <= 1 { 1233 | // Look for "OpusHead" marker in this page 1234 | pageData := data[i : i+pageSize] 1235 | headPos := bytes.Index(pageData, []byte("OpusHead")) 1236 | if headPos >= 0 && headPos+12 < len(pageData) { 1237 | // Found OpusHead, extract sample rate and pre-skip 1238 | // OpusHead format: Magic(8) + Version(1) + Channels(1) + PreSkip(2) + SampleRate(4) + ... 1239 | headPos += 8 // Skip "OpusHead" marker 1240 | // PreSkip is 2 bytes at offset 10 1241 | if headPos+12 <= len(pageData) { 1242 | preSkip = binary.LittleEndian.Uint16(pageData[headPos+10 : headPos+12]) 1243 | sampleRate = binary.LittleEndian.Uint32(pageData[headPos+12 : headPos+16]) 1244 | foundOpusHead = true 1245 | fmt.Printf("Found OpusHead: sampleRate=%d, preSkip=%d\n", sampleRate, preSkip) 1246 | } 1247 | } 1248 | } 1249 | 1250 | // Keep track of last valid granule position 1251 | if granulePos != 0 { 1252 | lastGranule = granulePos 1253 | } 1254 | 1255 | // Move to next page 1256 | i += pageSize 1257 | } 1258 | 1259 | if !foundOpusHead { 1260 | fmt.Println("Warning: OpusHead not found, using default values") 1261 | } 1262 | 1263 | // Calculate duration based on granule position 1264 | if lastGranule > 0 { 1265 | // Formula for duration: (lastGranule - preSkip) / sampleRate 1266 | durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate) 1267 | duration = uint32(math.Ceil(durationSeconds)) 1268 | fmt.Printf("Calculated Opus duration from granule: %f seconds (lastGranule=%d)\n", 1269 | durationSeconds, lastGranule) 1270 | } else { 1271 | // Fallback to rough estimation if granule position not found 1272 | fmt.Println("Warning: No valid granule position found, using estimation") 1273 | durationEstimate := float64(len(data)) / 2000.0 // Very rough approximation 1274 | duration = uint32(durationEstimate) 1275 | } 1276 | 1277 | // Make sure we have a reasonable duration (at least 1 second, at most 300 seconds) 1278 | if duration < 1 { 1279 | duration = 1 1280 | } else if duration > 300 { 1281 | duration = 300 1282 | } 1283 | 1284 | // Generate waveform 1285 | waveform = placeholderWaveform(duration) 1286 | 1287 | fmt.Printf("Ogg Opus analysis: size=%d bytes, calculated duration=%d sec, waveform=%d bytes\n", 1288 | len(data), duration, len(waveform)) 1289 | 1290 | return duration, waveform, nil 1291 | } 1292 | 1293 | // min returns the smaller of x or y 1294 | func min(x, y int) int { 1295 | if x < y { 1296 | return x 1297 | } 1298 | return y 1299 | } 1300 | 1301 | // placeholderWaveform generates a synthetic waveform for WhatsApp voice messages 1302 | // that appears natural with some variability based on the duration 1303 | func placeholderWaveform(duration uint32) []byte { 1304 | // WhatsApp expects a 64-byte waveform for voice messages 1305 | const waveformLength = 64 1306 | waveform := make([]byte, waveformLength) 1307 | 1308 | // Seed the random number generator for consistent results with the same duration 1309 | rand.Seed(int64(duration)) 1310 | 1311 | // Create a more natural looking waveform with some patterns and variability 1312 | // rather than completely random values 1313 | 1314 | // Base amplitude and frequency - longer messages get faster frequency 1315 | baseAmplitude := 35.0 1316 | frequencyFactor := float64(min(int(duration), 120)) / 30.0 1317 | 1318 | for i := range waveform { 1319 | // Position in the waveform (normalized 0-1) 1320 | pos := float64(i) / float64(waveformLength) 1321 | 1322 | // Create a wave pattern with some randomness 1323 | // Use multiple sine waves of different frequencies for more natural look 1324 | val := baseAmplitude * math.Sin(pos*math.Pi*frequencyFactor*8) 1325 | val += (baseAmplitude / 2) * math.Sin(pos*math.Pi*frequencyFactor*16) 1326 | 1327 | // Add some randomness to make it look more natural 1328 | val += (rand.Float64() - 0.5) * 15 1329 | 1330 | // Add some fade-in and fade-out effects 1331 | fadeInOut := math.Sin(pos * math.Pi) 1332 | val = val * (0.7 + 0.3*fadeInOut) 1333 | 1334 | // Center around 50 (typical voice baseline) 1335 | val = val + 50 1336 | 1337 | // Ensure values stay within WhatsApp's expected range (0-100) 1338 | if val < 0 { 1339 | val = 0 1340 | } else if val > 100 { 1341 | val = 100 1342 | } 1343 | 1344 | waveform[i] = byte(val) 1345 | } 1346 | 1347 | return waveform 1348 | } 1349 | -------------------------------------------------------------------------------- /whatsapp-mcp-server/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.11" 4 | 5 | [[package]] 6 | name = "annotated-types" 7 | version = "0.7.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.9.0" 17 | source = { registry = "https://pypi.org/simple" } 18 | dependencies = [ 19 | { name = "idna" }, 20 | { name = "sniffio" }, 21 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 22 | ] 23 | sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } 24 | wheels = [ 25 | { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, 26 | ] 27 | 28 | [[package]] 29 | name = "certifi" 30 | version = "2025.1.31" 31 | source = { registry = "https://pypi.org/simple" } 32 | sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } 33 | wheels = [ 34 | { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, 35 | ] 36 | 37 | [[package]] 38 | name = "charset-normalizer" 39 | version = "3.4.1" 40 | source = { registry = "https://pypi.org/simple" } 41 | sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } 42 | wheels = [ 43 | { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, 44 | { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, 45 | { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, 46 | { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, 47 | { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, 48 | { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, 49 | { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, 50 | { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, 51 | { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, 52 | { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, 53 | { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, 54 | { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, 55 | { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, 56 | { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, 57 | { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, 58 | { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, 59 | { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, 60 | { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, 61 | { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, 62 | { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, 63 | { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, 64 | { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, 65 | { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, 66 | { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, 67 | { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, 68 | { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, 69 | { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, 70 | { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, 71 | { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, 72 | { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, 73 | { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, 74 | { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, 75 | { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, 76 | { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, 77 | { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, 78 | { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, 79 | { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, 80 | { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, 81 | { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, 82 | { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, 83 | ] 84 | 85 | [[package]] 86 | name = "click" 87 | version = "8.1.8" 88 | source = { registry = "https://pypi.org/simple" } 89 | dependencies = [ 90 | { name = "colorama", marker = "sys_platform == 'win32'" }, 91 | ] 92 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 93 | wheels = [ 94 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 95 | ] 96 | 97 | [[package]] 98 | name = "colorama" 99 | version = "0.4.6" 100 | source = { registry = "https://pypi.org/simple" } 101 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 102 | wheels = [ 103 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 104 | ] 105 | 106 | [[package]] 107 | name = "h11" 108 | version = "0.14.0" 109 | source = { registry = "https://pypi.org/simple" } 110 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 111 | wheels = [ 112 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 113 | ] 114 | 115 | [[package]] 116 | name = "httpcore" 117 | version = "1.0.7" 118 | source = { registry = "https://pypi.org/simple" } 119 | dependencies = [ 120 | { name = "certifi" }, 121 | { name = "h11" }, 122 | ] 123 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 124 | wheels = [ 125 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 126 | ] 127 | 128 | [[package]] 129 | name = "httpx" 130 | version = "0.28.1" 131 | source = { registry = "https://pypi.org/simple" } 132 | dependencies = [ 133 | { name = "anyio" }, 134 | { name = "certifi" }, 135 | { name = "httpcore" }, 136 | { name = "idna" }, 137 | ] 138 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 139 | wheels = [ 140 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 141 | ] 142 | 143 | [[package]] 144 | name = "httpx-sse" 145 | version = "0.4.0" 146 | source = { registry = "https://pypi.org/simple" } 147 | sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } 148 | wheels = [ 149 | { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, 150 | ] 151 | 152 | [[package]] 153 | name = "idna" 154 | version = "3.10" 155 | source = { registry = "https://pypi.org/simple" } 156 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 157 | wheels = [ 158 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 159 | ] 160 | 161 | [[package]] 162 | name = "markdown-it-py" 163 | version = "3.0.0" 164 | source = { registry = "https://pypi.org/simple" } 165 | dependencies = [ 166 | { name = "mdurl" }, 167 | ] 168 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 169 | wheels = [ 170 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 171 | ] 172 | 173 | [[package]] 174 | name = "mcp" 175 | version = "1.6.0" 176 | source = { registry = "https://pypi.org/simple" } 177 | dependencies = [ 178 | { name = "anyio" }, 179 | { name = "httpx" }, 180 | { name = "httpx-sse" }, 181 | { name = "pydantic" }, 182 | { name = "pydantic-settings" }, 183 | { name = "sse-starlette" }, 184 | { name = "starlette" }, 185 | { name = "uvicorn" }, 186 | ] 187 | sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } 188 | wheels = [ 189 | { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, 190 | ] 191 | 192 | [package.optional-dependencies] 193 | cli = [ 194 | { name = "python-dotenv" }, 195 | { name = "typer" }, 196 | ] 197 | 198 | [[package]] 199 | name = "mdurl" 200 | version = "0.1.2" 201 | source = { registry = "https://pypi.org/simple" } 202 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 203 | wheels = [ 204 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 205 | ] 206 | 207 | [[package]] 208 | name = "pydantic" 209 | version = "2.11.1" 210 | source = { registry = "https://pypi.org/simple" } 211 | dependencies = [ 212 | { name = "annotated-types" }, 213 | { name = "pydantic-core" }, 214 | { name = "typing-extensions" }, 215 | { name = "typing-inspection" }, 216 | ] 217 | sdist = { url = "https://files.pythonhosted.org/packages/93/a3/698b87a4d4d303d7c5f62ea5fbf7a79cab236ccfbd0a17847b7f77f8163e/pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", size = 782817 } 218 | wheels = [ 219 | { url = "https://files.pythonhosted.org/packages/cc/12/f9221a949f2419e2e23847303c002476c26fbcfd62dc7f3d25d0bec5ca99/pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8", size = 442648 }, 220 | ] 221 | 222 | [[package]] 223 | name = "pydantic-core" 224 | version = "2.33.0" 225 | source = { registry = "https://pypi.org/simple" } 226 | dependencies = [ 227 | { name = "typing-extensions" }, 228 | ] 229 | sdist = { url = "https://files.pythonhosted.org/packages/b9/05/91ce14dfd5a3a99555fce436318cc0fd1f08c4daa32b3248ad63669ea8b4/pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", size = 434080 } 230 | wheels = [ 231 | { url = "https://files.pythonhosted.org/packages/f0/93/9e97af2619b4026596487a79133e425c7d3c374f0a7f100f3d76bcdf9c83/pydantic_core-2.33.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a608a75846804271cf9c83e40bbb4dab2ac614d33c6fd5b0c6187f53f5c593ef", size = 2042784 }, 232 | { url = "https://files.pythonhosted.org/packages/42/b4/0bba8412fd242729feeb80e7152e24f0e1a1c19f4121ca3d4a307f4e6222/pydantic_core-2.33.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e1c69aa459f5609dec2fa0652d495353accf3eda5bdb18782bc5a2ae45c9273a", size = 1858179 }, 233 | { url = "https://files.pythonhosted.org/packages/69/1f/c1c40305d929bd08af863df64b0a26203b70b352a1962d86f3bcd52950fe/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9ec80eb5a5f45a2211793f1c4aeddff0c3761d1c70d684965c1807e923a588b", size = 1909396 }, 234 | { url = "https://files.pythonhosted.org/packages/0f/99/d2e727375c329c1e652b5d450fbb9d56e8c3933a397e4bd46e67c68c2cd5/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e925819a98318d17251776bd3d6aa9f3ff77b965762155bdad15d1a9265c4cfd", size = 1998264 }, 235 | { url = "https://files.pythonhosted.org/packages/9c/2e/3119a33931278d96ecc2e9e1b9d50c240636cfeb0c49951746ae34e4de74/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bf68bb859799e9cec3d9dd8323c40c00a254aabb56fe08f907e437005932f2b", size = 2140588 }, 236 | { url = "https://files.pythonhosted.org/packages/35/bd/9267bd1ba55f17c80ef6cb7e07b3890b4acbe8eb6014f3102092d53d9300/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b2ea72dea0825949a045fa4071f6d5b3d7620d2a208335207793cf29c5a182d", size = 2746296 }, 237 | { url = "https://files.pythonhosted.org/packages/6f/ed/ef37de6478a412ee627cbebd73e7b72a680f45bfacce9ff1199de6e17e88/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1583539533160186ac546b49f5cde9ffc928062c96920f58bd95de32ffd7bffd", size = 2005555 }, 238 | { url = "https://files.pythonhosted.org/packages/dd/84/72c8d1439585d8ee7bc35eb8f88a04a4d302ee4018871f1f85ae1b0c6625/pydantic_core-2.33.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23c3e77bf8a7317612e5c26a3b084c7edeb9552d645742a54a5867635b4f2453", size = 2124452 }, 239 | { url = "https://files.pythonhosted.org/packages/a7/8f/cb13de30c6a3e303423751a529a3d1271c2effee4b98cf3e397a66ae8498/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7a7f2a3f628d2f7ef11cb6188bcf0b9e1558151d511b974dfea10a49afe192b", size = 2087001 }, 240 | { url = "https://files.pythonhosted.org/packages/83/d0/e93dc8884bf288a63fedeb8040ac8f29cb71ca52e755f48e5170bb63e55b/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:f1fb026c575e16f673c61c7b86144517705865173f3d0907040ac30c4f9f5915", size = 2261663 }, 241 | { url = "https://files.pythonhosted.org/packages/4c/ba/4b7739c95efa0b542ee45fd872c8f6b1884ab808cf04ce7ac6621b6df76e/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:635702b2fed997e0ac256b2cfbdb4dd0bf7c56b5d8fba8ef03489c03b3eb40e2", size = 2257786 }, 242 | { url = "https://files.pythonhosted.org/packages/cc/98/73cbca1d2360c27752cfa2fcdcf14d96230e92d7d48ecd50499865c56bf7/pydantic_core-2.33.0-cp311-cp311-win32.whl", hash = "sha256:07b4ced28fccae3f00626eaa0c4001aa9ec140a29501770a88dbbb0966019a86", size = 1925697 }, 243 | { url = "https://files.pythonhosted.org/packages/9a/26/d85a40edeca5d8830ffc33667d6fef329fd0f4bc0c5181b8b0e206cfe488/pydantic_core-2.33.0-cp311-cp311-win_amd64.whl", hash = "sha256:4927564be53239a87770a5f86bdc272b8d1fbb87ab7783ad70255b4ab01aa25b", size = 1949859 }, 244 | { url = "https://files.pythonhosted.org/packages/7e/0b/5a381605f0b9870465b805f2c86c06b0a7c191668ebe4117777306c2c1e5/pydantic_core-2.33.0-cp311-cp311-win_arm64.whl", hash = "sha256:69297418ad644d521ea3e1aa2e14a2a422726167e9ad22b89e8f1130d68e1e9a", size = 1907978 }, 245 | { url = "https://files.pythonhosted.org/packages/a9/c4/c9381323cbdc1bb26d352bc184422ce77c4bc2f2312b782761093a59fafc/pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43", size = 2025127 }, 246 | { url = "https://files.pythonhosted.org/packages/6f/bd/af35278080716ecab8f57e84515c7dc535ed95d1c7f52c1c6f7b313a9dab/pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd", size = 1851687 }, 247 | { url = "https://files.pythonhosted.org/packages/12/e4/a01461225809c3533c23bd1916b1e8c2e21727f0fea60ab1acbffc4e2fca/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6", size = 1892232 }, 248 | { url = "https://files.pythonhosted.org/packages/51/17/3d53d62a328fb0a49911c2962036b9e7a4f781b7d15e9093c26299e5f76d/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6", size = 1977896 }, 249 | { url = "https://files.pythonhosted.org/packages/30/98/01f9d86e02ec4a38f4b02086acf067f2c776b845d43f901bd1ee1c21bc4b/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4", size = 2127717 }, 250 | { url = "https://files.pythonhosted.org/packages/3c/43/6f381575c61b7c58b0fd0b92134c5a1897deea4cdfc3d47567b3ff460a4e/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61", size = 2680287 }, 251 | { url = "https://files.pythonhosted.org/packages/01/42/c0d10d1451d161a9a0da9bbef023b8005aa26e9993a8cc24dc9e3aa96c93/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862", size = 2008276 }, 252 | { url = "https://files.pythonhosted.org/packages/20/ca/e08df9dba546905c70bae44ced9f3bea25432e34448d95618d41968f40b7/pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a", size = 2115305 }, 253 | { url = "https://files.pythonhosted.org/packages/03/1f/9b01d990730a98833113581a78e595fd40ed4c20f9693f5a658fb5f91eff/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099", size = 2068999 }, 254 | { url = "https://files.pythonhosted.org/packages/20/18/fe752476a709191148e8b1e1139147841ea5d2b22adcde6ee6abb6c8e7cf/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6", size = 2241488 }, 255 | { url = "https://files.pythonhosted.org/packages/81/22/14738ad0a0bf484b928c9e52004f5e0b81dd8dabbdf23b843717b37a71d1/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3", size = 2248430 }, 256 | { url = "https://files.pythonhosted.org/packages/e8/27/be7571e215ac8d321712f2433c445b03dbcd645366a18f67b334df8912bc/pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2", size = 1908353 }, 257 | { url = "https://files.pythonhosted.org/packages/be/3a/be78f28732f93128bd0e3944bdd4b3970b389a1fbd44907c97291c8dcdec/pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48", size = 1955956 }, 258 | { url = "https://files.pythonhosted.org/packages/21/26/b8911ac74faa994694b76ee6a22875cc7a4abea3c381fdba4edc6c6bef84/pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6", size = 1903259 }, 259 | { url = "https://files.pythonhosted.org/packages/79/20/de2ad03ce8f5b3accf2196ea9b44f31b0cd16ac6e8cfc6b21976ed45ec35/pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", size = 2032214 }, 260 | { url = "https://files.pythonhosted.org/packages/f9/af/6817dfda9aac4958d8b516cbb94af507eb171c997ea66453d4d162ae8948/pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", size = 1852338 }, 261 | { url = "https://files.pythonhosted.org/packages/44/f3/49193a312d9c49314f2b953fb55740b7c530710977cabe7183b8ef111b7f/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365", size = 1896913 }, 262 | { url = "https://files.pythonhosted.org/packages/06/e0/c746677825b2e29a2fa02122a8991c83cdd5b4c5f638f0664d4e35edd4b2/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", size = 1986046 }, 263 | { url = "https://files.pythonhosted.org/packages/11/ec/44914e7ff78cef16afb5e5273d480c136725acd73d894affdbe2a1bbaad5/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", size = 2128097 }, 264 | { url = "https://files.pythonhosted.org/packages/fe/f5/c6247d424d01f605ed2e3802f338691cae17137cee6484dce9f1ac0b872b/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", size = 2681062 }, 265 | { url = "https://files.pythonhosted.org/packages/f0/85/114a2113b126fdd7cf9a9443b1b1fe1b572e5bd259d50ba9d5d3e1927fa9/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", size = 2007487 }, 266 | { url = "https://files.pythonhosted.org/packages/e6/40/3c05ed28d225c7a9acd2b34c5c8010c279683a870219b97e9f164a5a8af0/pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", size = 2121382 }, 267 | { url = "https://files.pythonhosted.org/packages/8a/22/e70c086f41eebd323e6baa92cc906c3f38ddce7486007eb2bdb3b11c8f64/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", size = 2072473 }, 268 | { url = "https://files.pythonhosted.org/packages/3e/84/d1614dedd8fe5114f6a0e348bcd1535f97d76c038d6102f271433cd1361d/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", size = 2249468 }, 269 | { url = "https://files.pythonhosted.org/packages/b0/c0/787061eef44135e00fddb4b56b387a06c303bfd3884a6df9bea5cb730230/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", size = 2254716 }, 270 | { url = "https://files.pythonhosted.org/packages/ae/e2/27262eb04963201e89f9c280f1e10c493a7a37bc877e023f31aa72d2f911/pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", size = 1916450 }, 271 | { url = "https://files.pythonhosted.org/packages/13/8d/25ff96f1e89b19e0b70b3cd607c9ea7ca27e1dcb810a9cd4255ed6abf869/pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", size = 1956092 }, 272 | { url = "https://files.pythonhosted.org/packages/1b/64/66a2efeff657b04323ffcd7b898cb0354d36dae3a561049e092134a83e9c/pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", size = 1908367 }, 273 | { url = "https://files.pythonhosted.org/packages/52/54/295e38769133363d7ec4a5863a4d579f331728c71a6644ff1024ee529315/pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", size = 1813331 }, 274 | { url = "https://files.pythonhosted.org/packages/4c/9c/0c8ea02db8d682aa1ef48938abae833c1d69bdfa6e5ec13b21734b01ae70/pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", size = 1986653 }, 275 | { url = "https://files.pythonhosted.org/packages/8e/4f/3fb47d6cbc08c7e00f92300e64ba655428c05c56b8ab6723bd290bae6458/pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", size = 1931234 }, 276 | { url = "https://files.pythonhosted.org/packages/2b/b2/553e42762e7b08771fca41c0230c1ac276f9e79e78f57628e1b7d328551d/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d8dc9f63a26f7259b57f46a7aab5af86b2ad6fbe48487500bb1f4b27e051e4c", size = 2041207 }, 277 | { url = "https://files.pythonhosted.org/packages/85/81/a91a57bbf3efe53525ab75f65944b8950e6ef84fe3b9a26c1ec173363263/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30369e54d6d0113d2aa5aee7a90d17f225c13d87902ace8fcd7bbf99b19124db", size = 1873736 }, 278 | { url = "https://files.pythonhosted.org/packages/9c/d2/5ab52e9f551cdcbc1ee99a0b3ef595f56d031f66f88e5ca6726c49f9ce65/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3eb479354c62067afa62f53bb387827bee2f75c9c79ef25eef6ab84d4b1ae3b", size = 1903794 }, 279 | { url = "https://files.pythonhosted.org/packages/2f/5f/a81742d3f3821b16f1265f057d6e0b68a3ab13a814fe4bffac536a1f26fd/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0310524c833d91403c960b8a3cf9f46c282eadd6afd276c8c5edc617bd705dc9", size = 2083457 }, 280 | { url = "https://files.pythonhosted.org/packages/b5/2f/e872005bc0fc47f9c036b67b12349a8522d32e3bda928e82d676e2a594d1/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eddb18a00bbb855325db27b4c2a89a4ba491cd6a0bd6d852b225172a1f54b36c", size = 2119537 }, 281 | { url = "https://files.pythonhosted.org/packages/d3/13/183f13ce647202eaf3dada9e42cdfc59cbb95faedd44d25f22b931115c7f/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ade5dbcf8d9ef8f4b28e682d0b29f3008df9842bb5ac48ac2c17bc55771cc976", size = 2080069 }, 282 | { url = "https://files.pythonhosted.org/packages/23/8b/b6be91243da44a26558d9c3a9007043b3750334136c6550551e8092d6d96/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c0afd34f928383e3fd25740f2050dbac9d077e7ba5adbaa2227f4d4f3c8da5c", size = 2251618 }, 283 | { url = "https://files.pythonhosted.org/packages/aa/c5/fbcf1977035b834f63eb542e74cd6c807177f383386175b468f0865bcac4/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7da333f21cd9df51d5731513a6d39319892947604924ddf2e24a4612975fb936", size = 2255374 }, 284 | { url = "https://files.pythonhosted.org/packages/2f/f8/66f328e411f1c9574b13c2c28ab01f308b53688bbbe6ca8fb981e6cabc42/pydantic_core-2.33.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8", size = 2082099 }, 285 | ] 286 | 287 | [[package]] 288 | name = "pydantic-settings" 289 | version = "2.8.1" 290 | source = { registry = "https://pypi.org/simple" } 291 | dependencies = [ 292 | { name = "pydantic" }, 293 | { name = "python-dotenv" }, 294 | ] 295 | sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } 296 | wheels = [ 297 | { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, 298 | ] 299 | 300 | [[package]] 301 | name = "pygments" 302 | version = "2.19.1" 303 | source = { registry = "https://pypi.org/simple" } 304 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 305 | wheels = [ 306 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 307 | ] 308 | 309 | [[package]] 310 | name = "python-dotenv" 311 | version = "1.1.0" 312 | source = { registry = "https://pypi.org/simple" } 313 | sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } 314 | wheels = [ 315 | { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, 316 | ] 317 | 318 | [[package]] 319 | name = "requests" 320 | version = "2.32.3" 321 | source = { registry = "https://pypi.org/simple" } 322 | dependencies = [ 323 | { name = "certifi" }, 324 | { name = "charset-normalizer" }, 325 | { name = "idna" }, 326 | { name = "urllib3" }, 327 | ] 328 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 329 | wheels = [ 330 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 331 | ] 332 | 333 | [[package]] 334 | name = "rich" 335 | version = "13.9.4" 336 | source = { registry = "https://pypi.org/simple" } 337 | dependencies = [ 338 | { name = "markdown-it-py" }, 339 | { name = "pygments" }, 340 | ] 341 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 342 | wheels = [ 343 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 344 | ] 345 | 346 | [[package]] 347 | name = "shellingham" 348 | version = "1.5.4" 349 | source = { registry = "https://pypi.org/simple" } 350 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } 351 | wheels = [ 352 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, 353 | ] 354 | 355 | [[package]] 356 | name = "sniffio" 357 | version = "1.3.1" 358 | source = { registry = "https://pypi.org/simple" } 359 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 360 | wheels = [ 361 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 362 | ] 363 | 364 | [[package]] 365 | name = "sse-starlette" 366 | version = "2.2.1" 367 | source = { registry = "https://pypi.org/simple" } 368 | dependencies = [ 369 | { name = "anyio" }, 370 | { name = "starlette" }, 371 | ] 372 | sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } 373 | wheels = [ 374 | { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, 375 | ] 376 | 377 | [[package]] 378 | name = "starlette" 379 | version = "0.46.1" 380 | source = { registry = "https://pypi.org/simple" } 381 | dependencies = [ 382 | { name = "anyio" }, 383 | ] 384 | sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } 385 | wheels = [ 386 | { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, 387 | ] 388 | 389 | [[package]] 390 | name = "typer" 391 | version = "0.15.2" 392 | source = { registry = "https://pypi.org/simple" } 393 | dependencies = [ 394 | { name = "click" }, 395 | { name = "rich" }, 396 | { name = "shellingham" }, 397 | { name = "typing-extensions" }, 398 | ] 399 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } 400 | wheels = [ 401 | { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, 402 | ] 403 | 404 | [[package]] 405 | name = "typing-extensions" 406 | version = "4.13.0" 407 | source = { registry = "https://pypi.org/simple" } 408 | sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 } 409 | wheels = [ 410 | { url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 }, 411 | ] 412 | 413 | [[package]] 414 | name = "typing-inspection" 415 | version = "0.4.0" 416 | source = { registry = "https://pypi.org/simple" } 417 | dependencies = [ 418 | { name = "typing-extensions" }, 419 | ] 420 | sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } 421 | wheels = [ 422 | { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, 423 | ] 424 | 425 | [[package]] 426 | name = "urllib3" 427 | version = "2.3.0" 428 | source = { registry = "https://pypi.org/simple" } 429 | sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } 430 | wheels = [ 431 | { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, 432 | ] 433 | 434 | [[package]] 435 | name = "uvicorn" 436 | version = "0.34.0" 437 | source = { registry = "https://pypi.org/simple" } 438 | dependencies = [ 439 | { name = "click" }, 440 | { name = "h11" }, 441 | ] 442 | sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } 443 | wheels = [ 444 | { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, 445 | ] 446 | 447 | [[package]] 448 | name = "whatsapp-mcp-server" 449 | version = "0.1.0" 450 | source = { virtual = "." } 451 | dependencies = [ 452 | { name = "httpx" }, 453 | { name = "mcp", extra = ["cli"] }, 454 | { name = "requests" }, 455 | ] 456 | 457 | [package.metadata] 458 | requires-dist = [ 459 | { name = "httpx", specifier = ">=0.28.1" }, 460 | { name = "mcp", extras = ["cli"], specifier = ">=1.6.0" }, 461 | { name = "requests", specifier = ">=2.32.3" }, 462 | ] 463 | --------------------------------------------------------------------------------