开发者文档
简体中文

服务器运维

服务器安装

使用 Docker Compose 在一台公网服务器上安装和运营 Pabal 服务器的方法。

本文介绍如何把 Pabal 服务器安装到一台公网服务器上。用 Docker Compose 启动 Pabal 服务器 · PostgreSQL · Caddy(HTTPS)三个容器,所有不能丢失的数据都绑定挂载到主机目录。从上到下依次照做即可。

本文中的示例值

域名 pabal.me,服务器公网 IP 203.0.113.10(用于说明的地址——阅读时请换成实际 IP),源代码位置 /opt/pabal,数据位置 /srv/pabal,操作系统 Ubuntu 24.04 LTS。所需文件在仓库的 deploy/ 文件夹中。

整体概览

互联网 docker compose · ssemiya-net /srv/pabal (绑定挂载) Pabal 应用Pabal.app 浏览器 · 机器人网站 · Bot API 运营方SSH pabal-server MTProto :8443 网页 · Bot API · 管理 :8080 uid 1000 · JRE 21 Webhook → 机器人服务器 pabal-caddy:80 · :443 自动证书 pabal-postgres 16仅限内网 · 无端口 pabal_server/keys · RSA 密钥 pabal_server/data · 照片·设置 pabal_server/logs postgres_data/ · 所有对话 caddy/ · 证书 backups/ · backup.sh MTProto :8443 (自带加密) HTTPS :443 SSH 隧道 → 127.0.0.1:8080/admin/
一台服务器、三个容器,需要保留的一切都在 /srv/pabal 中

图示说明

  • 三个区域:互联网上的用户 → 生产服务器上的三个容器 → 主机磁盘上的绑定目录。容器随时可以删除重建,需要保留的数据全部在 /srv/pabal 中。
  • 入口只有两个:应用通过 MTProto 端口直接连接服务器(自带加密,因此不需要 HTTPS),网站、文档和 Bot API 则经过 Caddy 的 443。Caddy 会拦截管理用的路径(/admin、/health …),只转发其余请求。
  • 红色虚线是运营方专用的路径。管理页面只在服务器的 127.0.0.1:8080 上开放,只能通过 SSH 隧道进入。PostgreSQL 则根本没有对外开放的端口。
  • 两个红色方框是最重要的数据。pabal_server/keys 中的 RSA 密钥在构建时内置于应用中,一旦丢失就必须重新分发所有应用;postgres_data/ 中保存着所有账号和对话。它们是备份的第一优先级。
  • 灰色箭头表示写入存储:服务器写入密钥、照片、设置和日志,PostgreSQL 写入事件,Caddy 写入证书,各自写到自己的目录中。

准备工作

项目内容
服务器Ubuntu 24.04 LTS,起步配置 2 vCPU · 4 GB RAM · 40 GB SSD。用户和消息增多时,请优先增加内存
公网 IPv4固定 IP。应用在构建时以 IP 形式内置服务器地址,服务器也以 IP 形式告知应用
域名一个可以修改 DNS 的域名(A 记录)
注册验证码的发送方式一个短信(Twilio · Solapi · Webhook)或电子邮件(SMTP)账号
Mac一台用于为这台服务器构建应用的 Mac(如果还要构建 Windows 应用,则另需一台 Windows PC)
Firebase 项目(可选)需要向 Android 应用发送推送通知时。用 Google 账号即可免费创建(第 10 步)

端口

端口使用者开放范围说明
22/tcp运营方建议只对运营方 IP 开放SSH
80/tcpCaddy所有人签发证书 · 跳转到 HTTPS
443/tcp, 443/udpCaddy所有人网站 · 文档 · Bot API(udp 用于 HTTP/3)
8443/tcp服务器所有人应用连接(MTProto)
8080/tcp服务器不开放管理页面 · 健康检查——仅限服务器的 127.0.0.1
5432/tcpPostgreSQL不开放仅限容器内部网络
为什么应用端口不是 443

443 由网站(HTTPS)使用。MTProto 既不是 HTTP 也不是 TLS,无法在同一个端口上分流接收。如果因为公司或学校的网络会拦截不常见的端口,而希望应用也通过 443 连接,请再申请一个 IP,把那个 IP 的 443 交给服务器(在 .env 中把 PUBLIC_IP 设为第二个 IP、设置 MTPROTO_PORT=443,并在 Compose 文件的端口前加上 IP 加以区分)。服务器会告诉应用“连接这个 IP 的这个端口”,因此容器内外的应用端口要保持一致。

1. 准备服务器

sudo apt update && sudo apt -y upgrade
timedatectl                      # 确认显示 "System clock synchronized: yes"

时间同步是必需的。MTProto 的消息编号由时间生成,服务器时钟不准时,应用会反复重新连接。如果显示 no,请执行 sudo timedatectl set-ntp true。

# 防火墙
sudo ufw allow OpenSSH           # 如果可以:sudo ufw allow from <运营方 IP> to any port 22
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 8443/tcp
sudo ufw enable

# Docker(官方安装脚本)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER     # 重新登录后无需 sudo 即可使用 docker
docker compose version            # v2 及以上
Docker 与 ufw

Docker 开放的(ports:)端口不受 ufw 规则的约束。因此 Compose 文件只开放需要公开的端口(80 · 443 · 8443),管理端口只以 127.0.0.1:8080 开放,PostgreSQL 则不开放。修改 ports: 时请记住这一点。

2. 获取源代码

sudo mkdir -p /opt/pabal && sudo chown $USER: /opt/pabal
git clone <仓库地址> /opt/pabal

如果从开发电脑复制,不要把密钥、数据和日志传过去(rsync --exclude 'keys/' --exclude 'data/' --exclude 'logs/' --exclude '**/target/')。生产服务器的 RSA 密钥会在生产服务器首次启动时生成——如果在生产环境中使用开发用的密钥,那台开发电脑就能解密生产流量。

3. 数据目录(绑定挂载)

sudo mkdir -p /srv/pabal/{postgres_data,pabal_server/keys,pabal_server/data,pabal_server/logs,caddy/data,caddy/config,backups}
sudo chown -R 1000:1000 /srv/pabal/pabal_server        # 服务器容器以 uid 1000 运行
sudo chown -R $USER: /srv/pabal/backups
sudo chmod 700 /srv/pabal/pabal_server/keys /srv/pabal/backups
目录容器内路径存放的内容丢失后
pabal_server/keys/app/keysprivate.pem(服务器 RSA 私钥)、private.pem.pub、(可选)firebase-service-account.json(Android 推送用的 Firebase 私钥)需要重新构建并分发所有应用(Firebase 密钥在控制台重新获取即可)
pabal_server/data/app/data照片(media/)、admin-token、operations.json(注册验证码、短信、SMTP 设置,包含机密值)照片、运营设置
pabal_server/logs/app/logs服务器日志(每天压缩)只丢失日志
postgres_data/var/lib/postgresql/data账号、对话、消息、登录、机器人、Webhook 设置等全部数据整个服务
caddy/data, caddy/config/data, /configHTTPS 证书会重新签发
backups(仅限主机)backup.sh 的输出—

4. 配置文件 .env

cd /opt/pabal/deploy
cp .env.example .env
chmod 600 .env
openssl rand -base64 30 | tr -d '/+=' | cut -c1-32     # 把输出的值填入 POSTGRES_PASSWORD
nano .env
DOMAIN=pabal.me
PUBLIC_IP=203.0.113.10
POSTGRES_PASSWORD=(上面生成的值)
PABAL_HOME=/srv/pabal
PABAL_SOURCE=..
PABAL_NETWORK=ssemiya-net
MTPROTO_PORT=8443
HTTP_PORT=80
HTTPS_PORT=443
ADMIN_PORT=8080
ADMIN_TOKEN=
JAVA_OPTS="-Xms512m -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=100"
FCM_CREDENTIALS=
FCM_PROJECT_ID=
PUSH_PREVIEWS=true
键填写内容备注
DOMAIN网站域名应用生成的链接(pabal.me/用户名)、文档中的地址、链接预览也都使用这个地址
PUBLIC_IP服务器的公网 IPv4如果是云服务器,请填写控制台中显示的公网 IP(不是服务器内部的私有 IP)
POSTGRES_PASSWORD随机值请在首次启动之前设定。PostgreSQL 只在首次创建数据目录时使用这个值
PABAL_HOME/srv/pabal第 3 步中的目录
PABAL_SOURCE..构建服务器镜像所用的代码仓库。Compose 文件在仓库的 deploy/ 中时为 ..,放在仓库外时填写源码路径
PABAL_NETWORKssemiya-net三个容器共同加入的 Docker 网络。需预先创建(第 6 步)
MTPROTO_PORT8443修改时,防火墙和应用构建也要一起修改
ADMIN_TOKEN留空留空时,首次启动会在 pabal_server/data/admin-token 中生成。如果要自行设定,至少 16 个字符
JAVA_OPTS默认值内存不足时请调高 -Xmx(最多约为服务器内存的一半)
FCM_CREDENTIALS留空Android 推送通知。启用时填写 /app/keys/firebase-service-account.json(第 10 步)。留空则不带推送运行
FCM_PROJECT_ID留空仅当要发送到密钥文件中 project_id 以外的 Firebase 项目时填写
PUSH_PREVIEWStrue设为 false 时,通知中不显示消息内容,只显示发送者

生产环境的 Compose 文件会在关闭测试号码的状态下(TELEGRAM_TEST_NUMBERS=false)启动服务器。服务器读取的全部设置项,请参阅管理与设置——环境变量。

5. DNS

名称类型值
pabal.meA203.0.113.10
www.pabal.meA203.0.113.10
dig +short pabal.me        # 应该输出 203.0.113.10(生效需要几分钟到几小时)

在 DNS 指向这台服务器之前启动也没关系。Caddy 会自动重试,直到拿到证书为止。

6. 构建并启动

cd /opt/pabal/deploy
docker network inspect ssemiya-net >/dev/null 2>&1 || docker network create ssemiya-net   # 只需一次
docker compose up -d --build       # 第一次需要几分钟(Maven 会下载依赖库)
docker compose ps                  # pabal-server、pabal-postgres、pabal-caddy 为 running (healthy)
docker compose logs -f pabal-server      # 按 Ctrl+C 退出

首次启动时,日志中会出现如下几行。

WARN  ServerKeys - Generated a new RSA key at /app/keys/private.pem (fingerprint -3898654385185406269). Clients must embed /app/keys/private.pem.pub
INFO  TelegramServer - JDBC persistence active (jdbc:postgresql://pabal-postgres:5432/pabal)
INFO  BotFather - BotFather is user 100000
INFO  AdminToken - Created the admin token in /app/data/admin-token
INFO  TelegramServer - Website: http://0.0.0.0:8080/ (published at https://pabal.me/)
  • 第一行虽然是 WARN,但属于正常情况(只是告知首次生成了密钥,只会出现一次)。之后会显示 Loaded RSA key … (fingerprint …)。fingerprint 因服务器而异。
  • 数据表会在服务器启动时自动创建。

7. 验证

# 在外部(自己的电脑)执行
curl -sI https://pabal.me/ | head -1                              # HTTP/2 200
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/docs/    # 200 — 本文档
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/admin/   # 404 — 管理页面不对外公开
curl -s https://pabal.me/docs/server-key.pem | head -1             # -----BEGIN RSA PUBLIC KEY-----
nc -vz 203.0.113.10 8443                                          # succeeded — 应用端口

# 在服务器上执行
curl -s http://127.0.0.1:8080/health          # {"status":"UP",…}
ls -l /srv/pabal/pabal_server/keys                  # private.pem(600), private.pem.pub

8. 管理页面与初始设置

# 在自己的电脑上执行(保持运行)
ssh -N -L 8080:127.0.0.1:8080 <用户>@203.0.113.10

# 令牌在服务器上查看
sudo cat /srv/pabal/pabal_server/data/admin-token

在浏览器中打开 http://localhost:8080/admin/,输入令牌。正式上线前务必完成的设置(详见管理与设置):

  1. 把验证码发送方式(코드 전달 방식)设为短信或电子邮件。“管理页面”(관리 화면)方式需要运营方逐一告知验证码。
  2. 填入 SMS 或电子邮件(SMTP)(이메일 (SMTP))的设置值并保存 → 用测试发送(테스트 발송)确认确实能够收到。
  3. 确认测试号码(테스트 번호)已关闭。开启后,任何人都能用 +99966… 号码登录。
  4. 如有需要,可以先关闭允许新注册(새 가입 허용)再开始,只让受邀的人先加入。

9. 让应用连接这台服务器

应用在构建时会内置服务器 IP · 端口 · 公钥。必须用这台服务器的公钥重新构建,应用才能连接这台服务器。在 Mac 上执行:

# 1. 这台服务器的公钥(是公钥而不是私钥——也可以从文档网站下载)
curl -s -o keys/production.pem.pub https://pabal.me/docs/server-key.pem

# 2. 让应用源代码指向这台服务器 + 应用品牌
python3 scripts/tdesktop/point_to_server.py ~/Developer/tdesktop \
    --host 203.0.113.10 --port 8443 --key keys/production.pem.pub
python3 scripts/tdesktop/apply_branding.py ~/Developer/tdesktop

然后执行仓库中 docs/tdesktop-build-guide.md 的第 5 步(configure)和第 6 步(构建)。请把生成的 out/Debug/Pabal.app 以其他名称另行复制保存——用同一份源代码为另一台服务器重新构建时,它会被覆盖。

在把应用分发给其他人之前

目前的构建是没有签名和公证的调试构建,收到的人第一次打开时需要右键 → 打开。调试构建会把数据存放在应用旁边的文件夹(tdata/)中,如果那里无法写入,就会使用与真正的 Telegram Desktop 相同的数据文件夹。请引导用户把应用放在用户文件夹中(例如 ~/Applications/Pabal/)。用于正式分发的发布构建(分离数据文件夹、Apple 签名和公证)是另外一项工作。

Android、iPhone、Windows 应用的做法相同(用脚本写入服务器地址和公钥 → 构建),见仓库中的 docs/mobile-apps-guide.md。要让 Android 应用带上推送通知,构建之前必须先完成第 10 步中的 Firebase 应用配置。

10. Android 推送通知(可选)

Android 很快就会让离开屏幕的应用进入休眠。如果不启用推送,只有在应用打开期间才会显示消息、响起来电。推送通过 Google 的 Firebase Cloud Messaging(FCM)发送(免费),通知内容(发送者名称、消息预览)由服务器用只有那部手机持有的密钥加密,因此 Google 无法读取。

Firebase 项目(运营方所有) 构建 · 安装 手机 · Google google-services.json 放进应用的配置 打包进 APK · 不是机密 服务账号密钥 firebase-service-account.json 机密 · 只放在服务器上 构建 Android 应用 apply_branding.py → APK pabal-server keys/ · chmod 600 FCM_CREDENTIALS 用手机的密钥加密通知 手机上的 Pabal 应用 首次打开时登记这部手机 Google FCM 只转发密文 · 读不到内容 ① 登记手机 ② 加密的通知 ③ 唤醒 → 通知
两个文件分别去往不同的地方,通知以密文传递

图示说明

  • 三个区域:运营方创建的 Firebase 项目 → 构建应用、运行服务器的地方 → 手机和 Google。从一个 Firebase 项目中获取两个文件。
  • 灰色箭头是只需做一次的准备。绿色框中的 google-services.json 会放进应用、随每个 APK 一起分发,所以不是机密;红色框中的服务账号密钥是只放在服务器 keys/ 中的机密。两个文件必须来自同一个项目。
  • 蓝色箭头是通知的传递路径:① 首次打开应用时,手机把 FCM 令牌和只有这部手机持有的密钥登记到服务器;② 有新消息或来电时,服务器用该密钥加密通知并交给 FCM;③ FCM 唤醒手机后,应用解密并显示通知,再重新连接服务器,取回错过的内容。
  • Google 能看到的只有设备令牌、发送时间、优先级之类的传递信息。发送者名称和消息都是密文,Google 无法读取。
  • 不会向正在查看应用的手机发送推送——它已经在通过连接接收了。已关闭通知的对话,其推送会以非紧急优先级发送,由应用自行过滤。
Firebase 项目与应用是配对的

服务器的密钥只能向用同一个 Firebase 项目构建的应用发送推送。别人构建的应用(例如从 Pabal.me 下载的应用)绑定的是那个人的 Firebase 项目,而且本来就是用那个人服务器的地址和公钥构建的。你自己服务器的应用请自己构建,Firebase 项目也请自己创建。

创建 Firebase 项目

  1. 在 Firebase 控制台中点击添加项目。名称随意,不需要 Google Analytics。
  2. 项目设置 → 常规 → 您的应用 → 添加应用 → Android。Android 软件包名称填 me.pabal.android(应用构建脚本给应用起的名称)。推送不需要证书指纹(SHA-1)。
  3. 下载这个应用的 google-services.json。这是放进应用的配置,不是机密。
  4. 在项目设置 → 服务账号 → 生成新的私钥中下载 JSON 文件,并把文件名改为 firebase-service-account.json。这个文件是机密——不要把它的内容放进代码仓库、聊天或 .env。
  5. 在项目设置 → Cloud Messaging 中确认 Firebase Cloud Messaging API(V1)已启用(新项目通常默认启用)。

把应用配置放进应用

构建 Android 应用之前,把 google-services.json 放到构建脚本查找的位置。脚本会先检查这个文件(是否是登记了 me.pabal.android 的项目、是否误放了私钥),然后把它放进应用源代码。

mkdir -p ~/.pabal/android
cp ~/Downloads/google-services.json ~/.pabal/android/     # 如要放在别处,给 apply_branding.py 加上 --google-services <路径>
python3 scripts/android/point_to_server.py <Android 源代码> \
    --host 203.0.113.10 --port 8443 --key keys/production.pem.pub
python3 scripts/android/apply_branding.py <Android 源代码>
# 最后应当显示 "Firebase: project <项目 ID>"。如果看到 pabal-placeholder,构建出的应用就没有推送

之后按照仓库中 docs/mobile-apps-guide.md 的“Android 应用”一节进行构建。之前在用加入推送前的旧版应用的手机,也必须更新到新构建的应用才能收到通知。

把私钥放到服务器上

sudo cp firebase-service-account.json /srv/pabal/pabal_server/keys/
sudo chown 1000:1000 /srv/pabal/pabal_server/keys/firebase-service-account.json    # 服务器容器中的用户
sudo chmod 600 /srv/pabal/pabal_server/keys/firebase-service-account.json

在 .env 中加入一行。在容器内,pabal_server/keys 就是 /app/keys。

FCM_CREDENTIALS=/app/keys/firebase-service-account.json
docker compose up -d pabal-server
docker compose logs pabal-server | grep -i push
INFO  TelegramServer - Push notifications on: Firebase project <项目 ID>, 0 device registrations, message text included (encrypted per device)
  • 看到这一行就说明已经启用。如果文件无法读取或格式不对,Push notifications are off: … 中会给出原因(文件内容不会出现在日志中)。
  • 设为 PUSH_PREVIEWS=false 时,这一行的末尾会变成 message text left out,通知中不显示消息内容,只显示发送者。
  • 容器必须能向 oauth2.googleapis.com、fcm.googleapis.com(443)发起出站连接。

确认

  1. 在手机上安装(或更新)新构建的应用,并打开一次。服务器日志中会出现 Push device registered: user … type 2 token … (push key 256 bytes)。令牌只以指纹的形式记录。
  2. 在最近使用的应用列表中把应用划掉。不要使用设置中的“强行停止”——在你自己再次打开应用之前,Android 不会把推送交给它。
  3. 用另一个账号发一条消息,日志中会出现 Push MESSAGE_TEXT to user …: sent,手机上会弹出通知。
  • 隐私政策:在管理页面(관리 화면)→ 设置(설정)→ 运营方信息(운영자 정보)的委托处理(처리 위탁)和跨境传输(국외 이전)中填写 Google LLC(Firebase Cloud Messaging,美国)。政策正文中已经有经由 FCM 的说明。Google Play 的“数据安全”表单中也要填写相同内容。
  • 如果密钥泄露,请在 Firebase 控制台中删除该密钥,重新获取并替换文件,然后重启服务器。无需重新构建应用。
iPhone 与桌面端

iPhone 不用 Firebase,而是用 Apple 开发者账号的 APNs 密钥(.p8)另行启用——见紧接着的第 11 步。macOS、Windows 应用没有推送,在运行期间接收消息。

11. iPhone 推送通知(可选)

iPhone 同样会很快停止离开屏幕的应用。如果不启用推送,只有在应用打开期间才能收到消息和来电。推送通过 Apple 推送通知服务(APNs)发送,并且和 Android 一样,通知内容用只有那部 iPhone 持有的密钥加密。Apple 能看到的通知文字只有应用名称和“You have a new notification”这类不含内容的话,由 iPhone 应用中的通知扩展(Notification Service Extension)解密密文,换成发送者和消息显示出来。

  • 登记:应用每次启动时,都会为每个账号登记两样东西——通知用(type 1)和来电用(VoIP,type 9)。服务器会随登记一起记住应用的语言(外层文字所用的语言)和构建号。
  • 消息以通知发送。已关闭通知的对话不发送任何内容。在其他设备上已读或删除的内容,用静默的后台推送告知,由应用清除已显示的通知。
  • 来电如果用 VoIP 发送,即使在锁屏状态下也会弹出 CallKit 来电界面。不过,收到 VoIP 的应用必须立即向 CallKit 报告来电;做不到的话 iOS 会终止应用,反复发生后就不再投递 VoIP。因此要用 APNS_VOIP_MIN_BUILD 指定,只从按此要求构建的版本起启用 VoIP;在此之前,来电也以通知发送。
APNs 密钥属于为应用签名的团队

与 Firebase 不同,没有需要放进应用的配置文件——应用在构建时就已经能使用 APNs(通知权限、App Group、通知扩展),只需把密钥放到服务器上。不过,服务器的密钥只能向由创建该密钥的团队签名、且 Bundle ID 与 APNS_TOPIC 相同的应用发送推送。如果你用自己的 Apple 开发者账号为自己服务器的 iPhone 应用签名,APNs 密钥也要在该账号中创建。

创建 APNs 密钥

  1. Apple Developer → Certificates, IDs & Profiles → Keys → +。必须是为应用签名的团队的账号。
  2. 填写名称,勾选 Apple Push Notifications service (APNs)。环境选 Sandbox & Production,范围选整个团队(Team Scoped)——因为要发往通知和 VoIP 两处。
  3. Register → Download。AuthKey_<密钥 ID>.p8 只能下载一次。这个文件是机密——不要把它的内容放进代码仓库、聊天或 .env。

把密钥放到服务器上

sudo cp AuthKey_<密钥 ID>.p8 /srv/pabal/pabal_server/keys/
sudo chown 1000:1000 /srv/pabal/pabal_server/keys/AuthKey_<密钥 ID>.p8    # 服务器容器中的用户
sudo chmod 600 /srv/pabal/pabal_server/keys/AuthKey_<密钥 ID>.p8

写入 .env。在容器内,pabal_server/keys 就是 /app/keys。

APNS_KEY=/app/keys/AuthKey_<密钥 ID>.p8
APNS_TEAM_ID=4Z8R468M2D
APNS_TOPIC=me.pabal.ios
APNS_MIN_BUILD=4
APNS_VOIP_MIN_BUILD=
键填写内容备注
APNS_KEY密钥文件的路径密钥 ID 从文件名中读取。如果文件用了别的名称,请在 APNS_KEY_ID 中填写密钥 ID
APNS_TEAM_ID为应用签名的团队 IDPabal 应用为 4Z8R468M2D。自己构建并签名的应用则填该团队
APNS_TOPIC应用的 Bundle IDPabal 应用为 me.pabal.ios。来电(VoIP)发往在此后加上 .voip 的主题
APNS_MIN_BUILD4低于此值的构建不发送任何内容(0 表示全部发送)。4 用于排除在测试推送之前制作的 Pabal 1.0 (3)
APNS_VOIP_MIN_BUILD留空留空则来电也以通知发送。填写第一个会立即向 CallKit 报告来电的构建号后,从该构建起显示 CallKit 来电界面
docker compose up -d pabal-server
docker compose logs pabal-server | grep -i push
INFO  TelegramServer - iPhone push notifications on: APNs key <密钥 ID> of team 4Z8R468M2D, topic me.pabal.ios, builds from 4, calls as alerts (TELEGRAM_APNS_VOIP_MIN_BUILD not set)
  • 看到这一行就说明已经启用。如果密钥文件无法读取或格式不对,iPhone push notifications are off: … 中会给出原因(文件内容不会出现在日志中)。它与 Android(FCM)分别启用和关闭——只启用其中一个也可以。
  • 容器必须能向 api.push.apple.com(443,HTTP/2)发起出站连接。用 Xcode 直接安装到设备上的调试构建会发往 api.sandbox.push.apple.com(应用在登记时会告知)。

确认

  1. 在真实的 iPhone 上打开一次应用并允许通知。服务器日志中会出现 Push device registered: user … type 1 token … (push key 256 bytes, build …) 和 type 9 的行。无法用模拟器确认——模拟器既没有 APNs 令牌,也没有 CallKit。
  2. 向上划动关闭应用。
  3. 用另一个账号发一条消息,日志中会出现 Push MESSAGE_TEXT to user …: sent,锁屏上会显示发送者和内容。
  • 隐私政策:在管理页面(관리 화면)→ 设置(설정)→ 运营方信息(운영자 정보)的委托处理(처리 위탁)和跨境传输(국외 이전)中填写 Apple Inc.(Apple 推送通知服务,美国)。政策正文中已经有经由 APNs 的说明。在 App Store Connect 的 App 隐私问卷中,也要确认设备 ID(推送令牌)一项。
  • 如果密钥泄露,请在开发者账号中 Revoke 该密钥,重新创建并替换文件,然后重启服务器。无需重新构建应用。

12. 语音机器人(可选)

语音机器人是指有人在应用中给机器人打电话时由 AI 语音模型接听的功能(面向机器人所有者的说明见语音机器人)。服务器还需要再接入两个组件,并且法律文件和应用版本都准备好之后才会开启。默认是关闭的。

语音通过附加的 Compose 文件开启

语音组件不在 deploy/docker-compose.yml 中,而在附加文件 deploy/docker-compose.voice.yml 中——服务器的语音设置和链路专用网络、网关 pabal-voice(profile voice)、手表的音频入口 pabal-voice-edge(profile voice-watch)。.env 不引用这个文件时,就根本没有语音设置。这套配置已用回声引擎运行过,但还没有在生产服务器上开启过语音,也还没有通过生产环境的中继(TURN)完整地打通过一次语音机器人通话——端到端测试是用 Docker 网络内的测试中继完成的(scripts/voice-e2e/run.sh)。开启前可以用代码仓库中的 deploy/voice_readiness.py 检查缺少什么。正式开放前的条件(法律审查、提供商的书面答复、应用发布)由运营方判断,代码只拦住其中一部分。

组件作用缺少时
pabal-voice(网关,镜像 0.11.0)代替机器人成为通话的一端,完成密钥交换,并与 AI 模型交换声音。Python 3.12 + ntgcalls。它与服务器通过链路(WebSocket,有认证但不加密)通信所有机器人通话都是忙音(no_gateway)
coturn(通话中继)机器人通话只走中继:应用和网关都关闭直连,只通过中继传送声音。与人与人之间通话使用的中继相同(TURN_SECRET)所有机器人通话都是忙音(no_relay),网关的连接也会被拒绝(4008)
Vertex AI 密钥Google Cloud 服务账号 JSON 密钥。只有网关的引擎工作进程会打开它只能运行不带模型的回声模式(VOICE_ENGINE=echo,声音不会离开本机)
(可选)主密钥用来封装机器人所有者自有密钥和音频流模式机密值的密钥自有密钥和音频流模式关闭
(可选)MCP 组件pabal-mcp · 签名器 · 出站代理(见下文)MCP 服务器关闭

开启条件

服务器检查的内容缺少时
链路机密值文件(TELEGRAM_VOICE_LINK_SECRET_FILE,32 字节)语音关闭——链路端口也不会打开
通话中继(TELEGRAM_TURN_HOST·TELEGRAM_TURN_SECRET)所有机器人通话都是忙音
网站隐私政策(ko 和 en 两种)中的 id="bot-calls" 一节——应用的确认窗口会指向这里任何机器人都不接听(ERROR 日志)。草稿在 docs/voice-bots/p0/privacy-*.draft.html
服务条款中的 id="voice-bot-owners" 一节谁都无法同意机器人所有者条款 → 文字记录、自有密钥、音频流模式和 MCP 都无法使用
政策中的 id="bot-calls-stream" · id="bot-calls-mcp" 两节音频流模式 · MCP 关闭
在管理页面中允许机器人(默认仅限已允许的机器人)以及正式开放(默认仅限测试者,开放需要输入确认短语)未被允许的机器人不接听,正式开放前只有测试者可以拨打
来电者的应用版本:桌面版 pabal-bc2(在确认窗口只有韩语和英语期间也包括 pabal-bc1)· Android 62151 · iPhone 5 及以上 · Apple Watch 1 及以上(TELEGRAM_VOICE_MIN_BUILD_*)这个人看不到通话按钮,拨打也是忙音
(可选)Apple Watch:服务器的手表 API(TELEGRAM_WATCH_API_PORT)和音频地址(TELEGRAM_WATCH_AUDIO_URL)、音频入口 pabal-voice-edge、声明了 direct 的网关(见下文)不能用手表拨打(手机和电脑应用不受影响)
(可选)保存对话:政策中的 id="bot-calls-keep-text" 一节、声明了 keep_text 的网关(VOICE_KEEP_TEXT,见下文)即使勾选了复选框,对话也不会保存
(可选)机器人打来的电话:政策中的 id="bot-calls-outbound" 一节和条款中的 id="voice-bot-owners-outbound" 一节、证据密钥文件夹、外呼最低版本、声明了 outbound 的网关、管理页面的全局开关(见下文)任何机器人都不能给人打电话

只有在测试服务器(TELEGRAM_TEST_NUMBERS=true)上,才能用 TELEGRAM_VOICE_NOTICE_CHECK=off 跳过法律条款的检查,并用 TELEGRAM_VOICE_BOTS(所有机器人用 *)·TELEGRAM_VOICE_TESTERS=* 让任何人都能测试。请不要在生产环境中使用——生产服务器会忽略 TELEGRAM_VOICE_BOTS 并给出警告。

通话中继(coturn)

  • 如果是 Linux 服务器,就使用 Compose 中的 pabal-turn(COMPOSE_PROFILES=turn)。在 .env 中设置 TURN_SECRET(openssl rand -hex 32),并在路由器和防火墙上开放 TURN_PORT(3478,udp 和 tcp)以及 TURN_MIN_PORT~TURN_MAX_PORT(49160~49200,udp)。扩大端口范围时,路由器和 .env 要一起修改。
  • 不要在 macOS 的 Docker Desktop 上以容器方式运行 coturn。所有来电者都会显示为 192.168.65.1,中继许可对不上,表面上一切正常,声音却全部被丢弃。在 Mac 服务器上,请直接安装 coturn(deploy/turnserver.conf.example、docs/turn-relay-guide.md)。
  • 交给网关的中继地址是服务器的局域网地址:在服务器的 TELEGRAM_VOICE_TURN_HOST 和网关的 VOICE_TURN_HOST 中填入相同的值。用 host.docker.internal 连不上,用 127.0.0.1 分配无法完成。应用拿到的仍是公网地址(TURN_HOST)。
  • 从外部网络用 deploy/check_relay.py --data 确认声音确实能穿过中继。有时 STUN 有应答、分配也成功,声音却传不过去。

Vertex AI 密钥

  1. 在 Google Cloud 项目中启用 Agent Platform API(旧称 Vertex AI API,aiplatform.googleapis.com)。必须关联结算账号。不使用 Gemini Developer API(AI Studio 密钥)。
  2. 创建一个服务账号,只授予 roles/aiplatform.user 这一个角色(想进一步收窄,可以用只含 aiplatform.endpoints.predict 一项权限的自定义角色)。生产用的密钥要与开发用的分开创建。
  3. 下载 JSON 密钥,放在代码仓库和 .env 之外,例如 /srv/pabal/pabal_voice/secrets/vertex-sa.json,并且只让网关的用户可以读取(Linux:chown 10001:10001、chmod 400)。如果任何人都能读取,网关会拒绝启动。
  4. 实时模型只在 us-central1(美国艾奥瓦州)提供(2026-09-26 确认)。隐私政策和确认窗口必须说明通话声音会发往美国,而且只有当应用确认窗口上写的地区与网关的地区(VOICE_VERTEX_REGION)一致时,服务器才会把通话交给该网关。
  5. 待办:预算提醒、并发会话配额(网关容量的两倍),以及 Vertex 的数据缓存(开启时会把输入输出在内存中保留最长 24 小时)——确认窗口的说明是“Google 在生成回答后不保留声音和文字”,所以在关闭缓存之前,这句话并不属实。建议:在向所有人开放之前关闭数据缓存(代码仓库 docs/voice-bots/vertex-setup.md 第 9 节),并把密钥的角色收窄到只有预测这一项权限(同一文档第 10 节)。

服务器和网关从文件中读取同一个链路机密值。链路不加密,所以只能放在只有这两个容器接入的专用网络上——不要放在与其他服务栈共用的 ssemiya-net 或局域网上。不要公开端口。

(umask 077; openssl rand -hex 32 > /tmp/voice-link.secret)
sudo install -m 400 -o 1000 /tmp/voice-link.secret /srv/pabal/pabal_server/keys/voice-link.secret   # 服务器(uid 1000)用
sudo install -d -m 700 -o 10001 /srv/pabal/pabal_voice/secrets
sudo install -m 400 -o 10001 /tmp/voice-link.secret /srv/pabal/pabal_voice/secrets/voice-link.secret # 网关(uid 10001)用
shred -u /tmp/voice-link.secret

这些值写在 .env 中。附加文件用它们为 pabal-server 加上语音设置(TELEGRAM_VOICE_*)和链路网络。

COMPOSE_FILE=docker-compose.yml:docker-compose.voice.yml
COMPOSE_PROFILES=voice                              # 若还有其他 profile(turn 等),用逗号并列
VOICE_LINK_SECRET_FILE=/app/keys/voice-link.secret  # 容器内的路径;为空则语音关闭
VOICE_TURN_HOST=192.168.1.64                        # 服务器的局域网地址(coturn 监听的地方)
VOICE_TESTERS=100001                                # 正式开放前可以拨打的人的 id(管理页面也可添加)
  • 附加文件把服务器接入内部专用网络 pabal-voice-link(10.231.53.0/29——服务器 .2,网关 .3),服务器只在这个网络上自己的地址监听(写 0.0.0.0 会在同时接入的共享网络上也开放)。如果这个范围与局域网、VPN 或其他 Docker 网络重叠,请把 VOICE_LINK_SUBNET、VOICE_LINK_SERVER_IP、VOICE_LINK_GATEWAY_IP 三个一起改。
  • 要绑定到非回环地址,必须设置 TELEGRAM_VOICE_LINK_ANY_ADDRESS=true,而且链路绝不会绑定到公网地址。服务器日志会告诉你链路看到的是哪个网络。
  • 认证之前的连接,升级限 3 秒、hello 限 3 秒,每个地址最多 2 个,总共最多 16 个连接。
  • 与语音有关的全部服务器设置见管理与设置——语音机器人环境变量。

网关(pabal-voice)

cd /opt/pabal
docker build -t pabal-voice:latest voice/                                       # 适用于本机的 CPU
docker buildx build --platform linux/amd64 -t pabal-voice:latest --load voice/  # 适用于 Intel 服务器
cd deploy && docker compose up -d                                         # 按 .env 中的 COMPOSE_FILE、COMPOSE_PROFILES 启动

镜像使用按哈希固定的 Python 3.12,不含编译器和 pip,以 uid 10001 运行。ntgcalls 只有 Linux x86_64/aarch64 和 macOS arm64 版本,所以在 Intel Mac 上只能用 Docker 运行。附加文件中的 pabal-voice 直接使用这个镜像而不构建它(image: pabal-voice:latest)。要在另一台电脑上为服务器的 CPU 构建并发送过去,请用 deploy/ship.sh <user@host> <目录> --voice(被替换的镜像保留为 pabal-voice:previous)。下面是该文件中网关服务的节选。

  pabal-voice:                             # 摘自 deploy/docker-compose.voice.yml(节选)
    image: pabal-voice:latest
    profiles: [voice]
    init: true
    stop_grace_period: 40s                 # 要比 VOICE_DRAIN_SECONDS(20)长
    security_opt: ["no-new-privileges:true"]
    cap_drop: [ALL]
    mem_limit: ${VOICE_MEMORY:-1g}
    ulimits:
      core: 0
    environment:
      VOICE_LINK_URL: ws://${VOICE_LINK_SERVER_IP:-10.231.53.2}:8095/voice/link
      VOICE_LINK_SECRET_FILE: /run/secrets/voice-link.secret
      VOICE_ENGINE: gemini
      VOICE_VERTEX_KEY_FILE: /run/secrets/vertex-sa.json
      VOICE_VERTEX_REGION: us-central1
      VOICE_TURN_HOST: ${VOICE_TURN_HOST:-}  # 与服务器的 TELEGRAM_VOICE_TURN_HOST 相同的值
      VOICE_CAPACITY: ${VOICE_CAPACITY:-2}
      VOICE_DAILY_MODEL_MINUTES: ${VOICE_DAILY_MODEL_MINUTES:-60}
      VOICE_SAFETY_THRESHOLD: ${VOICE_SAFETY_THRESHOLD:-MEDIUM}   # 测试者通话:需要运营方确认的假设(见下文)
      VOICE_BYOK: "false"
      VOICE_STREAM: "false"
    volumes:
      - ${PABAL_HOME:-/srv/pabal}/pabal_voice/secrets:/run/secrets:ro   # 链路机密值和 Vertex 密钥——不在 backup.sh 中
      - pabal_voice_state:/var/lib/pabal-voice
    networks:
      pabal-voice-link:                    # 只与服务器共用的链路网络(internal)
        ipv4_address: ${VOICE_LINK_GATEWAY_IP:-10.231.53.3}
      pabal-voice-out: {}                  # 通往外部(Google、中继)的自有网络
  • 除了链路,网关还需要通往外部的路径:到 Google(us-central1-aiplatform.googleapis.com:443)和通话中继(UDP)。链路网络是 internal: true,无法访问外部,所以要像上面那样再接入一个 pabal-voice-out 网络。
  • Landlock:使用模型或音频流模式的网关,如果没有 Linux 内核的 Landlock(5.13 及以上,Docker 默认 seccomp 下可用)就会拒绝启动(退出码 2)。这是因为用原生代码处理来电者所发内容的媒体工作进程会把自己锁住,使其无法读取密钥文件。在容器中,不要把机密文件挂载到 /app·/opt·/usr·/lib·/etc 下面(/run/secrets 可以——如上例所示)。VOICE_ALLOW_NO_LANDLOCK=true 只用于开发用的 Mac。尚未确认 macOS 的 Docker Desktop 中能否使用 Landlock。
  • 内存:每通电话需要媒体 60~100 MB + 引擎 30~40 MB。容量为 8(VOICE_CAPACITY)再加一组备用时,约 1.3 GB。附加文件的默认值是容量 2、内存 1 GB(VOICE_MEMORY)。
  • 安全过滤器 VOICE_SAFETY_THRESHOLD 的网关默认值是 LOW(最严格),但在测量中,它把 172 句正常的韩语话语中的 13 句变成了英语拒绝(MEDIUM 为 69 句中 0 句)。代码仓库的假设是测试者通话要明确设置 MEDIUM,修改默认值之前需要进行红队测试。
  • 限额:网关的默认值是每通电话 600 秒、40 万 token、每分钟 30 轮,每天(UTC)模型 300 分钟、800 万 token;附加文件把每天设为 60 分钟、160 万 token(VOICE_DAILY_MODEL_*,超过后新通话为忙音)。OpenAI 引擎只用假服务器测试过,处于关闭状态,引擎之间没有自动切换。
  • 语言:网关接入服务器时会声明自己能用哪些机器人语言通话(从 0.7.0 起支持全部 9 种),服务器只把机器人的通话交给能用该机器人的语言通话的网关——不声明语言的旧网关(0.6.0)只会收到韩语和英语机器人的通话。提示录音位于镜像的 assets/ 中,哪些录音已审查完毕由 assets/assets.json 决定。缺少这个文件时,网关无法加载 AI 提示,也就无法接听通话(启动日志中会有 ERROR)。尚未审查完毕的录音(目前是韩语、英语以外的所有语言)不会播放,这些语言的来电者听到的是英语提示。VOICE_DRAFT_NOTICES=true 是连草稿录音也播放的测试服务器专用开关,如果没有同时设置 VOICE_ALLOW_TEST_URLS=true,网关不会启动。VOICE_NOTICE_LANG(服务器没有告知来电者语言时使用的提示语言,默认 ko)必须是 10 种录音语言(ko、en、ja、zh、fr、es、id、vi、km、ar)之一,VOICE_LANGUAGE(没有机器人所有者设置的通话所用的机器人语言,默认 ko-KR)必须是机器人语言列表中的 id(语音机器人——音色和语言)之一。
  • 日志中不会留下链路机密值、密钥、声音、文字记录或指令。收到 SIGTERM 时不再接听新通话,给正在进行的通话 VOICE_DRAIN_SECONDS(默认 20 秒)后结束。

验证

docker compose logs pabal-server | grep -E "Voice|voice"
docker compose logs pabal-voice | head -20
INFO  TelegramServer - Voice bots on: gateways connect to ws://10.231.53.2:8095/voice/link; bots 0 listed, 2 calls each at most
INFO  VoiceGatewayLink - Voice gateway link listening on ws://10.231.53.2:8095/voice/link (网络)
INFO  TelegramServer - Voice bot callers: patched apps only (desktop pabal-bc1+, Android 62151+, iOS 5+), recipients google.vertex.gemini-live@us-central1, testers 0 listed + the admin page's
INFO  VoiceGatewayLink - Voice gateway pabal-voice-1 (boot 823c9600d05e811847ce95a8f1ba9efa) connected: new session, capacity 2, features […], audio to google.vertex.gemini-live@us-central1
  • Voice bots are off: … 表示链路机密值不存在、无法读取或不是 32 字节;行末的 — but without a TURN relay every bot call is busy 表示没有中继。
  • 如果看到 ERROR Voice bots are on, but no bot is callable: the privacy policy … has no id="bot-calls" section,需要先在网站的政策中发布机器人通话一节。
  • 网关连上后,服务器日志的 features 中会出现 bot_settings·end_call·context·tools,能运行所有者密钥的网关还会有 byok,带有文字记录提示语音的会有 transcripts(顺序每次不同)。audio to 必须与应用确认窗口中说明的接收方一致。
  • 网关一侧的第一行应为 starting as pabal-voice-1 (capacity 2, … engine gemini, audio to google.vertex.gemini-live@us-central1),模型那一行应为 session resumption off; no failover,并且应出现 media workers sandbox themselves with Landlock (ABI N)。recordings for the callers' notices in: Korean, English 表示能为来电者播放提示的语言(在连草稿也播放的测试服务器上,后面会加上 (drafts included: …))。
  • 然后在管理页面的语音(음성)标签页(管理与设置——语音标签页)中查看网关是否已连接,添加测试者,并允许机器人。

主密钥(自有密钥 · 音频流模式)

机器人所有者的模型密钥和音频流模式的机密值,都会用这个密钥封装后保存。它必须放在不会随备份一起被复制的地方。

sudo install -d -m 700 -o 1000 -g 1000 /etc/pabal/voice                 # /srv/pabal 之外,700
sudo sh -c 'umask 077; openssl rand -hex 32 > /etc/pabal/voice/voice-master.key'
sudo chown 1000:1000 /etc/pabal/voice/voice-master.key
      TELEGRAM_VOICE_KEYS_MASTER_FILE: /app/secrets/voice-master.key
    volumes:
      - /etc/pabal/voice:/app/secrets:ro
  • 如果它位于数据文件夹或 RSA 密钥文件夹之内或旁边,或者文件夹对其他用户有任何开放(不是 700),服务器就不会使用它(Voice keys off: …)。如果文件不存在,服务器会在收到第一个密钥时创建(如果是只读挂载,请像上面那样自己创建)。
  • 不要放进备份,另外单独保存一份离线副本。丢失后,所有机器人所有者都需要重新设置密钥。更换时,把旧密钥交给 TELEGRAM_VOICE_KEYS_PREVIOUS_MASTER_FILE 并启动,检查日志后再删除旧文件。
  • 即使有主密钥,在应用显示自有密钥机器人的确认窗口(第 2 版)并设置 TELEGRAM_VOICE_OWN_KEYS_APPS_READY=true 之前,也不接受自有密钥。音频流模式需要通过 TELEGRAM_VOICE_STREAM_MIN_BUILD_DESKTOP·_ANDROID·_IOS 告知包含音频流确认窗口的应用版本后才能拨打(默认 0 = 谁都不能拨打),并且网关上需要 VOICE_STREAM=true 以及本部署的公网 IP(VOICE_STREAM_DENY_ADDRESSES)。

MCP 组件(可选)

这是在通话中调用机器人所有者注册的 MCP 服务器的组件。默认关闭,用 deploy/docker-compose.mcp.yml(配置档 mcp)添加三个容器。

容器作用
pabal-mcp(uid 1101)MCP 客户端。与服务器只通过套接字文件连接;因为只有一个内部网络,只能经由出站代理访问外部。只读、无特权、512 MB
pabal-mcp-signer(uid 1102)签名器。完全没有网络,只通过套接字应答,并写出公开的签发者文档
pabal-mcp-egress(uid 1103)出站代理。只走 443,不会访问 Pabal 自身的地址
docker compose -f docker-compose.yml -f docker-compose.mcp.yml --profile mcp up -d
  • 要开启,需要 TELEGRAM_MCP_EGRESS_OWN_ADDRESSES(Pabal 的公网地址,必填)和密钥文件(链路机密值、K_grant、安装密钥——都放在 /srv/pabal 之外权限为 700 的文件夹中),网站上还需要政策中的 bot-calls-mcp 一节和条款中的机器人所有者一节。只需做一次的设置按顺序写在 docker-compose.mcp.yml 开头的注释中。保管库和签名密钥不要放进备份。
  • 等应用能在确认窗口中显示机器人的 MCP 接收方后,再设置 TELEGRAM_MCP_APPS_READY=true。在此之前只能注册,任何通话都不会带上 MCP 工具。
  • 为 MCP 请求单独设置专用出站地址不是代码中的条件,而是运营上的决定(因为这是来电者的话流向第三方服务器的出口)。
  • 开启后,日志中会出现 MCP tools on: pabal-mcp dials …; issuer …; ports …; own addresses N; clock by NTP …。deploy/ship.sh <服务器> <文件夹> --mcp 只是构建并发送三个镜像、复制 override 文件,不会开启任何东西(尚未在 Linux 的 Docker 上运行过)。

Apple Watch 入口(可选,准备中)

Apple Watch 应用不使用 MTProto,而是通过服务器的手表 API(HTTPS JSON,/api/watch/v1/)登录和拨打电话,通话声音经过音频入口(pabal-voice-edge,WSS)到达网关。不使用通话中继(coturn)。默认关闭。

手表的音频入口还无法从外部访问

deploy/docker-compose.voice.yml 中有音频入口 pabal-voice-edge(profile voice-watch,不带机密值,内部专用网络),但还没有服务器的手表 API 设置(TELEGRAM_WATCH_*)和反向代理中的手表路径,所以外部的手表无法访问。以下是设置值的说明,尚未在生产配置中运行过(只在开发服务器上测试过)。

设置默认值含义
TELEGRAM_WATCH_API_PORT—手表 API 端口。没有则手表 API 关闭
TELEGRAM_WATCH_API_HOST、TELEGRAM_WATCH_API_ANY_ADDRESS127.0.0.1、—监听地址。不是回环地址时需要 ANY_ADDRESS=true,并在前面放 TLS 代理
TELEGRAM_WATCH_AUDIO_URL—告诉手表的音频入口地址。路径必须正好是 wss://<主机>/api/watch/v1/audio。缺少或有误时,手表通话关闭
TELEGRAM_WATCH_ALLOW_PLAIN—允许 ws:// 地址。仅限开发(家中测试)
TELEGRAM_WATCH_SESSION_DAYS7手表登录保持的天数(1~365)
TELEGRAM_WATCH_AUTH_PER_MINUTE30整个服务器的登录验证码请求上限(每分钟)。每个地址另有上限
TELEGRAM_VOICE_MIN_BUILD_WATCHOS1显示完整确认窗口的第一个手表应用版本
  • 网关:VOICE_DIRECT=true 和 VOICE_EDGE_DIR(与入口共用的套接字文件夹,默认 /run/pabal-voice-edge)。手表通话只能用 16 kHz 引擎(Gemini)或回声引擎。
  • 音频入口:用同一个镜像的第二个服务运行 python -m pabal_voice.edge(VOICE_EDGE_HOST·VOICE_EDGE_PORT,默认 127.0.0.1·8096,VOICE_EDGE_DIR)。入口是最先解开来自互联网内容的地方,所以不要挂载任何机密,并把它放在只有代理和网关能访问的内部网络中。
  • 反向代理只转发两个手表路径——/api/watch/v1/audio(WebSocket,转给入口)和 /api/watch/v1/(转给手表 API)。手表 API 一侧,让代理把客户端地址追加到 X-Forwarded-For 的末尾(服务器按这个值统计每个地址的登录上限)。入口一侧,删除 X-Forwarded-For、X-Real-IP。
  • 不能在手表上注册,也不能给使用工具、MCP、自有密钥、音频流模式或机器人所有者文字记录的机器人打电话(WATCH_NOT_YET)。

保存对话(可选,准备中)

打电话的人在确认窗口中选择后,把通话中的对话以文字只保存在本人聊天中的功能(使用应用——语音机器人)。没有运营方的允许开关,以下条件全部具备时即会开启。

  • 隐私政策中的 id="bot-calls-keep-text" 一节(草稿:代码仓库的 docs/voice-bots/p0/privacy-*.draft.html)。这一节本身就是开关。应用通过 help.getAppConfig 的 pabal_voice_keep_text 得知是否显示复选框。
  • 网关 0.9.0 的 VOICE_KEEP_TEXT:使用模型引擎时默认 true。在回声引擎(包括仅用于音频流的网关)上设为 true 属于配置错误,网关不会启动。
  • 带复选框的应用版本。复选框文案要完成法律审查后才会放进正式版本。手表不显示未经审查的文案;TELEGRAM_VOICE_KEEP_TEXT_UNREVIEWED=true 是显示未审查文案的仅限内部测试服务器的开关。
  • 在开启了它的服务器上,所有非音频流模式机器人通话的 voice_call ended 最多晚 10 秒,并且会预先占用通话记录之后的 30 个消息编号(让机器人所有者无法知道来电者的选择)。
  • 通话结束后等待写入的对话文字只在内存中,如果这期间服务器重启,该通话的文字就不会保存。

机器人打来的电话(可选,等待法律审查)

语音机器人主动给许可了该机器人的人打电话的功能(语音机器人——机器人打来的电话)。条件比打给机器人的电话更多,在完成法律审查之前请不要开启。开启顺序:

  1. 服务器:用 scripts/voice-evidence-key.py 的 new 创建证据密钥文件夹(TELEGRAM_VOICE_EVIDENCE_KEY_DIR——700,放在会被备份的文件夹之外,比如数据和密钥文件夹之外;更换用 rotate,检查用 check),并把带许可窗口的第一个应用版本设为外呼最低版本,例如 TELEGRAM_VOICE_OUTBOUND_MIN_BUILD_ANDROID(环境变量)。只写在 .env 里的 TELEGRAM_VOICE_EVIDENCE_KEY_DIR 不会传到服务器——请把它加到 deploy/docker-compose.voice.yml 中 pabal-server 的 environment(容器内的路径)和 volumes(主机上的那个文件夹)。
  2. 应用:发布带许可窗口的应用版本。应用的许可链接规则只认识 pabal.me,如果是其他服务器,必须把该服务器的主机加入应用版本的链接规则,机器人发来的链接才会打开窗口(菜单和设置列表照常可用)。
  3. 法律:发布隐私政策的 id="bot-calls-outbound" 一节和服务条款的 id="voice-bot-owners-outbound" 一节(机器人所有者要同意条款,还需 voice-bot-owners 一节)。草稿在仓库的 docs/voice-bots/p0/ 中。许可窗口的文字和外呼提示录音(notice-out、apology-rule)的审查和批准也在这一步。
  4. 开关:在管理页面语音标签的机器人打来的电话中设定国家和限额,用确认文字开启(管理页面——语音标签)。每个机器人另有运营方的外呼审核。
  • 网关:只有 VOICE_OUTBOUND=true 的网关才会拨打机器人的电话。使用模型引擎时,四段外呼提示录音必须已批准才会启动;草稿录音(VOICE_OUTBOUND_DRAFT_NOTICES)仅限测试服务器。
  • 请不要在生产环境的 .env 中放 TELEGRAM_VOICE_CALLME_UNREVIEWED、TELEGRAM_VOICE_TEST_DIAL 这类仅限测试和开发的名字。
  • 机器人打来的电话同样不是端到端加密的——声音会经过本服务器的网关和 AI 提供方。

13. 位置消息中的地图图片(可选)

发送位置时,Android 应用和桌面(macOS、Windows)应用不会自己画出气泡中的地图,而是向服务器请求该地点的地图图片(upload.getWebFile)。服务器从 Google 的 Maps Static API 获取图片后转交。不开启时,Android 显示占位图片,桌面应用只在空白框中显示一个图钉。iPhone 在手机上自己绘制地图,与此设置无关;发送位置本身以及点开位置后的大地图,无论是否开启都能使用。

  1. 在 Google Cloud 控制台中选择一个已关联结算账号的项目,启用 Maps Static API。
  2. 创建 API 密钥:应用限制设为 IP 地址(服务器访问互联网时使用的公网 IP),API 限制只选 Maps Static API。这个密钥只放在服务器上,不会放进应用。
  3. 写入 .env,再用 docker compose up -d pabal-server 重启服务器。
  4. 在日志中确认。若处于关闭状态,会显示 Maps in location messages are off: … 及原因(密钥的值不会出现在日志中)。
STATIC_MAPS_KEY=AIza…               # 不要把密钥的值留在聊天、日志或代码仓库中
STATIC_MAPS_PER_DAY=300             # 每天新获取的图片数(0 = 不限)
STATIC_MAPS_PER_USER_HOUR=120       # 一个人每小时能让服务器新获取的图片数
INFO  TelegramServer - Maps in location messages from Google Static Maps (120 new per person an hour, 300 a day)
  • 费用:Maps Static API 每月 1 万次以内免费。服务器会把获取的图片记住一天,再次交给请求同一地图的设备,再次交出的图片不计入限额。按默认限额(每天 300 次),每月约 9 千次,在免费范围内。超过限额的请求会以 FLOOD_WAIT 拒绝,在此期间应用显示占位图片。
  • 隐私政策:共享位置的坐标(纬度、经度)会发送给 Google——不会发送是谁发送的。请在管理页面 → 设置 → 运营方信息的委托处理和境外转移中填写 Google LLC(Maps Static API,美国)。
  • 如果日志中出现 Map picture not made: Google Static Maps answered 403: …,请检查密钥的限制(IP、API)或结算账号。

运维

状态与日志

cd /opt/pabal/deploy
docker compose ps
docker compose logs --since 1h pabal-server
tail -f /srv/pabal/pabal_server/logs/telegram-server.log

更新

cd /opt/pabal && git pull
cd deploy
./backup.sh                                     # 先备份
docker compose up -d --build pabal-server             # 用新镜像只替换服务器
docker compose logs --since 5m pabal-server | grep -E "Website|ERROR"

在服务器重启的几十秒内,应用会断开,然后无需重新登录就会自动重新连接。数据表的变更会在服务器启动时自动应用。绑定目录与镜像无关,因此 docker compose down 或删除镜像都不会删除数据——只要不删除 /srv/pabal 就行。

回滚:即使回到上一个镜像,新版本已经写入数据库的内容也会保留。旧版本可能读不懂新版本写的记录(例如带有语音机器人代码的版本,即使不开启语音,也会写入聊天中的通话记录等新事件),所以回滚时要同时恢复更新前那份备份中的数据库(恢复)。

备份

./backup.sh            # → /srv/pabal/backups/<日期-时间>/{pabal.dump, server-keys-data.tar.gz}
crontab -e             # 每天 03:00:
# 0 3 * * * /opt/pabal/deploy/backup.sh >> /srv/pabal/backups/backup.log 2>&1
  • 超过 14 天的备份会被自动删除(可以像 KEEP_DAYS=30 ./backup.sh 这样修改)。
  • 请同时复制到其他地方。放在同一块磁盘上的备份扛不住磁盘故障。
  • 备份中包含 RSA 私钥以及短信、SMTP 的机密值。存放备份的地方也要像服务器一样严加保护。

恢复

cd /opt/pabal/deploy
B=/srv/pabal/backups/20260920-030000            # 要恢复的备份
docker compose stop pabal-server pabal-caddy
docker compose exec -T pabal-postgres dropdb -U pabal pabal
docker compose exec -T pabal-postgres createdb -U pabal pabal
docker compose exec -T pabal-postgres pg_restore -U pabal -d pabal --no-owner < $B/pabal.dump
docker compose exec -T pabal-postgres psql -U pabal -d pabal -c "ALTER DATABASE pabal SET search_path = public"   # 数据库的默认路径——恢复不会带回它,需重新设置
sudo tar -C /srv/pabal/pabal_server -xzf $B/server-keys-data.tar.gz
sudo chown -R 1000:1000 /srv/pabal/pabal_server
docker compose up -d

迁移到新服务器时也一样:完成第 1~5 步后,不执行第 6 步,而是执行上面的恢复再启动。恢复同一个密钥后,就不需要重新构建应用(如果服务器 IP 变了,则需要重新构建)。

启动与停止

docker compose restart pabal-server       # 只重启服务器
docker compose stop                 # 全部停止(数据保持不变)
docker compose up -d                # 重新启动

即使服务器主机重启,容器也因为设置了 restart: unless-stopped 而会自动重新启动。

安全检查清单

  • .env 权限为 600,POSTGRES_PASSWORD 为随机值
  • /srv/pabal/pabal_server/keys 权限为 700,私钥除了备份之外不存在于其他任何地方
  • (如果启用了推送)firebase-service-account.json 权限为 600,代码仓库和 .env 中没有它的内容
  • (如果启用了 iPhone 推送)AuthKey_<密钥 ID>.p8 权限为 600,代码仓库和 .env 中没有它的内容
  • 防火墙:只开放 22(尽量只对运营方 IP)、80、443、8443
  • 从外部访问 https://域名/admin/ 返回 404
  • 管理页面:测试号码(테스트 번호)已关闭,验证码发送方式(코드 전달 방식)为短信/电子邮件,测试发送(테스트 발송)成功
  • 没有开启 TELEGRAM_WEBHOOK_ALLOW_LOCAL(防止机器人 Webhook 访问内部网络)
  • (如果开启了语音机器人)链路机密值、Vertex 密钥和主密钥只能由各自读取它的用户读取(400/600),且主密钥位于 /srv/pabal 之外,不会进入备份。链路端口没有公开,链路网络只有服务器和网关使用。生产服务器上没有 TELEGRAM_VOICE_NOTICE_CHECK·TELEGRAM_VOICE_TESTERS=*
  • SSH:关闭密码登录,只允许密钥登录
  • 确认备份 cron 和外部复制,并演练一次恢复
  • 开启服务器操作系统的自动安全更新(unattended-upgrades)

故障排除

症状原因 → 解决方法
应用一直停在“正在连接…”① 8443 被拦截 → nc -vz IP 8443,检查 ufw 和云安全组 ② 应用是用其他公钥构建的 → 用这台服务器的密钥重新执行第 9 步 ③ PUBLIC_IP 有误 → 修改 .env 后执行 docker compose up -d pabal-server
一开始能连上,但很快断开应用会转到服务器告知的地址(PUBLIC_IP:MTPROTO_PORT),而这个地址有误 → 检查 PUBLIC_IP
HTTPS 证书错误DNS 尚未指向这台服务器,或者 80 端口被拦截 → dig +short 域名、docker compose logs pabal-caddy
server 不断重启,password authentication failed在创建数据之后修改了 POSTGRES_PASSWORD → 改回原来的值,或者在数据库中执行 ALTER USER pabal PASSWORD '…'
AccessDeniedException: /app/keys/…绑定目录的所有者不对 → sudo chown -R 1000:1000 /srv/pabal/pabal_server
OutOfMemoryError、运行变慢调高 JAVA_OPTS 中的 -Xmx,然后执行 docker compose up -d pabal-server
收不到注册验证码查看管理页面注册验证码(가입 코드)标签页中的发送状态和失败原因 → 检查设置中的短信、SMTP 值,并进行测试发送
Android 收不到通知① 日志中有 Push notifications are off → 检查 FCM_CREDENTIALS 路径和文件权限 ② 没有 Push device registered → 应用构建时没有 Firebase 配置(pabal-placeholder),或者还没有打开新应用 ③ 显示 sent 却没有弹出 → 检查手机的通知权限和电池设置,以及应用是否被“强行停止” ④ SENDER_ID_MISMATCH … Registrations are kept → 服务器的密钥和应用的 google-services.json 来自不同的项目 ⑤ answered 404 without naming the token → FCM_PROJECT_ID 有误 ⑥ refused an access token → 密钥已被废弃,或是其他项目的密钥(5 分钟内不会再次请求)
iPhone 收不到通知① 日志中有 iPhone push notifications are off → 检查 APNS_KEY 路径、文件权限和文件名(AuthKey_<密钥 ID>.p8) ② 没有 type 1 登记 → 没有在应用中允许通知,或者用的是模拟器 ③ 有登记却什么都没发出 → 该构建低于 APNS_MIN_BUILD(登记行中的 build),或者是已关闭通知的对话 ④ APNs refused the provider token → 密钥已被废弃,或 APNS_TEAM_ID 与密钥所属的团队不同(5 分钟内不会再次发送) ⑤ DeviceTokenNotForTopic … Registrations are kept → APNS_TOPIC 与应用的 Bundle ID 不同 ⑥ 显示 sent 却只看到“You have a new notification” → 通知扩展没能解密(如果重装过应用,请打开一次以重新登记) ⑦ 来电只以通知到达 → APNS_VOIP_MIN_BUILD 为空,或构建低于该值(这是预期行为)
无法进入管理页面SSH 隧道是否开着;自己电脑上的 8080 是否被其他程序占用(改用 -L 18080:127.0.0.1:8080,并访问 localhost:18080)
network ssemiya-net declared as external, but could not be found网络尚未创建 → docker network create ssemiya-net(若用其他名称,设置 .env 中的 PABAL_NETWORK)

已知限制

  • 单服务器架构。启动时会把所有账号、对话和消息加载到内存中。暂不支持拆分到多台服务器的水平扩展,数据增多时需要增加内存。
  • 等待中的注册验证码、机器人尚未取走的更新、等待写入的保存对话文字都保存在内存中,服务器重启后就会丢失。
  • 注册验证码的请求有间隔和每日上限,但其他所有请求都还没有频率限制。刚对外公开后,请经常查看日志和仪表盘。
  • 推送通知只发往手机应用——Android(第 10 步)和 iPhone(第 11 步)。iPhone 只发往 APNS_MIN_BUILD 及以上的构建(Pabal 1.0 (3) 不在其内),来电的 CallKit 界面要在设置 APNS_VOIP_MIN_BUILD 之后才会启用。macOS、Windows 应用在运行期间接收消息。
  • 按对话设置的“关闭通知”只保存在服务器内存中。已关闭通知的对话的推送,发往 Android 时使用非紧急优先级(由手机自行过滤),不发往 iPhone;但服务器重启后,两者都会照常发出(应用只在更改设置时才会重新告知服务器)。如果已关闭的应用从被唤醒到连上服务器用了 20 秒以上,来电可能会在拨打方那边以无人接听结束。
  • 消息附件支持照片和文件,能在应用内直接播放的视频和语音消息尚未支持。频道、超级群组、群组通话和两步验证也尚未支持。
  • 授权密钥以明文形式保存在数据库中(服务器解密时需要)。请像保护 RSA 密钥一样保护数据库和备份。

文件

文件作用
deploy/docker-compose.yml生产配置(pabal-server · pabal-postgres · pabal-caddy,绑定挂载)
deploy/CaddyfileHTTPS、拦截管理路径、转发网站、文档和 Bot API
deploy/.env.example配置样例 → deploy/.env
deploy/backup.sh数据库转储 + 密钥和数据归档,清理旧备份
Dockerfile服务器镜像(构建 → JRE 21,uid 1000)
voice/语音网关 pabal-voice(源代码、Dockerfile、README.md、链路规范 PROTOCOL.md)
deploy/docker-compose.mcp.yml语音机器人的三个 MCP 组件容器(配置档 mcp)
deploy/docker-compose.voice.yml添加语音机器人组件的文件——服务器的语音设置和链路网络、网关 pabal-voice(profile voice)、手表的音频入口 pabal-voice-edge(profile voice-watch)
deploy/ship.sh在另一台电脑上为服务器的 CPU 构建服务器镜像(加 --voice 时也构建网关镜像),通过 SSH 载入并复制部署文件
deploy/voice_readiness.py开启语音前的检查(Vertex 密钥、法律条款、应用版本、中继……)——不输出机密值
deploy/turnserver.conf.example, deploy/check_relay.py直接安装在 Mac 服务器上的 coturn 配置样例,以及检查中继的工具