matrix-hookshot-discord-com.../app.py

301 lines
16 KiB
Python

# -*- coding: utf-8 -*-
"""
Een Flask-applicatie die elke Discord-webhook op een dynamisch pad kan ontvangen,
weergeeft als HTML, en doorstuurt naar een dynamisch gegenereerde URL
op basis van een .env configuratie.
Vereisten:
pip install Flask python-dotenv requests Markdown waitress jinja2
Hoe te gebruiken:
1. Maak een `.env` bestand aan in dezelfde map.
2. Definieer 'OUTGOING_WEBHOOK_BASE_URL' en optioneel 'OUTGOING_WEBHOOK_PARAMS'.
3. Sla dit bestand op als `app.py`.
4. Voer het uit vanuit je terminal: `python app.py`
5. Stuur een POST-verzoek naar een willekeurig endpoint,
bijv. http://127.0.0.1:5001/webhook/any-string-you-want
"""
import os
import datetime
import markdown
import requests
import json
from flask import Flask, request, render_template, jsonify, abort, url_for
from dotenv import load_dotenv
from jinja2 import DictLoader
# Laad de omgevingsvariabelen uit het .env-bestand
load_dotenv()
# Initialiseer de Flask-applicatie
app = Flask(__name__)
# Lees de configuratie uit de omgevingsvariabelen.
OUTGOING_WEBHOOK_BASE_URL = os.getenv('OUTGOING_WEBHOOK_BASE_URL')
OUTGOING_WEBHOOK_PARAMS = os.getenv('OUTGOING_WEBHOOK_PARAMS', '') # Standaard lege string
if not OUTGOING_WEBHOOK_BASE_URL:
print("WAARSCHUWING: OUTGOING_WEBHOOK_BASE_URL niet gevonden in .env. Doorsturen van webhooks is uitgeschakeld.")
# In-memory opslag voor de laatst ontvangen webhook-data per ID.
# Deze wordt dynamisch gevuld als er nieuwe webhooks binnenkomen.
webhook_data_store = {}
# --- HTML TEMPLATES (ongewijzigd) ---
BASE_TEMPLATE = """
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discord Webhook Weergave</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root { --discord-dark-bg: #36393f; --discord-embed-bg: #2f3136; --discord-text: #dcddde; --discord-text-muted: #72767d; --discord-text-link: #0096cf; --discord-border: rgba(255, 255, 255, 0.1); }
body { background-color: var(--discord-dark-bg); color: var(--discord-text); font-family: 'Inter', sans-serif; margin: 0; padding: 20px; font-size: 16px; line-height: 1.4; }
.container { max-width: 700px; margin: auto; }
.discord-message { display: flex; padding: 10px 0; }
.avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 16px; flex-shrink: 0; }
.message-content { display: flex; flex-direction: column; width: 100%; }
.author-info { display: flex; align-items: center; margin-bottom: 4px; }
.author-name { font-weight: 500; color: #fff; margin-right: 8px; }
.timestamp { font-size: 12px; color: var(--discord-text-muted); }
.content-text { white-space: pre-wrap; word-wrap: break-word; }
.content-text a { color: var(--discord-text-link); text-decoration: none; }
.content-text a:hover { text-decoration: underline; }
.embed { background-color: var(--discord-embed-bg); border-left: 4px solid var(--discord-text-muted); border-radius: 4px; padding: 0.5rem 1rem; margin-top: 8px; display: grid; grid-template-columns: 1fr auto; gap: 1rem; }
.embed-main { grid-column: 1 / 2; display: flex; flex-direction: column; gap: 0.5rem; min-width: 0; }
.embed-thumbnail { grid-column: 2 / 3; grid-row: 1 / 2; align-self: start; }
.embed-thumbnail img { width: 80px; height: 80px; border-radius: 4px; object-fit: contain; }
.embed-image, .embed-footer { grid-column: 1 / -1; }
.embed-author { display: flex; align-items: center; font-weight: 500; font-size: 14px; }
.embed-author-icon { width: 24px; height: 24px; border-radius: 50%; margin-right: 8px; }
.embed-author a { color: #fff; text-decoration: none; }
.embed-author a:hover { text-decoration: underline; }
.embed-title { font-weight: 700; font-size: 16px; }
.embed-title a { color: var(--discord-text-link); text-decoration: none; }
.embed-title a:hover { text-decoration: underline; }
.embed-description { font-size: 14px; font-weight: 400; }
.embed-description p, .embed-description ul, .embed-description ol { margin: 0; }
.embed-fields { display: grid; gap: 8px 16px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.field { font-size: 14px; }
.field.full-width { grid-column: 1 / -1; }
.field-name { font-weight: 700; color: #fff; margin-bottom: 2px; }
.field-value p { margin: 0; }
.embed-image img { max-width: 100%; max-height: 400px; border-radius: 4px; margin-top: 1rem; }
.embed-footer { display: flex; align-items: center; font-size: 12px; color: var(--discord-text-muted); margin-top: 0.5rem; }
.embed-footer-icon { width: 20px; height: 20px; border-radius: 50%; margin-right: 8px; }
h1, h2 { color: #fff; border-bottom: 1px solid var(--discord-border); padding-bottom: 10px; margin-bottom: 20px; }
.instructions { background-color: var(--discord-embed-bg); padding: 15px; border-radius: 5px; font-size: 14px; line-height: 1.6; }
code { background-color: #202225; padding: 2px 4px; border-radius: 3px; font-family: Consolas, monospace; }
.webhook-list { list-style: none; padding: 0; }
.webhook-list li a { display: flex; justify-content: space-between; align-items: center; background-color: var(--discord-embed-bg); padding: 15px; margin-bottom: 10px; border-radius: 5px; text-decoration: none; color: var(--discord-text); transition: background-color 0.2s; }
.webhook-list li a:hover { background-color: #40444b; }
.webhook-list .url { font-size: 12px; color: var(--discord-text-muted); font-family: Consolas, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 50%; }
.back-link { display: inline-block; margin-bottom: 20px; color: var(--discord-text-link); text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body><div class="container">{% block content %}{% endblock %}</div></body>
</html>
"""
HOME_TEMPLATE = """
{% extends "base.html" %}
{% block content %}
<h1>Ontvangen Webhooks</h1>
<div class="instructions">
<p>Hieronder staat een lijst van alle webhooks die ten minste één keer een bericht hebben ontvangen. Klik op een webhook om de laatste data te zien.</p>
<p>Inkomende URL's hebben de vorm <code>/webhook/&lt;naam&gt;</code>.</p>
<p>Uitgaande URL: <code>{{ base_url or 'Niet geconfigureerd' }}&lt;naam&gt;{{ params }}</code></p>
</div>
<ul class="webhook-list">
{% for id, data in webhooks.items() %}
<li>
<a href="{{ url_for('display_webhook', webhook_id=id) }}">
<span>{{ id | capitalize }}</span>
<span class="url">Laatst gezien: {{ data.received_at }}</span>
</a>
</li>
{% else %}
<li>Nog geen webhooks ontvangen. Stuur een POST verzoek naar <code>/webhook/&lt;jouw-id&gt;</code>.</li>
{% endfor %}
</ul>
{% endblock %}
"""
FORWARD_WEBHOOK_TEMPLATE = """
{% if data and data.username != 'Nog geen data' %}
<div class="discord-message">
<img src="{{ data.avatar_url or 'https://cdn.discordapp.com/embed/avatars/0.png' }}" alt="Avatar" class="avatar" onerror="this.onerror=null;this.src='https://cdn.discordapp.com/embed/avatars/0.png';">
<div class="message-content">
<div class="author-info">
<span class="author-name">{{ data.username or 'Onbekende Bot' }}</span>
<span class="timestamp">{{ data.received_at }}</span>
</div>
{% if data.content %}<div class="content-text">{{ data.content }}</div>{% endif %}
{% for embed in data.embeds %}
<div class="embed" style="border-left-color: #{'%06x' % embed.color if embed.color else '4f545c'};">
<div class="embed-main">
{% if embed.author %}<div class="embed-author">{% if embed.author.icon_url %}<img src="{{ embed.author.icon_url }}" alt="" class="embed-author-icon">{% endif %}{% if embed.author.url %}<a href="{{ embed.author.url }}" target="_blank" rel="noopener noreferrer">{{ embed.author.name }}</a>{% else %}<span>{{ embed.author.name }}</span>{% endif %}</div>{% endif %}
{% if embed.title %}<div class="embed-title">{% if embed.url %}<a href="{{ embed.url }}" target="_blank" rel="noopener noreferrer">{{ embed.title }}</a>{% else %}<span>{{ embed.title }}</span>{% endif %}</div>{% endif %}
{% if embed.description %}<div class="embed-description">{{ embed.description | markdown }}</div>{% endif %}
{% if embed.fields %}
<div class="embed-fields">
{% for field in embed.fields %}
<div class="field {% if not field.inline %}full-width{% endif %}">
<div class="field-name">{{ field.name }}</div>
<div class="field-value">{{ field.value | markdown }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if embed.thumbnail and embed.thumbnail.url %}
<div class="embed-thumbnail">
<img src="{{ embed.thumbnail.url }}" alt="Thumbnail">
</div>
{% endif %}
{% if embed.image and embed.image.url %}
<div class="embed-image">
<img src="{{ embed.image.url }}" alt="Embed Image">
</div>
{% endif %}
{% if embed.footer %}
<div class="embed-footer">
{% if embed.footer.icon_url %}<img src="{{ embed.footer.icon_url }}" alt="" class="embed-footer-icon">{% endif %}<span>{{ embed.footer.text }}{% if embed.timestamp %} &bull; {{ embed.timestamp }}{% endif %}</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% else %}
<p style="color: var(--discord-text-muted);">{{ data.content }}</p>
{% endif %}
"""
UI_WEBHOOK_VIEW_TEMPLATE = """
{% extends "base.html" %}
{% block content %}
<a href="{{ url_for('list_webhooks') }}" class="back-link">&larr; Terug naar overzicht</a>
<h2>Laatste bericht voor: {{ webhook_id | capitalize }}</h2>
{{ content | safe }}
{% endblock %}
"""
# Configureer de Jinja2 loader
app.jinja_loader = DictLoader({
'base.html': BASE_TEMPLATE,
'home.html': HOME_TEMPLATE,
'forward_webhook.html': FORWARD_WEBHOOK_TEMPLATE,
'ui_webhook_view.html': UI_WEBHOOK_VIEW_TEMPLATE,
})
@app.template_filter('markdown')
def markdown_filter(s):
"""Rendert markdown en verwijdert de omringende <p> tags."""
if not s:
return ""
html = markdown.markdown(s, extensions=['fenced_code', 'nl2br'])
if html.startswith('<p>') and html.endswith('</p>'):
html = html[3:-4]
return html
def forward_webhook(data, target_url, webhook_id):
"""Genereert simpele HTML en stuurt dit door naar de opgegeven target_url."""
if not target_url:
print(f"Skipping forwarding voor webhook '{webhook_id}': geen uitgaande URL geconfigureerd.")
return
try:
html_parts = []
for embed in data.get('embeds', []):
if embed.get('author') and embed.get('author').get('name'):
html_parts.append(f"<strong>{embed.get('author').get('name')}</strong>")
if embed.get('title'):
title, url = embed.get('title'), embed.get('url')
html_parts.append(f"<strong><a href='{url}'>{title}</a></strong>" if url else f"<strong>{title}</strong>")
if embed.get('description'):
html_parts.append(markdown_filter(embed.get('description')))
if embed.get('fields'):
for field in embed.get('fields'):
html_parts.append(f"<br><strong>{field.get('name', '')}</strong><br>{markdown_filter(field.get('value', ''))}")
if embed.get('image') and embed.get('image').get('url'):
html_parts.append(f"<br><img src='{embed['image']['url']}' alt='Embed Image' style='max-width:100%; height:auto;'>")
html_content = "<br>".join(html_parts)
plain_text_parts = []
if data.get('username'): plain_text_parts.append(data.get('username'))
if data.get('content'): plain_text_parts.append(data.get('content'))
for embed in data.get('embeds', []):
if embed.get('title'): plain_text_parts.append(embed.get('title'))
if embed.get('description'): plain_text_parts.append(embed.get('description'))
if embed.get('fields'):
for field in embed.get('fields'): plain_text_parts.append(f"{field.get('name', '')}: {field.get('value', '')}")
plain_text_body = "\n".join(plain_text_parts)
simple_payload = {"text": plain_text_body, "html": html_content, "username": data.get('username')}
headers = {'Content-Type': 'application/json'}
response = requests.post(target_url, json=simple_payload, headers=headers, timeout=10)
response.raise_for_status()
print(f"Webhook '{webhook_id}' succesvol doorgestuurd als VEREENVOUDIGDE JSON naar {target_url}. Status: {response.status_code}")
except Exception as e:
print(f"FOUT: Kon webhook '{webhook_id}' niet doorsturen. Error: {e}")
@app.route('/')
def list_webhooks():
"""Toont de lijst met ontvangen webhooks."""
return render_template(
'home.html',
webhooks=webhook_data_store,
base_url=OUTGOING_WEBHOOK_BASE_URL,
params=OUTGOING_WEBHOOK_PARAMS
)
@app.route('/view/<webhook_id>')
def display_webhook(webhook_id):
"""Toont de laatst ontvangen data voor een specifieke webhook."""
data = webhook_data_store.get(webhook_id)
if not data:
# Toon een wacht-bericht als er nog nooit data is ontvangen voor deze ID
data = {
"username": "Nog geen data",
"content": f"Wacht op de eerste webhook op /webhook/{webhook_id}...",
"embeds": [],
"received_at": "Nooit"
}
content = render_template('forward_webhook.html', data=data)
return render_template('ui_webhook_view.html', content=content, webhook_id=webhook_id)
@app.route('/webhook/<webhook_id>', methods=['POST'])
def receive_webhook(webhook_id):
"""Ontvangt de JSON-payload, slaat deze op en stuurt deze door."""
if not request.is_json:
return jsonify({"error": "Request must be JSON"}), 400
data = request.json
data['received_at'] = datetime.datetime.now().strftime('%d-%m-%Y %H:%M:%S')
data['webhook_id'] = webhook_id
# Sla de data op in de store. Nieuwe ID's worden automatisch toegevoegd.
webhook_data_store[webhook_id] = data
# Bouw de uitgaande URL dynamisch op, alleen als de base URL is geconfigureerd.
if OUTGOING_WEBHOOK_BASE_URL:
# Zorg ervoor dat er maar één slash tussen de base url en de id zit.
target_url = f"{OUTGOING_WEBHOOK_BASE_URL.rstrip('/')}/{webhook_id}{OUTGOING_WEBHOOK_PARAMS}"
forward_webhook(data, target_url, webhook_id)
return jsonify({"status": "succes"}), 200
if __name__ == '__main__':
port = 5001
print(f"Server wordt gestart op http://127.0.0.1:{port}")
print("De app accepteert nu POST-verzoeken op ELK pad onder /webhook/...")
print("Bijvoorbeeld: http://127.0.0.1:5001/webhook/jellyfin")
print("Druk op CTRL+C om te stoppen.")
from waitress import serve
serve(app, host='0.0.0.0', port=port)