first commit
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal 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
12
.gitignore
vendored
Normal 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
17
Dockerfile
Normal 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
78
README.md
Normal 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
18
argocd/application.yaml
Normal 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
4
entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
exec python -m src.main
|
||||||
11
examples/configmap.yaml
Normal file
11
examples/configmap.yaml
Normal 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
11
examples/secret.yaml
Normal 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
56
k8s/deployment.yaml
Normal 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
4
k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: groupfactory
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal 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
200
scripts/mirror_repo.sh
Executable 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
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
129
src/api.py
Normal file
129
src/api.py
Normal 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
496
src/bot.py
Normal 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
36
src/config.py
Normal 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
29
src/main.py
Normal 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()
|
||||||
Reference in New Issue
Block a user