Matrix Synapse Server Notices:给用户发送媒体清理规则通知

用 Synapse Server Notices 给本服用户发送媒体维护规则通知,说明 5MiB 以内文件长期保留,超过 5MiB 的文件 90 天后清理,超过 100MiB 的超大文件 7 天后清理,并提供单用户测试、批量发送脚本和常见错误排查。

自建 Matrix Synapse 做媒体维护之前,最好先告诉用户规则。否则用户只知道“为什么不建议发大文件”,不知道服务器正在控制媒体仓库体积。

这里说的 media_store 不是“视频文件夹”。它是 Synapse 的媒体仓库,聊天里上传或缓存下来的媒体类内容都会放到这里。常见内容包括:

  • 聊天图片、截图、表情包、动图和视频。
  • 语音消息、音频文件、PDF、压缩包、文档等普通附件。
  • 用户头像和房间头像。
  • Synapse 为图片、视频等生成的缩略图。
  • 远程服务器媒体缓存,也就是本服用户查看过的其他服务器上的图片或文件副本。
  • URL 预览缓存,例如链接预览抓取到的图片和缩略图。

所以媒体维护规则影响的不是某一种文件,而是整个 Synapse 媒体仓库。本地上传的大图片、动图、视频、语音、文档和压缩包都会按大小和时间进入清理规则;远程服务器媒体缓存会定期清理;头像和房间头像也建议控制在 5MiB 以内,避免变成长期的大媒体负担。

本文目标是用 Synapse 官方的 Server Notices 给本服用户发送一条系统通知,内容类似:

5MiB 以内文件长期保留
超过 5MiB 的文件,90 天后清理
超过 100MiB 的超大文件 7 天后清理
重要文件请自行保存

这里不是做“每次上传都实时提醒”。实时拦截上传需要额外模块或反代日志分析,复杂度更高。Server Notices 更适合做上线公告、规则变更通知和新用户补发。

关于头像这条规则要先说清楚:清理脚本使用 keep_profiles=true,会尽量保留仍被用户头像、房间头像等 profile image 使用的媒体。通知里写“头像和房间头像建议不要超过 5MiB”,主要是为了减少客户端加载压力和长期存储负担。

官方参考:

一、准备信息

下面示例统一使用这些占位值:

Matrix server_name: 666666.xyz
Synapse 本机地址: http://127.0.0.1:8008
管理员 token 文件: /root/.config/synapse-admin/access_token
Server Notices 系统账号: @server:666666.xyz
测试接收用户: @alice:666666.xyz

实际操作时必须改成自己的值:

666666.xyz                              改成你的 Matrix server_name
@server:666666.xyz                      改成你的 Server Notices 系统账号
@alice:666666.xyz                       改成要测试接收通知的真实用户
/root/.config/synapse-admin/access_token 改成你实际保存管理员 token 的文件路径

文中的:

TOKEN_FILE="/root/.config/synapse-admin/access_token"
TOKEN="$(cat "$TOKEN_FILE")"

不是固定 token。它的意思是从 TOKEN_FILE 读取你自己的 Synapse 管理员 access token。如果你的 token 存在别的位置,要同步改掉 TOKEN_FILE

先确认 token 有效:

TOKEN_FILE="/root/.config/synapse-admin/access_token"
TOKEN="$(cat "$TOKEN_FILE")"

curl -sS \
  -H "Authorization: Bearer ${TOKEN}" \
  http://127.0.0.1:8008/_matrix/client/v3/account/whoami

echo

正常会返回类似:

{"user_id":"@media_purge_admin:666666.xyz","is_guest":false,"device_id":"MEDIA_PURGE_CRON"}

再确认它能访问 Admin API:

TOKEN_FILE="/root/.config/synapse-admin/access_token"
TOKEN="$(cat "$TOKEN_FILE")"

curl -sS -o /tmp/synapse-admin-check.json -w 'http_code=%{http_code}\n' \
  -H "Authorization: Bearer ${TOKEN}" \
  'http://127.0.0.1:8008/_synapse/admin/v2/users?from=0&limit=1&guests=false'

cat /tmp/synapse-admin-check.json
echo
rm -f /tmp/synapse-admin-check.json

看到 http_code=200 才继续。401 通常是 token 失效,403 通常是这个用户不是管理员。

二、检查系统通知账号是否被占用

Server Notices 需要一个本地系统账号。本文使用:

@server:666666.xyz

先查这个用户是否已经存在:

TOKEN_FILE="/root/.config/synapse-admin/access_token"
TOKEN="$(cat "$TOKEN_FILE")"

curl -sS -o /tmp/server-user-check.json -w 'http_code=%{http_code}\n' \
  -H "Authorization: Bearer ${TOKEN}" \
  'http://127.0.0.1:8008/_synapse/admin/v2/users/@server:666666.xyz'

cat /tmp/server-user-check.json
echo
rm -f /tmp/server-user-check.json

如果返回类似:

http_code=404

并且 JSON 里有:

{"errcode":"M_NOT_FOUND","error":"User not found"}

说明 @server:666666.xyz 没有被普通用户占用,可以作为 Server Notices 系统账号。

如果返回 200,说明这个用户已经存在。不要直接拿已存在的普通用户当系统通知账号,建议换一个 localpart,例如:

@notice:666666.xyz
@server_notice:666666.xyz

后文配置里的 system_mxid_localpart 也要跟着改。

三、启用 Server Notices

编辑 Synapse 配置:

vi /root/data/docker_data/synapse/data/homeserver.yaml

在文件末尾加入:

server_notices:
  system_mxid_localpart: server
  system_mxid_display_name: "服务器通知"
  room_name: "服务器通知"
  auto_join: true

这里的 system_mxid_localpart: server 会生成:

@server:<你的 server_name>

如果你想使用 @notice:666666.xyz,就写:

server_notices:
  system_mxid_localpart: notice
  system_mxid_display_name: "服务器通知"
  room_name: "服务器通知"
  auto_join: true

重启 Synapse:

cd /root/data/docker_data/synapse
docker compose restart synapse

等接口恢复:

curl -i http://127.0.0.1:8008/_matrix/client/versions

如果返回 HTTP/1.1 200 OK,说明 Synapse 已恢复。

四、先给单个用户测试

不要一上来群发。先挑一个自己的测试用户,例如:

@alice:666666.xyz

生成通知 JSON:

python3 - <<'PY' > /tmp/media-policy-notice.json
import json

body = """【服务器媒体保留规则】

为了避免 Matrix 媒体仓库长期堆积大文件,本服务器已启用媒体分层清理:

- 5MiB 以内文件长期保留。
- 超过 5MiB 的文件,90 天后清理。
- 超过 100MiB 的超大文件 7 天后清理。

请自行保存重要大文件。"""

print(json.dumps({
    "user_id": "@alice:666666.xyz",
    "content": {
        "msgtype": "m.notice",
        "body": body,
    },
}, ensure_ascii=False))
PY

@alice:666666.xyz 改成真实测试用户,然后发送:

TOKEN_FILE="/root/.config/synapse-admin/access_token"
TOKEN="$(cat "$TOKEN_FILE")"

curl -sS -X PUT \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  --data @/tmp/media-policy-notice.json \
  'http://127.0.0.1:8008/_synapse/admin/v1/send_server_notice/media-policy-v4-tiered-alice'

echo
rm -f /tmp/media-policy-notice.json

正常会返回类似:

{"event_id":"$example_event_id"}

然后登录测试用户,确认能看到一个名为“服务器通知”的房间和刚才那条通知。

这里的 URL 最后有一个 media-policy-v4-tiered-alice,它是 transaction id。相同 transaction id 重复请求时,Synapse 会按幂等逻辑处理,避免重复发送同一条通知。

五、批量发送给本服用户

单用户测试正常后,可以写成脚本批量发送。

创建脚本:

mkdir -p /root/data/docker_data/synapse/scripts
vi /root/data/docker_data/synapse/scripts/send-media-policy-notice.py

脚本内容如下。注意先改 SERVER_NAMETOKEN_PATHEXCLUDE

#!/usr/bin/env python3
import json
import urllib.error
import urllib.parse
import urllib.request

BASE = "http://127.0.0.1:8008"
TOKEN_PATH = "/root/.config/synapse-admin/access_token"
SERVER_NAME = "666666.xyz"

# 不给媒体清理专用账号和 Server Notices 系统账号发通知。
EXCLUDE = {
    f"@media_purge_admin:{SERVER_NAME}",
    f"@server:{SERVER_NAME}",
}

# 规则版本号。通知内容更新时,改这里,例如 media-policy-v5-20260601-。
TXN_PREFIX = "media-policy-v4-tiered-20260523-"

BODY = """【服务器媒体保留规则】

为了避免 Matrix 媒体仓库长期堆积大文件,本服务器已启用媒体分层清理:

- 5MiB 以内文件长期保留。
- 超过 5MiB 的文件,90 天后清理。
- 超过 100MiB 的超大文件 7 天后清理。

请自行保存重要大文件。"""

CONTENT = {
    "msgtype": "m.notice",
    "body": BODY,
}

with open(TOKEN_PATH, "r", encoding="utf-8") as f:
    TOKEN = f.read().strip()

HEADERS = {
    "Authorization": f"Bearer {TOKEN}",
}
JSON_HEADERS = {
    **HEADERS,
    "Content-Type": "application/json",
}


def request_json(method, url, payload=None):
    data = None if payload is None else json.dumps(payload).encode("utf-8")
    headers = JSON_HEADERS if payload is not None else HEADERS
    req = urllib.request.Request(url, data=data, headers=headers, method=method)

    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            raw = resp.read().decode("utf-8")
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode("utf-8")
        try:
            parsed = json.loads(raw)
        except Exception:
            parsed = {"raw": raw}
        return exc.code, parsed


users = []
start = 0

while True:
    url = f"{BASE}/_synapse/admin/v2/users?from={start}&limit=100&guests=false"
    status, data = request_json("GET", url)
    if status != 200:
        raise SystemExit(f"list users failed: HTTP {status} {data}")

    for user in data.get("users", []):
        mxid = user.get("name")
        if not mxid or not mxid.endswith(f":{SERVER_NAME}"):
            continue
        if mxid in EXCLUDE:
            continue
        if user.get("deactivated") or user.get("is_guest"):
            continue
        users.append(mxid)

    next_token = data.get("next_token")
    if next_token is None:
        break
    start = int(next_token)

sent = []
failed = []

for mxid in users:
    encoded_user_id = urllib.parse.quote(mxid, safe="")
    txn = TXN_PREFIX + encoded_user_id
    url = f"{BASE}/_synapse/admin/v1/send_server_notice/{txn}"
    payload = {
        "user_id": mxid,
        "content": CONTENT,
    }

    status, data = request_json("PUT", url, payload)
    if 200 <= status < 300:
        sent.append({"user_id": mxid, "event_id": data.get("event_id")})
    else:
        failed.append({"user_id": mxid, "http_code": status, "response": data})

print(json.dumps({
    "target_count": len(users),
    "sent": sent,
    "failed": failed,
}, ensure_ascii=False, indent=2))

if failed:
    raise SystemExit(1)

授权:

chmod 700 /root/data/docker_data/synapse/scripts/send-media-policy-notice.py

运行:

/root/data/docker_data/synapse/scripts/send-media-policy-notice.py

成功输出类似:

{
  "target_count": 5,
  "sent": [
    {
      "user_id": "@admin:666666.xyz",
      "event_id": "$example1"
    },
    {
      "user_id": "@alice:666666.xyz",
      "event_id": "$example2"
    }
  ],
  "failed": []
}

六、以后新增用户怎么办

如果 TXN_PREFIX 不变,脚本重复运行时,已经收到过这一版通知的老用户不会重复收到同一条。

以后新增用户后,可以手动再跑一次:

/root/data/docker_data/synapse/scripts/send-media-policy-notice.py

新用户会收到通知,老用户通常不会重复收到。

如果只是给后来新增的用户补发同一版媒体规则通知,TXN_PREFIX 不用改。

如果是新内容、新公告、新规则,就要同时改通知内容和 TXN_PREFIX。例如规则内容从“按大小到期清理”改成“安全维护公告”,就改成:

TXN_PREFIX = "media-policy-v4-20260601-"

这样所有用户都会收到新版通知。否则你只改 BODY,但 TXN_PREFIX 不变,Synapse 可能会认为这是同一个 transaction,同一用户不会重复收到。

简单说:

同一版通知补发给新用户:不改 TXN_PREFIX
新通知、新规则、新公告:修改 BODY,也修改 TXN_PREFIX

七、已经发出去的通知能不能修改

不能原地修改。

Server Notices 发出去后,本质上就是 Matrix 房间里的一个事件。事件内容已经进入用户的通知房间,不能像编辑网页公告那样统一改掉。

如果通知内容写错了,常见处理方式有三种:

1. 直接发一条新版通知,写明“以本通知为准”
2. 撤回旧通知,再发新版通知
3. 旧通知保留,新通知解释规则已经更新

实际运维里,我更推荐第 1 种或第 3 种。因为撤回后用户看到的通常是“消息已删除/已撤回”,反而可能让人困惑。

推荐做法:发新版通知

BODY 改成新版内容,同时修改 TXN_PREFIX

TXN_PREFIX = "media-policy-v4-20260601-"

BODY = """【服务器媒体保留规则更新】

此前通知中的媒体清理规则已更新,请以本通知为准:

- 5MiB 以内文件长期保留。
- 超过 5MiB 的文件,90 天后清理。
- 超过 100MiB 的超大文件 7 天后清理。

请自行保存重要大文件。"""

然后运行:

/root/data/docker_data/synapse/scripts/send-media-policy-notice.py

这样所有用户都会收到新版通知,而且上下文清楚。

确实要撤回旧通知

Matrix 里的撤回叫 redaction。它不会“编辑旧通知”,而是把旧事件内容抹掉。客户端通常会显示成“消息已删除”或“已撤回”。

要撤回旧通知,需要两个值:

event_id:发送 Server Notice 时返回的事件 ID,例如 $example
room_id:这条通知所在的通知房间 ID,例如 !abcdef:666666.xyz

如果你只有 event_id,可以先从 Synapse 数据库查 room_id。示例使用 Docker 里的 PostgreSQL 容器 postgres16

EVENT_ID='$这里填旧通知的event_id'

docker exec postgres16 psql -U synapse -d synapse -c "
SELECT event_id, room_id, sender, type
FROM events
WHERE event_id = '${EVENT_ID}';
"

返回里会有 room_id。拿到 room_id 后,调用 Matrix redaction API:

TOKEN_FILE="/root/.config/synapse-admin/access_token"
TOKEN="$(cat "$TOKEN_FILE")"

ROOM_ID='!这里填room_id:666666.xyz'
EVENT_ID='$这里填旧通知的event_id'

ENC_ROOM_ID="$(python3 - <<PY
import urllib.parse
print(urllib.parse.quote('''${ROOM_ID}''', safe=''))
PY
)"

ENC_EVENT_ID="$(python3 - <<PY
import urllib.parse
print(urllib.parse.quote('''${EVENT_ID}''', safe=''))
PY
)"

TXN_ID="redact-media-policy-v1-$(date +%s)"

curl -sS -X PUT \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"reason":"媒体保留规则通知已更新,请以新版通知为准"}' \
  "http://127.0.0.1:8008/_matrix/client/v3/rooms/${ENC_ROOM_ID}/redact/${ENC_EVENT_ID}/${TXN_ID}"

echo

如果你当时给 5 个用户都发了旧通知,就会有 5 个不同的 event_id。要撤回就得逐条查 room_id,逐条 redaction。

注意:

redaction 不能还原
redaction 不是物理硬删所有历史痕迹,而是移除事件内容
用户可能仍能看到“消息已删除/已撤回”的占位

官方参考:

八、常见错误

M_UNKNOWN_TOKEN

示例:

{"errcode":"M_UNKNOWN_TOKEN","error":"Invalid access token passed.","soft_logout":false}

说明 token 失效或 token 文件不对。重新登录管理员账号,刷新 /root/.config/synapse-admin/access_token

403

token 能识别,但用户不是 Synapse 管理员。换成管理员账号的 token,或把专用账号设置为管理员。

M_NOT_FOUND

检查 Server Notices 系统账号时返回 M_NOT_FOUND 是正常的,说明账号未被占用。

如果发送通知时返回 M_NOT_FOUND,通常是目标用户 ID 写错了。

用户看不到通知房间

先确认 homeserver.yaml 里有:

server_notices:
  auto_join: true

然后确认 Synapse 已经重启:

docker ps --filter name=matrix-synapse --format 'table {{.Names}}\t{{.Status}}'

再给单个测试用户发送一次,确认返回了 event_id

九、生产实测结果

一次生产环境实测:

启用 server_notices 后重启 Synapse
目标用户:5 个
成功发送:5 个
失败:0 个
Synapse 容器状态:healthy

这套做法适合媒体清理规则上线、隐私政策变更、维护公告等低频通知。不要用它刷屏;真的要做“每次上传大文件都提醒”,应该单独做上传检测模块或反代日志分析,不建议混在 Server Notices 公告脚本里。