Phase 1 MVP — echo bot factory
All checks were successful
Docker Build / build-and-push-image (push) Successful in 1m8s
All checks were successful
Docker Build / build-and-push-image (push) Successful in 1m8s
This commit is contained in:
41
.gitea/workflows/dockerimage.yaml
Normal file
41
.gitea/workflows/dockerimage.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'chart/**'
|
||||
- '.gitignore'
|
||||
- 'Makefile'
|
||||
- 'bots.example.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.arcodange.lab
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
- name: git checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push image to Gitea Container Registry
|
||||
run: |-
|
||||
TAGS="latest ${{ github.ref_name }}"
|
||||
docker build -t app .
|
||||
for TAG in $TAGS; do
|
||||
docker tag app gitea.arcodange.lab/${{ github.repository }}:$TAG
|
||||
docker push gitea.arcodange.lab/${{ github.repository }}:$TAG
|
||||
done
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
*.test
|
||||
*.out
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
bots.yaml
|
||||
117
DEPLOY.md
Normal file
117
DEPLOY.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Deploy `homelab-gateway` — Phase 1 (echo bot)
|
||||
|
||||
Procédure end-to-end pour mettre le gateway en ligne avec un bot
|
||||
`arcodange_factory_bot` (slug interne `factory`).
|
||||
|
||||
> Phase 1 simplifiée : **pas de Vault**. Le `Secret` k8s
|
||||
> `homelab-gateway-bots` est créé manuellement avec `kubectl create secret`.
|
||||
> La migration vers Vault Secrets Operator se fait plus tard (Phase 2+) via
|
||||
> `vault.enabled: true` dans `chart/values.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pré-requis
|
||||
|
||||
- Repo Gitea déjà créé : `arcodange/homelab-gateway`
|
||||
- Bot Telegram déjà créé via @BotFather : `@arcodange_factory_bot`
|
||||
- Token : `8737289837:…` (en variable d'env, jamais committé)
|
||||
- chat_id : récupéré via [@userinfobot](https://t.me/userinfobot)
|
||||
- DNS : Cloudflare route déjà `*.arcodange.fr` vers le home lab → rien
|
||||
à faire côté DNS, le sous-domaine `tg.arcodange.fr` arrive au cluster
|
||||
dès qu'on déclare l'Ingress Traefik.
|
||||
|
||||
## 2. Push du repo (déclenche le build Docker)
|
||||
|
||||
```bash
|
||||
cd /Users/gabrielradureau/Work/Vibe/homelab_gateway
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Phase 1 MVP — echo bot factory"
|
||||
git branch -M main
|
||||
git remote add origin ssh://git@192.168.1.202:2222/arcodange/homelab-gateway.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
Gitea Actions build l'image et la pousse :
|
||||
`gitea.arcodange.lab/arcodange/homelab-gateway:latest`.
|
||||
|
||||
## 3. Créer le `Secret` k8s avec le token + secret_token
|
||||
|
||||
```bash
|
||||
# Génère un secret_token frais (256 bits hex)
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
|
||||
# Le namespace est créé par ArgoCD si absent — on le crée explicitement avant
|
||||
# pour pouvoir y poser le Secret tout de suite.
|
||||
kubectl create namespace homelab-gateway --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl -n homelab-gateway create secret generic homelab-gateway-bots \
|
||||
--from-literal=BOT_FACTORY_TOKEN='8737289837:AAEVIygazfxgqJTxaxOh3X-mEoKaV7Rw1Gw' \
|
||||
--from-literal=BOT_FACTORY_SECRET="$SECRET"
|
||||
|
||||
# Garde $SECRET sous le coude pour l'étape 5 (setWebhook).
|
||||
echo "secret_token = $SECRET"
|
||||
```
|
||||
|
||||
> Le bot est mappé sur le slug `factory` dans `chart/values.yaml` :
|
||||
> `BOT_FACTORY_TOKEN` / `BOT_FACTORY_SECRET` correspondent.
|
||||
> Pour ajouter d'autres bots ultérieurement, ajouter `BOT_<UPPER_SLUG>_TOKEN/SECRET`
|
||||
> au même Secret + une clé sous `bots:` dans `chart/values.yaml`.
|
||||
|
||||
## 4. Activer l'Application ArgoCD
|
||||
|
||||
L'entrée `homelab-gateway` est ajoutée dans
|
||||
`/Users/gabrielradureau/Work/Arcodange/factory/argocd/values.yaml` (PR
|
||||
ouverte). Une fois la PR mergée :
|
||||
|
||||
```bash
|
||||
kubectl -n argocd get app homelab-gateway -w
|
||||
# attends Healthy + Synced
|
||||
kubectl -n homelab-gateway logs deploy/homelab-gateway -f
|
||||
# attends "homelab-gateway listening on :8080 (1 bot(s) loaded)"
|
||||
|
||||
# Smoke
|
||||
curl -I https://tg.arcodange.fr/healthz # → 200
|
||||
```
|
||||
|
||||
## 5. Enregistrer le webhook côté Telegram
|
||||
|
||||
```bash
|
||||
export BOT_FACTORY_TOKEN='8737289837:AAEVIygazfxgqJTxaxOh3X-mEoKaV7Rw1Gw'
|
||||
export BOT_FACTORY_SECRET="$SECRET" # même valeur qu'à l'étape 3
|
||||
cd /Users/gabrielradureau/Work/Vibe/homelab_gateway
|
||||
make setwebhook SLUG=factory BASE_URL=https://tg.arcodange.fr
|
||||
# → "webhook set: url=https://tg.arcodange.fr/bot/factory pending=0 last_err=\"\""
|
||||
```
|
||||
|
||||
Vérification côté Telegram :
|
||||
|
||||
```bash
|
||||
curl -s "https://api.telegram.org/bot$BOT_FACTORY_TOKEN/getWebhookInfo" | jq
|
||||
```
|
||||
|
||||
## 6. Test réel
|
||||
|
||||
Envoyer n'importe quel message à `@arcodange_factory_bot` dans
|
||||
l'app Telegram → réponse identique en < 2 s.
|
||||
Pour le test `/echo coucou` répond `coucou`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptôme | Action |
|
||||
|---|---|
|
||||
| Pod `CreateContainerConfigError` | Le Secret `homelab-gateway-bots` manque. Le créer (étape 3). |
|
||||
| Pod `CrashLoopBackOff` "no bots in /etc/…/bots.yaml" | ConfigMap pas généré ou mal monté. `kubectl get cm -n homelab-gateway -o yaml`. |
|
||||
| `curl https://tg.arcodange.fr/healthz` → 502/504 | Ingress pas encore propagé OU le pod n'est pas Ready. `kubectl describe ingress` + `kubectl describe pod`. |
|
||||
| `setWebhook` → `Wrong response from the webhook: 401` | `BOT_FACTORY_SECRET` côté Secret ≠ celui passé à setWebhook. Régénérer + recréer le Secret avec `kubectl delete && create`. |
|
||||
| Webhook accepté mais pas de réponse Telegram | `kubectl logs` côté gateway → erreur sendMessage. Token bot invalide (révoqué via @BotFather ?) ou rate-limit Telegram. |
|
||||
|
||||
## Pour aller plus loin
|
||||
|
||||
- Phase 2 : handler `http` (forward vers svc interne) + queue Postgres durable.
|
||||
- Phase 3 : handlers `shell` / `script` / `ollama` async, retry quand le
|
||||
Macbook Ollama est endormi.
|
||||
- Phase 4 : passage à Vault (toggle `vault.enabled: true` + provisionner
|
||||
`kvv2/homelab-gateway/config`), Wake-on-LAN, multi-provider.
|
||||
|
||||
Plan complet : `~/.claude/plans/pour-les-notifications-on-inherited-seal.md`.
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o gateway .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates && \
|
||||
addgroup -g 65532 -S app && \
|
||||
adduser -u 65532 -S app -G app
|
||||
|
||||
WORKDIR /home/app
|
||||
COPY --from=builder /app/gateway /usr/local/bin/gateway
|
||||
|
||||
USER 65532:65532
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/gateway"]
|
||||
CMD ["serve"]
|
||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
SHELL := /bin/bash
|
||||
APP := homelab-gateway
|
||||
IMAGE := gitea.arcodange.lab/arcodange-org/$(APP)
|
||||
TAG ?= dev
|
||||
|
||||
.PHONY: build test vet tidy run docker push setwebhook deletewebhook
|
||||
|
||||
build:
|
||||
go build -o bin/gateway .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
run: build
|
||||
CONFIG_PATH=./bots.example.yaml ./bin/gateway serve
|
||||
|
||||
docker:
|
||||
docker build -t $(IMAGE):$(TAG) .
|
||||
|
||||
push: docker
|
||||
docker push $(IMAGE):$(TAG)
|
||||
|
||||
# Usage: make setwebhook SLUG=echo BASE_URL=https://tg.arcodange.fr
|
||||
# BOT_<SLUG>_TOKEN and BOT_<SLUG>_SECRET must be exported in your shell.
|
||||
setwebhook:
|
||||
@test -n "$(SLUG)" || (echo "SLUG=<bot-slug> required" && exit 1)
|
||||
@test -n "$(BASE_URL)" || (echo "BASE_URL=https://… required" && exit 1)
|
||||
go run . setwebhook --slug $(SLUG) --base-url $(BASE_URL)
|
||||
|
||||
deletewebhook:
|
||||
@test -n "$(SLUG)" || (echo "SLUG=<bot-slug> required" && exit 1)
|
||||
go run . deletewebhook --slug $(SLUG)
|
||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# homelab-gateway
|
||||
|
||||
Telegram **webhook gateway** for the Arcodange home lab. Replaces polling-based
|
||||
bots (e.g. those scheduled in Cowork) with direct webhook delivery from
|
||||
Telegram, routed to per-bot handlers running on the k3s cluster.
|
||||
|
||||
> Phase 1 (MVP): single sync `echo` handler, end-to-end flow validated.
|
||||
> Phase 2 (planned): `http` forward handler + Postgres-backed durable queue.
|
||||
> Phase 3 (planned): async `shell` / `script` / `ollama` handlers.
|
||||
|
||||
See the design doc at `~/.claude/plans/pour-les-notifications-on-inherited-seal.md`.
|
||||
|
||||
## Architecture (current)
|
||||
|
||||
```
|
||||
Telegram → Cloudflare Tunnel (tg.arcodange.fr) → Service homelab-gateway:8080
|
||||
→ /bot/<slug> → secret_token check → handler dispatch → Bot API sendMessage
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------------------|--------------------------------------------|
|
||||
| GET | `/healthz` | Liveness probe |
|
||||
| GET | `/readyz` | Readiness probe |
|
||||
| POST | `/bot/{slug}` | Telegram webhook entry (validates secret) |
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
# 1. Provide a config + env
|
||||
export BOT_FACTORY_TOKEN='8737289837:…' # from @BotFather
|
||||
export BOT_FACTORY_SECRET=$(openssl rand -hex 32)
|
||||
|
||||
# 2. Run
|
||||
make run # uses bots.example.yaml
|
||||
|
||||
# 3. Smoke a webhook
|
||||
curl -X POST -H "X-Telegram-Bot-Api-Secret-Token: $BOT_FACTORY_SECRET" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"update_id":1,"message":{"chat":{"id":<your-chat-id>},"text":"hi"}}' \
|
||||
http://localhost:8080/bot/factory
|
||||
```
|
||||
|
||||
## Set / delete webhook
|
||||
|
||||
```bash
|
||||
# Once the gateway is reachable at https://tg.arcodange.fr:
|
||||
export BOT_FACTORY_TOKEN=…
|
||||
export BOT_FACTORY_SECRET=…
|
||||
make setwebhook SLUG=factory BASE_URL=https://tg.arcodange.fr
|
||||
make deletewebhook SLUG=factory
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Routing** (non-secret): YAML at `$CONFIG_PATH` (default
|
||||
`/etc/homelab-gateway/bots.yaml`, mounted from a ConfigMap in cluster).
|
||||
- **Secrets**: per-bot env vars `BOT_<UPPER_SLUG>_TOKEN`,
|
||||
`BOT_<UPPER_SLUG>_SECRET`. Sourced from Vault path
|
||||
`kvv2/homelab-gateway/config` via Vault Secrets Operator.
|
||||
|
||||
## Cluster deploy
|
||||
|
||||
- Image: `gitea.arcodange.lab/arcodange/homelab-gateway:<tag>`
|
||||
- Helm chart: `chart/`
|
||||
- ArgoCD app: `homelab-gateway` (in `factory/argocd/values.yaml`)
|
||||
- Public URL: `https://tg.arcodange.fr` (Cloudflare déjà configuré pour
|
||||
router `*.arcodange.fr` vers le home lab → Traefik route par Host)
|
||||
- Secrets Phase 1 : `kubectl create secret generic homelab-gateway-bots …`
|
||||
(sans Vault). Migration vers Vault Secrets Operator en Phase 2+ via
|
||||
`vault.enabled: true` dans `chart/values.yaml`.
|
||||
|
||||
Voir `DEPLOY.md` pour la procédure end-to-end.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── main.go # bootstrap, subcommand dispatch
|
||||
├── server.go # HTTP routes
|
||||
├── middleware.go # secret validation, recover, access log
|
||||
├── handlers.go # Handler interface + Registry
|
||||
├── handler_echo.go # echo handler
|
||||
├── telegram.go # Telegram Bot API client
|
||||
├── telegram_types.go # Update / Message structs
|
||||
├── config.go # YAML routing config + per-bot env merge
|
||||
├── setwebhook.go # CLI subcommands (setwebhook / deletewebhook)
|
||||
├── chart/ # Helm chart
|
||||
└── .gitea/workflows/ # CI: docker build → gitea registry
|
||||
```
|
||||
15
bots.example.yaml
Normal file
15
bots.example.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Bot routing config (non-secret). One entry per bot.
|
||||
# Tokens / secret_token live in Vault and are injected as env vars
|
||||
# BOT_<UPPER_SLUG>_TOKEN, BOT_<UPPER_SLUG>_SECRET
|
||||
#
|
||||
# `<UPPER_SLUG>` = uppercase(slug) with `-` replaced by `_`.
|
||||
|
||||
bots:
|
||||
factory:
|
||||
handler: echo
|
||||
|
||||
# Phase 2 example (not implemented yet)
|
||||
# webappbot:
|
||||
# handler: http
|
||||
# url: http://webapp.webapp.svc.cluster.local:8080/telegram/update
|
||||
# timeout: 5s
|
||||
6
chart/Chart.yaml
Normal file
6
chart/Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: homelab-gateway
|
||||
description: Telegram webhook gateway for the Arcodange home lab
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.1.0"
|
||||
60
chart/templates/_helpers.tpl
Normal file
60
chart/templates/_helpers.tpl
Normal file
@@ -0,0 +1,60 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "homelab-gateway.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "homelab-gateway.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Chart name + version label value.
|
||||
*/}}
|
||||
{{- define "homelab-gateway.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels.
|
||||
*/}}
|
||||
{{- define "homelab-gateway.labels" -}}
|
||||
helm.sh/chart: {{ include "homelab-gateway.chart" . }}
|
||||
{{ include "homelab-gateway.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels.
|
||||
*/}}
|
||||
{{- define "homelab-gateway.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "homelab-gateway.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Service account name.
|
||||
*/}}
|
||||
{{- define "homelab-gateway.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "homelab-gateway.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
14
chart/templates/configmap.yaml
Normal file
14
chart/templates/configmap.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "homelab-gateway.fullname" . }}-bots
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 4 }}
|
||||
data:
|
||||
bots.yaml: |
|
||||
bots:
|
||||
{{- range $slug, $cfg := .Values.bots }}
|
||||
{{ $slug }}:
|
||||
{{ toYaml $cfg | indent 8 }}
|
||||
{{- end }}
|
||||
84
chart/templates/deployment.yaml
Normal file
84
chart/templates/deployment.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "homelab-gateway.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 4 }}
|
||||
spec:
|
||||
revisionHistoryLimit: 3
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "homelab-gateway.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/bots-config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "homelab-gateway.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args: ["serve", "--config", "/etc/homelab-gateway/bots.yaml", "--addr", ":{{ .Values.service.port }}"]
|
||||
env:
|
||||
- name: LISTEN_ADDR
|
||||
value: ":{{ .Values.service.port }}"
|
||||
- name: CONFIG_PATH
|
||||
value: /etc/homelab-gateway/bots.yaml
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ .Values.secret.name }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: bots-config
|
||||
mountPath: /etc/homelab-gateway
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: bots-config
|
||||
configMap:
|
||||
name: {{ include "homelab-gateway.fullname" . }}-bots
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
36
chart/templates/ingress.yaml
Normal file
36
chart/templates/ingress.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "homelab-gateway.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "homelab-gateway.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
16
chart/templates/service.yaml
Normal file
16
chart/templates/service.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "homelab-gateway.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "homelab-gateway.selectorLabels" . | nindent 4 }}
|
||||
14
chart/templates/serviceaccount.yaml
Normal file
14
chart/templates/serviceaccount.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "homelab-gateway.serviceAccountName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
17
chart/templates/vaultauth.yaml
Normal file
17
chart/templates/vaultauth.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
{{- if .Values.vault.enabled -}}
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultAuth
|
||||
metadata:
|
||||
name: auth
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 4 }}
|
||||
spec:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
kubernetes:
|
||||
role: {{ .Values.vault.role }}
|
||||
serviceAccount: {{ include "homelab-gateway.serviceAccountName" . }}
|
||||
audiences:
|
||||
- vault
|
||||
{{- end }}
|
||||
18
chart/templates/vaultsecret.yaml
Normal file
18
chart/templates/vaultsecret.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
{{- if .Values.vault.enabled -}}
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: bots-secrets
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "homelab-gateway.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: {{ .Values.vault.mount }}
|
||||
path: {{ .Values.vault.path }}
|
||||
destination:
|
||||
name: {{ .Values.secret.name }}
|
||||
create: true
|
||||
refreshAfter: {{ .Values.vault.refreshAfter }}
|
||||
vaultAuthRef: auth
|
||||
{{- end }}
|
||||
106
chart/values.yaml
Normal file
106
chart/values.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: gitea.arcodange.lab/arcodange/homelab-gateway
|
||||
pullPolicy: Always
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
automount: true
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
fsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
|
||||
# Public exposure via Traefik. Cloudflare routes *.arcodange.fr to the home lab
|
||||
# already, so we just declare the hostname here. CF terminates TLS, Traefik
|
||||
# receives plain HTTP on entrypoint `web`.
|
||||
ingress:
|
||||
enabled: true
|
||||
className: ""
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||
traefik.ingress.kubernetes.io/router.middlewares: kube-system-crowdsec@kubernetescrd
|
||||
hosts:
|
||||
- host: tg.arcodange.fr
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: http
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
targetCPUUtilizationPercentage: 80
|
||||
|
||||
# Bot routing config — non-secret, becomes the bots.yaml ConfigMap entry.
|
||||
# Tokens & secret_token values live in a k8s Secret named `secret.name`.
|
||||
# In Phase 1 the Secret is created out-of-band (kubectl create secret); in a
|
||||
# later phase Vault will produce it via VSO (toggle `vault.enabled`).
|
||||
bots:
|
||||
factory:
|
||||
handler: echo
|
||||
|
||||
# k8s Secret consumed by `envFrom`. Phase 1: create it manually with kubectl.
|
||||
# kubectl -n homelab-gateway create secret generic homelab-gateway-bots \
|
||||
# --from-literal=BOT_FACTORY_TOKEN=… --from-literal=BOT_FACTORY_SECRET=…
|
||||
secret:
|
||||
name: homelab-gateway-bots
|
||||
|
||||
# Vault Secrets Operator integration (Phase 2+). When enabled, VSO writes the
|
||||
# secret named `secret.name` automatically from `kvv2/homelab-gateway/config`.
|
||||
vault:
|
||||
enabled: false
|
||||
role: homelab-gateway
|
||||
mount: kvv2
|
||||
path: homelab-gateway/config
|
||||
refreshAfter: 30s
|
||||
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: pi1
|
||||
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
43
config.go
Normal file
43
config.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Bots map[string]BotConfig `yaml:"bots"`
|
||||
}
|
||||
|
||||
type BotConfig struct {
|
||||
Handler string `yaml:"handler"`
|
||||
Token string `yaml:"-"`
|
||||
Secret string `yaml:"-"`
|
||||
}
|
||||
|
||||
// LoadConfig reads the YAML routing config and merges per-bot secrets pulled
|
||||
// from the process environment. Per-bot env keys are derived from the bot
|
||||
// slug uppercased: BOT_<UPPER_SLUG>_TOKEN, BOT_<UPPER_SLUG>_SECRET.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse yaml: %w", err)
|
||||
}
|
||||
if len(cfg.Bots) == 0 {
|
||||
return nil, fmt.Errorf("no bots in %s", path)
|
||||
}
|
||||
for slug, b := range cfg.Bots {
|
||||
envSlug := strings.ToUpper(strings.ReplaceAll(slug, "-", "_"))
|
||||
b.Token = os.Getenv("BOT_" + envSlug + "_TOKEN")
|
||||
b.Secret = os.Getenv("BOT_" + envSlug + "_SECRET")
|
||||
cfg.Bots[slug] = b
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module github.com/arcodange-org/homelab-gateway
|
||||
|
||||
go 1.23
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
33
handler_echo.go
Normal file
33
handler_echo.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EchoHandler struct {
|
||||
tg *TelegramClient
|
||||
}
|
||||
|
||||
func (e *EchoHandler) Handle(ctx context.Context, update Update, bot Bot) error {
|
||||
chatID, ok := update.ChatID()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(update.Text())
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
reply := text
|
||||
if strings.HasPrefix(text, "/echo") {
|
||||
reply = strings.TrimSpace(strings.TrimPrefix(text, "/echo"))
|
||||
if reply == "" {
|
||||
reply = "echo bot online — send me anything"
|
||||
}
|
||||
}
|
||||
if err := e.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: reply}); err != nil {
|
||||
return fmt.Errorf("sendMessage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
75
handlers.go
Normal file
75
handlers.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
Handle(ctx context.Context, update Update, bot Bot) error
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
Slug string
|
||||
Token string
|
||||
Secret string
|
||||
Handler Handler
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
bots map[string]Bot
|
||||
}
|
||||
|
||||
func NewRegistry(cfg *Config) (*Registry, error) {
|
||||
if len(cfg.Bots) == 0 {
|
||||
return nil, fmt.Errorf("no bots configured")
|
||||
}
|
||||
|
||||
tg := NewTelegramClient()
|
||||
bots := make(map[string]Bot, len(cfg.Bots))
|
||||
|
||||
for slug, b := range cfg.Bots {
|
||||
token := b.Token
|
||||
secret := b.Secret
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("bot %s: token missing", slug)
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("bot %s: secret missing", slug)
|
||||
}
|
||||
|
||||
var h Handler
|
||||
switch b.Handler {
|
||||
case "echo":
|
||||
h = &EchoHandler{tg: tg}
|
||||
case "":
|
||||
return nil, fmt.Errorf("bot %s: handler missing", slug)
|
||||
default:
|
||||
return nil, fmt.Errorf("bot %s: unknown handler %q", slug, b.Handler)
|
||||
}
|
||||
|
||||
bots[slug] = Bot{
|
||||
Slug: slug,
|
||||
Token: token,
|
||||
Secret: secret,
|
||||
Handler: h,
|
||||
}
|
||||
}
|
||||
|
||||
return &Registry{bots: bots}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Get(slug string) (Bot, bool) {
|
||||
b, ok := r.bots[slug]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Count() int { return len(r.bots) }
|
||||
|
||||
func (r *Registry) Slugs() []string {
|
||||
out := make([]string, 0, len(r.bots))
|
||||
for s := range r.bots {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
86
main.go
Normal file
86
main.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultListenAddr = ":8080"
|
||||
const defaultConfigPath = "/etc/homelab-gateway/bots.yaml"
|
||||
|
||||
func main() {
|
||||
subcmd := ""
|
||||
if len(os.Args) > 1 && os.Args[1] != "" && os.Args[1][0] != '-' {
|
||||
subcmd = os.Args[1]
|
||||
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
||||
}
|
||||
|
||||
switch subcmd {
|
||||
case "setwebhook":
|
||||
runSetWebhook()
|
||||
case "deletewebhook":
|
||||
runDeleteWebhook()
|
||||
case "", "serve":
|
||||
runServer()
|
||||
default:
|
||||
log.Fatalf("unknown subcommand: %s (expected: serve | setwebhook | deletewebhook)", subcmd)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer() {
|
||||
addr := flag.String("addr", envOr("LISTEN_ADDR", defaultListenAddr), "listen address")
|
||||
configPath := flag.String("config", envOr("CONFIG_PATH", defaultConfigPath), "bot routing config (YAML)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
registry, err := NewRegistry(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("build registry: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: NewServer(registry).Routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Printf("homelab-gateway listening on %s (%d bot(s) loaded)", *addr, registry.Count())
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Print("shutdown signal received, draining...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("graceful shutdown error: %v", err)
|
||||
}
|
||||
log.Print("bye")
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
54
middleware.go
Normal file
54
middleware.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(mws) - 1; i >= 0; i-- {
|
||||
h = mws[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func recoverMW(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (sr *statusRecorder) WriteHeader(code int) {
|
||||
sr.status = code
|
||||
sr.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func accessLogMW(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sr, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sr.status, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
func verifyTelegramSecret(provided, expected string) bool {
|
||||
if expected == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
||||
}
|
||||
75
server.go
Normal file
75
server.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
registry *Registry
|
||||
}
|
||||
|
||||
func NewServer(r *Registry) *Server {
|
||||
return &Server{registry: r}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.health)
|
||||
mux.HandleFunc("/readyz", s.ready)
|
||||
mux.HandleFunc("/bot/", s.botWebhook)
|
||||
return chain(mux, recoverMW, accessLogMW)
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprint(w, "OK")
|
||||
}
|
||||
|
||||
func (s *Server) ready(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprint(w, "OK")
|
||||
}
|
||||
|
||||
func (s *Server) botWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/bot/")
|
||||
slug = strings.Trim(slug, "/")
|
||||
if slug == "" || strings.Contains(slug, "/") {
|
||||
http.Error(w, "bot slug missing or malformed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
bot, ok := s.registry.Get(slug)
|
||||
if !ok {
|
||||
http.Error(w, "unknown bot", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !verifyTelegramSecret(r.Header.Get("X-Telegram-Bot-Api-Secret-Token"), bot.Secret) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var update Update
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&update); err != nil {
|
||||
http.Error(w, "bad update payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bot.Handler.Handle(r.Context(), update, bot); err != nil {
|
||||
log.Printf("bot=%s update=%d handler error: %v", slug, update.UpdateID, err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprint(w, "{}")
|
||||
}
|
||||
74
setwebhook.go
Normal file
74
setwebhook.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runSetWebhook() {
|
||||
slug := flag.String("slug", "", "bot slug (must match a key in bots.yaml)")
|
||||
baseURL := flag.String("base-url", envOr("BASE_URL", ""), "public base URL of the gateway, e.g. https://tg.arcodange.fr")
|
||||
dropPending := flag.Bool("drop-pending", true, "drop pending updates")
|
||||
flag.Parse()
|
||||
|
||||
if *slug == "" {
|
||||
log.Fatal("--slug required")
|
||||
}
|
||||
if *baseURL == "" {
|
||||
log.Fatal("--base-url required (or set BASE_URL)")
|
||||
}
|
||||
|
||||
envSlug := strings.ToUpper(strings.ReplaceAll(*slug, "-", "_"))
|
||||
token := os.Getenv("BOT_" + envSlug + "_TOKEN")
|
||||
secret := os.Getenv("BOT_" + envSlug + "_SECRET")
|
||||
if token == "" || secret == "" {
|
||||
log.Fatalf("BOT_%s_TOKEN and BOT_%s_SECRET must be set in env", envSlug, envSlug)
|
||||
}
|
||||
|
||||
hookURL := strings.TrimSuffix(*baseURL, "/") + "/bot/" + *slug
|
||||
tg := NewTelegramClient()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := tg.SetWebhook(ctx, token, SetWebhookParams{
|
||||
URL: hookURL,
|
||||
SecretToken: secret,
|
||||
DropPendingUpdates: *dropPending,
|
||||
AllowedUpdates: []string{"message", "edited_message", "callback_query"},
|
||||
MaxConnections: 20,
|
||||
}); err != nil {
|
||||
log.Fatalf("setWebhook: %v", err)
|
||||
}
|
||||
|
||||
info, err := tg.GetWebhookInfo(ctx, token)
|
||||
if err != nil {
|
||||
log.Fatalf("getWebhookInfo: %v", err)
|
||||
}
|
||||
fmt.Printf("webhook set: url=%s pending=%d last_err=%q\n", info.URL, info.PendingUpdateCount, info.LastErrorMessage)
|
||||
}
|
||||
|
||||
func runDeleteWebhook() {
|
||||
slug := flag.String("slug", "", "bot slug")
|
||||
flag.Parse()
|
||||
if *slug == "" {
|
||||
log.Fatal("--slug required")
|
||||
}
|
||||
envSlug := strings.ToUpper(strings.ReplaceAll(*slug, "-", "_"))
|
||||
token := os.Getenv("BOT_" + envSlug + "_TOKEN")
|
||||
if token == "" {
|
||||
log.Fatalf("BOT_%s_TOKEN must be set in env", envSlug)
|
||||
}
|
||||
tg := NewTelegramClient()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := tg.DeleteWebhook(ctx, token); err != nil {
|
||||
log.Fatalf("deleteWebhook: %v", err)
|
||||
}
|
||||
fmt.Println("webhook deleted")
|
||||
}
|
||||
166
telegram.go
Normal file
166
telegram.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const telegramAPIBase = "https://api.telegram.org"
|
||||
|
||||
type TelegramClient struct {
|
||||
httpClient *http.Client
|
||||
apiBase string
|
||||
}
|
||||
|
||||
func NewTelegramClient() *TelegramClient {
|
||||
return &TelegramClient{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
apiBase: telegramAPIBase,
|
||||
}
|
||||
}
|
||||
|
||||
type SendMessageParams struct {
|
||||
ChatID int64 `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
func (c *TelegramClient) SendMessage(ctx context.Context, token string, params SendMessageParams) error {
|
||||
body, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/bot%s/sendMessage", c.apiBase, token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var ar apiResponse
|
||||
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
|
||||
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
|
||||
}
|
||||
if !ar.OK {
|
||||
return fmt.Errorf("telegram api error (code=%d): %s", ar.ErrorCode, ar.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SetWebhookParams struct {
|
||||
URL string `json:"url"`
|
||||
SecretToken string `json:"secret_token,omitempty"`
|
||||
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
|
||||
AllowedUpdates []string `json:"allowed_updates,omitempty"`
|
||||
MaxConnections int `json:"max_connections,omitempty"`
|
||||
}
|
||||
|
||||
func (c *TelegramClient) SetWebhook(ctx context.Context, token string, params SetWebhookParams) error {
|
||||
body, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/bot%s/setWebhook", c.apiBase, token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var ar apiResponse
|
||||
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
|
||||
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
|
||||
}
|
||||
if !ar.OK {
|
||||
return fmt.Errorf("telegram setWebhook error (code=%d): %s", ar.ErrorCode, ar.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramClient) DeleteWebhook(ctx context.Context, token string) error {
|
||||
endpoint := fmt.Sprintf("%s/bot%s/deleteWebhook", c.apiBase, token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var ar apiResponse
|
||||
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
|
||||
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
|
||||
}
|
||||
if !ar.OK {
|
||||
return fmt.Errorf("telegram deleteWebhook error (code=%d): %s", ar.ErrorCode, ar.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WebhookInfo struct {
|
||||
URL string `json:"url"`
|
||||
HasCustomCert bool `json:"has_custom_certificate"`
|
||||
PendingUpdateCount int `json:"pending_update_count"`
|
||||
LastErrorDate int64 `json:"last_error_date,omitempty"`
|
||||
LastErrorMessage string `json:"last_error_message,omitempty"`
|
||||
}
|
||||
|
||||
func (c *TelegramClient) GetWebhookInfo(ctx context.Context, token string) (*WebhookInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/bot%s/getWebhookInfo", c.apiBase, token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var ar apiResponse
|
||||
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
|
||||
return nil, fmt.Errorf("decode telegram response: %w", jerr)
|
||||
}
|
||||
if !ar.OK {
|
||||
return nil, fmt.Errorf("telegram getWebhookInfo error: %s", ar.Description)
|
||||
}
|
||||
var info WebhookInfo
|
||||
if jerr := json.Unmarshal(ar.Result, &info); jerr != nil {
|
||||
return nil, fmt.Errorf("decode webhook info: %w", jerr)
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// botBuildURL is exposed for tests; not used directly.
|
||||
var _ = url.Parse
|
||||
59
telegram_types.go
Normal file
59
telegram_types.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
type Update struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *Message `json:"message,omitempty"`
|
||||
EditedMessage *Message `json:"edited_message,omitempty"`
|
||||
CallbackQuery *CallbackQuery `json:"callback_query,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
From *User `json:"from,omitempty"`
|
||||
Chat Chat `json:"chat"`
|
||||
Date int64 `json:"date,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type Chat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
IsBot bool `json:"is_bot,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
}
|
||||
|
||||
type CallbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
From *User `json:"from,omitempty"`
|
||||
Message *Message `json:"message,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (u Update) ChatID() (int64, bool) {
|
||||
switch {
|
||||
case u.Message != nil:
|
||||
return u.Message.Chat.ID, true
|
||||
case u.EditedMessage != nil:
|
||||
return u.EditedMessage.Chat.ID, true
|
||||
case u.CallbackQuery != nil && u.CallbackQuery.Message != nil:
|
||||
return u.CallbackQuery.Message.Chat.ID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (u Update) Text() string {
|
||||
switch {
|
||||
case u.Message != nil:
|
||||
return u.Message.Text
|
||||
case u.EditedMessage != nil:
|
||||
return u.EditedMessage.Text
|
||||
case u.CallbackQuery != nil:
|
||||
return u.CallbackQuery.Data
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user