├── whatsapp-mcp ├── whatsapp-mcp-server │ ├── .python-version │ ├── pyproject.toml │ ├── Dockerfile │ ├── audio.py │ ├── main.py │ ├── whatsapp.py │ └── uv.lock ├── example-use.png ├── whatsapp-bridge │ ├── Dockerfile │ ├── go.mod │ ├── go.sum │ └── main.go ├── whatsapp-dashboard │ ├── vite.config.js │ ├── src │ │ ├── App.jsx │ │ ├── main.jsx │ │ ├── readme │ │ ├── components │ │ │ ├── MessageList.jsx │ │ │ ├── ChatList.jsx │ │ │ └── ChatInput.jsx │ │ ├── App.css │ │ ├── api.js │ │ ├── index.css │ │ └── assets │ │ │ └── react.svg │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── README.md │ ├── eslint.config.js │ └── public │ │ └── vite.svg └── README.md ├── image.png ├── .env ├── docker-compose.yml └── readme.MD /whatsapp-mcp/whatsapp-mcp-server/.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zie619/automation-stack/HEAD/image.png -------------------------------------------------------------------------------- /whatsapp-mcp/example-use.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zie619/automation-stack/HEAD/whatsapp-mcp/example-use.png -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-bridge/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | WORKDIR /app/whatsapp-bridge 8 | 9 | RUN go mod download 10 | 11 | CMD ["go", "run", "main.go"] 12 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | export default defineConfig({ 5 | base: '/ui/', 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/App.jsx: -------------------------------------------------------------------------------- 1 | function App() { 2 | return ( 3 |
4 | This page is building... 5 |
6 | ); 7 | } 8 | 9 | export default App; 10 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/readme: -------------------------------------------------------------------------------- 1 | docker compose stop whatsapp-mcp-server 2 | 3 | docker compose rm -f whatsapp-mcp-server 4 | 5 | docker compose up -d whatsapp-mcp-server 6 | 7 | root@vultr:~/automation-stack/whatsapp-mcp/whatsapp-dashboard# npm run build 8 | 9 | docker compose build whatsapp-mcp-server 10 | docker compose up -d whatsapp-mcp-server -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-mcp-server/pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml (fixed) 2 | 3 | [project] 4 | name = "whatsapp-mcp-server" 5 | version = "0.1.0" 6 | description = "WhatsApp MCP Server using FastMCP" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "httpx>=0.28.1", 11 | "fastapi", 12 | "fastmcp", 13 | "requests>=2.32.3", 14 | ] 15 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # PostgreSQL 2 | POSTGRES_USER=yourusername 3 | POSTGRES_PASSWORD=yourpassword 4 | POSTGRES_DB=n8n 5 | POSTGRES_PORT=5432 6 | POSTGRES_HOST=postgres 7 | 8 | # Redis 9 | REDIS_HOST=redis 10 | QUEUE_MODE=redis 11 | 12 | # n8n 13 | N8N_PORT=5678 14 | N8N_AUTH_ACTIVE=true 15 | N8N_AUTH_USER=yourusername 16 | N8N_AUTH_PASS=yourpassword 17 | N8N_BASE_URL=https://n8n.yourdomain.com 18 | N8N_WEBHOOK_URL=https://n8n.yourdomain.com 19 | 20 | # WhatsApp MCP 21 | MCP_PORT=8000 22 | MCP_AUTH_USER=yourusername 23 | MCP_AUTH_PASS=yourpassword -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/components/MessageList.jsx: -------------------------------------------------------------------------------- 1 | export default function MessageList({ messages }) { 2 | return ( 3 |
4 | {messages.map((msg, i) => ( 5 |
6 |
7 | {msg.sender_name || msg.sender_phone_number || "Unknown"} —{" "} 8 | {new Date(msg.timestamp).toLocaleString()} 9 |
10 |
11 | {msg.text ? msg.text : (No text)} 12 |
13 |
14 | ))} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/components/ChatList.jsx: -------------------------------------------------------------------------------- 1 | export default function ChatList({ chats, onSelect }) { 2 | return ( 3 |
4 |

Chats

5 | {chats.map(chat => ( 6 |
onSelect(chat)} 9 | className="p-4 cursor-pointer hover:bg-gray-200 border-b" 10 | > 11 |
{chat.name || chat.jid}
12 |
13 | {chat.last_message} 14 |
15 |
16 | ))} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-mcp-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | # Python 3.11 might have vulnerabilities, considering trying a different ver. 3 | WORKDIR /app 4 | 5 | # Install base tools 6 | RUN apt-get update && \ 7 | apt-get install -y ffmpeg curl git 8 | 9 | # Install uv and expose it globally 10 | RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ 11 | cp /root/.local/bin/uv /usr/local/bin/uv 12 | 13 | # Copy backend code 14 | COPY . . 15 | 16 | # ✅ Copy frontend dist folder 17 | COPY whatsapp-dashboard/dist /app/whatsapp-mcp-server/ui 18 | 19 | WORKDIR /app/whatsapp-mcp-server 20 | 21 | # Install Python deps 22 | RUN uv pip install --system -r pyproject.toml 23 | 24 | # Run the app 25 | CMD ["uv", "run", "main.py"] 26 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whatsapp-dashboard", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0" 15 | }, 16 | "devDependencies": { 17 | "@eslint/js": "^9.25.0", 18 | "@types/react": "^19.1.2", 19 | "@types/react-dom": "^19.1.2", 20 | "@vitejs/plugin-react": "^4.4.1", 21 | "eslint": "^9.25.0", 22 | "eslint-plugin-react-hooks": "^5.2.0", 23 | "eslint-plugin-react-refresh": "^0.4.19", 24 | "globals": "^16.0.0", 25 | "vite": "^6.3.5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/components/ChatInput.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export default function ChatInput({ onSend }) { 4 | const [text, setText] = useState(""); 5 | 6 | const handleSend = () => { 7 | if (text.trim()) { 8 | onSend(text); 9 | setText(""); 10 | } 11 | }; 12 | 13 | return ( 14 |
15 | setText(e.target.value)} 20 | onKeyDown={e => e.key === "Enter" && handleSend()} 21 | /> 22 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /whatsapp-mcp/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 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. 13 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | 6 | export default [ 7 | { ignores: ['dist'] }, 8 | { 9 | files: ['**/*.{js,jsx}'], 10 | languageOptions: { 11 | ecmaVersion: 2020, 12 | globals: globals.browser, 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | ecmaFeatures: { jsx: true }, 16 | sourceType: 'module', 17 | }, 18 | }, 19 | plugins: { 20 | 'react-hooks': reactHooks, 21 | 'react-refresh': reactRefresh, 22 | }, 23 | rules: { 24 | ...js.configs.recommended.rules, 25 | ...reactHooks.configs.recommended.rules, 26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 27 | 'react-refresh/only-export-components': [ 28 | 'warn', 29 | { allowConstantExport: true }, 30 | ], 31 | }, 32 | }, 33 | ] 34 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/api.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = "/run_tool"; 2 | 3 | const authHeader = { 4 | Authorization: "Basic " + btoa("mcpadmin:mcppass"), // update with .env or prompt in production 5 | "Content-Type": "application/json" 6 | }; 7 | 8 | export async function listChats() { 9 | const res = await fetch(BASE_URL, { 10 | method: "POST", 11 | headers: authHeader, 12 | body: JSON.stringify({ 13 | tool: "list_chats", 14 | params: { limit: 10, include_last_message: true } 15 | }) 16 | }); 17 | return await res.json(); 18 | } 19 | 20 | export async function listMessages(chat_jid) { 21 | const res = await fetch(BASE_URL, { 22 | method: "POST", 23 | headers: authHeader, 24 | body: JSON.stringify({ 25 | tool: "list_messages", 26 | params: { chat_jid } 27 | }) 28 | }); 29 | return await res.json(); 30 | } 31 | 32 | export async function sendMessage(recipient, message) { 33 | const res = await fetch(BASE_URL, { 34 | method: "POST", 35 | headers: authHeader, 36 | body: JSON.stringify({ 37 | tool: "send_message", 38 | params: { recipient, message } 39 | }) 40 | }); 41 | return await res.json(); 42 | } 43 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | postgres: 5 | image: postgres:15 6 | container_name: n8n-postgres 7 | restart: unless-stopped 8 | environment: 9 | - POSTGRES_USER=${POSTGRES_USER} 10 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 11 | - POSTGRES_DB=${POSTGRES_DB} 12 | volumes: 13 | - postgres_data:/var/lib/postgresql/data 14 | networks: 15 | - internal 16 | 17 | redis: 18 | image: redis:6 19 | container_name: n8n-redis 20 | restart: unless-stopped 21 | networks: 22 | - internal 23 | 24 | n8n: 25 | image: n8nio/n8n 26 | container_name: n8n 27 | restart: unless-stopped 28 | ports: 29 | - "${N8N_PORT}:5678" 30 | environment: 31 | - DB_TYPE=postgresdb 32 | - DB_POSTGRESDB_HOST=${POSTGRES_HOST} 33 | - DB_POSTGRESDB_PORT=${POSTGRES_PORT} 34 | - DB_POSTGRESDB_DATABASE=${POSTGRES_DB} 35 | - DB_POSTGRESDB_USER=${POSTGRES_USER} 36 | - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD} 37 | - QUEUE_MODE=${QUEUE_MODE} 38 | - REDIS_HOST=${REDIS_HOST} 39 | - N8N_BASIC_AUTH_ACTIVE=${N8N_AUTH_ACTIVE} 40 | - N8N_BASIC_AUTH_USER=${N8N_AUTH_USER} 41 | - N8N_BASIC_AUTH_PASSWORD=${N8N_AUTH_PASS} 42 | - N8N_EDITOR_BASE_URL=${N8N_BASE_URL} 43 | - WEBHOOK_URL=${N8N_WEBHOOK_URL} 44 | depends_on: 45 | - postgres 46 | - redis 47 | volumes: 48 | - n8n_data:/home/node/.n8n 49 | networks: 50 | - internal 51 | 52 | whatsapp-bridge: 53 | build: 54 | context: ./whatsapp-mcp 55 | dockerfile: whatsapp-bridge/Dockerfile 56 | container_name: whatsapp-bridge 57 | volumes: 58 | - whatsapp_store:/app/whatsapp-bridge/store 59 | restart: unless-stopped 60 | networks: 61 | - internal 62 | 63 | whatsapp-mcp-server: 64 | build: 65 | context: ./whatsapp-mcp 66 | dockerfile: whatsapp-mcp-server/Dockerfile 67 | container_name: whatsapp-mcp-server 68 | volumes: 69 | - whatsapp_store:/app/whatsapp-bridge/store 70 | ports: 71 | - "${MCP_PORT}:8000" 72 | restart: unless-stopped 73 | depends_on: 74 | - whatsapp-bridge 75 | networks: 76 | - internal 77 | 78 | volumes: 79 | n8n_data: 80 | postgres_data: 81 | whatsapp_store: 82 | 83 | networks: 84 | internal: 85 | driver: bridge 86 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-dashboard/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /whatsapp-mcp/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-mcp/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 | 🚀 Automation Stack – Full Self-Hosted Deployment Guide 2 | 3 | This repo sets up: 4 | 5 | - n8n automation with Redis & PostgreSQL 6 | - WhatsApp MCP (Multi-tool control API) 7 | - React dashboard at `/ui` 8 | - Cloudflare tunneling (HTTPS access without ports) 9 | - Auto-start & daily Postgres backup 10 | 11 | ## ☁️ Requirements 12 | 13 | - A domain name (required for Cloudflare tunnels) 14 | - Docker & Docker Compose 15 | - curl, git, and crontab (Linux) 16 | 17 | --- 18 | 19 | ## 📦 1. Clone Repository 20 | 21 | ```bash 22 | git clone https://github.com/Zie619/automation-stack.git 23 | cd automation-stack 24 | ``` 25 | 26 | --- 27 | 28 | ## 🌐 2. Install & Configure Cloudflared 29 | 30 | **Linux:** 31 | 32 | ```bash 33 | wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb 34 | sudo dpkg -i cloudflared-linux-amd64.deb 35 | ``` 36 | 37 | **Mac:** 38 | 39 | ```bash 40 | brew install cloudflared 41 | ``` 42 | 43 | **Windows:** 44 | 45 | ```bash 46 | winget install --id Cloudflare.cloudflared 47 | # or 48 | choco install cloudflared 49 | ``` 50 | 51 | ### Login and create tunnel 52 | 53 | ```bash 54 | cloudflared tunnel login 55 | cloudflared tunnel create n8n-tunnel-example 56 | ``` 57 | 58 | After login, note the JSON credentials path. For example: 59 | `/root/.cloudflared/ac167143-2428-4b3c-91fc-0d1579e984cf.json` 60 | 61 | ### Create config.yml 62 | 63 | **Linux:** 64 | ```bash 65 | sudo nano /etc/cloudflared/config.yml 66 | ``` 67 | 68 | **Windows:** Edit `C:\Users\Administrator\.cloudflared\config.yml` 69 | 70 | ```yaml 71 | tunnel: n8n-tunnel 72 | credentials-file: /root/.cloudflared/your-tunnel.json 73 | 74 | ingress: 75 | - hostname: n8n.yourdomain.com 76 | service: http://localhost:5678 77 | - hostname: whatsapp.yourdomain.com 78 | service: http://localhost:8000 79 | - hostname: dashboard.yourdomain.com 80 | service: http://localhost:5137 81 | - service: http_status:404 82 | ``` 83 | in cloudflare go to the dns managment and add the domains as in the image whatsapp.yourdomain.com and dashboard.yourdomain.com 84 | ![alt text](image.png) 85 | 86 | ### Enable tunnel on boot (Linux) 87 | 88 | ```bash 89 | sudo tee /etc/systemd/system/cloudflared.service > /dev/null < 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 | ## Installation 16 | 17 | ### Prerequisites 18 | 19 | - Go 20 | - Python 3.6+ 21 | - Anthropic Claude Desktop app (or Cursor) 22 | - UV (Python package manager), install with `curl -LsSf https://astral.sh/uv/install.sh | sh` 23 | - 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. 24 | 25 | ### Steps 26 | 27 | 1. **Clone this repository** 28 | 29 | ```bash 30 | git clone https://github.com/lharries/whatsapp-mcp.git 31 | cd whatsapp-mcp 32 | ``` 33 | 34 | 2. **Run the WhatsApp bridge** 35 | 36 | Navigate to the whatsapp-bridge directory and run the Go application: 37 | 38 | ```bash 39 | cd whatsapp-bridge 40 | go run main.go 41 | ``` 42 | 43 | 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. 44 | 45 | After approximately 20 days, you will might need to re-authenticate. 46 | 47 | 3. **Connect to the MCP server** 48 | 49 | Copy the below json with the appropriate {{PATH}} values: 50 | 51 | ```json 52 | { 53 | "mcpServers": { 54 | "whatsapp": { 55 | "command": "{{PATH_TO_UV}}", // Run `which uv` and place the output here 56 | "args": [ 57 | "--directory", 58 | "{{PATH_TO_SRC}}/whatsapp-mcp/whatsapp-mcp-server", // cd into the repo, run `pwd` and enter the output here + "/whatsapp-mcp-server" 59 | "run", 60 | "main.py" 61 | ] 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | For **Claude**, save this as `claude_desktop_config.json` in your Claude Desktop configuration directory at: 68 | 69 | ``` 70 | ~/Library/Application Support/Claude/claude_desktop_config.json 71 | ``` 72 | 73 | For **Cursor**, save this as `mcp.json` in your Cursor configuration directory at: 74 | 75 | ``` 76 | ~/.cursor/mcp.json 77 | ``` 78 | 79 | 4. **Restart Claude Desktop / Cursor** 80 | 81 | Open Claude Desktop and you should now see WhatsApp as an available integration. 82 | 83 | Or restart Cursor. 84 | 85 | ### Windows Compatibility 86 | 87 | 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. 88 | 89 | #### Steps to get it working: 90 | 91 | 1. **Install a C compiler** 92 | 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`. 93 | → A step-by-step guide is available [here](https://code.visualstudio.com/docs/cpp/config-mingw). 94 | 95 | 2. **Enable CGO and run the app** 96 | 97 | ```bash 98 | cd whatsapp-bridge 99 | go env -w CGO_ENABLED=1 100 | go run main.go 101 | ``` 102 | 103 | Without this setup, you'll likely run into errors like: 104 | 105 | > `Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work.` 106 | 107 | ## Architecture Overview 108 | 109 | This application consists of two main components: 110 | 111 | 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. 112 | 113 | 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. 114 | 115 | ### Data Storage 116 | 117 | - All message history is stored in a SQLite database within the `whatsapp-bridge/store/` directory 118 | - The database maintains tables for chats and messages 119 | - Messages are indexed for efficient searching and retrieval 120 | 121 | ## Usage 122 | 123 | Once connected, you can interact with your WhatsApp contacts through Claude, leveraging Claude's AI capabilities in your WhatsApp conversations. 124 | 125 | ### MCP Tools 126 | 127 | Claude can access the following tools to interact with WhatsApp: 128 | 129 | - **search_contacts**: Search for contacts by name or phone number 130 | - **list_messages**: Retrieve messages with optional filters and context 131 | - **list_chats**: List available chats with metadata 132 | - **get_chat**: Get information about a specific chat 133 | - **get_direct_chat_by_contact**: Find a direct chat with a specific contact 134 | - **get_contact_chats**: List all chats involving a specific contact 135 | - **get_last_interaction**: Get the most recent message with a contact 136 | - **get_message_context**: Retrieve context around a specific message 137 | - **send_message**: Send a WhatsApp message to a specified phone number or group JID 138 | - **send_file**: Send a file (image, video, raw audio, document) to a specified recipient 139 | - **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) 140 | - **download_media**: Download media from a WhatsApp message and get the local file path 141 | 142 | ### Media Handling Features 143 | 144 | The MCP server supports both sending and receiving various media types: 145 | 146 | #### Media Sending 147 | 148 | You can send various media types to your WhatsApp contacts: 149 | 150 | - **Images, Videos, Documents**: Use the `send_file` tool to share any supported media type. 151 | - **Voice Messages**: Use the `send_audio_message` tool to send audio files as playable WhatsApp voice messages. 152 | - For optimal compatibility, audio files should be in `.ogg` Opus format. 153 | - With FFmpeg installed, the system will automatically convert other audio formats (MP3, WAV, etc.) to the required format. 154 | - Without FFmpeg, you can still send raw audio files using the `send_file` tool, but they won't appear as playable voice messages. 155 | 156 | #### Media Downloading 157 | 158 | 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. 159 | 160 | ## Technical Details 161 | 162 | 1. Claude sends requests to the Python MCP server 163 | 2. The MCP server queries the Go bridge for WhatsApp data or directly to the SQLite database 164 | 3. The Go accesses the WhatsApp API and keeps the SQLite database up to date 165 | 4. Data flows back through the chain to Claude 166 | 5. When sending messages, the request flows from Claude through the MCP server to the Go bridge and to WhatsApp 167 | 168 | ## Troubleshooting 169 | 170 | - 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. 171 | - Make sure both the Go application and the Python server are running for the integration to work properly. 172 | 173 | ### Authentication Issues 174 | 175 | - **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. 176 | - **WhatsApp Already Logged In**: If your session is already active, the Go bridge will automatically reconnect without showing a QR code. 177 | - **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). 178 | - **No Messages Loading**: After initial authentication, it can take several minutes for your message history to load, especially if you have many chats. 179 | - **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. 180 | 181 | 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. 182 | -------------------------------------------------------------------------------- /whatsapp-mcp/whatsapp-mcp-server/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request, Depends, HTTPException, status 2 | from fastapi.responses import JSONResponse, FileResponse ,Response 3 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 4 | from fastapi.staticfiles import StaticFiles 5 | from pydantic import BaseModel, Field 6 | from typing import List, Dict, Any, Optional, Callable 7 | from datetime import datetime, date 8 | from dotenv import load_dotenv 9 | import secrets 10 | import os 11 | import json 12 | 13 | load_dotenv() 14 | 15 | # ========== BASIC AUTH ========== 16 | security = HTTPBasic() 17 | AUTH_USER = os.getenv("MCP_AUTH_USER", "yourusername") 18 | AUTH_PASS = os.getenv("MCP_AUTH_PASS", "yourpassword") 19 | 20 | def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)): 21 | if not (secrets.compare_digest(credentials.username, AUTH_USER) and 22 | secrets.compare_digest(credentials.password, AUTH_PASS)): 23 | raise HTTPException( 24 | status_code=status.HTTP_401_UNAUTHORIZED, 25 | detail="Invalid credentials", 26 | headers={"WWW-Authenticate": "Basic"}, 27 | ) 28 | return credentials.username 29 | 30 | # ========== APP INIT ========== 31 | app = FastAPI() 32 | tools: Dict[str, Callable] = {} 33 | tool_descriptions: Dict[str, Dict[str, Any]] = {} 34 | 35 | # ========== SERIALIZER ========== 36 | def serialize(obj): 37 | if isinstance(obj, (datetime, date)): 38 | return obj.isoformat() 39 | elif isinstance(obj, list): 40 | return [serialize(item) for item in obj] 41 | elif isinstance(obj, dict): 42 | return {k: serialize(v) for k, v in obj.items()} 43 | elif hasattr(obj, "__dict__"): 44 | return serialize(vars(obj)) 45 | else: 46 | return obj 47 | 48 | # ========== TOOL REGISTRATION ========== 49 | def register_tool(name: str, description: str = "", params: Optional[Dict[str, str]] = None): 50 | def decorator(func: Callable): 51 | tools[name] = func 52 | tool_descriptions[name] = { 53 | "description": description, 54 | "params": params or {} 55 | } 56 | return func 57 | return decorator 58 | 59 | # ========== SCHEMA ========== 60 | class ToolRequest(BaseModel): 61 | tool: str = Field(..., example="list_chats") 62 | params: Dict[str, Any] = Field( 63 | default_factory=lambda: { 64 | "limit": 5, 65 | "include_last_message": True 66 | }, 67 | example={ 68 | "limit": 5, 69 | "include_last_message": True 70 | } 71 | ) 72 | 73 | # ========== STATIC UI ========== 74 | app.mount("/ui", StaticFiles(directory="ui", html=True), name="ui") 75 | 76 | @app.get("/ui", include_in_schema=False) 77 | async def serve_ui_root(): 78 | return FileResponse("ui/index.html") 79 | 80 | # ========== TOOL ROUTES ========== 81 | @app.get("/tools") 82 | async def list_tools(): 83 | return tool_descriptions 84 | 85 | @app.post("/run_tool") 86 | async def run_tool( 87 | data: ToolRequest, 88 | username: str = Depends(verify_credentials) 89 | ): 90 | try: 91 | tool_name = data.tool 92 | params = data.params 93 | 94 | tool_func = tools.get(tool_name) 95 | if not tool_func: 96 | return JSONResponse(content={"error": f"Tool '{tool_name}' not found"}, status_code=400) 97 | 98 | result = tool_func(**params) 99 | return JSONResponse(content=serialize(result)) 100 | 101 | except Exception as e: 102 | return JSONResponse(content={"error": str(e)}, status_code=500) 103 | 104 | # ========== WHATSAPP MCP TOOLS ========== 105 | from whatsapp import ( 106 | list_chats as whatsapp_list_chats, 107 | search_contacts as whatsapp_search_contacts, 108 | list_messages as whatsapp_list_messages, 109 | get_chat as whatsapp_get_chat, 110 | get_direct_chat_by_contact as whatsapp_get_direct_chat_by_contact, 111 | get_contact_chats as whatsapp_get_contact_chats, 112 | get_last_interaction as whatsapp_get_last_interaction, 113 | get_message_context as whatsapp_get_message_context, 114 | send_message as whatsapp_send_message, 115 | send_file as whatsapp_send_file, 116 | send_audio_message as whatsapp_audio_voice_message, 117 | download_media as whatsapp_download_media 118 | ) 119 | 120 | @register_tool("list_chats", "List recent WhatsApp chats", { 121 | "query": "Optional name or content search", 122 | "limit": "Number of chats to return", 123 | "page": "Page number", 124 | "include_last_message": "Include last message content", 125 | "sort_by": "Sort field (e.g. last_active)" 126 | }) 127 | def list_chats(query: Optional[str] = None, limit: int = 20, page: int = 0, 128 | include_last_message: bool = True, sort_by: str = "last_active") -> List[Dict[str, Any]]: 129 | return whatsapp_list_chats(query, limit, page, include_last_message, sort_by) 130 | 131 | @register_tool("search_contacts", "Search contacts by name or phone", { 132 | "query": "Search string" 133 | }) 134 | def search_contacts(query: str) -> List[Dict[str, Any]]: 135 | return whatsapp_search_contacts(query) 136 | 137 | @register_tool("list_messages", "List messages from a chat or contact", { 138 | "chat_jid": "Chat ID", 139 | "sender_phone_number": "Filter by sender", 140 | "query": "Search in message text", 141 | "limit": "Number of messages", 142 | "include_context": "Include message context" 143 | }) 144 | def list_messages(after: Optional[str] = None, before: Optional[str] = None, 145 | sender_phone_number: Optional[str] = None, chat_jid: Optional[str] = None, 146 | query: Optional[str] = None, limit: int = 20, page: int = 0, 147 | include_context: bool = True, context_before: int = 1, context_after: int = 1 148 | ) -> List[Dict[str, Any]]: 149 | return whatsapp_list_messages(after, before, sender_phone_number, chat_jid, query, 150 | limit, page, include_context, context_before, context_after) 151 | 152 | @register_tool("get_chat", "Get full chat info by jid", { 153 | "chat_jid": "Chat ID", 154 | "include_last_message": "Include last message" 155 | }) 156 | def get_chat(chat_jid: str, include_last_message: bool = True) -> Dict[str, Any]: 157 | return whatsapp_get_chat(chat_jid, include_last_message) 158 | 159 | @register_tool("get_direct_chat_by_contact", "Get 1-on-1 chat with contact", { 160 | "sender_phone_number": "Phone number" 161 | }) 162 | def get_direct_chat_by_contact(sender_phone_number: str) -> Dict[str, Any]: 163 | return whatsapp_get_direct_chat_by_contact(sender_phone_number) 164 | 165 | @register_tool("get_contact_chats", "Get all chats for contact jid", { 166 | "jid": "WhatsApp JID", 167 | "limit": "Pagination limit", 168 | "page": "Pagination page" 169 | }) 170 | def get_contact_chats(jid: str, limit: int = 20, page: int = 0) -> List[Dict[str, Any]]: 171 | return whatsapp_get_contact_chats(jid, limit, page) 172 | 173 | @register_tool("get_last_interaction", "Returns date of last message in chat", { 174 | "jid": "Chat JID" 175 | }) 176 | def get_last_interaction(jid: str) -> str: 177 | return whatsapp_get_last_interaction(jid) 178 | 179 | @register_tool("get_message_context", "Get context around a message", { 180 | "message_id": "Target message ID", 181 | "before": "Messages before", 182 | "after": "Messages after" 183 | }) 184 | def get_message_context(message_id: str, before: int = 5, after: int = 5) -> Dict[str, Any]: 185 | return whatsapp_get_message_context(message_id, before, after) 186 | 187 | @register_tool("send_message", "Send text message", { 188 | "recipient": "Recipient JID", 189 | "message": "Message body" 190 | }) 191 | def send_message(recipient: str, message: str) -> Dict[str, Any]: 192 | if not recipient: 193 | return {"success": False, "message": "Recipient must be provided"} 194 | success, status_message = whatsapp_send_message(recipient, message) 195 | return {"success": success, "message": status_message} 196 | 197 | @register_tool("send_file", "Send a file attachment", { 198 | "recipient": "Recipient JID", 199 | "media_path": "Local file path" 200 | }) 201 | def send_file(recipient: str, media_path: str) -> Dict[str, Any]: 202 | success, status_message = whatsapp_send_file(recipient, media_path) 203 | return {"success": success, "message": status_message} 204 | 205 | @register_tool("send_audio_message", "Send an audio voice message", { 206 | "recipient": "Recipient JID", 207 | "media_path": "Path to audio file" 208 | }) 209 | def send_audio_message(recipient: str, media_path: str) -> Dict[str, Any]: 210 | success, status_message = whatsapp_audio_voice_message(recipient, media_path) 211 | return {"success": success, "message": status_message} 212 | 213 | @register_tool("download_media", "Download media from a message", { 214 | "message_id": "Message ID", 215 | "chat_jid": "Chat JID" 216 | }) 217 | def download_media(message_id: str, chat_jid: str) -> Dict[str, Any]: 218 | file_path = whatsapp_download_media(message_id, chat_jid) 219 | if file_path: 220 | return {"success": True, "message": "Media downloaded successfully", "file_path": file_path} 221 | else: 222 | return {"success": False, "message": "Failed to download media"} 223 | 224 | 225 | 226 | @app.get("/openai-tools", tags=["agent"]) 227 | def get_openai_tool_schema(): 228 | openai_tools = [] 229 | 230 | for name, meta in tool_descriptions.items(): 231 | params_schema = { 232 | "type": "object", 233 | "properties": {}, 234 | "required": [] 235 | } 236 | 237 | for param, desc in meta["params"].items(): 238 | # Default everything to string — adjust logic if needed for types 239 | params_schema["properties"][param] = { 240 | "type": "string", 241 | "description": desc 242 | } 243 | params_schema["required"].append(param) 244 | 245 | openai_tools.append({ 246 | "name": name, 247 | "description": meta["description"], 248 | "parameters": params_schema 249 | }) 250 | 251 | return JSONResponse(content=openai_tools) 252 | 253 | 254 | # ========== DEV SERVER ========== 255 | if __name__ == "__main__": 256 | import uvicorn 257 | uvicorn.run("main:app", host="0.0.0.0", port=8000) 258 | -------------------------------------------------------------------------------- /whatsapp-mcp/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-mcp/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/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 | --------------------------------------------------------------------------------