feat: Add initial telegram functions
All checks were successful
Build Docker / BuildImage (push) Successful in 1m0s

This commit is contained in:
2025-07-25 15:33:56 +10:00
parent e2d0122f91
commit 61cc135a6f
8 changed files with 413 additions and 18 deletions

317
alerts.py
View File

@@ -7,6 +7,10 @@ from email.mime.text import MIMEText
from email.headerregistry import Address from email.headerregistry import Address
import ssl import ssl
import dotenv import dotenv
import asyncio
import threading
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes
dotenv.load_dotenv() dotenv.load_dotenv()
@@ -15,6 +19,12 @@ SMTP_PORT = int(os.getenv('SMTP_PORT', 465))
SMTP_USERNAME = os.getenv('SMTP_USERNAME', None) SMTP_USERNAME = os.getenv('SMTP_USERNAME', None)
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None) SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None)
TG_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', None)
TG_BOT_NAME = os.getenv('TELEGRAM_BOT', None)
TG_app = None
TG_bot_running = False
NOTIFICATION_TYPES = [ NOTIFICATION_TYPES = [
{ {
"type": "discord_webhook", "type": "discord_webhook",
@@ -39,6 +49,24 @@ NOTIFICATION_TYPES = [
} }
], ],
"description": "Send an email notification." "description": "Send an email notification."
},
{
"type": "telegram",
"fields": [
{
"name": "username",
"label": "Username",
"type": "username",
"required": True
}
],
"description": "Send a telegram notification.",
"links": [
{
"label": "Link your Telegram account",
"url": "/telegram/link"
}
]
} }
] ]
@@ -55,6 +83,9 @@ def handle_alert(domain: str, notification: dict, alert_data: dict):
elif alert_type == 'email': elif alert_type == 'email':
email(notification['email'], domain, email(notification['email'], domain,
alert_data, notification['blocks']) alert_data, notification['blocks'])
elif alert_type == 'telegram':
telegram(notification['username'], domain,
alert_data, notification['blocks'])
else: else:
print(f"Unknown alert type: {alert_type} for domain: {domain}") print(f"Unknown alert type: {alert_type} for domain: {domain}")
@@ -146,3 +177,289 @@ Visit your FireAlerts account: https://alerts.firewallet.au/account/{domain}
f"Connection refused to SMTP server {SMTP_SERVER}:{SMTP_PORT} - {e}") f"Connection refused to SMTP server {SMTP_SERVER}:{SMTP_PORT} - {e}")
except Exception as e: except Exception as e:
print(f"Unexpected error sending email to {email_addr}: {e}") print(f"Unexpected error sending email to {email_addr}: {e}")
async def link_tg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Try to read a token
if not update.message or not update.message.text:
print("No message text found in update.")
return
# Check to make sure that the message is in format /start <token>
if not update.message.text.startswith('/start '):
await update.message.reply_markdown_v2("Please link your Telegram account from [FireAlerts](https://alerts.firewallet.au/telegram/link)")
return
token = update.message.text.split(' ', 1)[1].strip()
if not token:
await update.message.reply_text("Please provide a valid token.")
return
# Try to validate the token
user_data = requests.get(f"https://login.hns.au/auth/user?token={token}")
if user_data.status_code != 200:
await update.message.reply_text("Invalid token. Please try again.")
return
user_data = user_data.json()
user_name = user_data.get('username')
if not user_name:
await update.message.reply_text("Invalid token. Please try again.")
return
if not os.path.exists('data'):
os.makedirs('data')
if not os.path.exists('data/telegram.json'):
with open('data/telegram.json', 'w') as f:
json.dump({}, f)
# Load existing Telegram data
with open('data/telegram.json', 'r') as f:
telegram_data = json.load(f)
if not update.message.from_user:
await update.message.reply_text("Could not retrieve your Telegram user information.")
return
# Update or add the user
telegram_data[user_name] = {
"user_id": update.message.from_user.id,
"username": update.message.from_user.username
}
# Save the updated data
with open('data/telegram.json', 'w') as f:
json.dump(telegram_data, f, indent=4)
await update.message.reply_text(f'You have linked your Telegram account with username: {user_name}. You will now receive notifications for your domains.')
async def ping_tg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message:
print("No message or user found in update.")
return
await update.message.reply_text(f"Pong!")
async def help_tg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message:
print("No message or user found in update.")
return
help_text = (
"Welcome to FireAlerts Telegram Bot!\n\n"
"Here are the commands you can use:\n"
"/start - Link your Telegram account with FireAlerts.\n"
"/ping - Check if the bot is running.\n"
"/help - Show this help message."
)
await update.message.reply_text(help_text)
def startTGBot(mainThread: bool = False):
"""
Start the Telegram bot in a separate thread.
"""
global TG_bot_running
if not TG_BOT_TOKEN or not TG_BOT_NAME:
print(
"Telegram bot token or name not set. Notifications via Telegram will not work.")
return
if TG_bot_running:
print("Telegram bot is already running.")
return
# Check if this is the Flask reloader process (only skip if not main thread)
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true' and not mainThread:
print("Skipping Telegram bot start in Flask reloader process.")
return
def run_bot():
"""Run the bot in a separate thread with its own event loop."""
global TG_bot_running
TG_bot_running = True
loop = None
try:
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
global TG_app
if TG_app is None:
if not TG_BOT_TOKEN:
print("Telegram bot token is not set. Cannot start bot.")
return
TG_app = ApplicationBuilder().token(TG_BOT_TOKEN).build()
TG_app.add_handler(CommandHandler("start", link_tg))
TG_app.add_handler(CommandHandler("ping", ping_tg))
TG_app.add_handler(CommandHandler("help", help_tg))
print("Starting Telegram bot...")
# Use start_polling and idle instead of run_polling
async def start_bot():
if not TG_app:
print("Telegram app is not initialized. Cannot start bot.")
return
retry_count = 0
max_retries = 5
while TG_bot_running and retry_count < max_retries:
try:
await TG_app.initialize()
await TG_app.start()
if not TG_app.updater:
print("Telegram app updater is not initialized. Cannot start bot.")
return
# Start polling with error handling
await TG_app.updater.start_polling(
drop_pending_updates=True,
allowed_updates=["message"],
timeout=30
)
print("Telegram bot is now running...")
retry_count = 0 # Reset retry count on successful start
# Keep the bot running
while TG_bot_running:
await asyncio.sleep(1)
except Exception as e:
print(f"Telegram bot error (attempt {retry_count + 1}/{max_retries}): {e}")
retry_count += 1
if retry_count < max_retries and TG_bot_running:
wait_time = min(2 ** retry_count, 60) # Exponential backoff, max 60 seconds
print(f"Retrying in {wait_time} seconds...")
await asyncio.sleep(wait_time)
else:
print("Max retries reached or bot stopped. Exiting.")
break
finally:
try:
if TG_app:
await TG_app.stop()
await TG_app.shutdown()
except Exception as e:
print(f"Error stopping Telegram app: {e}")
# Run the bot
loop.run_until_complete(start_bot())
except Exception as e:
print(f"Error running Telegram bot: {e}")
finally:
TG_bot_running = False
try:
if loop and not loop.is_closed():
loop.close()
except Exception as e:
print(f"Error closing event loop: {e}")
# Start the bot in a daemon thread so it doesn't prevent the main program from exiting
bot_thread = threading.Thread(target=run_bot, daemon=True)
bot_thread.start()
print("Telegram bot started in background thread")
def stopTGBot():
"""
Stop the Telegram bot.
"""
global TG_bot_running
TG_bot_running = False
print("Stopping Telegram bot...")
def telegram(username: str, domain: str, content: dict, alert_blocks: int):
"""
Send a Telegram notification.
"""
# Load Telegram user data
if not os.path.exists('data/telegram.json'):
print(
f"No Telegram data file found. Cannot send notification to {username}")
return
try:
with open('data/telegram.json', 'r') as f:
telegram_data = json.load(f)
except Exception as e:
print(f"Error reading Telegram data: {e}")
return
if username not in telegram_data:
print(
f"Username {username} not found in Telegram data. User needs to link their account.")
return
user_id = telegram_data[username].get('user_id')
if not user_id:
print(f"No user_id found for username {username}")
return
# Create the message
message = f"""🔥 *FireAlerts Notification*
Domain: `{domain}`
Expires in: *{content['blocks']} blocks* (~{content['time']})
Alert threshold: {alert_blocks} blocks
[Open your FireAlerts account](https://alerts.firewallet.au/account/{domain})"""
# Send the message in a separate thread with its own bot instance
def send_telegram_message():
loop = None
local_app = None
try:
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def send_message():
nonlocal local_app
try:
if not TG_BOT_TOKEN:
print("Telegram bot token is not set. Cannot send message.")
return
# Create a new bot instance for this thread
local_app = ApplicationBuilder().token(TG_BOT_TOKEN).build()
await local_app.initialize()
await local_app.bot.send_message(
chat_id=user_id,
text=message,
parse_mode='Markdown',
disable_web_page_preview=True
)
print(
f"Telegram notification sent to {username} (ID: {user_id}) for domain {domain}")
except Exception as e:
print(f"Error sending Telegram message to {username}: {e}")
finally:
if local_app:
try:
await local_app.shutdown()
except Exception as e:
print(f"Error shutting down local Telegram app: {e}")
# Run the async function
loop.run_until_complete(send_message())
except Exception as e:
print(f"Error in Telegram message thread: {e}")
finally:
try:
if loop and not loop.is_closed():
loop.close()
except Exception as e:
print(f"Error closing Telegram message loop: {e}")
# Start the message sending in a daemon thread
message_thread = threading.Thread(
target=send_telegram_message, daemon=True)
message_thread.start()

View File

@@ -18,6 +18,7 @@ HSD_PORTS = {
HSD_PORT = HSD_PORTS.get(HSD_NETWORK, 12037) HSD_PORT = HSD_PORTS.get(HSD_NETWORK, 12037)
HSD_URL_FULL = f'http://x:{HSD_API_KEY}@{HSD_URL}:{HSD_PORT}' if HSD_API_KEY else f'http://{HSD_URL}:{HSD_PORT}' HSD_URL_FULL = f'http://x:{HSD_API_KEY}@{HSD_URL}:{HSD_PORT}' if HSD_API_KEY else f'http://{HSD_URL}:{HSD_PORT}'
print(f"Using HSD URL: {HSD_URL_FULL}")
if not os.path.exists('data'): if not os.path.exists('data'):
os.makedirs('data') os.makedirs('data')

View File

@@ -5,3 +5,5 @@ SMTP_SERVER=smtp.hostname.com
SMTP_PORT=465 SMTP_PORT=465
SMTP_USERNAME=noreply@email.au SMTP_USERNAME=noreply@email.au
SMTP_PASSWORD=Secretpassword123 SMTP_PASSWORD=Secretpassword123
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_BOT=telegrambotname

17
main.py
View File

@@ -7,6 +7,7 @@ import dotenv
import threading import threading
import time import time
import domains import domains
from alerts import startTGBot, stopTGBot
class GunicornApp(BaseApplication): class GunicornApp(BaseApplication):
@@ -38,6 +39,19 @@ def run_expiry_checker():
# Wait 2 minutes (120 seconds) # Wait 2 minutes (120 seconds)
time.sleep(120) time.sleep(120)
def post_worker_init(worker):
"""
Called just after a worker has been forked.
Start the Telegram bot in each worker process.
"""
print(f"Starting Telegram bot in worker {worker.pid}")
startTGBot(mainThread=True)
# Register cleanup function for this worker
import atexit
atexit.register(stopTGBot)
if __name__ == '__main__': if __name__ == '__main__':
dotenv.load_dotenv() dotenv.load_dotenv()
@@ -46,6 +60,8 @@ if __name__ == '__main__':
expiry_thread.start() expiry_thread.start()
print("Started background expiry checker thread") print("Started background expiry checker thread")
# Don't start the Telegram bot here - it will be started in worker processes
workers = os.getenv('WORKERS', 1) workers = os.getenv('WORKERS', 1)
threads = os.getenv('THREADS', 2) threads = os.getenv('THREADS', 2)
workers = int(workers) workers = int(workers)
@@ -55,6 +71,7 @@ if __name__ == '__main__':
'bind': '0.0.0.0:5000', 'bind': '0.0.0.0:5000',
'workers': workers, 'workers': workers,
'threads': threads, 'threads': threads,
'post_worker_init': post_worker_init,
} }
gunicorn_app = GunicornApp(server.app, options) gunicorn_app = GunicornApp(server.app, options)

View File

@@ -2,3 +2,4 @@ flask
gunicorn gunicorn
requests requests
python-dotenv python-dotenv
python-telegram-bot

View File

@@ -18,7 +18,8 @@ import dotenv
import threading import threading
import time import time
import domains import domains
from alerts import NOTIFICATION_TYPES import atexit
from alerts import NOTIFICATION_TYPES, startTGBot, stopTGBot, handle_alert
dotenv.load_dotenv() dotenv.load_dotenv()
@@ -209,6 +210,20 @@ def delete_notification(notification_id: str):
domains.delete_notification(notification_id, user_data['username']) domains.delete_notification(notification_id, user_data['username'])
return redirect(f"{request.host_url}account") return redirect(f"{request.host_url}account")
@app.route("/telegram/link")
def telegram_link():
"""
Redirect to Telegram login.
"""
token = request.cookies.get("token")
if not token:
return redirect(f"https://login.hns.au/auth?return={request.host_url}login")
TG_NAME = os.getenv("TELEGRAM_BOT", None)
if not TG_NAME:
return jsonify({"error": "Telegram bot name not configured"}), 500
return redirect(f"https://t.me/{TG_NAME}?start={token}")
@app.route("/account/<domain>") @app.route("/account/<domain>")
def account_domain(domain: str): def account_domain(domain: str):
@@ -327,8 +342,28 @@ def api_add_notification(token: str):
# endregion # endregion
@app.route("/test")
def test():
"""
Test route to check if the server is running.
"""
user = request.args.get("user", "nathan.woodburn")
domain_data = {
"blocks": 1008,
"time": f"{1008 // 144} days" # Assuming 144 blocks per day
}
notification = {
"username": "nathan.woodburn",
"blocks": 1008,
"type": "telegram",
"id": "f8b5ad1222b9fe636911421147392385",
"user_name": "nathan.woodburn"
}
handle_alert("woodburn", notification, domain_data)
return jsonify({"message": "Server is running"})
# region Error Catching # region Error Catching
# 404 catch all # 404 catch all
@app.errorhandler(404) @app.errorhandler(404)
@@ -343,4 +378,10 @@ if __name__ == "__main__":
expiry_thread.start() expiry_thread.start()
print("Started background expiry checker thread") print("Started background expiry checker thread")
# Start the Telegram bot in a separate thread (only in main process)
startTGBot()
# Register cleanup function
atexit.register(stopTGBot)
app.run(debug=True, port=5000, host="127.0.0.1") app.run(debug=True, port=5000, host="127.0.0.1")

View File

@@ -72,15 +72,19 @@
{% for field in notificationType.fields %} {% for field in notificationType.fields %}
{% if field.type == 'username' %}
<div class="form-group hidden">
<label for="{{field.name}}">{{field.label}}:</label>
<input type="{{field.type}}" id="{{field.name}}" name="{{field.name}}" {% if field.required %}required{%
endif %} value="{{user.username}}">
</div>
{% else %}
<div class="form-group"> <div class="form-group">
<label for="{{field.name}}">{{field.label}}:</label> <label for="{{field.name}}">{{field.label}}:</label>
<input <input type="{{field.type}}" id="{{field.name}}" name="{{field.name}}" {% if field.required %}required{%
type="{{field.type}}" endif %}>
id="{{field.name}}"
name="{{field.name}}"
{% if field.required %}required{% endif %}
>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
<!-- Add required blocks before expiry field --> <!-- Add required blocks before expiry field -->
@@ -88,10 +92,18 @@
<label for="blocks">Blocks before expiry</label> <label for="blocks">Blocks before expiry</label>
<input type="number" id="blocks" name="blocks" required min="1" max="50000" value="1008"> <input type="number" id="blocks" name="blocks" required min="1" max="50000" value="1008">
<!-- Add a note that 144 blocks is 1 day, 1008 blocks is a week, and 4320 blocks is a month --> <!-- Add a note that 144 blocks is 1 day, 1008 blocks is a week, and 4320 blocks is a month -->
<p class="note">Note: 144 blocks is approximately 1 day, 1008 blocks is approximately 1 week, and 4320 blocks is approximately 1 month.</p> <p class="note">Note: 144 blocks is approximately 1 day, 1008 blocks is approximately 1 week, and 4320
blocks is approximately 1 month.</p>
</div> </div>
<button type="submit" class="button">Add {{notificationType.type.replace('_', ' ').title()}} Notification</button> <button type="submit" class="button">Add {{notificationType.type.replace('_', ' ').title()}}
Notification</button>
{% if notificationType.links %}
{% for link in notificationType.links %}
<a href="{{link.url}}" target="_blank">{{link.label}}</a>
{% endfor %}
{% endif %}
</form> </form>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -130,3 +130,7 @@ span.user {
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
} }
.hidden {
display: none;
}