commit bcc4bc68bdff86194a0b65dcd52833757ca7f4e2 Author: Corrado Mulas Date: Fri Jun 19 18:02:07 2026 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a1917d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TELEGRAM_BOT_TOKEN=replace-with-bot-token +GROUPFACTORY_API_BASE_URL=http://localhost:8000 +GROUPFACTORY_API_KEY=change-me-api-key +BOT_ALLOWED_CHAT_IDS=-1001234567890 +GROUPFACTORY_API_TIMEOUT=300 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..786b38a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a146844 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ +COPY entrypoint.sh . + +RUN chmod +x entrypoint.sh && \ + adduser --disabled-password --gecos '' --uid 1000 appuser && \ + chown -R appuser:appuser /app + +USER 1000:1000 + +CMD ["./entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e0d185 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# telegram-groupfactory-bot + +Conventional Telegram Bot API client for `telegram-groupfactory`. + +This service does not use Telethon and does not replace the userbot commands. It talks to the userbot REST API with `X-API-Key`, so the userbot remains the only service that owns Telegram user-session operations. + +## Commands + +```text +/newgrp - Guided group creation through the API +/get_group - Read group info +/group_add_users ... + +/get_users - Show default group users +/set_users ... +/add_users ... +/remove_users ... +/add_user + +/get_qr [qr_group] +/set_qr +/set_qr +/set_qr_group - Forward a .importbackup QR image +/qr_groups [qr_group] +/qr_group_add ... +/qr_group_remove ... +/sync_qr [qr_group|all] + +/users +/user +/delete_user +/ping +/cancel +``` + +`/newgrp` also accepts quick input: + +```text +/newgrp Group name | Group description +``` + +## Configuration + +```text +TELEGRAM_BOT_TOKEN BotFather token for the conventional bot +GROUPFACTORY_API_BASE_URL Internal URL for the userbot REST API +GROUPFACTORY_API_KEY Must match API_KEY on telegram-groupfactory +BOT_ALLOWED_CHAT_IDS Comma-separated chat IDs allowed to use this bot +GROUPFACTORY_API_TIMEOUT API timeout in seconds, default 300 +``` + +Inside the same Kubernetes namespace, `GROUPFACTORY_API_BASE_URL` can be: + +```text +http://groupfactory:8000 +``` + +## Local Run + +```bash +cp .env.example .env +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +python -m src.main +``` + +## Kubernetes + +Apply the example config and secret after replacing values: + +```bash +kubectl apply -f examples/configmap.yaml +kubectl apply -f examples/secret.yaml +kubectl apply -f k8s/ +``` + +The Deployment runs in namespace `groupfactory`, the same namespace as the userbot. diff --git a/argocd/application.yaml b/argocd/application.yaml new file mode 100644 index 0000000..ca267c1 --- /dev/null +++ b/argocd/application.yaml @@ -0,0 +1,18 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: groupfactory-client-bot + namespace: argocd +spec: + project: default + source: + repoURL: https://git.devops.lnxc.it/dev/IMS/telegram-groupfactory-bot.git + targetRevision: master + path: k8s + destination: + server: https://kubernetes.default.svc + namespace: groupfactory + syncPolicy: + syncOptions: + - CreateNamespace=true + - PruneLast=true diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..8c72d5e --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu + +exec python -m src.main diff --git a/examples/configmap.yaml b/examples/configmap.yaml new file mode 100644 index 0000000..227e4d1 --- /dev/null +++ b/examples/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: groupfactory-client-bot-config + namespace: groupfactory + labels: + app.kubernetes.io/name: groupfactory-client-bot +data: + GROUPFACTORY_API_BASE_URL: "http://groupfactory:8000" + GROUPFACTORY_API_TIMEOUT: "300" + BOT_ALLOWED_CHAT_IDS: "-1001234567890" diff --git a/examples/secret.yaml b/examples/secret.yaml new file mode 100644 index 0000000..f216512 --- /dev/null +++ b/examples/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: groupfactory-client-bot-secret + namespace: groupfactory + labels: + app.kubernetes.io/name: groupfactory-client-bot +type: Opaque +stringData: + TELEGRAM_BOT_TOKEN: "replace-with-telegram-bot-token" + GROUPFACTORY_API_KEY: "change-me-api-key" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..3562713 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: groupfactory-client-bot + namespace: groupfactory + labels: + app.kubernetes.io/name: groupfactory-client-bot + app.kubernetes.io/component: telegram-bot +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: groupfactory-client-bot + app.kubernetes.io/component: telegram-bot + template: + metadata: + labels: + app.kubernetes.io/name: groupfactory-client-bot + app.kubernetes.io/component: telegram-bot + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: groupfactory-client-bot + image: registry.example.com/groupfactory-client-bot:latest + imagePullPolicy: IfNotPresent + workingDir: /app + command: + - python + args: + - -m + - src.main + envFrom: + - configMapRef: + name: groupfactory-client-bot-config + - secretRef: + name: groupfactory-client-bot-secret + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + cpu: 250m + memory: 256Mi + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..7b0042e --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: groupfactory diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8c0119e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-telegram-bot>=21.0,<22.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 diff --git a/scripts/mirror_repo.sh b/scripts/mirror_repo.sh new file mode 100755 index 0000000..a5ec5e0 --- /dev/null +++ b/scripts/mirror_repo.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEFAULT_SOURCE_URL="https://git.mulas.me/corrado/telegram-groupfactory-bot.git" +DEFAULT_DESTINATION_URL="https://git.devops.lnxc.it/dev/IMS/telegram-groupfactory-bot.git" + +SOURCE_URL="${SOURCE_URL:-$DEFAULT_SOURCE_URL}" +DESTINATION_URL="${DESTINATION_URL:-$DEFAULT_DESTINATION_URL}" +SOURCE_BRANCH="${SOURCE_BRANCH:-master}" +TARGET_BRANCH="${TARGET_BRANCH:-master}" +MR_BRANCH="${MR_BRANCH:-feat}" +MR_TITLE="${MR_TITLE:-Mirror telegram-groupfactory-bot}" +MR_DESCRIPTION="${MR_DESCRIPTION:-Mirror from ${SOURCE_URL} into ${DESTINATION_URL}.}" +COMMIT_MESSAGE="${COMMIT_MESSAGE:-Mirror source repository tree}" +WORKDIR="" +KEEP_WORKDIR=0 +ASSUME_YES=0 +FORCE_WITH_LEASE=1 + +usage() { + cat <<'EOF' +Mirror telegram-groupfactory-bot through a GitLab merge request. + +Usage: + scripts/mirror_repo.sh [options] + +Options: + -s, --source URL Source repository URL. + -d, --destination URL Destination repository URL. + --source-branch NAME Source branch to copy. Default: master. + --target-branch NAME MR target branch. Default: master. + --mr-branch NAME Destination feature branch. Default: feat. + --title TEXT Merge request title. + --description TEXT Merge request description. + --commit-message TEXT Commit message for the source tree update. + --no-force-with-lease Use plain --force instead of --force-with-lease. + -w, --workdir DIR Directory used for the temporary clone. + -k, --keep-workdir Keep the temporary clone after finishing. + -y, --yes Skip the confirmation prompt. + -h, --help Show this help. + +Environment overrides: + SOURCE_URL, DESTINATION_URL, SOURCE_BRANCH, TARGET_BRANCH, MR_BRANCH, + MR_TITLE, MR_DESCRIPTION, COMMIT_MESSAGE +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -s|--source) + SOURCE_URL="$2" + shift 2 + ;; + -d|--destination) + DESTINATION_URL="$2" + shift 2 + ;; + --source-branch) + SOURCE_BRANCH="$2" + shift 2 + ;; + --target-branch) + TARGET_BRANCH="$2" + shift 2 + ;; + --mr-branch) + MR_BRANCH="$2" + shift 2 + ;; + --title) + MR_TITLE="$2" + shift 2 + ;; + --description) + MR_DESCRIPTION="$2" + shift 2 + ;; + --commit-message) + COMMIT_MESSAGE="$2" + shift 2 + ;; + --no-force-with-lease) + FORCE_WITH_LEASE=0 + shift + ;; + -w|--workdir) + WORKDIR="$2" + shift 2 + ;; + -k|--keep-workdir) + KEEP_WORKDIR=1 + shift + ;; + -y|--yes) + ASSUME_YES=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$WORKDIR" ]]; then + WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/telegram-groupfactory-bot-mr.XXXXXX")" +else + mkdir -p "$WORKDIR" +fi + +cleanup() { + if [[ "$KEEP_WORKDIR" -eq 0 && -n "$WORKDIR" && -d "$WORKDIR" ]]; then + rm -rf "$WORKDIR" + fi +} +trap cleanup EXIT + +echo "Source: $SOURCE_URL" +echo "Destination: $DESTINATION_URL" +echo "Source ref: $SOURCE_BRANCH" +echo "MR branch: $MR_BRANCH" +echo "MR target: $TARGET_BRANCH" +echo "Workdir: $WORKDIR" +echo + +if [[ "$ASSUME_YES" -eq 0 ]]; then + read -r -p "Create ${MR_BRANCH} from destination/${TARGET_BRANCH}, apply source/${SOURCE_BRANCH}, and create MR? [y/N] " reply + case "$reply" in + y|Y|yes|YES) + ;; + *) + echo "Aborted." + exit 1 + ;; + esac +fi + +echo "Cloning destination target branch..." +git clone --single-branch --branch "$TARGET_BRANCH" "$DESTINATION_URL" "$WORKDIR/repo" + +cd "$WORKDIR/repo" +git remote add source "$SOURCE_URL" + +echo "Fetching source branch..." +git fetch source "refs/heads/${SOURCE_BRANCH}:refs/remotes/source/${SOURCE_BRANCH}" + +echo "Fetching destination MR branch for lease..." +if git fetch origin "refs/heads/${MR_BRANCH}:refs/remotes/origin/${MR_BRANCH}"; then + MR_REMOTE_COMMIT="$(git rev-parse "refs/remotes/origin/${MR_BRANCH}")" +else + MR_REMOTE_COMMIT="" +fi + +SOURCE_REF="refs/remotes/source/${SOURCE_BRANCH}" +TARGET_REF="refs/remotes/origin/${TARGET_BRANCH}" +SOURCE_COMMIT="$(git rev-parse "$SOURCE_REF")" +TARGET_COMMIT="$(git rev-parse "$TARGET_REF")" + +echo "Creating MR branch from destination target..." +git checkout -B "$MR_BRANCH" "$TARGET_REF" + +echo "Applying source tree from ${SOURCE_COMMIT}..." +git read-tree --reset -u "$SOURCE_REF" + +if git diff --cached --quiet; then + echo "Destination ${TARGET_BRANCH} already has the same tree as source ${SOURCE_BRANCH}." + echo "Nothing to push." + exit 0 +fi + +git commit \ + -m "$COMMIT_MESSAGE" \ + -m "Source: $SOURCE_URL" \ + -m "Source branch: $SOURCE_BRANCH" \ + -m "Source commit: $SOURCE_COMMIT" \ + -m "Destination base: $DESTINATION_URL" \ + -m "Destination target: $TARGET_BRANCH" \ + -m "Destination base commit: $TARGET_COMMIT" + +push_args=() +if [[ "$FORCE_WITH_LEASE" -eq 1 ]]; then + push_args+=(--force-with-lease="refs/heads/${MR_BRANCH}:${MR_REMOTE_COMMIT}") +else + push_args+=(--force) +fi + +echo "Pushing MR branch and asking GitLab to create the merge request..." +git push "${push_args[@]}" \ + -o merge_request.create \ + -o merge_request.target="$TARGET_BRANCH" \ + -o merge_request.title="$MR_TITLE" \ + -o merge_request.description="$MR_DESCRIPTION" \ + origin "HEAD:refs/heads/${MR_BRANCH}" + +echo "Merge request push complete." diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ + diff --git a/src/api.py b/src/api.py new file mode 100644 index 0000000..5862601 --- /dev/null +++ b/src/api.py @@ -0,0 +1,129 @@ +from typing import Any, Dict, Optional + +import httpx + + +class GroupFactoryApi: + def __init__(self, base_url: str, api_key: str, timeout: float = 300): + self.base_url = base_url.rstrip("/") + self.headers = {"X-API-Key": api_key} + self.timeout = timeout + + async def request( + self, + method: str, + path: str, + *, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, + ) -> str: + url = f"{self.base_url}{path}" + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + response = await client.request( + method, + url, + headers=self.headers, + json=json, + params=params, + data=data, + files=files, + ) + except httpx.HTTPError as e: + return f"API request failed: {e}" + + if response.status_code >= 400: + try: + detail = response.json().get("detail", response.text) + except ValueError: + detail = response.text + return f"API error {response.status_code}: {detail}" + + try: + payload = response.json() + except ValueError: + return response.text + + return payload.get("message") or str(payload) + + async def create_group(self, name: str, description: str) -> str: + return await self.request( + "POST", + "/api/groups", + json={"name": name, "description": description}, + ) + + async def default_users(self) -> str: + return await self.request("GET", "/api/admin/default-users") + + async def set_default_users(self, user_ids) -> str: + return await self.request("PUT", "/api/admin/default-users", json={"user_ids": user_ids}) + + async def add_default_users(self, user_ids) -> str: + return await self.request("POST", "/api/admin/default-users", json={"user_ids": user_ids}) + + async def remove_default_users(self, user_ids) -> str: + return await self.request("DELETE", "/api/admin/default-users", json={"user_ids": user_ids}) + + async def add_user(self, username: str) -> str: + return await self.request("POST", "/api/admin/users", json={"username": username}) + + async def list_users(self) -> str: + return await self.request("GET", "/api/users") + + async def get_user(self, user_id: int) -> str: + return await self.request("GET", f"/api/users/{user_id}") + + async def delete_user(self, user_id: int) -> str: + return await self.request("DELETE", f"/api/users/{user_id}") + + async def get_group(self, group_id: str) -> str: + return await self.request("GET", f"/api/groups/{group_id}") + + async def add_users_to_group(self, group_id: str, user_ids) -> str: + return await self.request( + "POST", + f"/api/groups/{group_id}/users", + json={"user_ids": user_ids}, + ) + + async def get_qr(self, qr_group: str = "default") -> str: + return await self.request("GET", "/api/admin/qr-backup", params={"qr_group": qr_group}) + + async def set_qr(self, qr_data: str, qr_group: str = "default") -> str: + return await self.request( + "PUT", + "/api/admin/qr-backup", + json={"qr_group": qr_group, "qr_data": qr_data}, + ) + + async def set_qr_image(self, qr_group: str, image_bytes: bytes, filename: str, content_type: str) -> str: + return await self.request( + "POST", + "/api/admin/qr-backup/image", + data={"qr_group": qr_group}, + files={"file": (filename, image_bytes, content_type)}, + ) + + async def qr_groups(self, qr_group: str = None) -> str: + params = {"qr_group": qr_group} if qr_group else None + return await self.request("GET", "/api/admin/qr-groups", params=params) + + async def assign_qr_group(self, qr_group: str, group_ids) -> str: + return await self.request( + "POST", + f"/api/admin/qr-groups/{qr_group}/assignments", + json={"group_ids": group_ids}, + ) + + async def remove_qr_group_assignments(self, group_ids) -> str: + return await self.request( + "DELETE", + "/api/admin/qr-groups/assignments", + json={"group_ids": group_ids}, + ) + + async def sync_qr(self, qr_group: str = "default") -> str: + return await self.request("POST", "/api/admin/qr-sync", json={"qr_group": qr_group}) diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..3219ebb --- /dev/null +++ b/src/bot.py @@ -0,0 +1,496 @@ +import logging +from functools import wraps +from typing import Callable, Iterable, List, Optional + +from telegram import Update +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) + +from src.api import GroupFactoryApi +from src.config import Config + + +logger = logging.getLogger(__name__) + +NEWGRP_NAME, NEWGRP_DESCRIPTION, NEWGRP_CONFIRM = range(3) +QR_IMAGE = 10 + + +HELP_TEXT = """GroupFactory client bot + +Group creation: +/newgrp - start guided group creation +/get_group +/group_add_users ... + +Default users: +/get_users +/set_users ... +/add_users ... +/remove_users ... +/add_user + +QR configs: +/get_qr [qr_group] +/set_qr +/set_qr +/set_qr_group - forward a .importbackup QR image +/qr_groups [qr_group] +/qr_group_add ... +/qr_group_remove ... +/sync_qr [qr_group|all] + +Other: +/users - list database users +/user +/delete_user +/ping +/cancel +""" + + +def _api(context: ContextTypes.DEFAULT_TYPE) -> GroupFactoryApi: + return context.application.bot_data["api"] + + +def _config(context: ContextTypes.DEFAULT_TYPE) -> Config: + return context.application.bot_data["config"] + + +def _is_allowed(update: Update, config: Config) -> bool: + if not config.allowed_chat_ids: + return True + chat = update.effective_chat + return bool(chat and chat.id in config.allowed_chat_ids) + + +def require_allowed(handler: Callable): + @wraps(handler) + async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not _is_allowed(update, _config(context)): + if update.effective_message: + await update.effective_message.reply_text("This bot is not enabled for this chat.") + return ConversationHandler.END + return await handler(update, context) + + return wrapped + + +async def _reply(update: Update, text: str): + message = update.effective_message + if not message: + return + text = text or "No response." + for start in range(0, len(text), 3900): + await message.reply_text(text[start:start + 3900]) + + +def _require_args(context: ContextTypes.DEFAULT_TYPE, usage: str) -> Optional[List[str]]: + args = list(context.args or []) + if not args: + return None + return args + + +def _parse_ints(values: Iterable[str]) -> List[int]: + return [int(value.strip().rstrip(",")) for value in values if value.strip().rstrip(",")] + + +@require_allowed +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + await _reply(update, HELP_TEXT) + + +@require_allowed +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + await _reply(update, HELP_TEXT) + + +@require_allowed +async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE): + await _reply(update, "PONG - GroupFactory Client Bot") + + +@require_allowed +async def get_default_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + await _reply(update, await _api(context).default_users()) + + +@require_allowed +async def set_default_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/set_users ...") + if not args: + await _reply(update, "Usage: /set_users ...") + return + await _reply(update, await _api(context).set_default_users(args)) + + +@require_allowed +async def add_default_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/add_users ...") + if not args: + await _reply(update, "Usage: /add_users ...") + return + await _reply(update, await _api(context).add_default_users(args)) + + +@require_allowed +async def remove_default_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/remove_users ...") + if not args: + await _reply(update, "Usage: /remove_users ...") + return + await _reply(update, await _api(context).remove_default_users(args)) + + +@require_allowed +async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/add_user ") + if not args: + await _reply(update, "Usage: /add_user ") + return + await _reply(update, await _api(context).add_user(" ".join(args))) + + +@require_allowed +async def list_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + await _reply(update, await _api(context).list_users()) + + +@require_allowed +async def get_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/user ") + if not args: + await _reply(update, "Usage: /user ") + return + try: + user_id = int(args[0]) + except ValueError: + await _reply(update, "User ID must be numeric.") + return + await _reply(update, await _api(context).get_user(user_id)) + + +@require_allowed +async def delete_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/delete_user ") + if not args: + await _reply(update, "Usage: /delete_user ") + return + try: + user_id = int(args[0]) + except ValueError: + await _reply(update, "User ID must be numeric.") + return + await _reply(update, await _api(context).delete_user(user_id)) + + +@require_allowed +async def get_group(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/get_group ") + if not args: + await _reply(update, "Usage: /get_group ") + return + await _reply(update, await _api(context).get_group(args[0])) + + +@require_allowed +async def group_add_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/group_add_users ...") + if not args or len(args) < 2: + await _reply(update, "Usage: /group_add_users ...") + return + try: + user_ids = _parse_ints(args[1:]) + except ValueError: + await _reply(update, "User IDs must be numeric for group invites.") + return + await _reply(update, await _api(context).add_users_to_group(args[0], user_ids)) + + +@require_allowed +async def newgrp_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + raw_text = (update.effective_message.text or "").strip() + if raw_text.lower().startswith("!newgrp"): + text = raw_text[len("!newgrp"):].strip() + else: + text = " ".join(context.args or []).strip() + context.user_data.pop("newgrp", None) + context.user_data["newgrp"] = {} + + if "|" in text: + name, description = [item.strip() for item in text.split("|", 1)] + if name and description: + context.user_data["newgrp"] = {"name": name, "description": description} + await _reply(update, _newgrp_summary(name, description)) + return NEWGRP_CONFIRM + + if text: + context.user_data["newgrp"]["name"] = text + await _reply(update, "Group name set. Send the group description.") + return NEWGRP_DESCRIPTION + + await _reply(update, "Send the group name.") + return NEWGRP_NAME + + +@require_allowed +async def newgrp_name(update: Update, context: ContextTypes.DEFAULT_TYPE): + name = (update.effective_message.text or "").strip() + if name.lower() == "!cancel": + return await cancel(update, context) + if not name: + await _reply(update, "Group name cannot be empty. Send the group name.") + return NEWGRP_NAME + context.user_data.setdefault("newgrp", {})["name"] = name + await _reply(update, "Send the group description.") + return NEWGRP_DESCRIPTION + + +@require_allowed +async def newgrp_description(update: Update, context: ContextTypes.DEFAULT_TYPE): + description = (update.effective_message.text or "").strip() + if description.lower() == "!cancel": + return await cancel(update, context) + if not description: + await _reply(update, "Group description cannot be empty. Send the group description.") + return NEWGRP_DESCRIPTION + data = context.user_data.setdefault("newgrp", {}) + data["description"] = description + await _reply(update, _newgrp_summary(data["name"], description)) + return NEWGRP_CONFIRM + + +def _newgrp_summary(name: str, description: str) -> str: + return ( + "Group creation summary\n\n" + f"Name: {name}\n" + f"Description: {description}\n\n" + "Send /confirm or !confirm to create it. Send /cancel or !cancel to abort." + ) + + +@require_allowed +async def newgrp_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE): + text = (update.effective_message.text or "").strip().lower() + if text in ("!cancel", "cancel", "no", "n"): + return await cancel(update, context) + if text not in ("/confirm", "!confirm", "confirm", "yes", "y"): + await _reply(update, "Send /confirm or !confirm to create it, or /cancel to abort.") + return NEWGRP_CONFIRM + + data = context.user_data.get("newgrp") or {} + name = data.get("name") + description = data.get("description") + if not name or not description: + context.user_data.pop("newgrp", None) + await _reply(update, "Missing group data. Start again with /newgrp.") + return ConversationHandler.END + + await _reply(update, "Creating group through GroupFactory API...") + response = await _api(context).create_group(name, description) + context.user_data.pop("newgrp", None) + await _reply(update, response) + return ConversationHandler.END + + +@require_allowed +async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE): + context.user_data.pop("newgrp", None) + context.user_data.pop("qr_group", None) + await _reply(update, "Operation canceled.") + return ConversationHandler.END + + +@require_allowed +async def get_qr(update: Update, context: ContextTypes.DEFAULT_TYPE): + qr_group = context.args[0] if context.args else "default" + await _reply(update, await _api(context).get_qr(qr_group)) + + +@require_allowed +async def set_qr(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = list(context.args or []) + if not args: + context.user_data["qr_group"] = "default" + await _reply(update, "Forward the original .importbackup QR image for default.") + return QR_IMAGE + + if len(args) == 1: + qr_group = "default" + qr_data = args[0] + else: + qr_group = args[0] + qr_data = " ".join(args[1:]) + await _reply(update, await _api(context).set_qr(qr_data, qr_group=qr_group)) + return ConversationHandler.END + + +@require_allowed +async def set_qr_group(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/set_qr_group ") + if not args: + await _reply(update, "Usage: /set_qr_group ") + return ConversationHandler.END + context.user_data["qr_group"] = args[0] + await _reply(update, f"Forward the original .importbackup QR image for {args[0]}.") + return QR_IMAGE + + +def _is_importbackup_caption(update: Update) -> bool: + text = (update.effective_message.caption or update.effective_message.text or "").strip().lower() + return text in (".importbackup", "/importbackup") + + +def _is_forwarded(update: Update) -> bool: + message = update.effective_message + return bool( + getattr(message, "forward_origin", None) + or getattr(message, "forward_from", None) + or getattr(message, "forward_from_chat", None) + or getattr(message, "forward_sender_name", None) + ) + + +def _image_reference(update: Update): + message = update.effective_message + if message.photo: + return message.photo[-1].file_id, "grouphelp-importbackup.jpg", "image/jpeg" + document = message.document + if document and (document.mime_type or "").startswith("image/"): + return ( + document.file_id, + document.file_name or "grouphelp-importbackup.png", + document.mime_type, + ) + return None + + +@require_allowed +async def receive_qr_image(update: Update, context: ContextTypes.DEFAULT_TYPE): + qr_group = context.user_data.get("qr_group", "default") + if not _is_forwarded(update): + await _reply(update, "Please forward the original GroupHelp QR image message.") + return QR_IMAGE + if not _is_importbackup_caption(update): + await _reply(update, "Forwarded QR message must have .importbackup as its caption/body.") + return QR_IMAGE + + image_reference = _image_reference(update) + if not image_reference: + await _reply(update, "Forwarded .importbackup message must contain a QR image.") + return QR_IMAGE + + file_id, filename, content_type = image_reference + telegram_file = await context.bot.get_file(file_id) + image_bytes = bytes(await telegram_file.download_as_bytearray()) + response = await _api(context).set_qr_image(qr_group, image_bytes, filename, content_type) + if response.startswith("✅"): + context.user_data.pop("qr_group", None) + await _reply(update, response) + return ConversationHandler.END + + await _reply(update, response) + return QR_IMAGE + + +@require_allowed +async def qr_groups(update: Update, context: ContextTypes.DEFAULT_TYPE): + qr_group = context.args[0] if context.args else None + await _reply(update, await _api(context).qr_groups(qr_group)) + + +@require_allowed +async def qr_group_add(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/qr_group_add ...") + if not args or len(args) < 2: + await _reply(update, "Usage: /qr_group_add ...") + return + await _reply(update, await _api(context).assign_qr_group(args[0], args[1:])) + + +@require_allowed +async def qr_group_remove(update: Update, context: ContextTypes.DEFAULT_TYPE): + args = _require_args(context, "/qr_group_remove ...") + if not args: + await _reply(update, "Usage: /qr_group_remove ...") + return + await _reply(update, await _api(context).remove_qr_group_assignments(args)) + + +@require_allowed +async def sync_qr(update: Update, context: ContextTypes.DEFAULT_TYPE): + qr_group = context.args[0] if context.args else "default" + await _reply(update, await _api(context).sync_qr(qr_group)) + + +def register_handlers(application: Application, config: Config): + application.bot_data["config"] = config + application.bot_data["api"] = GroupFactoryApi( + config.api_base_url, + config.api_key, + timeout=config.request_timeout, + ) + + newgrp_conversation = ConversationHandler( + entry_points=[ + CommandHandler("newgrp", newgrp_start), + MessageHandler(filters.Regex(r"^!newgrp(?:\s+.*)?$"), newgrp_start), + ], + states={ + NEWGRP_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, newgrp_name)], + NEWGRP_DESCRIPTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, newgrp_description)], + NEWGRP_CONFIRM: [ + CommandHandler("confirm", newgrp_confirm), + MessageHandler(filters.Regex(r"^!confirm$"), newgrp_confirm), + MessageHandler(filters.TEXT & ~filters.COMMAND, newgrp_confirm), + ], + }, + fallbacks=[ + CommandHandler("cancel", cancel), + MessageHandler(filters.Regex(r"^!cancel$"), cancel), + ], + ) + + qr_conversation = ConversationHandler( + entry_points=[ + CommandHandler("set_qr", set_qr), + CommandHandler("set_qr_group", set_qr_group), + ], + states={ + QR_IMAGE: [ + MessageHandler((filters.PHOTO | filters.Document.IMAGE) & ~filters.COMMAND, receive_qr_image), + ], + }, + fallbacks=[ + CommandHandler("cancel", cancel), + MessageHandler(filters.Regex(r"^!cancel$"), cancel), + ], + ) + + application.add_handler(newgrp_conversation) + application.add_handler(qr_conversation) + application.add_handler(CommandHandler("start", start)) + application.add_handler(CommandHandler("help", help_command)) + application.add_handler(CommandHandler("ping", ping)) + application.add_handler(CommandHandler("get_users", get_default_users)) + application.add_handler(CommandHandler("set_users", set_default_users)) + application.add_handler(CommandHandler("add_users", add_default_users)) + application.add_handler(CommandHandler("remove_users", remove_default_users)) + application.add_handler(CommandHandler("add_user", add_user)) + application.add_handler(CommandHandler("users", list_users)) + application.add_handler(CommandHandler("user", get_user)) + application.add_handler(CommandHandler("delete_user", delete_user)) + application.add_handler(CommandHandler("get_group", get_group)) + application.add_handler(CommandHandler("group_add_users", group_add_users)) + application.add_handler(CommandHandler("get_qr", get_qr)) + application.add_handler(CommandHandler("qr_groups", qr_groups)) + application.add_handler(CommandHandler("qr_group_add", qr_group_add)) + application.add_handler(CommandHandler("qr_group_remove", qr_group_remove)) + application.add_handler(CommandHandler("sync_qr", sync_qr)) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..43cf4b6 --- /dev/null +++ b/src/config.py @@ -0,0 +1,36 @@ +import os +from dataclasses import dataclass +from typing import Set + + +def _parse_chat_ids(value: str) -> Set[int]: + chat_ids = set() + for item in (value or "").replace(";", ",").split(","): + item = item.strip() + if not item: + continue + chat_ids.add(int(item)) + return chat_ids + + +@dataclass(frozen=True) +class Config: + bot_token: str + api_base_url: str + api_key: str + allowed_chat_ids: Set[int] + request_timeout: float + + +def load_config() -> Config: + allowed_chat_ids = _parse_chat_ids( + os.environ.get("BOT_ALLOWED_CHAT_IDS") or os.environ.get("STAFF_CHAT_ID", "") + ) + + return Config( + bot_token=os.environ["TELEGRAM_BOT_TOKEN"], + api_base_url=os.environ.get("GROUPFACTORY_API_BASE_URL", "http://groupfactory:8000").rstrip("/"), + api_key=os.environ["GROUPFACTORY_API_KEY"], + allowed_chat_ids=allowed_chat_ids, + request_timeout=float(os.environ.get("GROUPFACTORY_API_TIMEOUT", "300")), + ) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..c123bae --- /dev/null +++ b/src/main.py @@ -0,0 +1,29 @@ +import logging + +from telegram.ext import Application + +from src.bot import register_handlers +from src.config import load_config + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def main(): + config = load_config() + application = Application.builder().token(config.bot_token).build() + register_handlers(application, config) + + logger.info("Starting GroupFactory client bot") + application.run_polling( + allowed_updates=["message"], + drop_pending_updates=True, + ) + + +if __name__ == "__main__": + main()