first commit

This commit is contained in:
Corrado Mulas
2026-06-19 18:02:07 +02:00
commit bcc4bc68bd
17 changed files with 1110 additions and 0 deletions

5
.env.example Normal file
View File

@@ -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

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
.env
.env.*
!.env.example
*.log
.DS_Store

17
Dockerfile Normal file
View File

@@ -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"]

78
README.md Normal file
View File

@@ -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 <group_id> - Read group info
/group_add_users <group_id> <user_id> ...
/get_users - Show default group users
/set_users <id_or_username_or_id:username> ...
/add_users <id_or_username_or_id:username> ...
/remove_users <id_or_username_or_id:username> ...
/add_user <username_or_id:username>
/get_qr [qr_group]
/set_qr <payload>
/set_qr <qr_group> <payload>
/set_qr_group <qr_group> - Forward a .importbackup QR image
/qr_groups [qr_group]
/qr_group_add <qr_group> <telegram_group_id> ...
/qr_group_remove <telegram_group_id> ...
/sync_qr [qr_group|all]
/users
/user <user_id>
/delete_user <user_id>
/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.

18
argocd/application.yaml Normal file
View File

@@ -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

4
entrypoint.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
set -eu
exec python -m src.main

11
examples/configmap.yaml Normal file
View File

@@ -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"

11
examples/secret.yaml Normal file
View File

@@ -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"

56
k8s/deployment.yaml Normal file
View File

@@ -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

4
k8s/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: groupfactory

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
python-telegram-bot>=21.0,<22.0
httpx>=0.27.0
python-dotenv>=1.0.0

200
scripts/mirror_repo.sh Executable file
View File

@@ -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."

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@

129
src/api.py Normal file
View File

@@ -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})

496
src/bot.py Normal file
View File

@@ -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_id>
/group_add_users <group_id> <user_id> ...
Default users:
/get_users
/set_users <id_or_username_or_id:username> ...
/add_users <id_or_username_or_id:username> ...
/remove_users <id_or_username_or_id:username> ...
/add_user <username_or_id:username>
QR configs:
/get_qr [qr_group]
/set_qr <payload>
/set_qr <qr_group> <payload>
/set_qr_group <qr_group> - forward a .importbackup QR image
/qr_groups [qr_group]
/qr_group_add <qr_group> <telegram_group_id> ...
/qr_group_remove <telegram_group_id> ...
/sync_qr [qr_group|all]
Other:
/users - list database users
/user <user_id>
/delete_user <user_id>
/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 <id_or_username_or_id:username> ...")
if not args:
await _reply(update, "Usage: /set_users <id_or_username_or_id:username> ...")
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 <id_or_username_or_id:username> ...")
if not args:
await _reply(update, "Usage: /add_users <id_or_username_or_id:username> ...")
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 <id_or_username_or_id:username> ...")
if not args:
await _reply(update, "Usage: /remove_users <id_or_username_or_id:username> ...")
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 <username_or_id:username>")
if not args:
await _reply(update, "Usage: /add_user <username_or_id:username>")
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 <user_id>")
if not args:
await _reply(update, "Usage: /user <user_id>")
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 <user_id>")
if not args:
await _reply(update, "Usage: /delete_user <user_id>")
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 <group_id>")
if not args:
await _reply(update, "Usage: /get_group <group_id>")
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 <group_id> <user_id> ...")
if not args or len(args) < 2:
await _reply(update, "Usage: /group_add_users <group_id> <user_id> ...")
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 <qr_group>")
if not args:
await _reply(update, "Usage: /set_qr_group <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 <qr_group> <telegram_group_id> ...")
if not args or len(args) < 2:
await _reply(update, "Usage: /qr_group_add <qr_group> <telegram_group_id> ...")
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 <telegram_group_id> ...")
if not args:
await _reply(update, "Usage: /qr_group_remove <telegram_group_id> ...")
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))

36
src/config.py Normal file
View File

@@ -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")),
)

29
src/main.py Normal file
View File

@@ -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()