WhatsApp -> Discord message forwarder for go-whatsapp-web-multidevice (GOWA) — forwards messages from a specific WhatsApp chat to a Discord channel via webhook, with sender avatar, formatted phone ...

whatsapp_discord_forwarder.py

"""
WhatsApp -> Discord message forwarder for go-whatsapp-web-multidevice (GOWA).
Built against: https://github.com/aldinokemal/go-whatsapp-web-multidevice
(GOWA - WhatsApp REST API with webhook, multi-device, and MCP support)
What this does, end to end:
1. GOWA sends a webhook POST every time a message event happens.
2. We filter for messages that belong to ONE specific chat (TARGET_GROUP_JID).
3. We figure out who sent it, format their number nicely, and grab their
WhatsApp profile picture (GOWA doesn't include this in the webhook, so we
have to call a separate GOWA endpoint for it — device-scoped, so we pass
the device_id GOWA already gives us in the webhook body).
4. We repost the message into Discord via a webhook, disguised as if it came
from a user named after the sender, with their WhatsApp DP as the avatar.
SETUP (running locally):
1. Fill in the values in the CONFIGURATION block below (DISCORD_WEBHOOK_URL,
TARGET_GROUP_JID, GOWA_BASE_URL, and the GOWA basic-auth credentials if
you set any). Every value to fill in is marked with a "<-- SET THIS" comment.
2. Install dependencies:
pip install flask requests
3. Run it:
python app.py
This starts a local server on http://0.0.0.0:5000, listening for
webhooks at the /webhook path.
4. Point GOWA at it. Since GOWA needs to reach this server over the
internet, expose your local port with a tunnel tool like ngrok:
ngrok http 5000
Then set GOWA's webhook URL (--webhook flag, or WHATSAPP_WEBHOOK env
var) to the ngrok URL + "/webhook", e.g.:
https://abcd1234.ngrok-free.app/webhook
(This can just as easily run on a host like Render instead of locally —
same file, same variables, just deploy it there and skip the ngrok step
since it'll already have a public URL.)
NOTE: this file is meant to be committed with the CONFIGURATION values
left BLANK (as they are below). Never commit your real webhook URL or
GOWA credentials — fill them in locally after cloning, or set them as
environment variables if you'd rather not have them in the file at all.
"""
from flask import Flask, request, jsonify
from datetime import datetime, timezone, timedelta
import requests
import time
import re
app = Flask(__name__)
# ======================= CONFIGURATION =======================
# Fill these in before running. Leave this file's committed values blank —
# only fill them in on your own local copy / deployment.
# Discord webhook URL (Server Settings -> Integrations -> Webhooks).
# Every forwarded WhatsApp message gets POSTed here.
DISCORD_WEBHOOK_URL = "" # <-- SET THIS, e.g. "https://discord.com/api/webhooks/XXXX/YYYY"
# The WhatsApp chat JID to forward messages from. Everything else (other
# groups/DMs) is silently ignored. Group JIDs end in @g.us, individual DM
# JIDs end in @s.whatsapp.net. You can find this in GOWA's chat list/logs.
TARGET_GROUP_JID = "" # <-- SET THIS, e.g. "1203xxxxxxxxxxxxxx@g.us"
# Base URL of your running GOWA REST server. Needed because the webhook
# payload only gives us a phone number/JID — the actual profile picture URL
# has to be fetched separately from GOWA's /user/avatar endpoint.
GOWA_BASE_URL = "" # <-- SET THIS, e.g. "http://localhost:3000"
# Basic auth credentials for GOWA, ONLY if you started it with
# --basic-auth=user:pass. Leave both as None if GOWA has no basic auth.
GOWA_BASIC_AUTH_USER = None # <-- SET THIS if applicable, e.g. "myuser"
GOWA_BASIC_AUTH_PASS = None # <-- SET THIS if applicable, e.g. "mypassword"
GOWA_BASIC_AUTH = (GOWA_BASIC_AUTH_USER, GOWA_BASIC_AUTH_PASS) \
if GOWA_BASIC_AUTH_USER and GOWA_BASIC_AUTH_PASS else None
# How long (in seconds) to remember a sender's avatar URL before re-fetching it.
# Avoids hammering GOWA's /user/avatar endpoint on every single message from
# the same person — WhatsApp DPs don't change that often.
AVATAR_CACHE_TTL = 3600
# ===============================================================
# Fail loudly and immediately if required config hasn't been filled in,
# instead of silently misbehaving (e.g. posting to nowhere, or forwarding
# every chat because TARGET_GROUP_JID is blank and matches nothing/everything).
_missing = [
name for name, value in [
("DISCORD_WEBHOOK_URL", DISCORD_WEBHOOK_URL),
("TARGET_GROUP_JID", TARGET_GROUP_JID),
("GOWA_BASE_URL", GOWA_BASE_URL),
] if not value
]
if _missing:
raise RuntimeError(
f"Missing required configuration: {', '.join(_missing)}. "
"Fill these in at the top of app.py before running — see the "
"CONFIGURATION block and the module docstring for details."
)
# GOWA sends timestamps in UTC (RFC3339). We convert to IST for display
# since that's the timezone that actually matters here.
IST = timezone(timedelta(hours=5, minutes=30))
# Simple in-memory cache: { "919876543210": (avatar_url, timestamp_fetched) }
# Lives only as long as the Flask process is running — that's fine here,
# worst case is one extra API call after a restart.
_avatar_cache = {}
def format_phone_in(jid_or_phone: str) -> str:
"""
Convert a raw WhatsApp JID or phone number into a readable Indian
format: "+91 XXXXX XXXXX".
Examples:
"919876543210@s.whatsapp.net" -> "+91 98765 43210"
"919876543210" -> "+91 98765 43210"
"9876543210" -> "+91 98765 43210"
If the number doesn't look like a 10-digit Indian number (e.g. it's a
different country code), we fall back to just slapping a "+" on the
front so we still show *something* sane instead of crashing or hiding it.
"""
# Strip the "@s.whatsapp.net" / "@g.us" suffix, then keep only digits.
digits = re.sub(r"\D", "", jid_or_phone.split("@")[0])
if digits.startswith("91") and len(digits) == 12:
# Standard case: "91" country code + 10-digit number.
national = digits[2:]
elif len(digits) == 10:
# Already just the 10-digit number, no country code attached.
national = digits
else:
# Unknown/foreign format — don't guess, just return it prefixed with "+".
return f"+{digits}"
# Indian mobile numbers are conventionally split 5+5 for readability.
return f"+91 {national[:5]} {national[5:]}"
def format_timestamp_ist(raw_timestamp: str) -> str:
"""
Convert GOWA's RFC3339 UTC timestamp (e.g. "2023-10-15T10:30:00Z") into
a readable IST date/time string, e.g. "15 Oct 2026, 4:00 PM IST".
Falls back to the current IST time if the timestamp is missing or in a
format we don't recognize — we never want a parsing hiccup here to
block the message from being forwarded.
"""
if not raw_timestamp:
dt_utc = datetime.now(timezone.utc)
else:
try:
# Python's fromisoformat doesn't accept a trailing "Z" directly,
# so swap it for the explicit "+00:00" UTC offset first.
dt_utc = datetime.fromisoformat(raw_timestamp.replace("Z", "+00:00"))
except ValueError:
dt_utc = datetime.now(timezone.utc)
dt_ist = dt_utc.astimezone(IST)
# Example output: "15 Aug 2026, 4:00 PM IST"
# NOTE: "%-I" (no leading zero on hour) works on Linux/macOS.
# On Windows, swap it for "%#I" instead.
return dt_ist.strftime("%d %b %Y, %-I:%M %p IST")
def get_avatar_url(phone_digits: str, device_id: str) -> str | None:
"""
Fetch the WhatsApp profile picture URL for a given phone number by
calling GOWA's GET /user/avatar endpoint.
IMPORTANT: since GOWA added multi-device support, device-scoped
endpoints (this one included) require you to identify WHICH connected
WhatsApp device should handle the request. GOWA only auto-picks a
default device if you have exactly one registered — anything else and
the call fails with "device_id is required". So we always pass it
explicitly as a `device_id` query parameter, using the device_id GOWA
already includes at the top level of every webhook payload.
Results are cached in memory (keyed by phone number) for AVATAR_CACHE_TTL
seconds so repeated messages from the same person don't trigger repeated
API calls.
Returns None (instead of raising) if the lookup fails for any reason —
e.g. the person has privacy settings hiding their DP, GOWA is briefly
unreachable, wrong credentials, etc. We never want an avatar-fetch
hiccup to block the actual message from being forwarded. The failure
reason is printed so it shows up in your terminal/server logs for
debugging.
"""
now = time.time()
# Check cache first — skip the network call entirely if still fresh.
cached = _avatar_cache.get(phone_digits)
if cached and (now - cached[1]) < AVATAR_CACHE_TTL:
return cached[0]
try:
resp = requests.get(
f"{GOWA_BASE_URL}/user/avatar",
params={"phone": phone_digits, "device_id": device_id} if device_id
else {"phone": phone_digits},
auth=GOWA_BASIC_AUTH,
timeout=5, # don't let a slow GOWA instance hang the webhook handler
)
resp.raise_for_status()
url = resp.json().get("results", {}).get("url")
# Cache both successes and "no url found" so we don't keep retrying
# a lookup that's just going to fail again immediately.
_avatar_cache[phone_digits] = (url, now)
return url
except Exception as e:
# Printed so it shows up in your logs — tells you exactly why the
# avatar lookup failed (bad auth, wrong device_id, 404, timeout...).
print(f"[avatar fetch failed] phone={phone_digits} device_id={device_id!r} error={e}")
_avatar_cache[phone_digits] = (None, now)
return None
@app.route('/webhook', methods=['POST'])
def forward_to_discord():
"""
Main webhook receiver. GOWA calls this endpoint for every WhatsApp event.
We only care about "message" events from our target chat — everything
else gets a quiet 200 OK with no further action, so GOWA doesn't retry it.
"""
data = request.json or {}
# --- Step 1: only handle actual chat messages, ignore everything else
# (reactions, acks, group updates, calls, etc.) ---
if data.get("event") != "message":
return jsonify({"status": "ignored"}), 200
# GOWA includes this at the TOP LEVEL of the webhook body (sibling of
# "event" and "payload"), not inside payload itself — it identifies
# which connected WhatsApp device received the message, and we need it
# to make device-scoped API calls like /user/avatar later on.
device_id = data.get("device_id", "")
payload = data.get("payload", {})
chat_id = payload.get("chat_id", "")
# --- Step 2: only forward messages from the one chat we care about ---
if chat_id != TARGET_GROUP_JID:
return jsonify({"status": "ignored"}), 200
# --- Step 3: extract and format sender info ---
# Full JID of whoever sent the message, e.g. "919876543210@s.whatsapp.net"
sender_jid = payload.get("from", "")
# Just the digits, needed for the /user/avatar API call (no @ suffix).
phone_digits = re.sub(r"\D", "", sender_jid.split("@")[0])
# Pretty "+91 XXXXX XXXXX" version for display.
formatted_phone = format_phone_in(sender_jid)
# Message text. GOWA sends an empty/missing body for media-only messages
# (images, stickers, etc.), so we show a placeholder in that case.
body = payload.get("body", "[Media or Attachment]")
# GOWA gives us an RFC3339 UTC timestamp of when the message was sent.
# We convert it to a readable IST date/time for the Discord message.
formatted_time = format_timestamp_ist(payload.get("timestamp", ""))
# Figure out the best name to display, in priority order:
# 1. sender_display_name -> resolved saved contact / WhatsApp profile name
# (only present on newer GOWA versions)
# 2. from_name -> the sender's pushname (what they've set as their own
# WhatsApp display name, always present)
# 3. formatted_phone -> last resort if neither name is available
display_name = (
payload.get("sender_display_name")
or payload.get("from_name")
or formatted_phone
)
# Build the Discord webhook "username" (the name shown above the message).
# If we actually have a real name, show it; otherwise just show the
# formatted phone number. Both get the "- on WhatsApp" suffix.
username = f"{display_name} - on WhatsApp" if display_name != formatted_phone \
else f"{formatted_phone} - on WhatsApp"
# --- Step 4: fetch their WhatsApp profile picture for the Discord avatar ---
avatar_url = get_avatar_url(phone_digits, device_id) if phone_digits else None
# --- Step 5: build and send the Discord webhook payload ---
discord_payload = {
"username": username,
# Quote-block with the sender's number, then the date/time it was
# sent, then the actual message body underneath.
"content": f"> **{formatted_phone}**\n> {formatted_time}\n{body}",
}
# Only include avatar_url if we actually found one — Discord will use the
# webhook's default avatar if this key is omitted.
if avatar_url:
discord_payload["avatar_url"] = avatar_url
requests.post(DISCORD_WEBHOOK_URL, json=discord_payload)
return jsonify({"status": "ok"}), 200
if __name__ == '__main__':
# Runs locally on port 5000 by default — see the SETUP section at the
# top of this file for how to expose this to GOWA via ngrok (or deploy
# it to a host like Render instead, if you'd rather not run it locally).
app.run(host='0.0.0.0', port=5000)
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论