Tinode 0.25.3 常用 CLI 命令详解
Tinode 0.25.3 常用 CLI 命令详解:tinode 启动参数、tn-cli 全命令集与宏、REST 长轮询 curl 调试矩阵与 WebSocket 帧级调试,全部实测回显逐行沉淀。
Tinode 0.25.3 常用 CLI 命令详解
目标:把 Tinode v0.25.3 日常运维会用到的四类命令入口一次讲透:服务端
tinode二进制的启动参数、官方 Python 命令行客户端tn-cli的完整命令集(会话、话题、账号、文件四组命令加 8 个宏)、不装客户端时用curl驱动 REST/长轮询通道的调试矩阵,以及用裸 WebSocket 做的帧级调试。重点沉淀实测边界:409 must attach first、useradd缺--cred的NoneType异常、单行.await不被识别、file命令 gRPC 桩缺失、大文件下载 307 双拼路径、长轮询缺Content-Type引发的409 command out of sequence。本文所有命令与回显均来自真实服务器实测,与安装篇、配置篇同一环境。
一、环境信息
本文的命令实验在以下环境完成(与《Ubuntu 22.04 安装 Tinode 0.25.3 指南》同一台服务器,版本自证取自 tn-cli 连接横幅与服务器 -help 输出):
| 项目 | 实测值 |
|---|---|
| 主机名 | tinode-sys |
| 操作系统 | Ubuntu 22.04.5 LTS |
| 服务端版本 | Tinode v0.25.3(-config 读取失败时的启动日志显示 Server v0.25:/opt/tinode-app/tinode:v0.25.3) |
| 数据库 | MySQL 8.0.46(适配器 build: mysql:v0.25.3) |
| HTTP 端点 | 6060(REST/长轮询/WebSocket/Web 控制台共用) |
| gRPC 端点 | 16060(tn-cli 专用) |
| tn-cli 版本 | Version 3.1.0/0.22.3; gRPC/1.85.0; Python 3.10.12(启动横幅实测) |
| apikey | AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy(安装篇 keygen 生成,C1 演示同值) |
| 实验账号 | 管理员 admin(uid usr2N8sq7g8bwU,安装篇 init-db 创建);本文实验另注册 alice/bobby/grace/harry/wanda 及多个演示群组 |
除特别注明「在服务器上」执行数据库查询外,其余命令均在服务器本机的 shell 中执行(localhost 直连)。
二、命令工具总览
2.1 四个命令入口
v0.25.3 的日常操作分散在四个入口里,传输通道与能力边界各不相同:
| 入口 | 形态 | 连接端点 | 定位 |
|---|---|---|---|
tinode | 服务端二进制 | 监听 6060/16060 | 唯一服务进程,所有配置经它生效(参数详见第三章) |
tn-cli | Python 客户端(官方 release 附件) | gRPC localhost:16060 | 运维管理客户端:账号/话题/消息/文件命令 + .await 等宏,支持脚本化 |
curl + REST/LP | HTTP JSON | /v0/channels/lp?apikey=... | 不装客户端时的接口级调试,逐包发收(详见第十章) |
| 裸 WebSocket | HTTP 升级帧 | /v0/channels?apikey=... | 帧级调试,验证协议包顺序(详见第十一章) |
四个入口收发的业务包(hi/login/sub/pub/get/set/del/note)是同一套,差别只在封装:gRPC 走二进制桩,REST/长轮询与 WebSocket 走 JSON 文本。选型篇介绍的「三传输端点」落到命令层就是这张表。
2.2 tn-cli 与 REST 的分工
- tn-cli 有而 REST 没有的:宏命令(
useradd/userdel/usermod/userpasswd/resolve)——这类管理操作是 tn-cli 在客户端把一个命令展开成多个协议包完成的;.await/.must/.sleep等脚本控制宏也只在 tn-cli 里存在。 - REST 有而 tn-cli 弱的:大文件上传下载(
/v0/file/u/与/v0/file/s/),以及一切需要拿原始 JSON 回显做抓包分析的场景。 - 两者共享服务器能力:
hi横幅里下发的参数(maxFileUploadSize: 8388608、msgDelAge: 600等)在 tn-cli 连接串与 RESThi回执里完全一致(可对照第 5.1 与 10.2 节)。
💡 技术拓展:Tinode 把协议设计成「包 + 通道」两层。同一个
{pub:{...}}包,走 gRPC 时是 protobuf 消息、走 REST 时是 HTTP POST 的 JSON body、走 WebSocket 时是一帧文本,服务端处理逻辑只有一份(配置篇grpc_listen、api_path两个键分别控制 gRPC 与 HTTP 的入口)。因此本文第三章、第九~十一章的实测结论可以互相印证:409 must attach first这类错误码在三条通道上语义相同。
三、服务端 tinode 启动参数详解
3.1 -help 实测全量输出
在服务器上执行 ./tinode -help(安装目录 /opt/tinode-app),回显共 12 个参数、25 行:
Usage of ./tinode:
-api_path string
Override the base URL path where API is served.
-cluster_self string
Override the name of the current cluster node.
-config string
Path to config file. (default "tinode.conf")
-expvar string
Override the URL path where runtime stats are exposed. Use '-' to disable.
-grpc_listen string
Override address and port to listen on for gRPC clients.
-listen string
Override address and port to listen on for HTTP(S) clients.
-log_flags string
Comma-separated list of log flags (as defined in https://golang.org/pkg/log/#pkg-constants without the L prefix) (default "stdFlags")
-pprof string
File name to save profiling info to. Disabled if not set.
-pprof_url string
Debugging only! URL path for exposing profiling info. Disabled if not set.
-server_status string
Override the URL path where the server's internal status is displayed. Use '-' to disable.
-static_data string
File path to directory with static files to be served. (default "static")
-tls_enabled
Override config value for enabling TLS.
3.2 参数逐项说明
| 参数 | 作用 | 与 tinode.conf 的关系 |
|---|---|---|
-config | 配置文件路径 | 唯一不是「覆盖」的参数,指定 conf 本身;默认值 tinode.conf(相对当前目录) |
-listen | 覆盖 HTTP(S) 监听地址端口 | 对应 listen 键(conf 中 ":6060") |
-grpc_listen | 覆盖 gRPC 监听地址端口 | 对应 grpc_listen 键(conf 中 ":16060") |
-api_path | 覆盖 API 基路径 | 对应 api_path 键(默认 /v0/) |
-expvar | 覆盖运行时指标路径,传 - 禁用 | 对应 expvar 键(默认 /debug/vars,用法见 10.6) |
-server_status | 覆盖服务器内部状态页路径,传 - 禁用 | 对应 server_status 键 |
-static_data | 静态文件目录 | 对应 static_data 键(默认 static,Web 控制台托管见安装篇) |
-tls_enabled | 覆盖 TLS 开关 | 对应 tls_enabled 键 |
-log_flags | 日志标志位 | 对应 log_flags 键(默认 stdFlags) |
-cluster_self | 覆盖当前集群节点名 | 对应 cluster 配置中的节点名;单机部署无用 |
-pprof | 把性能剖析信息写入指定文件 | conf 无同名键;未设置即禁用 |
-pprof_url | 性能剖析 HTTP 路径 | conf 无同名键;官方注释明确「Debugging only!」 |
要点:命令行参数都是 conf 同名键的覆盖器(-pprof/-pprof_url 除外)。优先级为「命令行 > conf > 内置默认」。日常运维保持 tinode -config tinode.conf 一个参数即可——本文实测服务器上 systemd 托管的进程就是这一形态:
$ ps -ef | grep [t]inode
root 6307 1 0 13:06 ? 00:00:00 /opt/tinode-app/tinode -config /opt/tinode-app/tinode.conf
3.3 边界实测:-config 指向不存在的文件
$ ./tinode -config /tmp/nope.conf
I2026/10/26 13:45:54 Server v0.25:/opt/tinode-app/tinode:v0.25.3; pid 6401; 6 process(es)
I2026/10/26 13:45:54 Using config from '/tmp/nope.conf'
E2026/10/26 13:45:54 Failed to read config file: open /tmp/nope.conf: no such file or directory
版本横幅先打印,接着回显 Using config from '...',然后以 E 级错误行退出、进程不监听任何端口。这与配置篇的结论一致:配置错误一律表现为启动失败并打 E 级日志,不会静默回退默认配置。
四、tn-cli 客户端:获取、安装与连接
4.1 tn-cli 是什么与获取方式
tn-cli 是官方仓库 tn-cli/ 目录随 release 发布的 Python 客户端,v0.25.3 对应附件 tn-cli.tar.gz(约 18 KB)。解压后 10 个文件(tar tzf tn-cli.tar.gz 实测):
./
./sample-script.txt
./requirements.txt
./client.py
./tn_globals.py
./tn-cli.py
./utils.py
./sample-macro-script.txt
./macros.py
./input_handler.py
./commands.py
依赖清单 requirements.txt 实测内容:
futures>=3.2.0; python_version<'3'
grpcio>=1.40.0
Pillow>=5.4.1
requests>=2.21.0
tinode-grpc>=0.22.0
prompt_toolkit>=2.0.10
importlib-metadata>=1.0; python_version<'3.8'
服务器上有 Python 3.10 即可安装:pip3 install -r requirements.txt。注意它并不随发行包内置,需要单独下载附件(本文装在服务器本机,与 gRPC 端点同机)。
4.2 启动与登录横幅
以管理员身份启动(--host 显式指定 gRPC 端点、--no-cookie 不落登录 cookie、--login-basic 直接 basic 登录):
$ cd tn-cli
$ python3 tn-cli.py --host localhost:16060 --no-cookie --login-basic admin:q1w2e3r4
Tinode command line client. Version 3.1.0/0.22.3; gRPC/1.85.0; Python 3.10.12.
Server at 'localhost:16060'
Logging in with login:password admin:q1w2e3r4
<= <= Connected to server: maxTagCount: 16; servingAt: http://:6060/; maxSubscriberCount: 128; reqCred: None; build: mysql:v0.25.3; maxFileUploadSize: 8388608; maxMessageSize: 131072; minTagLength: 2; maxTagLength: 96; msgDelAge: 600; clusterSize: 1; ver: 0.25
<= <= 201 created
<= Authenticated as usr2N8sq7g8bwU
<= <= 200 ok
四行关键回显的语义:
- 版本横幅:
tn-cli 3.1.0,内嵌 gRPC 包tinode-grpc 0.22.3+grpcio 1.85.0,Python 3.10.12。 Connected to server: ...:服务端hi回执的参数串,与 REST 通道hi返回的 JSON 完全同源(ver: 0.25、build: mysql:v0.25.3、reqCred: None——本环境未启用注册凭据强制校验,见配置篇)。201 created:会话建立(gRPC 通道会话创建与登录合并,login包成功前先回会话确认)。Authenticated as usr2N8sq7g8bwU+200 ok:登录成功,uid 即安装篇 init-db 创建的admin。
若不带 --login-basic 启动,tn-cli 会在横幅后报凭据缺失并退出,不进入交互界面;若 --host 不写,默认即 localhost:16060。
4.3 会话 cookie 与 --no-cookie
tn-cli 默认把登录 token 存到当前目录的 .tn-cli-cookie(实测为一个 165 字节文本文件,内容是 base64 的 token 与有效期),下次启动免登录。多脚本并行跑同一账号时建议统一加 --no-cookie,避免 cookie 互相覆盖——本文所有实测均带 --no-cookie。
4.4 命令总表与在线帮助
在交互界面敲一个不存在的名字(例如把启动参数误敲进来),tn-cli 会打印完整命令总表——这张表本身就是最好的帮助页(实测原文):
tn> Unrecognized: --login-basic
Possible commands:
.await - wait for completion of an operation
.delmark - custom delete marker to use instead of default DEL!
.exit - exit the program (also .quit)
.log - write value of a variable to stdout
.must - wait for completion of an operation, terminate on failure
.sleep - pause execution
.use - set default user (on_behalf_of) or topic
.verbose - toggle logging verbosity on/off
acc - create or alter an account
del - delete message(s), topic, subscription, or user
file - download or upload a large file
get - query topic for metadata or messages
leave - detach or unsubscribe from topic
login - authenticate current session
note - send a notification
pub - post message to topic
set - update topic metadata
sub - subscribe to topic
upload - upload file out of band over HTTP(S)
usermod - modify user account
Type <command> -h for help
Macro commands:
chacs - Change default permissions/acs for a user (requires root privileges)
chcred - Add/delete/validate credentials (requires root privileges)
passwd - Set user's password (requires root privileges)
resolve - Resolve login and print the corresponding user id
thecard - Print theCard for a user (requires root privileges)
useradd - Create a new user account
userdel - Delete user account (requires root privileges)
usermod - Modify user account (requires root privileges)
每个命令都支持 <command> -h 查用法。Macro commands 段里 useradd/resolve 未标注 root 权限要求(useradd 本质是把 acc 组包),而 userdel/usermod/chacs/chcred/passwd/thecard 明确标注 requires root privileges——需要 ROOT 登录态(配置篇 6.1 的 :admin ROOT 账号场景)。
五、会话与登录命令(login / get)
5.1 login 登录
交互界面内的登录命令用法(login -h 实测 usage):
usage: login
[-h]
[--scheme SCHEME]
[--secret SECRET]
[--uname UNAME]
[--password PASSWORD]
[--cred CRED]
[--as_root]
[secret]
实测登录(进入空会话后):
tn> login --uname admin --password q1w2e3r4
<= <= Connected to server: clusterSize: 1; maxMessageSize: 131072; servingAt: http://:6060/; minTagLength: 2; maxTagLength: 96; maxFileUploadSize: 8388608; maxTagCount: 16; reqCred: None; maxSubscriberCount: 128; msgDelAge: 600; ver: 0.25; build: mysql:v0.25.3
<= <= 201 created
<= Authenticated as usr2N8sq7g8bwU
<= <= 200 ok
回显顺序:hi 能力串 → 201 created(会话)→ Authenticated as ... → 200 ok(登录成功)。
5.2 边界:同一会话重复登录
同一 tn-cli 会话内再执行一次 login(实测回显):
tn> login --uname admin --password q1w2e3r4
<= <= 409 already authenticated
一个会话只允许认证一次;要换账号就退出重开(.exit 后重新启动 tn-cli),不要在已登录会话里重复 login。
5.3 get 查询个人资料
get 是读命令,get me --desc 拉自己的资料卡:
tn> get me --desc
<= <= meta desc me
tn-cli 把资料卡渲染成缩进块打印(受终端宽度影响);要看原始 JSON,用第九章的 .verbose 打开原始包回显即可。get -h 的完整 usage(实测):
usage: get
[-h]
[--desc]
[--sub]
[--data]
[--del]
[--cred]
[--tags]
[--user USER]
[topic]
六、话题操作命令(sub / pub / get / del)
6.1 sub:创建群组并订阅
sub --topic new 是「建群」语义,可同时带 --fn 公开名称、--tags 检索标签、--get-query 让本次 sub 顺带回元数据(实测,命令与回显均真实):
tn> sub --topic new --fn 项目讨论组 --tags project-x,demo --get-query sub
<= <= 200 ok (grpFUJdHPLTPCI)
<= <= meta sub grpFUJdHPLTPCI
服务器分配的群组 topic 名就是括号里的 grpFUJdHPLTPCI,后续所有操作都用它。注意 tn-cli 的 sub 对新话题同时完成 attach(挂接会话)与订阅,之后可直接 pub/get。
6.2 边界:不 attach 就操作 → 409/403
在新会话里对已有群组不先 sub 直接删消息、读消息(实测回显,del 与 get 的回执按序各占一行):
tn> del msg --topic grpFUJdHPLTPCI --seq 1 --hard
tn> get grpFUJdHPLTPCI --data
<= <= 409 must attach first (grpFUJdHPLTPCI)
<= <= 403 permission denied (grpFUJdHPLTPCI)
规律:写操作(del,pub 同理)被拒为 409 must attach first,读操作(get data)被拒为 403 permission denied——错误码不同但根因相同:会话与话题之间没有 attach 关系。这是 Tinode 实测中出现频率最高的错误码,处置永远是先 sub --topic <群组名>。
6.3 pub:发送消息与引号边界
attach 之后发送消息,--head mime:text/plain 声明正文类型,--content 是消息体(实测):
tn> .await pub --topic grpNQlnZQDtAYg --head mime:text/plain --content "tn-cli await chain test"
<= <= 200 ok (grpNQlnZQDtAYg)
attach(sub)与 pub 的成功回执在 tn-cli 中显示形式相同,都是 200 ok (话题名),按出现顺序区分即可。两个实测边界都出在 --content 不加引号时:
| 输入 | 实测回显 | 原因 |
|---|---|---|
pub --content tn-cli await chain test(不带引号) | pub: error: unrecognized arguments: test | shlex 按空格切词,多出来的词落不进任何参数 |
pub --topic new --content tn-cli group message(不带引号) | 409 must attach first (group) | 第一个多余的词 group 被同名位置参数 [topic] 吃掉,覆盖了 --topic 的值,消息发去了不存在的话题 group |
第二条尤其隐蔽:不报语法错误,而是把消息发到一个错误的话题名上。多词消息体必须整体加引号。
6.4 get:读取消息
attach 后拉取消息流(实测,--head 值按 tn-cli 原样打印为字节字面量):
tn> .await get grpNQlnZQDtAYg --data
From: usr2N8sq7g8bwU
<= Topic: grpNQlnZQDtAYg
<= Seq: 1
<= Headers:
<= mime: b'"text/plain"'
<= tn-cli await chain test
<= <= 208 delivered (grpNQlnZQDtAYg)
Seq: 1 是该话题内消息序号(服务端按话题单调递增,REST 通道回执的 params.seq 与此一致),208 delivered 是 tn-cli 自动发送的已读回执确认。
6.5 del:删除消息
硬删除(--hard 对所有人删除)第 1 条消息(实测):
tn> .await del msg --topic grpNQlnZQDtAYg --seq 1 --hard
<= <= 200 ok (grpNQlnZQDtAYg)
del 的取值边界也在实测里(客户端侧直接拒绝,不发包):
| 输入 | 实测回显 | 说明 |
|---|---|---|
--seq 1 | 200 ok | 单条删除,--seq N 即第 N 条 |
--seq "1-1" | Invalid message ID range 1-1 | 区间必须低<高,1-1 不是合法区间;单条就用 --seq N |
del 还支持 --topic 下的 del topic(删群)、del sub(退订他人)等形态,用法同理查 del -h。
七、账号管理命令(acc / useradd / resolve / set)
7.1 acc:创建账号
acc --user new 注册新账号(匿名会话或已登录会话均可执行;实测):
tn> acc --user new --uname grace --password q1w2e3r4 --fn "Grace Hopper" --cred email:grace@example.com
<= <= 201 created
--cred email:... 提交邮箱凭据;本环境 reqCred: None(未强制验证),账号即建即用。同法注册了 harry(--fn "Harry Potter"),实测同样 201 created。
7.2 useradd 宏:-P 位置参数边界
useradd 是 tn-cli 客户端宏,第一个参数是位置参数 login,密码用 -P:
tn> useradd --uname wanda --password q1w2e3r4 --fn "Wanda Maximoff"
[-h]
[-P PASSWORD]
[--cred CRED]
[--name NAME]
[--comment COMMENT]
[--note NOTE]
[--trusted TRUSTED]
[--tags TAGS]
[--avatar AVATAR]
[--auth AUTH]
[--anon ANON]
[--explain]
[--as_root]
login
useradd: error: unrecognized arguments: --uname --fn Wanda Maximoff
要点:useradd 没有 --uname/--password/--fn 这几个参数——用户名是位置参数,密码是 -P,显示名是 --name。正确形态:
useradd <login> -P <password> --name "<显示名>" --cred email:<邮箱>
7.3 边界:缺 --cred 的 NoneType 异常
省略 --cred 执行 useradd wanda -P q1w2e3r4 --name "Wanda Maximoff"(实测):
tn> useradd wanda -P q1w2e3r4 --name "Wanda Maximoff"
<= <= 201 created
<= Authenticated as usr2N8sq7g8bwU
<= <= 200 ok
<= Exception in generator: 'NoneType' object has no attribute 'synchronous'
macro 源码在缺 --cred 时只打印一句提示并返回 None,主循环随后对该空结果取 .synchronous 属性,于是抛出 NoneType 异常。此时账号并没有被创建——服务器上直接查库确认(实测):
$ mysql tinode -e 'SELECT id,uname FROM auth WHERE uname="basic:wanda"'
(空结果,无 basic:wanda 行)
遇到 Exception in generator: 'NoneType' ...,第一反应应检查是否漏了 --cred。
7.4 完整创建 wanda 并三路验证
补上 --cred 后完整执行(实测):
tn> useradd wanda -P q1w2e3r4 --cred "email:wanda@example.com" --name "Wanda Maximoff"
<= <= 201 created
tn> resolve wanda
<= usrpC22fQJIPU4
三路验证互为闭环:
- 数据库(服务器上查询 auth 表,实测):
SELECT id,uname,userid,authlvl FROM tinode.auth WHERE uname="basic:wanda"→6 | basic:wanda | 1588833251621867520 | 20; - tn-cli resolve:
usrpC22fQJIPU4(与库中 userid 同一账号); - REST/长轮询登录:用
basicscheme、base64 的wanda:q1w2e3r4发 login,实测回执{"ctrl":{"id":"2","code":200,"text":"ok","params":{"user":"usrpC22fQJIPU4","authlvl":"ImF1dGg=",...}}}——uid 与 resolve 输出一致,账号可登录。
7.5 resolve 与 set 的边界
resolve的登录名同样是位置参数,resolve --login wanda会报resolve: error: unrecognized arguments: --login(实测);正确写法resolve wanda。当账号不存在时,resolve的 fnd 查询返回空数组,宏里.log $temp.sub[0].topic取不到下标,回显Error in '.log $temp.sub[0].topic': sub——这行 Error 就是「账号不存在」的信号。set me的实测边界:set me --fn "Admin Operator"与set me --public '{...}'均返回304 not modified且数据库users.public维持{"fn": "Administrator"}不变(实测),而set me --private "operator console"返回200 ok (me)且库中 me 话题订阅行的private字段更新为"operator console"。结论:tn-cli 0.22.3 的set me --fn/--public在 gRPC 通道上不生效(服务器按「无变化」应答 304),要改公开资料请走 REST/WS 通道的{set desc}(安装篇 8.3 有实测 200 闭环);--private正常可用。
八、文件命令(file / upload)
8.1 file:gRPC 通道上传(实测不可用)
file 命令设计上走 gRPC 大文件通道,实测报桩缺失(加 .verbose 抓到原始包):
tn> .await file --what up /tmp/d1-upload.txt
<= Failed to upload '/tmp/d1-upload.txt': 'NodeStub' object has no attribute 'LargeFileReceive'
<= Timeout while waiting for 'file' response
'NodeStub' object has no attribute 'LargeFileReceive' 是 Python 端 tinode-grpc 桩与官方 tn-cli 3.1.0 代码不匹配导致的:tn-cli 调用 LargeFileReceive 流式 RPC,而安装的桩里没有该方法,客户端在发包前就失败,随后 .await 等不到回执超时。结论:本版本组合下 file 上传不可用,文件传输用 upload。 另一处用法边界(实测):file up /tmp/d1-upload.txt(把 up 当位置参数)会打印 usage 并报 file: error: unrecognized arguments: /tmp/d1-upload.txt——方向必须写成 --what up/--what down。
8.2 upload:HTTP 通道上传(可用)
upload 走 HTTP 大文件端点(与第十章 curl 用的同一个 /v0/file/u/),需要 --web-host 指定 HTTP 地址(实测):
$ python3 tn-cli.py --host localhost:16060 --no-cookie --login-basic admin:q1w2e3r4 --web-host localhost:6060
...
tn> .await upload /tmp/d1-upload.txt
<= <= 200 ok
服务器侧核对(实测):上传目录 /opt/tinode-app/uploads/ 同步新增一个 17 字节的对象文件(与被上传的 /tmp/d1-upload.txt 字节数一致),专名即下载 url 末段。下载侧与 REST 完全一致(返回的 params.url 拼到 http://localhost:6060 后 GET,见 10.5)。
九、宏命令与脚本化执行
9.1 宏总表
| 宏 | 作用 | 实测回显特征 |
|---|---|---|
.await <命令> | 等待该命令完成再继续 | 无独立回显,命令回执按序出现 |
.must <命令> | 同 .await,失败即终止脚本 | 失败时进程退出 |
.sleep <毫秒> | 暂停脚本 | Pausing for 1200ms... |
.use --topic <t> | 设置默认话题 | Default topic='grpNQlnZQDtAYg' |
.verbose | 开关原始包回显 | 出现 <= => 发包行与 JSON 回包行 |
.log <变量> | 打印变量 | 成功打印值 / Error in '.log ...': ... |
.delmark | 自定义删除标记 | (本文未实测) |
.exit | 退出(.quit 同义) | Shutting down... |
用户管理宏(useradd/userdel/usermod/userpasswd/resolve)在客户端展开成协议包,用法见第七章。
9.2 .await 同行衔接全链
.await 必须与被等待的命令写在同一行,脚本化才能保证顺序(实测三连):
tn> .await sub --topic grpNQlnZQDtAYg
<= <= 200 ok (grpNQlnZQDtAYg)
tn> .await pub --topic grpNQlnZQDtAYg --head mime:text/plain --content "tn-cli await chain test"
<= <= 200 ok (grpNQlnZQDtAYg)
tn> .await get grpNQlnZQDtAYg --data
<= <= 208 delivered (grpNQlnZQDtAYg)
(随后打印消息内容块,见 6.4)
attach → pub → get 按序完成。把 .await 拆成单独一行喂进去则是另一个结局(见 9.3)。
9.3 边界:单行 .await 不被识别
tn> .await
Unrecognized: .await
Possible commands:
(打印 9.4 所示完整命令表)
源码层面:tn-cli 的输入解析只在 .await/.must 后面跟着命令时才进入等待分支,单独一行的 .await 解析不出命令,落到兜底分支按「不识别的名字」处理。因此**.await 永远不能独占一行**,管道喂脚本时尤其容易踩(脚本里想换行分步,实际得到一串 Unrecognized 与乱序回执)。
9.4 .use 与 .sleep
tn> .await sub --topic grpNQlnZQDtAYg
<= <= 200 ok (grpNQlnZQDtAYg)
tn> .use --topic grpNQlnZQDtAYg
<= Default topic='grpNQlnZQDtAYg'
tn> .sleep 1200
<= Pausing for 1200ms...
此后 pub/get 可省略 --topic,默认落到该群组;脚本里控制节奏用 .sleep <毫秒>。
9.5 .verbose:原始包回显
打开 .verbose 后,所有 gRPC 包以 JSON 原样打印(节选实测,登录包与 hi 回包):
<= => {"hi": {"id": "15629", "userAgent": "tn-cli/3.0.1 (Linux/7.0.0-38-generic); gRPC-python/0.22.3+1.85.0", "ver": "0.22.3", "lang": "EN"}}
<= <= {"ctrl": {"id": "15629", "code": 201, "text": "created", "params": {"msgDelAge": "NjAw", "minTagLength": "Mg==", ..., "build": "Im15c3FsOnYwLjI1LjMi", "maxMessageSize": "MTMxMDcy"}}}
<= => {"login": {"id": "15630", "scheme": "basic", "secret": "YWRtaW46cTF3MmUzcjQ="}}
<= <= {"ctrl": {"id": "15630", "code": 200, "text": "ok", "params": {"authlvl": "ImF1dGgi", "token": "<96 bytes: IjJOOHNxN2c4...TXdhSWs9Ig==>", "user": "InVzcjJOOHNxN2c4YndVIg==", "expires": "IjIwMjYtMTEtMDlUMTM6NDg6MjMuNjgzWiI="}}}
gRPC 通道回执里的字符串值统一 base64 编码(ImF1dGgi 解码即 auth,InVzcjJOOHNxN2c4YndVIg== 解码即 "usr2N8sq7g8bwU"),token 超长时 tn-cli 折叠为 <96 bytes: 前 8...后 8>。对照 REST 通道同一登录(10.2 节)的明文 JSON,可以看清「同一包、两通道」的编码差异。
9.6 边界:启动参数不能当命令喂
--login-basic admin:q1w2e3r4、python3 这类非 tn-cli 词法的内容喂进交互界面,统一得到 Unrecognized: <首词> 加完整命令表(实测均已触发)。tn-cli 不是 shell:启动参数只在启动命令行上有效,会话内只有 9.1 表中的命令与宏。
十、REST API curl 调试矩阵(长轮询通道)
不装 tn-cli 也能完成全部调试——用 curl 对 /v0/channels/lp 端点「一次 POST 发包、一次 POST 收包」。以下实测共用变量:K=apikey,B=http://localhost:6060/v0/channels/lp。
10.1 建会话:空 body POST 拿 sid
$ curl -s -i -X POST "$B?apikey=$K"
HTTP/1.1 201 Created
Access-Control-Allow-Origin: *
Cache-Control: no-cache, no-store, must-revalidate
Content-Type: text/plain
Expires: 0
Vary: Accept-Encoding
Content-Length: 102
{"ctrl":{"params":{"sid":"AbbmwnbROT8"},"code":201,"text":"created","ts":"2026-10-26T13:42:49.515Z"}}
后续所有发包/收包都在 URL 上带 &sid=$SID。发包必须显式 Content-Type: application/json(反例见 10.4)。
10.2 hi 与 login(登录拿 token)
$ send '{"hi":{"id":"1","ver":"0.25","ua":"curl-rest/0.25.3"}}'; poll
{"ctrl":{"id":"1","params":{"build":"mysql:v0.25.3","maxFileUploadSize":8388608,"maxMessageSize":131072,"maxSubscriberCount":128,"maxTagCount":16,"maxTagLength":96,"minTagLength":2,"msgDelAge":600,"reqCred":null,"ver":"0.25"},"code":200,"text":"ok","ts":"2026-10-26T12:31:21.262Z"}}
$ send '{"login":{"id":"2","scheme":"basic","secret":"'"$(echo -n 'admin:q1w2e3r4' | base64 -w0)"'"}}'; poll
{"ctrl":{"id":"2","params":{"authlvl":"auth","expires":"2026-11-09T12:31:21.319Z","token":"2N8sq7g8bwUZvfFqFAABAAEA...","user":"usr2N8sq7g8bwU"},"code":200,"text":"ok","ts":"2026-10-26T12:31:21.28Z"}}
与 tn-cli 连接串(5.1 节)逐项对得上。token 与 expires(10 天后)在后续大文件请求里直接复用(10.5)。
10.3 fnd 搜索与群组消息闭环
- 建群:
{"sub":{"id":"3","topic":"new","desc":{"defaultAcs":...},"tags":["rest-demo"],"get":{"what":"sub"}}}→{"ctrl":{"id":"3","topic":"grpcUClKshckeg",...,"code":200,"text":"ok"}},群名与 tn-cli 的sub --topic new同语义; - 群组搜索:
set fnd写入查询词,再{"get":{"id":"6","topic":"fnd","what":"sub"}}→{"meta":{...,"sub":[{"public":{"fn":"REST 演示群"},"private":["rest-demo"],"topic":"grpcUClKshckeg","subcnt":1}]}}; - 用户搜索:用标签
basic:alice检索 →{"meta":{...,"sub":[{"public":{"fn":"Alice"},"private":["basic:alice"],"topic":"usrumQ4gJFy6ro"}]}}; - 发消息 + 读消息:
{"pub":{"id":"4","topic":"grpFUJdHPLTPCI","head":{"mime":"text/plain"},"content":"REST curl 发出的群组消息"}}→{"ctrl":{"id":"4","topic":"grpFUJdHPLTPCI","params":{"seq":1},"code":202,"text":"accepted"}};再{"get":{"id":"5","topic":"grpFUJdHPLTPCI","what":"data","data":{"limit":2}}}收到{"data":{"topic":"grpFUJdHPLTPCI","from":"usr2N8sq7g8bwU","ts":"...","seq":1,"head":{"mime":"text/plain"},"content":"REST curl 发出的群组消息"}}。
注意 REST 侧 pub 的成功码是 202 accepted(服务端确认入库并返回 seq),而 tn-cli 里显示为 200 ok(客户端把 202 归并显示);同一动作两条通道的 ctrl 码不完全同名,写脚本时以各自实测为准。
10.4 排障实录:缺 Content-Type → 400 malformed → 409 command out of sequence
首版脚本漏了发包 POST 的 -H 'Content-Type: application/json',实测两步回显:
(发包 hi,无 Content-Type)
$ poll
{"ctrl":{"id":"1","code":400,"text":"malformed"}}
(随后发 login)
$ poll
{"ctrl":{"id":"2","code":409,"text":"command out of sequence"}}
服务器把无 Content-Type 的 body 拒为 400 malformed(hi 没有生效),会话状态机仍停在「未问候」,后续 login 便报 409 command out of sequence。看到 409 command out of sequence,先回查前一个包是不是 400——补上 Content-Type 后同一序列实测 201 → 200 全通。
10.5 大文件上传与下载
上传走 /v0/file/u/,认证用两个头(X-Tinode-APIKey + X-Tinode-Auth: Token <token>),multipart 字段名固定 file(实测):
$ curl -s -X POST "http://localhost:6060/v0/file/u/?apikey=$K" \
-H "X-Tinode-APIKey: $K" -H "X-Tinode-Auth: Token $TOKEN" \
-F "file=@/tmp/d1-file.txt"
{"ctrl":{"params":{"expires":"2026-10-26T13:11:30.718","url":"/v0/file/s/VjeGtVOg73s.text"},"code":200,"text":"ok","ts":"2026-10-26T13:10:30.718Z"}}
不带认证头上传的对照实测:{"ctrl":{"code":401,"text":"authentication required"}}。
下载直接 GET 返回的 url(它已是完整路径):
$ curl -s -i "http://localhost:6060$FURL?apikey=$K" -H "X-Tinode-APIKey: $K" -H "X-Tinode-Auth: Token $TOKEN"
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: max-age=86400
Content-Disposition: attachment
Content-Length: 55
Content-Type: text/plain
Etag: "agt2dnwpkkp2vb2ztqsmqhxuie"
Last-Modified: Mon, 26 Oct 2026 13:10:30 GMT
Vary: Accept-Encoding
tn file attachment body (created for REST matrix test)
两个实测边界:
| 操作 | 实测回显 | 教训 |
|---|---|---|
把返回的 /v0/file/s/xxx 再拼一层 /v0/file/s/ 前缀后 GET | HTTP/1.1 307 Temporary Redirect,Location: /v0/file/s/v0/file/s/xxx?apikey=...(畸形路径循环指向自身) | url 值已是完整路径,直接拼基址即可 |
| 下载不带认证头 | 401 | 文件端点同样受 token 保护,与上传同权限 |
10.6 运行时指标端点 /debug/vars
$ curl -s http://localhost:6060/debug/vars | head -8
{
"ClusterLeader": 0,
"CtrlCodesTotal2xx": 140,
"CtrlCodesTotal3xx": 11,
"CtrlCodesTotal4xx": 35,
"CtrlCodesTotal5xx": 0,
"DbStats": {"MaxOpenConnections":64,"OpenConnections":1,"InUse":0,"Idle":1,...
Go expvar 风格的运行时计数(2xx/3xx/4xx/5xx 各码累计与数据库连接池状态),做健康巡检或压测观察都很直接;路径可用 -expvar - 禁用(第三章)。
十一、WebSocket 裸调测试
11.1 端点与握手
WebSocket 端点是 /v0/channels?apikey=...(注意没有 /lp 后缀)。用十几行 Python 裸 socket 就能完成握手与帧收发(实测脚本骨架):
import socket, base64, os, json, time
HOST, PORT = "localhost", 6060
APIKEY = "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy"
s = socket.create_connection((HOST, PORT), timeout=10)
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /v0/channels?apikey={APIKEY} HTTP/1.1\r\nHost: {HOST}:{PORT}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Key: {key}\r\n\r\n")
s.sendall(req.encode())
# 读取握手响应,断言状态行含 101
客户端帧必须按 RFC 6455 加掩码(0x81 文本帧 + 4 字节 mask),服务端回帧不带掩码,直接按 length + payload 解析即可。
11.2 严格顺序闭环实测
握手成功后按 hi → login → sub fnd → set → get → attach → pub 七步发包,每步只等对应 id 的回包(实测逐帧回显):
[WS] 握手成功: HTTP/1.1 101 Switching Protocols
[<- 1] {"ctrl": {"id": "1", "params": {"build": "mysql:v0.25.3", ..., "ver": "0.25"}, "code": 201, "text": "created", "ts": "2026-10-26T13:09:47.271Z"}}
[<- 2] {"ctrl": {"id": "2", "params": {"authlvl": "auth", "expires": "2026-11-09T13:09:47.313Z", "token": "2N8sq7g8bwUbxvFqFAABAAEA...", "user": "usr2N8sq7g8bwU"}, "code": 200, "text": "ok", "ts": "2026-10-26T13:09:47.272Z"}}
[<- 3] {"ctrl": {"id": "3", "topic": "fnd", "code": 200, "text": "ok", "ts": "2026-10-26T13:09:47.314Z"}}
[<- 4] {"ctrl": {"id": "4", "topic": "fnd", "code": 200, "text": "ok", "ts": "2026-10-26T13:09:47.314Z"}}
[<- 5] {"meta": {"id": "5", "topic": "fnd", "ts": "2026-10-26T13:09:47.315Z", "sub": [{"updated": "2026-10-26T11:10:04.958Z", "acs": {"mode": "JRWPS"}, "private": ["beta"], "topic": "grpeomGUOyvOTA", "subcnt": 2}]}}
[<- 6] {"ctrl": {"id": "6", "topic": "grpeomGUOyvOTA", "code": 200, "text": "ok", "ts": "2026-10-26T13:09:47.316Z"}}
[<- 7] {"ctrl": {"id": "7", "topic": "grpeomGUOyvOTA", "params": {"seq": 4}, "code": 202, "text": "accepted", "ts": "2026-10-26T13:09:47.317Z"}}
[WS] closed
11.3 WS 与 LP 的差异要点
| 维度 | 长轮询(LP) | WebSocket |
|---|---|---|
| 连接建立 | POST /v0/channels/lp → 201 created + sid | 101 Switching Protocols 升级握手 |
| 会话标识 | 每次请求带 &sid= | 握手后同连接直通,无 sid 参数 |
| hi 回执码 | 200 ok(复用已建会话时)/ 201 created | 201 created |
| 收包方式 | 显式轮询,一次一包 | 服务端主动推帧 |
| 适用场景 | curl 快速调试 | 长连接客户端行为模拟、推送时序验证 |
三条通道(gRPC/LP/WS)上 login 回执的 params.user、token、authlvl 完全同构(对照 5.1、10.2、11.2 三处实测),可放心把 tn-cli 里的操作翻译成任意通道的原始包。
十二、注意事项与常见问题
12.1 注意事项清单
| 类别 | 要点 | 依据(章节) |
|---|---|---|
| 登录态 | 一个会话只能 login 一次,重复得 409 already authenticated | 5.2 |
| attach 前置 | pub/del 前 sub,否则 409 must attach first;get data 则 403 permission denied | 6.2 |
| 引号 | 多词 --content 必须整体引号,多余词会覆盖同名位置参数 topic | 6.3 |
| useradd | 用户名是位置参数、密码用 -P、显示名 --name、--cred 必带 | 7.2/7.3 |
| set me | --fn/--public 经 tn-cli(gRPC)不生效(304),改公开资料走 REST/WS | 7.5 |
| 文件 | tn-cli 的 file(gRPC)桩缺失不可用,用 upload(HTTP)+ --web-host | 8.1/8.2 |
| 宏 | .await/.must 必须与命令同行,单行 .await 按不识别处理 | 9.3 |
| REST | 发包 POST 必须带 Content-Type: application/json,否则 400 连锁 409 | 10.4 |
| 文件 url | /v0/file/u/ 返回的 url 已是完整路径,勿再拼 /v0/file/s/ 前缀 | 10.5 |
| 认证 | 文件上传下载都要 X-Tinode-APIKey + X-Tinode-Auth: Token 两个头 | 10.5 |
12.2 常见问题
Q1:409 must attach first 到底缺了哪一步?
tn-cli 里先执行 sub --topic <群组名>(既订阅又挂接会话),之后 pub/get/del 才有资格。REST/WS 里对应先发 {sub} 包再发后续包。
Q2:useradd 报 Exception in generator: 'NoneType' object has no attribute 'synchronous'?
缺 --cred。该异常出现时账号未创建(可用 resolve <名> 或查 auth 表确认);补上 --cred email:... 即可 201 created。
Q3:resolve 只打出一行 Error in '.log $temp.sub[0].topic': sub?
这是「账号不存在」:fnd 查询结果为空数组,宏打印变量时取 [0] 失败。存在的账号会直接打印 uid(如 usrpC22fQJIPU4)。
Q4:长轮询接口连 login 都发不出去,409 command out of sequence?
前一个包(通常是 hi)因缺 Content-Type: application/json 被拒为 400 malformed,会话状态机没前进。先修 Content-Type,再按 hi → login 顺序重发。
Q5:tn-cli 敲任何命令都提示 Unrecognized 并刷一大屏命令表?
三种可能:把启动参数(--login-basic 等)当命令喂了;.await/.must 单独占了一行;或者命令拼写不在第四章总表里。命令表本身就是速查页,逐词对照即可。
十三、参考链接
- 本系列选型篇:《Tinode 0.25.3 介绍与选型指南》
- 本系列安装篇:《Ubuntu 22.04 安装 Tinode 0.25.3 指南》
- 本系列配置篇:《Tinode 0.25.3 配置参数详解》
- tn-cli 官方目录(客户端源码与示例脚本):https://github.com/tinode/chat/tree/master/tn-cli
- 官方 REST/大文件端点文档:https://github.com/tinode/chat/blob/master/docs/API.md
- WebSocket 与 XHR 长轮询通道说明:https://github.com/tinode/chat/blob/master/docs/API.md#websocket
- v0.25.3 发布页(含 tn-cli.tar.gz 附件):https://github.com/tinode/chat/releases/tag/v0.25.3
- 官方仓库:https://github.com/tinode/chat