feat: whatsapp-mcp-go

This commit is contained in:
iamatulsingh
2026-02-21 21:12:14 +01:00
commit dd5e347436
35 changed files with 4159 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KubernetesApiProvider">{}</component>
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/whatsapp-mcp-server-golang.iml" filepath="$PROJECT_DIR$/.idea/whatsapp-mcp-server-golang.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../../.." vcs="Git" />
</component>
</project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+17
View File
@@ -0,0 +1,17 @@
module whatsapp-mcp-server
go 1.25.0
require (
github.com/mattn/go-sqlite3 v1.14.34
github.com/modelcontextprotocol/go-sdk v1.3.1
)
require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+92
View File
@@ -0,0 +1,92 @@
package helpers
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ConvertToOpusOggTemp creates a temporary .ogg file in Opus format
// Uses reasonable defaults for WhatsApp voice messages
func ConvertToOpusOggTemp(inputFile string) (string, error) {
// WhatsApp voice messages usually use:
// bitrate → 24k32k is very common and good quality/size balance
// sample rate → 48000 Hz (Opus native)
const defaultBitrate = "32k"
const defaultSampleRate = 48000
return ConvertToOpusOggTempWithParams(inputFile, defaultBitrate, defaultSampleRate)
}
// ConvertToOpusOgg converts an audio file to Opus format in an Ogg container.
func ConvertToOpusOgg(inputFile string, outputFile string, bitrate string, sampleRate int) (string, error) {
// Check if input file exists
if _, err := os.Stat(inputFile); os.IsNotExist(err) {
return "", fmt.Errorf("input file not found: %s", inputFile)
}
// If no output file is specified, replace extension with .ogg
if outputFile == "" {
ext := filepath.Ext(inputFile)
outputFile = strings.TrimSuffix(inputFile, ext) + ".ogg"
}
// Ensure output directory exists
outputDir := filepath.Dir(outputFile)
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
// Build the ffmpeg command
cmd := exec.Command("ffmpeg",
"-i", inputFile,
"-c:a", "libopus",
"-b:a", bitrate,
"-ar", fmt.Sprintf("%d", sampleRate),
"-application", "voip",
"-vbr", "on",
"-compression_level", "10",
"-frame_duration", "60",
"-y",
outputFile,
)
// Run command and capture output for error reporting
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to convert audio. ffmpeg error: %s (%w)", string(output), err)
}
return outputFile, nil
}
// ConvertToOpusOggTempWithParams converts audio to a temporary .ogg file.
func ConvertToOpusOggTempWithParams(inputFile string, bitrate string, sampleRate int) (string, error) {
// Create a temporary file
tempFile, err := os.CreateTemp("", "audio-*.ogg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
tempPath := tempFile.Name()
err = tempFile.Close()
if err != nil {
return "", err
} // Close it so ffmpeg can write to the path
// Convert the audio
result, err := ConvertToOpusOgg(inputFile, tempPath, bitrate, sampleRate)
if err != nil {
// Clean up on failure
err := os.Remove(tempPath)
if err != nil {
return "", err
}
return "", err
}
return result, nil
}
+554
View File
@@ -0,0 +1,554 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"path/filepath"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// InitMcpTool initializes MCP tool for the MCP server
func InitMcpTool() {
server := mcp.NewServer(&mcp.Implementation{
Name: "whatsapp-mcp",
Version: "v1.0.0",
}, nil)
mcp.AddTool[searchContactsInput, any](server, &mcp.Tool{
Name: "search_contacts",
Description: "Search WhatsApp contacts by name or phone number.",
}, searchContactsHandler)
mcp.AddTool[listMessagesInput, any](server, &mcp.Tool{
Name: "list_messages",
Description: "Get WhatsApp messages matching specified criteria with optional context around matches.",
}, listMessagesHandler)
mcp.AddTool[getMessageContextInput, any](server, &mcp.Tool{
Name: "get_message_context",
Description: "Get surrounding messages (context) around a specific WhatsApp message.",
}, getMessageContextHandler)
mcp.AddTool[listChatsInput, any](server, &mcp.Tool{
Name: "list_chats",
Description: "Get list of WhatsApp chats, optionally filtered and sorted.",
}, listChatsHandler)
mcp.AddTool[getChatInput, any](server, &mcp.Tool{
Name: "get_chat",
Description: "Get metadata of a specific WhatsApp chat by JID.",
}, getChatHandler)
mcp.AddTool[getDirectChatByContactInput, any](server, &mcp.Tool{
Name: "get_direct_chat_by_contact",
Description: "Get direct (1:1) chat metadata by contact phone number.",
}, getDirectChatByContactHandler)
mcp.AddTool[getContactChatsInput, any](server, &mcp.Tool{
Name: "get_contact_chats",
Description: "Get all chats involving a specific contact (JID).",
}, getContactChatsHandler)
mcp.AddTool[getLastInteractionInput, any](server, &mcp.Tool{
Name: "get_last_interaction",
Description: "Get most recent WhatsApp message involving the contact.",
}, getLastInteractionHandler)
mcp.AddTool[sendMessageInput, map[string]any](server, &mcp.Tool{
Name: "send_message",
Description: "Send a text message to a person or group on WhatsApp. For groups use the group JID.",
}, sendMessageHandler)
mcp.AddTool[sendFileInput, map[string]any](server, &mcp.Tool{
Name: "send_file",
Description: "Send image, video, document or any file via WhatsApp.",
}, sendFileHandler)
mcp.AddTool[sendAudioMessageInput, map[string]any](server, &mcp.Tool{
Name: "send_audio_message",
Description: "Send audio/voice message (converted to Opus .ogg if needed).",
}, sendAudioMessageHandler)
mcp.AddTool[downloadMediaInput, map[string]any](server, &mcp.Tool{
Name: "download_media",
Description: "Download media from a WhatsApp message and return local file path.",
}, downloadMediaHandler)
isSSE := strings.ToLower(ReadEnv("IS_SSE", "false")) == "true" ||
strings.ToLower(ReadEnv("IS_SSE", "0")) == "1"
ctx := context.Background()
if isSSE {
addr := ReadEnv("SSE_BASE_URL", "0.0.0.0:5777")
slog.Info("Starting WhatsApp MCP HTTP server (SSE)", "addr", addr)
handler := mcp.NewSSEHandler(func(request *http.Request) *mcp.Server {
// A logic can be added here to return different servers based on URL path
// For now, it returns the same WhatsApp server for the root/default path
return server
}, nil)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("http server failed: %v", err)
}
} else {
slog.Info("Starting WhatsApp MCP server in stdio mode")
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
log.Fatalf("stdio failed: %v", err)
}
}
}
type searchContactsInput struct {
Query string `json:"query" mcp:"description:Search term to match against contact names or phone numbers"`
}
type listMessagesInput struct {
After *string `mcp:"description:ISO-8601 formatted string"`
Before *string `json:"before,omitempty" jsonschema:"description:ISO-8601 formatted string"`
SenderPhoneNumber *string `json:"sender_phone_number,omitempty"`
ChatJid *string `json:"chat_jid,omitempty"`
Query *string `json:"query,omitempty" jsonschema:"description:Search term in message content"`
Limit int `json:"limit" jsonschema:"default:20"`
Page int `json:"page" jsonschema:"default:0"`
IncludeContext bool `json:"include_context" jsonschema:"default:true"`
ContextBefore int `json:"context_before" jsonschema:"default:1"`
ContextAfter int `json:"context_after" jsonschema:"default:1"`
}
type getMessageContextInput struct {
MessageID string `json:"message_id" jsonschema:"description:The ID of the message"`
Before int `json:"before" jsonschema:"default:5"`
After int `json:"after" jsonschema:"default:5"`
}
type listChatsInput struct {
Query *string `json:"query,omitempty"`
Limit int `json:"limit" jsonschema:"default:20"`
Page int `json:"page" jsonschema:"default:0"`
IncludeLastMessage bool `json:"include_last_message" jsonschema:"default:true"`
SortBy string `json:"sort_by" jsonschema:"default:last_active,enum:last_active|name"`
}
type getChatInput struct {
ChatJid string `json:"chat_jid" jsonschema:"description:The JID of the chat"`
IncludeLastMessage bool `json:"include_last_message" jsonschema:"default:true"`
}
type getDirectChatByContactInput struct {
SenderPhoneNumber string `json:"sender_phone_number" jsonschema:"description:Phone number with country code"`
}
type getContactChatsInput struct {
Jid string `json:"jid"`
Limit int `json:"limit" jsonschema:"default:20"`
Page int `json:"page" jsonschema:"default:0"`
}
type getLastInteractionInput struct {
Jid string `json:"jid" jsonschema:"description:The contact's JID"`
}
type sendMessageInput struct {
Recipient string `json:"recipient" jsonschema:"description:Phone number (no +) or group JID like 123@g.us"`
Message string `json:"message"`
}
type sendFileInput struct {
Recipient string `json:"recipient"`
MediaPath string `json:"media_path" jsonschema:"description:Absolute path to the file"`
}
type sendAudioMessageInput struct {
Recipient string `json:"recipient"`
MediaPath string `json:"media_path" jsonschema:"description:Absolute path to audio file"`
}
type downloadMediaInput struct {
MessageID string `json:"message_id"`
ChatJid string `json:"chat_jid"`
}
func callAPI(method, path string, body any) ([]byte, error) {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, apiBaseURL+path, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: apiTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(data))
}
return data, nil
}
func sendMessageHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in sendMessageInput,
) (*mcp.CallToolResult, map[string]any, error) {
if in.Recipient == "" || in.Message == "" {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: "recipient and message are required"},
},
}, map[string]any{
"success": false,
"error": "recipient and message are required",
}, nil
}
payload := map[string]any{
"recipient": in.Recipient,
"message": in.Message,
}
data, err := callAPI(http.MethodPost, "/send", payload)
if err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: err.Error()},
},
}, map[string]any{
"success": false,
"error": err.Error(),
}, nil
}
var resp map[string]any
if err := json.Unmarshal(data, &resp); err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: "failed to parse API response"},
},
}, map[string]any{
"success": false,
"error": "failed to parse API response",
}, nil
}
return &mcp.CallToolResult{}, resp, nil
}
func searchContactsHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in searchContactsInput,
) (*mcp.CallToolResult, any, error) {
if in.Query == "" {
return ErrResult("query is required"), nil, nil
}
data, err := callAPI(http.MethodGet, "/contacts/search?q="+in.Query, nil)
if err != nil {
return ErrResult(err.Error()), nil, nil
}
var result struct {
Contacts []map[string]any `json:"contacts"`
}
if err := json.Unmarshal(data, &result); err != nil {
return ErrResult("invalid response format"), nil, nil
}
return OkResult(result.Contacts), nil, nil
}
// list_messages (similar for all read/list tools that return slices/maps)
func listMessagesHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in listMessagesInput,
) (*mcp.CallToolResult, any, error) {
q := ""
if in.After != nil {
q += "&after=" + *in.After
}
if in.Before != nil {
q += "&before=" + *in.Before
}
if in.SenderPhoneNumber != nil {
q += "&sender=" + *in.SenderPhoneNumber
}
if in.ChatJid != nil {
q += "&chat=" + *in.ChatJid
}
if in.Query != nil {
q += "&search=" + *in.Query
}
q += fmt.Sprintf("&limit=%d&page=%d", in.Limit, in.Page)
if in.IncludeContext {
q += "&context=true"
}
data, err := callAPI(http.MethodGet, "/messages?"+strings.TrimPrefix(q, "&"), nil)
if err != nil {
return ErrResult(err.Error()), nil, nil
}
// The /messages endpoint returns plain text → we return it as string
return OkResult(string(data)), nil, nil
}
// download_media (same idea)
func downloadMediaHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in downloadMediaInput,
) (*mcp.CallToolResult, map[string]any, error) {
path, err := DownloadMedia(in.MessageID, in.ChatJid)
if err != nil || path == "" {
msg := "failed to download media"
if err != nil {
msg += ": " + err.Error()
}
return &mcp.CallToolResult{}, map[string]any{
"success": false,
"message": msg,
}, nil
}
return &mcp.CallToolResult{}, map[string]any{
"success": true,
"message": "Media downloaded successfully",
"file_path": path,
}, nil
}
func getMessageContextHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in getMessageContextInput,
) (*mcp.CallToolResult, any, error) {
if in.MessageID == "" {
return ErrResult("message_id is required"), nil, nil
}
path := fmt.Sprintf("/messages/context/%s?before=%d&after=%d",
in.MessageID, in.Before, in.After)
data, err := callAPI(http.MethodGet, path, nil)
if err != nil {
return ErrResult(err.Error()), nil, nil
}
var ctxData map[string]any
if err := json.Unmarshal(data, &ctxData); err != nil {
return ErrResult("invalid context response"), nil, nil
}
return OkResult(ctxData), nil, nil
}
func listChatsHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in listChatsInput,
) (*mcp.CallToolResult, any, error) {
q := fmt.Sprintf("?limit=%d&page=%d", in.Limit, in.Page)
if in.Query != nil && *in.Query != "" {
q += "&q=" + *in.Query
}
if in.SortBy != "" {
q += "&sort=" + in.SortBy
}
data, err := callAPI(http.MethodGet, "/chats"+q, nil)
if err != nil {
return ErrResult(err.Error()), nil, nil
}
var result struct {
Chats []map[string]any `json:"chats"`
Count int `json:"count"`
}
_ = json.Unmarshal(data, &result) // best effort
return OkResult(result.Chats), nil, nil
}
func getChatHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in getChatInput,
) (*mcp.CallToolResult, any, error) {
if in.ChatJid == "" {
return ErrResult("chat_jid is required"), nil, nil
}
data, err := callAPI(http.MethodGet, "/chats/"+in.ChatJid, nil)
if err != nil {
return ErrResult(err.Error()), nil, nil
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
return ErrResult("invalid chat response"), nil, nil
}
return OkResult(result["chat"]), nil, nil
}
func getDirectChatByContactHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in getDirectChatByContactInput,
) (*mcp.CallToolResult, any, error) {
if in.SenderPhoneNumber == "" {
return ErrResult("sender_phone_number is required"), nil, nil
}
// GET /api/direct-contacts/{phone}/chat
path := fmt.Sprintf("/direct-contacts/%s/chat", in.SenderPhoneNumber)
data, err := callAPI(http.MethodGet, path, nil)
if err != nil {
if strings.Contains(err.Error(), "404") {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: "No direct (1:1) chat found for this phone number"},
},
}, nil, nil
}
return ErrResult(err.Error()), nil, nil
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
return ErrResult("failed to parse chat response"), nil, nil
}
chat, ok := result["chat"]
if !ok {
return ErrResult("chat object missing in response"), nil, nil
}
return &mcp.CallToolResult{}, chat, nil
}
func getContactChatsHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in getContactChatsInput,
) (*mcp.CallToolResult, any, error) {
if in.Jid == "" {
return ErrResult("jid is required"), nil, nil
}
limit := in.Limit
if limit <= 0 {
limit = 20
}
page := in.Page
if page < 0 {
page = 0
}
// GET /api/contacts/{jid}/chats?limit=...&page=...
path := fmt.Sprintf("/contacts/%s/chats?limit=%d&page=%d", in.Jid, limit, page)
data, err := callAPI(http.MethodGet, path, nil)
if err != nil {
return ErrResult(err.Error()), nil, nil
}
var result struct {
Chats []map[string]any `json:"chats"`
Count int `json:"count"`
}
if err := json.Unmarshal(data, &result); err != nil {
return ErrResult("failed to parse chats response"), nil, nil
}
return &mcp.CallToolResult{}, result.Chats, nil
}
func getLastInteractionHandler(
ctx context.Context,
req *mcp.CallToolRequest,
in getLastInteractionInput,
) (*mcp.CallToolResult, any, error) {
if in.Jid == "" {
return ErrResult("jid is required"), nil, nil
}
// We simulate it by asking for 1 message from that sender
data, err := callAPI(http.MethodGet, "/messages?sender="+in.Jid+"&limit=1", nil)
if err != nil {
return ErrResult(err.Error()), nil, nil
}
return OkResult(string(data)), nil, nil
}
func sendFileHandler(ctx context.Context,
req *mcp.CallToolRequest,
in sendFileInput) (*mcp.CallToolResult, map[string]any, error) {
if in.MediaPath == "" {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: "media_path is required"}},
}, nil, nil
}
absPath, err := filepath.Abs(in.MediaPath)
if err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid path: %v", err)}},
}, nil, nil
}
success, msg := SendFile(in.Recipient, absPath)
resultData := map[string]any{"success": success, "message": msg}
return &mcp.CallToolResult{IsError: !success}, resultData, nil
}
func sendAudioMessageHandler(ctx context.Context,
req *mcp.CallToolRequest,
in sendAudioMessageInput) (*mcp.CallToolResult, map[string]any, error) {
success, msg := SendAudioVoiceMessage(in.Recipient, in.MediaPath)
resultData := map[string]any{"success": success, "message": msg}
return &mcp.CallToolResult{IsError: !success}, resultData, nil
}
+51
View File
@@ -0,0 +1,51 @@
package helpers
import (
"encoding/json"
"os"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var apiBaseURL = ReadEnv("API_BASE_URL", "http://192.168.178.119:30015/api")
const apiTimeout = 25 * time.Second
// OkResult return proper ok result for mcp tool
func OkResult(v any) *mcp.CallToolResult {
b, err := json.Marshal(v)
if err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: "Failed to encode JSON: " + err.Error()},
},
}
}
return &mcp.CallToolResult{
IsError: false,
Content: []mcp.Content{
&mcp.TextContent{Text: string(b)},
},
}
}
// ErrResult return error result for mcp tool
func ErrResult(msg string) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: msg},
},
}
}
// ReadEnv read return value for an env
func ReadEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
+254
View File
@@ -0,0 +1,254 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Message struct {
Timestamp time.Time `json:"timestamp"`
Sender string `json:"sender"`
Content string `json:"content"`
IsFromMe bool `json:"is_from_me"`
ChatJID string `json:"chat_jid"`
ID string `json:"id"`
ChatName string `json:"chat_name,omitempty"`
MediaType string `json:"media_type,omitempty"`
}
type Chat struct {
JID string `json:"jid"`
Name string `json:"name,omitempty"`
LastMessageTime time.Time `json:"last_message_time,omitempty"`
LastMessage string `json:"last_message,omitempty"`
LastSender string `json:"last_sender,omitempty"`
LastIsFromMe bool `json:"last_is_from_me,omitempty"`
}
func (c *Chat) IsGroup() bool {
return strings.HasSuffix(c.JID, "@g.us")
}
type Contact struct {
PhoneNumber string `json:"phone_number"`
Name string `json:"name,omitempty"`
JID string `json:"jid"`
}
type MessageContext struct {
Message Message `json:"message"`
Before []Message `json:"before"`
After []Message `json:"after"`
}
type ListMessagesParams struct {
After, Before string
SenderPhoneNumber *string
ChatJid *string
Query *string
Limit, Page int
IncludeContext bool
ContextBefore int
ContextAfter int
}
func SendMessage(recipient, message string) (bool, string) {
if recipient == "" {
return false, "Recipient must be provided"
}
payload := map[string]string{
"recipient": recipient,
"message": message,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiBaseURL+"/send", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return false, "Request error: " + err.Error()
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return false, fmt.Sprintf("HTTP %d - %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, "Failed to parse response"
}
success, _ := result["success"].(bool)
msg, _ := result["message"].(string)
if msg == "" {
msg = "Unknown response"
}
return success, msg
}
func SendFile(recipient, mediaPath string) (bool, string) {
if recipient == "" {
return false, "Recipient must be provided"
}
if mediaPath == "" {
return false, "Media path must be provided"
}
if _, err := os.Stat(mediaPath); os.IsNotExist(err) {
return false, "Media file not found: " + mediaPath
}
payload := map[string]string{
"recipient": recipient,
"media_path": mediaPath,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiBaseURL+"/send", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return false, "Request error: " + err.Error()
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return false, fmt.Sprintf("HTTP %d - %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, "Failed to parse response"
}
success, _ := result["success"].(bool)
msg, _ := result["message"].(string)
if msg == "" {
msg = "Unknown response"
}
return success, msg
}
func SendAudioVoiceMessage(recipient, mediaPath string) (bool, string) {
if recipient == "" {
return false, "Recipient must be provided"
}
if mediaPath == "" {
return false, "Media path must be provided"
}
if _, err := os.Stat(mediaPath); os.IsNotExist(err) {
return false, "Media file not found: " + mediaPath
}
finalPath := mediaPath
if !strings.HasSuffix(strings.ToLower(mediaPath), ".ogg") {
converted, err := ConvertToOpusOggTemp(mediaPath)
if err != nil {
return false, "Audio conversion failed (ffmpeg required?): " + err.Error()
}
finalPath = converted
defer func(name string) {
err := os.Remove(name)
if err != nil {
}
}(finalPath)
}
payload := map[string]string{
"recipient": recipient,
"media_path": finalPath,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiBaseURL+"/send", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return false, "Request error: " + err.Error()
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return false, fmt.Sprintf("HTTP %d - %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, "Failed to parse response"
}
success, _ := result["success"].(bool)
msg, _ := result["message"].(string)
if msg == "" {
msg = "Unknown response"
}
return success, msg
}
func DownloadMedia(messageID, chatJID string) (string, error) {
payload := map[string]string{
"message_id": messageID,
"chat_jid": chatJID,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiBaseURL+"/download", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("HTTP %d - %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if success, ok := result["success"].(bool); !ok || !success {
msg, _ := result["message"].(string)
if msg == "" {
msg = "Unknown error"
}
return "", errors.New(msg)
}
path, ok := result["path"].(string)
if !ok || path == "" {
return "", errors.New("no path returned")
}
log.Printf("Media downloaded: %s", path)
return path, nil
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "whatsapp-mcp-server/helpers"
func main() {
helpers.InitMcpTool()
}