Compare commits

...

18 Commits

Author SHA1 Message Date
forust 0c5cf29f83 feat: add userbot k8s deployment method
Deploy to Server / deploy (push) Has been cancelled
- Kustomize: base + overlays/dev + overlays/prod
2026-06-07 19:41:28 +02:00
forust 43c38767a1 fix: removed git checkout (was destructive)
- Remove git init/fetch/checkout from utils/misc.py
- Hardcode userbot_version to 2.5.0
2026-06-07 19:40:52 +02:00
forust a68c01a68f chore: add gitea container registry compose support for localy builded apps
Deploy to Server / deploy (push) Has been cancelled
2026-06-07 14:37:57 +02:00
forust bdcdb1cb3d feat: router for gitea container registry (unproxied) 2026-06-07 14:37:53 +02:00
forust fe2b80b51f fix: errorpages intercepted gitea container registry endpoint
gitea registry introduced
2026-06-07 13:39:54 +02:00
forust b8d5bc747d hack: commented out local certs until i figure out how to route certs for local routers 2026-06-07 13:38:14 +02:00
forust 6f77b7d845 switch to embedded dockmon login page
Deploy to Server / deploy (push) Has been cancelled
2026-05-27 20:11:48 +02:00
forust ea286e117d feat: add diary (/diary) command with calendar parsing via Playwright
Deploy to Server / deploy (push) Has been cancelled
- Parse school diary calendar HTML table for daily/weekly/monthly views
- Cache diary data with 5-minute TTL
- Inline keyboard for today/tomorrow/week/month selection
- Ukrainian month/weekday names and formatting
2026-05-27 19:39:45 +02:00
forust 6a050669e8 chore: ignore all generated searxng core-config files 2026-05-27 19:39:41 +02:00
forust b9c11b8eab Merge branch 'main' into optimize/userbot
Deploy to Server / deploy (push) Has been cancelled
2026-05-25 01:48:31 +02:00
forust 6dac841b2c updated traefik to v3.7,
Deploy to Server / deploy (push) Has been cancelled
resolved maxResponseBodySize not set
2026-05-25 01:47:59 +02:00
forust 526a2b617a refactor: update .dockerignore
pull_policy never to use local images
2026-05-25 01:43:46 +02:00
forust 1e08391e0e refactor: improved error handling, timeouts and use temp files 2026-05-21 22:14:29 +02:00
forust 0a31601b77 refactor: imporved layer caching for dockerfile
using existing built image for account 2
2026-05-21 22:12:33 +02:00
forust 25eb89c902 feat: add searxng service (metasearch engine) 2026-05-21 21:34:40 +02:00
forust d988b0f7db refactor: change Cloudlfare DDNS config to env-based
Deploy to Server / deploy (push) Failing after 14m7s
2026-05-08 17:38:41 +02:00
forust e873f37159 refactor: nextcloud now requires mounting /dev/dri inside the container.
Deploy to Server / deploy (push) Failing after 0s
Changed network name for nextcloud
2026-05-08 16:39:09 +02:00
forust 8362f45bdc upd: updated image tags for services:
- gitea 1.25.1 --> 1.26
- portainer-ce latest --> 2.41.0
- traefik latest --> 3.6.15
2026-05-08 16:37:26 +02:00
38 changed files with 727 additions and 98 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ sync.ffs_lock
# Copyparty # Copyparty
*.hist/ *.hist/
# Volumes and data directories # Volumes, configs and data directories
gitea/gitea-db/ gitea/gitea-db/
gitea/gitea-data/* gitea/gitea-data/*
n8n/n8n-data/* n8n/n8n-data/*
@@ -21,6 +21,7 @@ checkmk/checkmk/*
downtify/Downtify_downloads downtify/Downtify_downloads
headscale/config/* headscale/config/*
headscale/data/* headscale/data/*
searxng/core-config/*
# Steaming services files # Steaming services files
streaming/jellyfin/* streaming/jellyfin/*
+15
View File
@@ -0,0 +1,15 @@
CLOUDFLARE_API_TOKEN=YOUR_CLOUDFLARE_API_TOKEN
DOMAINS=example.com,dns.example.com,mc.example.com,auth.example.com,ssh.example.com
IP4_DOMAINS=
IP6_DOMAINS=
IP4_PROVIDER=cloudflare.trace
IP6_PROVIDER=none # change if you want to update AAAA
UPDATE_CRON=@every 5m
UPDATE_ON_START=true
DELETE_ON_STOP=false
DELETE_ON_FAILURE=true
TTL=1
PROXIED=!is(dns.example.com) && !is(mc.example.com) && !is(ssh.example.com)
EMOJI=true
UPTIMEKUMA=https://uptime-kuma.example.com/api/push/AsaSDFGFkfklaFALSKffkfFKfkfkfkFK?status=up&msg=OK&ping=
REJECT_CLOUDFLARE_IPS=true
+21 -4
View File
@@ -6,8 +6,25 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
network_mode: 'host' network_mode: 'host'
# https://github.com/timothymiller/cloudflare-ddns#-quick-start
environment: environment:
- PUID=1000 - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?Cloudflare API token is required}
- PGID=1000 - DOMAINS=${DOMAINS:-}
volumes: - IP4_DOMAINS=${IP4_DOMAINS:-}
- ./config.json:/config.json - IP6_DOMAINS=${IP6_DOMAINS:-}
- IP4_PROVIDER=${IP4_PROVIDER:-cloudflare.trace}
- IP6_PROVIDER=${IP6_PROVIDER:-none}
- UPDATE_CRON=${UPDATE_CRON:-@every 5m}
- UPDATE_ON_START=${UPDATE_ON_START:-true}
- DELETE_ON_STOP=${DELETE_ON_STOP:-false}
- DELETE_ON_FAILURE=${DELETE_ON_FAILURE:-true}
- TTL=${TTL:-1} # 1=auto
# to proxy only "dns.example.com" and "wfs.example.com" use "!is(dns.domain.com) && !is (wfs.domain.com)"
- PROXIED=${PROXIED:-true}
- EMOJI=${EMOJI:-true}
- UPTIMEKUMA=${UPTIMEKUMA:-}
- HEALTHCHECKS=${HEALTHCHECKS:-}
- REJECT_CLOUDFLARE_IPS=${REJECT_CLOUDFLARE_IPS:-true}
# volumes:
# Prefer using environment variables for configuration, config.json legacy support
# - ./config.json:/config.json
+1 -1
View File
@@ -22,7 +22,7 @@ services:
# Prod Router # Prod Router
- "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)" - "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)"
- "traefik.http.routers.dockmon.entrypoints=websecure" - "traefik.http.routers.dockmon.entrypoints=websecure"
- "traefik.http.routers.dockmon.middlewares=security-chain@file" - "traefik.http.routers.dockmon.middlewares=security-headers@file"
- "traefik.http.routers.dockmon.tls=true" - "traefik.http.routers.dockmon.tls=true"
# Local Router # Local Router
- "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.internal`)" - "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.internal`)"
+2 -1
View File
@@ -3,10 +3,11 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: gcr.forust.xyz/forust/dtek-notif:latest
pull_policy: build
restart: unless-stopped restart: unless-stopped
environment: environment:
- TZ=Europe/Kyiv - TZ=Europe/Kyiv
dns: dns:
- 1.1.1.1 - 1.1.1.1
- 8.8.8.8 - 8.8.8.8
+4
View File
@@ -17,6 +17,8 @@ services:
session-keeper: session-keeper:
build: ./phpsessid-bot build: ./phpsessid-bot
image: gcr.forust.xyz/forust/session-keeper:latest
pull_policy: build
env_file: .env env_file: .env
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -31,6 +33,8 @@ services:
webinar-checker: webinar-checker:
build: ./webinar-checker build: ./webinar-checker
image: gcr.forust.xyz/forust/webinar-checker:latest
pull_policy: build
env_file: .env env_file: .env
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
+274 -1
View File
@@ -1,7 +1,10 @@
import os import os
import re
import logging import logging
import redis import redis
import json import json
import time
from datetime import datetime, timedelta
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatMember from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatMember
from telegram.constants import ChatType from telegram.constants import ChatType
@@ -30,6 +33,7 @@ def _env(key, default=None):
EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua') EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua')
EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive') EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive')
WEBINAR_URL = f"{EDU_BASE.rstrip('/')}/{EDU_WEBINAR_PATH.lstrip('/')}" WEBINAR_URL = f"{EDU_BASE.rstrip('/')}/{EDU_WEBINAR_PATH.lstrip('/')}"
DIARY_URL = f"{EDU_BASE.rstrip('/')}/user/diary"
WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60)) WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60))
REDIS_HOST = _env('REDIS_HOST', 'redis') REDIS_HOST = _env('REDIS_HOST', 'redis')
@@ -252,6 +256,205 @@ def get_admin_keyboard(user_id: int):
] ]
return InlineKeyboardMarkup(keyboard) return InlineKeyboardMarkup(keyboard)
# --- Diary Functions ---
DIARY_MONTH_NAMES = ['', 'Січня', 'Лютого', 'Березня', 'Квітня', 'Травня', 'Червня',
'Липня', 'Серпня', 'Вересня', 'Жовтня', 'Листопада', 'Грудня']
DIARY_WEEKDAYS_SHORT = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд']
def get_diary_keyboard():
today = datetime.now()
keyboard = [
[
InlineKeyboardButton(f"📌 Сьогодні ({today.day}.{today.month:02d})", callback_data="diary_today"),
InlineKeyboardButton("📌 Завтра", callback_data="diary_tomorrow"),
],
[
InlineKeyboardButton("📅 Цей тиждень", callback_data="diary_week"),
InlineKeyboardButton("📅 Весь місяць", callback_data="diary_month"),
],
]
return InlineKeyboardMarkup(keyboard)
def _parse_calendar_html(table_html: str) -> tuple:
"""Parse calendar HTML table into (month_text, {day_num: {weekday, events}})."""
days = {}
weekdays = []
rows = re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL)
month_text = ''
for r_idx, row in enumerate(rows):
cells = re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', row, re.DOTALL)
if r_idx == 0:
# Month navigation row: extract "Травень 2026" from nav text
raw = re.sub(r'<[^>]+>', ' ', row).strip()
raw = re.sub(r'\s+', ' ', raw)
m = re.search(r'([А-Яа-яіїєґ\']+\s*:?\s*\d{4})', raw)
if m:
month_text = m.group(1).replace(' : ', ' ').strip()
else:
month_text = raw
elif r_idx == 1:
# Day names row
for cell in cells:
name = re.sub(r'<[^>]+>', '', cell).strip()
if name:
weekdays.append(name)
else:
# Data rows: each cell = a day
for col_idx, cell in enumerate(cells):
# Extract day number — first number in the cell text
text = re.sub(r'<[^>]+>', ' ', cell).strip()
text = re.sub(r'\s+', ' ', text)
dm = re.match(r'(\d+)', text)
if not dm:
continue
day_num = dm.group(1)
# Extract events: title attribute (full name) of ALL <a> tags inside the cell
events = []
for a_match in re.finditer(r'<a[^>]*>(.*?)</a>', cell, re.DOTALL):
a_tag = a_match.group(0)
# Prefer the title attribute (contains full name, not truncated)
title_m = re.search(r'title\s*=\s*"([^"]*)"', a_tag)
if title_m:
et = title_m.group(1).strip()
else:
et = re.sub(r'<[^>]+>', '', a_match.group(1)).strip()
if et:
events.append(et)
weekday = weekdays[col_idx] if col_idx < len(weekdays) else ''
days[day_num] = {'weekday': weekday, 'events': events}
return month_text, days
async def fetch_diary_data(phpsessid: str) -> dict | None:
logger.info("Fetching diary data via Playwright...")
try:
async with async_playwright() as p:
browser = await p.chromium.connect(PLAYWRIGHT_WS)
try:
context_browser = await browser.new_context(user_agent=USER_AGENT)
await context_browser.add_cookies([{
'name': 'PHPSESSID',
'value': phpsessid,
'domain': 'edu.edu.vn.ua',
'path': '/'
}])
page = await context_browser.new_page()
try:
await page.goto(DIARY_URL, wait_until='domcontentloaded')
await page.wait_for_selector('table.calendar', timeout=10000)
await page.wait_for_timeout(1500)
table_html = await page.evaluate("""
() => {
const t = document.querySelector('table.calendar');
return t ? t.outerHTML : null;
}
""")
if not table_html:
logger.error("table.calendar not found in DOM")
return None
# Debug: save HTML for troubleshooting
try:
with open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f:
f.write(table_html)
except Exception:
pass
month_text, days = _parse_calendar_html(table_html)
logger.info(f"Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d['events'])}/{len(days)}")
return {'monthFullText': month_text, 'days': days}
except Exception as e:
logger.error(f"Error parsing diary: {e}")
return None
finally:
await page.close()
await context_browser.close()
finally:
await browser.close()
except Exception as e:
logger.error(f"Playwright error in diary fetch: {e}")
return None
def _parse_diary_month(text: str) -> str:
match = re.search(r'([А-Яа-яіїєґ\']+\s*:\s*\d{4})', text)
if match:
return match.group(1).replace(' : ', ' ').strip()
return text.strip()
def format_diary_day(data: dict, day_num: int) -> str:
days = data.get('days', {})
month_str = _parse_diary_month(data.get('monthFullText', ''))
day_data = days.get(str(day_num))
lines = [f"📅 <b>{day_num} {month_str}</b>", "" * 18]
if not day_data or not day_data.get('events'):
lines.append("Немає подій")
else:
for e in day_data['events']:
lines.append(f"📌 {e}")
lines.append(f"\n🔗 {DIARY_URL}")
return "\n".join(lines)
def format_diary_week(data: dict, today: datetime) -> str:
days = data.get('days', {})
month_str = _parse_diary_month(data.get('monthFullText', ''))
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
lines = [f"📅 <b>Тиждень {monday.day}.{monday.month} {sunday.day}.{sunday.month}</b>\n"]
for i in range(7):
d = monday + timedelta(days=i)
day_data = days.get(str(d.day))
lines.append(f"─ <b>{DIARY_WEEKDAYS_SHORT[i]} {d.day}.{d.month}</b> ─")
if not day_data or not day_data.get('events'):
lines.append("Немає подій\n")
else:
for e in day_data['events']:
lines.append(f"📌 {e}")
lines.append("")
lines.append(f"🔗 {DIARY_URL}")
return "\n".join(lines)
def format_diary_month(data: dict) -> str:
days = data.get('days', {})
month_str = _parse_diary_month(data.get('monthFullText', ''))
lines = [f"📅 <b>{month_str}</b>\n"]
for day_num in sorted(days.keys(), key=int):
day_data = days[day_num]
events = day_data.get('events', [])
weekday = day_data.get('weekday', '')
lines.append(f"─ <b>{weekday} {day_num}</b> ─")
if not events:
lines.append("Немає подій\n")
else:
for e in events:
lines.append(f"📌 {e}")
lines.append("")
lines.append(f"🔗 {DIARY_URL}")
return "\n".join(lines)
async def _get_diary_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None:
cached = context.user_data.get('diary_cache')
now_ts = time.time()
if cached and (now_ts - cached.get('timestamp', 0)) < 300:
return cached['data']
phpsessid = redis_client.get(KEY_PHPSESSID)
if not phpsessid:
return None
data = await fetch_diary_data(phpsessid)
if data:
context.user_data['diary_cache'] = {'data': data, 'timestamp': now_ts}
return data
# --- Command Handlers --- # --- Command Handlers ---
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -376,6 +579,74 @@ async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
logger.error(f"Failed to clear history: {e}") logger.error(f"Failed to clear history: {e}")
await update.message.reply_text(t(admin_id, 'history_clear_failed')) await update.message.reply_text(t(admin_id, 'history_clear_failed'))
async def diary_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
chat = update.effective_chat
if not is_whitelisted(user.id):
await update.message.reply_text(t(user.id, 'access_denied'))
return
await update.message.reply_text(
"📅 <b>Щоденник</b> — виберіть період:",
parse_mode='HTML',
reply_markup=get_diary_keyboard()
)
async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()
if user_id != ADMIN_ID:
if not is_whitelisted(user_id):
await query.edit_message_text("⛔ Доступ заборонено.")
return
data = query.data
if data == "diary_refresh":
context.user_data.pop('diary_cache', None)
await query.edit_message_text("🔄 Завантажую щоденник...")
diary_data = await _get_diary_data(context)
if not diary_data:
await query.edit_message_text("❌ Не вдалося завантажити щоденник. Немає сесії або помилка.")
return
await query.edit_message_text(
"📅 <b>Щоденник</b> — виберіть період:",
parse_mode='HTML',
reply_markup=get_diary_keyboard()
)
return
await query.edit_message_text("🔄 Завантажую щоденник...")
diary_data = await _get_diary_data(context)
if not diary_data:
await query.edit_message_text("❌ Не вдалося завантажити щоденник.")
return
today = datetime.now()
if data == "diary_today":
text = format_diary_day(diary_data, today.day)
elif data == "diary_tomorrow":
tomorrow = today + timedelta(days=1)
if tomorrow.day < today.day:
text = "❌ Дані за наступний місяць недоступні. Перейдіть на сайт."
else:
text = format_diary_day(diary_data, tomorrow.day)
elif data == "diary_week":
text = format_diary_week(diary_data, today)
elif data == "diary_month":
text = format_diary_month(diary_data)
else:
return
if len(text) > 4096:
text = text[:4090] + "\n\n✂️ ...(обрізано)"
await query.edit_message_text(
text,
parse_mode='HTML',
reply_markup=get_diary_keyboard()
)
# --- Admin Callbacks --- # --- Admin Callbacks ---
async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -649,8 +920,10 @@ def main():
app.add_handler(CommandHandler("adduser", add_user)) app.add_handler(CommandHandler("adduser", add_user))
app.add_handler(CommandHandler("removeuser", remove_user)) app.add_handler(CommandHandler("removeuser", remove_user))
app.add_handler(CommandHandler("clearhistory", clear_history)) app.add_handler(CommandHandler("clearhistory", clear_history))
app.add_handler(CommandHandler("diary", diary_command))
# Callback handlers - language selection first, then admin panel # Callback handlers - diary first, then language selection, then admin panel
app.add_handler(CallbackQueryHandler(diary_callback, pattern="^diary_"))
app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_")) app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_"))
app.add_handler(CallbackQueryHandler(admin_callback)) app.add_handler(CallbackQueryHandler(admin_callback))
+3 -1
View File
@@ -1,6 +1,8 @@
services: services:
errorpage: errorpage:
build: . build: .
image: gcr.forust.xyz/forust/error-pages:latest
pull_policy: build
container_name: error-pages container_name: error-pages
restart: unless-stopped restart: unless-stopped
# ports: # ports:
@@ -11,7 +13,7 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.services.error-pages.loadbalancer.server.port=80" - "traefik.http.services.error-pages.loadbalancer.server.port=80"
# Error handler middleware # Error handler middleware
- "traefik.http.middlewares.error-pages.errors.status=400-599" - "traefik.http.middlewares.error-pages.errors.status=400,402-599"
- "traefik.http.middlewares.error-pages.errors.service=error-pages" - "traefik.http.middlewares.error-pages.errors.service=error-pages"
- "traefik.http.middlewares.error-pages.errors.query=/{status}.html" - "traefik.http.middlewares.error-pages.errors.query=/{status}.html"
networks: networks:
+6 -1
View File
@@ -1,6 +1,6 @@
services: services:
server: server:
image: docker.gitea.com/gitea:1.25.1 image: docker.gitea.com/gitea:1.26
container_name: gitea container_name: gitea
restart: always restart: always
environment: environment:
@@ -49,6 +49,11 @@ services:
- "traefik.tcp.services.gitea.loadbalancer.server.port=22" - "traefik.tcp.services.gitea.loadbalancer.server.port=22"
- "traefik.tcp.routers.gitea.entrypoints=ssh" - "traefik.tcp.routers.gitea.entrypoints=ssh"
- "traefik.tcp.routers.gitea.rule=HostSNI(`*`)" - "traefik.tcp.routers.gitea.rule=HostSNI(`*`)"
# Gitea container registry Router
- "traefik.http.routers.gitea-registry.rule=Host(`gcr.forust.xyz`) && PathPrefix(`/v2`)"
- "traefik.http.routers.gitea-registry.entrypoints=websecure"
- "traefik.http.routers.gitea-registry.tls.certresolver=letsencrypt"
- "traefik.http.routers.gitea-registry.tls=true"
ports: ports:
- "2221:22" - "2221:22"
networks: networks:
+4
View File
@@ -3,6 +3,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.forust dockerfile: Dockerfile.forust
image: gcr.forust.xyz/forust/forust-homepage:latest
pull_policy: build
# ports: # ports:
# - "8085:80" # - "8085:80"
restart: unless-stopped restart: unless-stopped
@@ -30,6 +32,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.xdfnx dockerfile: Dockerfile.xdfnx
image: gcr.forust.xyz/forust/xdfnx-homepage:latest
pull_policy: build
restart: unless-stopped restart: unless-stopped
# ports: # ports:
# - "8086:80" # - "8086:80"
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
kener: kener:
image: rajnandan1/kener:v4.0.23 image: rajnandan1/kener:4.0.23
container_name: kener container_name: kener
restart: unless-stopped restart: unless-stopped
# ports: # ports:
+3 -1
View File
@@ -7,6 +7,7 @@ services:
volumes: volumes:
- nextcloud_aio_mastercontainer:/mnt/docker-aio-config # Do not change (backup) - nextcloud_aio_mastercontainer:/mnt/docker-aio-config # Do not change (backup)
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- /dev/dri:/dev/dri
networks: networks:
- nextcloud-aio - nextcloud-aio
- proxy # Optional: Connects the mastercontainer to the proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - proxy # Optional: Connects the mastercontainer to the proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
@@ -57,7 +58,7 @@ services:
NEXTCLOUD_STARTUP_APPS: deck twofactor_totp tasks calendar contacts notes # Allows to modify the Nextcloud apps that are installed on starting AIO the first time. See https://github.com/nextcloud/all-in-one#how-to-change-the-nextcloud-apps-that-are-installed-on-the-first-startup NEXTCLOUD_STARTUP_APPS: deck twofactor_totp tasks calendar contacts notes # Allows to modify the Nextcloud apps that are installed on starting AIO the first time. See https://github.com/nextcloud/all-in-one#how-to-change-the-nextcloud-apps-that-are-installed-on-the-first-startup
NEXTCLOUD_ADDITIONAL_APKS: imagemagick # This allows to add additional packages to the Nextcloud container permanently. Default is imagemagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-os-packages-permanently-to-the-nextcloud-container NEXTCLOUD_ADDITIONAL_APKS: imagemagick # This allows to add additional packages to the Nextcloud container permanently. Default is imagemagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-os-packages-permanently-to-the-nextcloud-container
NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # dditional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # dditional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container
NEXTCLOUD_ENABLE_DRI_DEVICE: true # This allows to enable the /dev/dri device for containers that profit from it. ⚠️⚠️⚠️ Warning: this only works if the '/dev/dri' device is present on the host! If it should not exist on your host, don't set this to true as otherwise the Nextcloud container will fail to start! See https://github.com/nextcloud/all-in-one#how-to-enable-hardware-acceleration-for-nextcloud # NEXTCLOUD_ENABLE_DRI_DEVICE: true # This allows to enable the /dev/dri device for containers that profit from it. ⚠️⚠️⚠️ Warning: this only works if the '/dev/dri' device is present on the host! If it should not exist on your host, don't set this to true as otherwise the Nextcloud container will fail to start! See https://github.com/nextcloud/all-in-one#how-to-enable-hardware-acceleration-for-nextcloud
# NEXTCLOUD_KEEP_DISABLED_APPS: false # Setting this to true will keep Nextcloud apps that are disabled in the AIO interface and not uninstall them if they should be installed. See https://github.com/nextcloud/all-in-one#how-to-keep-disabled-apps # NEXTCLOUD_KEEP_DISABLED_APPS: false # Setting this to true will keep Nextcloud apps that are disabled in the AIO interface and not uninstall them if they should be installed. See https://github.com/nextcloud/all-in-one#how-to-keep-disabled-apps
SKIP_DOMAIN_VALIDATION: true # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation SKIP_DOMAIN_VALIDATION: true # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation
# TALK_PORT: 3478 # This a-llows to adjust the port that the talk container is using which is exposed on the host. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-talk-port # TALK_PORT: 3478 # This a-llows to adjust the port that the talk container is using which is exposed on the host. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-talk-port
@@ -67,6 +68,7 @@ networks:
proxy: proxy:
external: true external: true
nextcloud-aio: nextcloud-aio:
name: nextcloud-aio
driver: bridge driver: bridge
external: false external: false
volumes: volumes:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
portainer: portainer:
image: portainer/portainer-ce:latest image: portainer/portainer-ce:2.41.0
container_name: portainer container_name: portainer
restart: always restart: always
volumes: volumes:
+15
View File
@@ -0,0 +1,15 @@
# Read the documentation before using the `docker-compose.yml` file:
# https://docs.searxng.org/admin/installation-docker.html
#
# Additional ENVs:
# https://docs.searxng.org/admin/settings/settings_general.html#settings-general
# https://docs.searxng.org/admin/settings/settings_server.html#settings-server
# Use a specific version tag. E.g. "latest" or "2026.3.25-541c6c3cb".
#SEARXNG_VERSION=latest
# Listen to a specific address.
#SEARXNG_HOST=[::]
# Listen to a specific port.
SEARXNG_PORT=8080
+45
View File
@@ -0,0 +1,45 @@
# Read the documentation before using the `docker-compose.yml` file:
# https://docs.searxng.org/admin/installation-docker.html
services:
core:
container_name: searxng-core
image: docker.io/searxng/searxng:${SEARXNG_VERSION:-latest}
restart: unless-stopped
# ports:
# - ${SEARXNG_PORT:-8080}
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.services.searxng.loadbalancer.server.port=8080"
# Prod Router
- "traefik.http.routers.searxng.rule=Host(`s.forust.xyz` || `searxng.forust.xyz`)"
- "traefik.http.routers.searxng.entrypoints=websecure"
- "traefik.http.routers.searxng.tls=true"
# Local Router
- "traefik.http.routers.searxng-local.rule=Host(`s.workstation.internal` || `searxng.workstation.internal`)"
- "traefik.http.routers.searxng-local.entrypoints=websecure"
- "traefik.http.routers.searxng-local.tls=true"
# Dev Router
- "traefik.http.routers.searxng-dev.rule=Host(`searxng.gigaforust.internal`)"
- "traefik.http.routers.searxng-dev.entrypoints=websecure"
- "traefik.http.routers.searxng-dev.tls=true"
networks:
- proxy
volumes:
- ./core-config/:/etc/searxng/:Z
- core-data:/var/cache/searxng/
valkey:
container_name: searxng-valkey
image: docker.io/valkey/valkey:9-alpine
command: valkey-server --save 30 1 --loglevel warning
restart: unless-stopped
volumes:
- valkey-data:/data/
volumes:
core-data:
valkey-data:
networks:
proxy:
external: true
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: traefik:latest image: traefik:v3.7
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
command: command:
+1
View File
@@ -35,6 +35,7 @@ http:
forwardAuth: forwardAuth:
address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader: true trustForwardHeader: true
maxResponseBodySize: 1048576
authResponseHeaders: authResponseHeaders:
- X-authentik-username - X-authentik-username
- X-authentik-groups - X-authentik-groups
+6 -6
View File
@@ -4,12 +4,12 @@ tls:
# Cloudflare Origin CA *.forust.xyz # Cloudflare Origin CA *.forust.xyz
# - certFile: /certs/cloudflare.pem # - certFile: /certs/cloudflare.pem
# keyFile: /certs/cloudflare.key # keyFile: /certs/cloudflare.key
stores: # stores:
default: # default:
defaultCertificate: # defaultCertificate:
# Fallback local certificate # # Fallback local certificate
certFile: /certs/local.pem # certFile: /certs/local.pem
keyFile: /certs/local-key.pem # keyFile: /certs/local-key.pem
options: options:
default: default:
+11
View File
@@ -1 +1,12 @@
.unused .unused
Downloads
.venv
volumes
.env
.env.*
my_account.session
.gitignore
README.md
.git
uv.lock
.deepsource.toml
+3
View File
@@ -0,0 +1,3 @@
API_ID=""
API_HASH=""
STRINGSESSION=""
+14
View File
@@ -0,0 +1,14 @@
# apiflash api key only for webshot plugin
APIFLASH_KEY=""
# gemini api key only for gemini plugin
GEMINI_KEY=""
# VT api key only for VirusTotal plugin
VT_KEY=""
# rmbg api key only for removebg plugin
RMBG_KEY=""
# cohere api key only for cohere plugin
COHERE_KEY=""
# sqlite/sqlite3 or mongo/mongodb
DATABASE_TYPE=""
# file name for sqlite3, database name for mongodb
DATABASE_NAME=""
+3
View File
@@ -14,3 +14,6 @@ __pycache__/
/downloads/ /downloads/
/Downloads/ /Downloads/
config.ini config.ini
k8s/*/*secret*.yaml
!k8s/account-secrets.yaml.example
+16 -8
View File
@@ -1,10 +1,18 @@
FROM python:3.11 FROM python:3.11-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
git wget ffmpeg mediainfo && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/
RUN python -m pip install --no-cache-dir --upgrade pip && \
python -m pip install --no-cache-dir -r /app/requirements.txt
COPY . /app COPY . /app
RUN apt-get -qq update && apt-get -qq install -y git wget ffmpeg mediainfo\
&& apt-get clean \ ENV PYTHONUNBUFFERED=1
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv --copies /opt/venv CMD ["python", "-u", "main.py"]
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]
+7 -4
View File
@@ -3,6 +3,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: gcr.forust.xyz/forust/userbot:latest
pull_policy: build
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
@@ -10,15 +12,16 @@ services:
volumes: volumes:
- ./volumes/data_forust:/app/data - ./volumes/data_forust:/app/data
- ./Downloads:/app/downloads - ./Downloads:/app/downloads
- ./volumes/logs_forust-:/app/logs - ./volumes/logs_forust:/app/logs
dns: dns:
- 8.8.8.8 - 8.8.8.8
- 1.1.1.1 - 1.1.1.1
anna: anna:
build: image: gcr.forust.xyz/forust/userbot:latest
context: . pull_policy: build
dockerfile: Dockerfile depends_on:
- forust
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: userbot-common-config
data:
DATABASE_TYPE: ""
DATABASE_NAME: ""
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- userbots.yaml
- common-secret.yaml
- common-config.yaml
- forust-secrets.yaml
- anna-secrets.yaml
+114
View File
@@ -0,0 +1,114 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: forust-userbot-deployment
labels:
app: forust-userbot
spec:
replicas: 1
selector:
matchLabels:
app: forust-userbot
template:
metadata:
labels:
app: forust-userbot
spec:
containers:
- name: forust-userbot
image: gcr.forust.xyz/forust/userbot:latest
imagePullPolicy: Always
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "100m"
envFrom:
- secretRef:
name: userbot-common-secrets
- configMapRef:
name: userbot-common-config
- secretRef:
name: userbot-forust-secrets
volumeMounts:
- name: forust-storage
mountPath: /app/data
subPath: data
- name: forust-storage
mountPath: /app/logs
subPath: logs
volumes:
- name: forust-storage
persistentVolumeClaim:
claimName: forust-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: forust-pvc
spec:
resources:
requests:
storage: 1Gi
accessModes:
- ReadWriteOnce
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: anna-userbot-deployment
labels:
app: anna-userbot
spec:
replicas: 1
selector:
matchLabels:
app: anna-userbot
template:
metadata:
labels:
app: anna-userbot
spec:
containers:
- name: anna-userbot
image: gcr.forust.xyz/forust/userbot:latest
imagePullPolicy: Always
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "100m"
envFrom:
- secretRef:
name: userbot-common-secrets
- configMapRef:
name: userbot-common-config
- secretRef:
name: userbot-anna-secrets
volumeMounts:
- name: anna-storage
mountPath: /app/data
subPath: data
- name: anna-storage
mountPath: /app/logs
subPath: logs
volumes:
- name: anna-storage
persistentVolumeClaim:
claimName: anna-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: anna-pvc
spec:
resources:
requests:
storage: 1Gi
accessModes:
- ReadWriteOnce
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-downloads.yaml
@@ -0,0 +1,35 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: forust-userbot-deployment
spec:
template:
spec:
containers:
- name: forust-userbot
volumeMounts:
- name: downloads
mountPath: /app/downloads
volumes:
- name: downloads
hostPath:
path: /home/user/projects/homelab/userbot/Downloads
type: DirectoryOrCreate
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: anna-userbot-deployment
spec:
template:
spec:
containers:
- name: anna-userbot
volumeMounts:
- name: downloads
mountPath: /app/downloads
volumes:
- name: downloads
hostPath:
path: /home/user/projects/homelab/userbot/Downloads
type: DirectoryOrCreate
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-downloads.yaml
@@ -0,0 +1,35 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: forust-userbot-deployment
spec:
template:
spec:
containers:
- name: forust-userbot
volumeMounts:
- name: downloads
mountPath: /app/downloads
volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: anna-userbot-deployment
spec:
template:
spec:
containers:
- name: anna-userbot
volumeMounts:
- name: downloads
mountPath: /app/downloads
volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
+13 -5
View File
@@ -90,11 +90,19 @@ def load_missing_modules():
os.makedirs(custom_modules_path, exist_ok=True) os.makedirs(custom_modules_path, exist_ok=True)
try: try:
f = requests.get( resp = requests.get(
"https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/full.txt" "https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/full.txt",
).text timeout=10,
except Exception: )
logging.error("Failed to fetch custom modules list") if not resp.ok:
logging.error(
"Failed to fetch custom modules list: HTTP %s",
resp.status_code,
)
return
f = resp.text
except Exception as e:
logging.error("Failed to fetch custom modules list: %s", e)
return return
modules_dict = { modules_dict = {
line.split("/")[-1].split()[0]: line.strip() for line in f.splitlines() line.split("/")[-1].split()[0]: line.strip() for line in f.splitlines()
+8 -8
View File
@@ -59,26 +59,26 @@ async def version(client: Client, message: Message):
await message.delete() await message.delete()
if gitrepo is not None:
remote_url = list(gitrepo.remote().urls)[0] remote_url = list(gitrepo.remote().urls)[0]
commit_time = ( commit_time = (
datetime.datetime.fromtimestamp(gitrepo.head.commit.committed_date) datetime.datetime.fromtimestamp(gitrepo.head.commit.committed_date)
.astimezone(datetime.timezone.utc) .astimezone(datetime.timezone.utc)
.strftime("%Y-%m-%d %H:%M:%S %Z") .strftime("%Y-%m-%d %H:%M:%S %Z")
) )
git_info = (
f"\n<b>Branch: <a href={remote_url}/tree/{gitrepo.active_branch}>{gitrepo.active_branch}</a>\n"
if gitrepo.active_branch != "master" else "\n"
) + f"Commit: <a href={remote_url}/commit/{gitrepo.head.commit.hexsha}>" f"{gitrepo.head.commit.hexsha[:7]}</a> by {gitrepo.head.commit.author.name}\n" f"Commit time: {commit_time}</b>"
else:
git_info = ""
await message.reply( await message.reply(
f"<b>Moon Userbot version: {userbot_version}\n" f"<b>Moon Userbot version: {userbot_version}\n"
f"Changelog </b><i><a href=https://t.me/moonuserbot/{changelog}>in channel</a></i>.<b>\n" f"Changelog </b><i><a href=https://t.me/moonuserbot/{changelog}>in channel</a></i>.<b>\n"
f"Changelog written by </b><i>" f"Changelog written by </b><i>"
f"<a href=https://t.me/Qbtaumai>Abhi</a></i>\n\n" f"<a href=https://t.me/Qbtaumai>Abhi</a></i>\n\n"
+ ( f"{git_info}",
f"<b>Branch: <a href={remote_url}/tree/{gitrepo.active_branch}>{gitrepo.active_branch}</a>\n"
if gitrepo.active_branch != "master"
else ""
)
+ f"Commit: <a href={remote_url}/commit/{gitrepo.head.commit.hexsha}>"
f"{gitrepo.head.commit.hexsha[:7]}</a> by {gitrepo.head.commit.author.name}\n"
f"Commit time: {commit_time}</b>",
) )
+1 -1
View File
@@ -3,7 +3,7 @@ name = "userbot"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.11"
dependencies = [ dependencies = [
"aiofiles>=25.1.0", "aiofiles>=25.1.0",
"aiohttp>=3.13.2", "aiohttp>=3.13.2",
+2
View File
@@ -18,3 +18,5 @@ aiohttp
aiofiles aiofiles
pySmartDL pySmartDL
qrcode qrcode
bottle
dulwich
+1 -1
View File
@@ -1,8 +1,8 @@
import os import os
import environs import environs
try:
env = environs.Env() env = environs.Env()
try:
env.read_env("./.env") env.read_env("./.env")
except FileNotFoundError: except FileNotFoundError:
print("No .env file found, using os.environ.") print("No .env file found, using os.environ.")
+5 -29
View File
@@ -1,19 +1,3 @@
# Moon-Userbot - telegram userbot
# Copyright (C) 2020-present Moon Userbot Organization
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sys import version_info from sys import version_info
from .db import db from .db import db
import git import git
@@ -37,19 +21,11 @@ prefix = db.get("core.main", "prefix", ".")
try: try:
gitrepo = git.Repo(".") gitrepo = git.Repo(".")
except git.exc.InvalidGitRepositoryError: except (git.exc.InvalidGitRepositoryError, git.exc.NoSuchPathError):
repo = git.Repo.init() gitrepo = None
origin = repo.create_remote(
"origin", "https://github.com/The-MoonTg-project/Moon-Userbot"
)
origin.fetch()
repo.create_head("main", origin.refs.main)
repo.heads.main.set_tracking_branch(origin.refs.main)
repo.heads.main.checkout(True)
gitrepo = git.Repo(".")
if len(gitrepo.tags) > 0: if gitrepo is not None and len(gitrepo.tags) > 0:
commits_since_tag = list(gitrepo.iter_commits(f"{gitrepo.tags[-1].name}..HEAD")) commits_since_tag = list(gitrepo.iter_commits(f"{gitrepo.tags[-1].name}..HEAD"))
else:
commits_since_tag = []
userbot_version = f"2.5.{len(commits_since_tag)}" userbot_version = f"2.5.{len(commits_since_tag)}"
else:
userbot_version = "2.5.0"
+11 -11
View File
@@ -22,6 +22,7 @@ import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
import traceback import traceback
from PIL import Image from PIL import Image
@@ -85,13 +86,17 @@ async def edit_or_send_as_file(
return return
if len(tex) > 1024: if len(tex) > 1024:
await message.edit("<code>OutPut is Too Large, Sending As File!</code>") await message.edit("<code>OutPut is Too Large, Sending As File!</code>")
file_names = f"{file_name}.txt" with tempfile.NamedTemporaryFile(
with open(file_names, "w") as fn: "w", delete=False, suffix=".txt", prefix=f"{file_name}_"
) as fn:
fn.write(tex) fn.write(tex)
await client.send_document(message.chat.id, file_names, caption=caption) temp_path = fn.name
try:
await client.send_document(message.chat.id, temp_path, caption=caption)
await message.delete() await message.delete()
if os.path.exists(file_names): finally:
os.remove(file_names) if os.path.exists(temp_path):
os.remove(temp_path)
return return
return await message.edit(tex) return await message.edit(tex)
@@ -249,12 +254,7 @@ def is_admin(func):
chat_member = await client.get_chat_member( chat_member = await client.get_chat_member(
message.chat.id, message.from_user.id message.chat.id, message.from_user.id
) )
chat_admins = [] if chat_member.status in ("administrator", "creator"):
async for member in client.get_chat_members(
message.chat.id, filter=ChatMembersFilter.ADMINISTRATORS
):
chat_admins.append(member)
if chat_member in chat_admins:
return await func(client, message) return await func(client, message)
await message.edit("You need to be an admin to perform this action.") await message.edit("You need to be an admin to perform this action.")
except UserNotParticipant: except UserNotParticipant: