From ee832de0895840459c857b823c06760caa8363e6 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sat, 9 May 2026 12:23:59 +0200 Subject: [PATCH] =?UTF-8?q?Phase=201=20MVP=20=E2=80=94=20echo=20bot=20fact?= =?UTF-8?q?ory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/dockerimage.yaml | 41 +++++++ .gitignore | 7 ++ DEPLOY.md | 117 ++++++++++++++++++++ Dockerfile | 22 ++++ Makefile | 38 +++++++ README.md | 91 +++++++++++++++ bots.example.yaml | 15 +++ chart/Chart.yaml | 6 + chart/templates/_helpers.tpl | 60 ++++++++++ chart/templates/configmap.yaml | 14 +++ chart/templates/deployment.yaml | 84 ++++++++++++++ chart/templates/ingress.yaml | 36 ++++++ chart/templates/service.yaml | 16 +++ chart/templates/serviceaccount.yaml | 14 +++ chart/templates/vaultauth.yaml | 17 +++ chart/templates/vaultsecret.yaml | 18 +++ chart/values.yaml | 106 ++++++++++++++++++ config.go | 43 +++++++ go.mod | 5 + go.sum | 4 + handler_echo.go | 33 ++++++ handlers.go | 75 +++++++++++++ main.go | 86 ++++++++++++++ middleware.go | 54 +++++++++ server.go | 75 +++++++++++++ setwebhook.go | 74 +++++++++++++ telegram.go | 166 ++++++++++++++++++++++++++++ telegram_types.go | 59 ++++++++++ 28 files changed, 1376 insertions(+) create mode 100644 .gitea/workflows/dockerimage.yaml create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 bots.example.yaml create mode 100644 chart/Chart.yaml create mode 100644 chart/templates/_helpers.tpl create mode 100644 chart/templates/configmap.yaml create mode 100644 chart/templates/deployment.yaml create mode 100644 chart/templates/ingress.yaml create mode 100644 chart/templates/service.yaml create mode 100644 chart/templates/serviceaccount.yaml create mode 100644 chart/templates/vaultauth.yaml create mode 100644 chart/templates/vaultsecret.yaml create mode 100644 chart/values.yaml create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler_echo.go create mode 100644 handlers.go create mode 100644 main.go create mode 100644 middleware.go create mode 100644 server.go create mode 100644 setwebhook.go create mode 100644 telegram.go create mode 100644 telegram_types.go diff --git a/.gitea/workflows/dockerimage.yaml b/.gitea/workflows/dockerimage.yaml new file mode 100644 index 0000000..e53aa02 --- /dev/null +++ b/.gitea/workflows/dockerimage.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47af3d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +*.test +*.out +.DS_Store +.env +.env.local +bots.yaml diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..34dc024 --- /dev/null +++ b/DEPLOY.md @@ -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__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`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c55e31d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf3d81e --- /dev/null +++ b/Makefile @@ -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__TOKEN and BOT__SECRET must be exported in your shell. +setwebhook: + @test -n "$(SLUG)" || (echo "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= required" && exit 1) + go run . deletewebhook --slug $(SLUG) diff --git a/README.md b/README.md new file mode 100644 index 0000000..2844868 --- /dev/null +++ b/README.md @@ -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/ → 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":},"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__TOKEN`, + `BOT__SECRET`. Sourced from Vault path + `kvv2/homelab-gateway/config` via Vault Secrets Operator. + +## Cluster deploy + +- Image: `gitea.arcodange.lab/arcodange/homelab-gateway:` +- 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 +``` diff --git a/bots.example.yaml b/bots.example.yaml new file mode 100644 index 0000000..8fd37c1 --- /dev/null +++ b/bots.example.yaml @@ -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__TOKEN, BOT__SECRET +# +# `` = 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 diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..a010abb --- /dev/null +++ b/chart/Chart.yaml @@ -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" diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..f173471 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -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 }} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml new file mode 100644 index 0000000..4bca72f --- /dev/null +++ b/chart/templates/configmap.yaml @@ -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 }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..72cb2f7 --- /dev/null +++ b/chart/templates/deployment.yaml @@ -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 }} diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..ee51f53 --- /dev/null +++ b/chart/templates/ingress.yaml @@ -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 }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..8a9d123 --- /dev/null +++ b/chart/templates/service.yaml @@ -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 }} diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml new file mode 100644 index 0000000..e247b45 --- /dev/null +++ b/chart/templates/serviceaccount.yaml @@ -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 }} diff --git a/chart/templates/vaultauth.yaml b/chart/templates/vaultauth.yaml new file mode 100644 index 0000000..a9eff0e --- /dev/null +++ b/chart/templates/vaultauth.yaml @@ -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 }} diff --git a/chart/templates/vaultsecret.yaml b/chart/templates/vaultsecret.yaml new file mode 100644 index 0000000..81e006a --- /dev/null +++ b/chart/templates/vaultsecret.yaml @@ -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 }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..722c267 --- /dev/null +++ b/chart/values.yaml @@ -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: {} diff --git a/config.go b/config.go new file mode 100644 index 0000000..6e4ff69 --- /dev/null +++ b/config.go @@ -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__TOKEN, BOT__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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a0900b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/arcodange-org/homelab-gateway + +go 1.23 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/handler_echo.go b/handler_echo.go new file mode 100644 index 0000000..bf90475 --- /dev/null +++ b/handler_echo.go @@ -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 +} diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..e87ad01 --- /dev/null +++ b/handlers.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..14bd4ca --- /dev/null +++ b/main.go @@ -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 +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..710cdb6 --- /dev/null +++ b/middleware.go @@ -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 +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..df35b83 --- /dev/null +++ b/server.go @@ -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, "{}") +} diff --git a/setwebhook.go b/setwebhook.go new file mode 100644 index 0000000..12f9324 --- /dev/null +++ b/setwebhook.go @@ -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") +} diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..376a12b --- /dev/null +++ b/telegram.go @@ -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 diff --git a/telegram_types.go b/telegram_types.go new file mode 100644 index 0000000..35b46da --- /dev/null +++ b/telegram_types.go @@ -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 "" +}