Matrix Synapse 机器人:天气推送与服务器监控提醒

记录在自建 Matrix Synapse 上创建 bot 机器人账号,用 Open-Meteo 获取中国上海天气,支持本服用户邀请、聊天命令设置推送时间,并保留管理员服务器监控提醒。

这篇记录一个很实用的小自动化:在自建 Matrix Synapse 上创建一个真正的机器人账号 @bot:666666.xyz。本服用户可以主动邀请它,发送“帮助”查看命令,发送“设置天气时间 07:30”自行设置每日天气推送时间;管理员仍然可以接收磁盘、Docker、TURN / LiveKit 等服务器监控提醒。

这个方案不是 Synapse Server Notices,而是普通 Matrix 机器人账号。好处是体验更像一个联系人:用户可以手动邀请它进私聊,普通用户只使用天气功能,服务器监控提醒则只发给管理员。

一、最终效果

完成后会得到这些文件和配置:

机器人账号:
  @bot:666666.xyz

机器人 token:
  /root/.config/matrix-bot/access_token

机器人密码:
  /root/.config/matrix-bot/bot-password

主脚本:
  /root/data/docker_data/synapse/scripts/matrix-bot-weather.py

配置文件:
  /root/data/docker_data/synapse/scripts/matrix-bot-weather.json

定时包装脚本:
  /root/data/docker_data/synapse/scripts/run-matrix-bot-weather.sh

天气循环脚本:
  /root/data/docker_data/synapse/scripts/run-matrix-bot-weather-loop.sh

天气 OpenRC 服务:
  /etc/init.d/matrix-bot-weather

聊天同步脚本:
  /root/data/docker_data/synapse/scripts/matrix-bot-sync.py

聊天同步 OpenRC 服务:
  /etc/init.d/matrix-bot-sync

日志:
  /var/log/matrix-bot-weather.log
  /var/log/matrix-bot-sync.log

监控脚本:
  /root/data/docker_data/synapse/scripts/matrix-bot-monitor.py

监控配置:
  /root/data/docker_data/synapse/scripts/matrix-bot-monitor.json

监控日志:
  /var/log/matrix-bot-monitor.log

天气推送和聊天命令使用 OpenRC 常驻服务控制。天气服务每 20 秒检查一次配置,谁到了自己的推送时间,就给谁发送天气;聊天同步服务每 20 秒处理一次邀请和命令。

rc-service matrix-bot-weather status
rc-service matrix-bot-sync status

服务器监控提醒仍然由 root crontab 控制:

*/5 * * * * /root/data/docker_data/synapse/scripts/run-matrix-bot-monitor.sh

其中 matrix-bot-weather.py 只负责天气内容和到点判断,matrix-bot-sync.py 负责邀请和聊天命令,matrix-bot-monitor.py 每 5 分钟检查服务器异常。天气和监控共用同一个机器人账号,但普通用户只会收到天气,监控提醒只发给监控配置里的管理员。

天气数据使用 Open-Meteo,无需申请 API key。脚本里写的是上海坐标:

latitude: 31.2304
longitude: 121.4737
timezone: Asia/Shanghai

二、前置条件

本文假设 Synapse 在本机 8008 提供客户端 API:

http://127.0.0.1:8008

并且有一个可用的 Synapse 管理员 access token:

/root/.config/synapse-admin/access_token

先在服务器上确认 Synapse 正常:

curl -sS -o /tmp/synapse_versions.json -w 'http_code=%{http_code}\n' \
  http://127.0.0.1:8008/_matrix/client/versions
rm -f /tmp/synapse_versions.json

返回 http_code=200 就可以继续。

再准备脚本目录:

mkdir -p /root/data/docker_data/synapse/scripts
mkdir -p /root/.config/matrix-bot
chmod 700 /root/.config/matrix-bot

三、创建安装脚本

注意:下面的安装脚本用于创建账号、保存 token 和生成基础配置;后文第七节会把机器人升级为可交互模式,最终运行以 matrix-bot-sync.py、新版 matrix-bot-weather.pyweather_users 配置为准。

下面这个安装脚本会做几件事:

  • 创建或复用 @bot:666666.xyz
  • 给机器人设置显示名“机器人”。
  • 为机器人生成本地保存的随机密码。
  • 登录机器人并保存 access token。
  • 写入天气推送配置文件。
  • 写入主推送脚本。
  • 写入天气调度脚本。
  • 写入聊天同步脚本,用于自动接受本服用户邀请和处理命令。
  • 配置天气和聊天同步后台服务。

创建文件:

vi /root/data/docker_data/synapse/scripts/install-matrix-bot.py

写入:

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

BASE = "http://127.0.0.1:8008"
SERVER_NAME = "666666.xyz"
BOT_LOCALPART = "bot"
BOT_MXID = f"@{BOT_LOCALPART}:{SERVER_NAME}"
TARGET_MXID = "@honey:666666.xyz"
TOKEN_PATH = "/root/.config/synapse-admin/access_token"
BOT_TOKEN_PATH = "/root/.config/matrix-bot/access_token"
BOT_PASSWORD_PATH = "/root/.config/matrix-bot/bot-password"
SCRIPT_PATH = "/root/data/docker_data/synapse/scripts/matrix-bot-weather.py"
CONFIG_PATH = "/root/data/docker_data/synapse/scripts/matrix-bot-weather.json"

def req(method, url, token=None, data=None):
    headers = {}
    body = None
    if token:
        headers["Authorization"] = f"Bearer {token}"
    if data is not None:
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    request = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request, timeout=20) as resp:
            raw = resp.read().decode("utf-8")
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace")
        try:
            payload = json.loads(raw)
        except Exception:
            payload = {"raw": raw}
        return e.code, payload

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

os.makedirs(os.path.dirname(BOT_TOKEN_PATH), mode=0o700, exist_ok=True)

if not os.path.exists(BOT_PASSWORD_PATH):
    with open(BOT_PASSWORD_PATH, "w", encoding="utf-8") as f:
        f.write(secrets.token_urlsafe(36) + "\n")
    os.chmod(BOT_PASSWORD_PATH, 0o600)

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

encoded_bot = urllib.parse.quote(BOT_MXID, safe="")
status, payload = req(
    "GET",
    f"{BASE}/_synapse/admin/v2/users/{encoded_bot}",
    admin_token,
)

if status == 404:
    status, payload = req(
        "PUT",
        f"{BASE}/_synapse/admin/v2/users/{encoded_bot}",
        admin_token,
        {
            "password": bot_password,
            "displayname": "机器人",
            "admin": False,
            "deactivated": False,
            "user_type": None,
        },
    )
    if status not in (200, 201):
        raise SystemExit(f"create bot failed: HTTP {status} {payload}")
    print(f"created {BOT_MXID}")
elif status == 200:
    status, payload = req(
        "PUT",
        f"{BASE}/_synapse/admin/v2/users/{encoded_bot}",
        admin_token,
        {
            "password": bot_password,
            "displayname": "机器人",
            "admin": False,
            "deactivated": False,
            "user_type": None,
        },
    )
    if status not in (200, 201):
        raise SystemExit(f"update bot failed: HTTP {status} {payload}")
    print(f"reused {BOT_MXID}")
else:
    raise SystemExit(f"check bot failed: HTTP {status} {payload}")

status, payload = req(
    "POST",
    f"{BASE}/_matrix/client/v3/login",
    None,
    {
        "type": "m.login.password",
        "identifier": {"type": "m.id.user", "user": BOT_LOCALPART},
        "password": bot_password,
        "initial_device_display_name": "matrix-bot",
    },
)

if status != 200 or "access_token" not in payload:
    raise SystemExit(f"bot login failed: HTTP {status} {payload}")

with open(BOT_TOKEN_PATH, "w", encoding="utf-8") as f:
    f.write(payload["access_token"] + "\n")
os.chmod(BOT_TOKEN_PATH, 0o600)

config = {
    "homeserver": BASE,
    "server_name": SERVER_NAME,
    "bot_user_id": BOT_MXID,
    "token_path": BOT_TOKEN_PATH,
    "city": "中国上海",
    "latitude": 31.2304,
    "longitude": 121.4737,
    "timezone": "Asia/Shanghai",
    "default_weather_time": "05:00",
    "allow_local_users_only": True,
    "weather_users": {
        TARGET_MXID: {
            "enabled": True,
            "time": "05:00",
        },
    },
    "direct_rooms": {},
    "state_path": "/root/data/docker_data/synapse/scripts/matrix-bot-weather.state.json",
    "sync_state_path": "/root/data/docker_data/synapse/scripts/matrix-bot-sync.state.json",
    "schedule_grace_minutes": 15,
}

if os.path.exists(CONFIG_PATH):
    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
        old = json.load(f)
    for key in ("weather_users", "direct_rooms", "default_weather_time", "sync_state_path", "schedule_grace_minutes"):
        if key in old:
            config[key] = old[key]
    config.setdefault("weather_users", {}).setdefault(TARGET_MXID, {"enabled": True, "time": "05:00"})

with open(CONFIG_PATH, "w", encoding="utf-8") as f:
    json.dump(config, f, ensure_ascii=False, indent=2)
    f.write("\n")
os.chmod(CONFIG_PATH, 0o600)

print(f"bot={BOT_MXID}")
print(f"target={TARGET_MXID}")
print(f"config={CONFIG_PATH}")

先赋予执行权限:

chmod 700 /root/data/docker_data/synapse/scripts/install-matrix-bot.py

这个安装脚本先负责账号和配置,主推送脚本下一节单独写,方便排查和修改。

四、创建主推送脚本

创建:

vi /root/data/docker_data/synapse/scripts/matrix-bot-weather.py

写入:

#!/usr/bin/env python3
import datetime as dt
import hashlib
import html
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

CONFIG_PATH = "/root/data/docker_data/synapse/scripts/matrix-bot-weather.json"
WEATHER_CODES = {
    0: "晴", 1: "大部晴朗", 2: "局部多云", 3: "阴", 45: "雾", 48: "雾凇",
    51: "小毛毛雨", 53: "毛毛雨", 55: "较强毛毛雨", 56: "冻毛毛雨", 57: "较强冻毛毛雨",
    61: "小雨", 63: "中雨", 65: "大雨", 66: "冻雨", 67: "强冻雨",
    71: "小雪", 73: "中雪", 75: "大雪", 77: "雪粒",
    80: "小阵雨", 81: "阵雨", 82: "强阵雨", 85: "小阵雪", 86: "强阵雪",
    95: "雷雨", 96: "雷雨伴小冰雹", 99: "雷雨伴强冰雹",
}
HELP_HINT = "发送“设置天气时间 07:30”可修改推送时间,发送“帮助”可查询更多功能。"

def load_json(path, default):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return default

def save_json(path, data, mode=0o600):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")
    os.chmod(tmp, mode)
    os.replace(tmp, path)

def request(method, url, token=None, data=None, timeout=30):
    headers = {}
    body = None
    if token:
        headers["Authorization"] = f"Bearer {token}"
    if data is not None:
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8", errors="replace")
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace")
        try:
            payload = json.loads(raw)
        except Exception:
            payload = {"raw": raw}
        return e.code, payload
    except Exception as e:
        return 0, {"error": str(e)}

def quote(value):
    return urllib.parse.quote(value, safe="")

def token(config):
    with open(config["token_path"], "r", encoding="utf-8") as f:
        return f.read().strip()

def get_weather(config):
    params = urllib.parse.urlencode({
        "latitude": config["latitude"],
        "longitude": config["longitude"],
        "timezone": config.get("timezone", "Asia/Shanghai"),
        "forecast_days": 3,
        "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,wind_speed_10m_max",
    })
    url = "https://api.open-meteo.com/v1/forecast?" + params
    with urllib.request.urlopen(url, timeout=30) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    daily = data["daily"]
    lines = [f"{config['city']}天气预报", ""]
    today = dt.date.today().isoformat()
    alerts = []
    advice = []
    for i, day in enumerate(daily["time"]):
        label = "今天" if day == today else ("明天" if i == 1 else "后天")
        code = daily["weather_code"][i]
        desc = WEATHER_CODES.get(code, f"天气代码 {code}")
        tmin = daily["temperature_2m_min"][i]
        tmax = daily["temperature_2m_max"][i]
        pop = daily["precipitation_probability_max"][i]
        rain = daily["precipitation_sum"][i]
        wind = daily["wind_speed_10m_max"][i]
        lines.append(
            f"{label} {day}: {desc}{tmin:.0f}-{tmax:.0f}℃,"
            f"降水概率 {pop}% ,降水量 {rain:.1f} mm,最大风速 {wind:.0f} km/h"
        )
        if i == 0:
            if tmax >= 35:
                alerts.append("高温提醒:今天最高温达到 35℃ 以上,注意防暑补水。")
            if tmin <= 0:
                alerts.append("低温提醒:今天最低温 0℃ 以下,注意防寒防冻。")
            if rain >= 20 or pop >= 70:
                alerts.append("降雨提醒:今天降水概率较高或雨量较大,出门建议带伞。")
            if wind >= 40:
                alerts.append("大风提醒:今天风力偏强,骑车和户外活动注意安全。")
            if code in (95, 96, 99):
                alerts.append("雷雨提醒:今天可能有雷雨,尽量避开强对流时段外出。")
            if tmax >= 30:
                advice.append("穿衣建议:短袖、轻薄透气衣物为主,注意防晒。")
            elif tmin <= 8:
                advice.append("穿衣建议:建议外套或薄棉服,早晚注意保暖。")
            elif tmin <= 15:
                advice.append("穿衣建议:长袖加薄外套比较稳妥。")
            else:
                advice.append("穿衣建议:轻薄长袖或短袖均可,按体感调整。")
            if (tmax - tmin) >= 10:
                advice.append("温差提醒:昼夜温差较大,早晚可多备一件外套。")
            if pop >= 50 or rain > 0:
                advice.append("出行提醒:有降水可能,建议带伞。")
    if alerts:
        lines.extend(["", "天气提醒:"] + [f"- {x}" for x in alerts])
    if advice:
        lines.extend(["", "生活建议:"] + [f"- {x}" for x in advice])
    lines.extend(["", "数据源:Open-Meteo。", HELP_HINT])
    return "\n".join(lines)

def send_room(config, token_value, room_id, body):
    txn = "weather-" + dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S")
    txn += "-" + hashlib.sha1((room_id + body).encode("utf-8")).hexdigest()[:10]
    html_body = "<br>".join(html.escape(line) for line in body.splitlines())
    content = {"msgtype": "m.text", "body": body, "format": "org.matrix.custom.html", "formatted_body": html_body}
    url = f"{config['homeserver']}/_matrix/client/v3/rooms/{quote(room_id)}/send/m.room.message/{txn}"
    status, payload = request("PUT", url, token_value, content)
    if status not in (200, 201):
        raise RuntimeError(f"send to {room_id} failed: HTTP {status} {payload}")

def create_dm(config, token_value, user_id):
    status, payload = request("POST", f"{config['homeserver']}/_matrix/client/v3/createRoom", token_value, {
        "preset": "trusted_private_chat",
        "is_direct": True,
        "invite": [user_id],
        "name": "天气预报",
        "topic": "天气查询与每日自动推送",
    })
    if status not in (200, 201) or "room_id" not in payload:
        raise RuntimeError(f"create DM for {user_id} failed: HTTP {status} {payload}")
    return payload["room_id"]

def get_room(config, token_value, user_id):
    rooms = config.setdefault("direct_rooms", {})
    room_id = rooms.get(user_id)
    if room_id:
        return room_id
    room_id = create_dm(config, token_value, user_id)
    rooms[user_id] = room_id
    save_json(CONFIG_PATH, config)
    return room_id

def parse_hhmm(value):
    hour_text, minute_text = str(value).split(":", 1)
    hour = int(hour_text)
    minute = int(minute_text)
    if not (0 <= hour <= 23 and 0 <= minute <= 59):
        raise ValueError(f"invalid HH:MM value: {value}")
    return hour, minute

def normalized_hhmm(value):
    hour, minute = parse_hhmm(value)
    return f"{hour:02d}:{minute:02d}"

def sent_key(today, time_value):
    return f"{today} {normalized_hhmm(time_value)}"

def due_users(config, now=None):
    now = now or dt.datetime.now()
    today = now.date().isoformat()
    state = load_json(config["state_path"], {})
    sent = state.setdefault("last_sent", {})
    grace_minutes = int(config.get("schedule_grace_minutes", 15))
    users = []
    for user_id, settings in config.get("weather_users", {}).items():
        if not settings.get("enabled", True):
            continue
        time_value = settings.get("time", config.get("default_weather_time", "05:00"))
        try:
            hour, minute = parse_hhmm(time_value)
        except Exception as exc:
            print(f"skip {user_id}: invalid weather time {time_value!r}: {exc}", file=sys.stderr)
            continue
        if sent.get(user_id) == sent_key(today, time_value):
            continue
        scheduled = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
        delta_seconds = (now - scheduled).total_seconds()
        if 0 <= delta_seconds < grace_minutes * 60:
            users.append(user_id)
    return users, state, today

def send_weather_to_users(config, user_ids, mark_sent=False, state=None, today=None):
    token_value = token(config)
    body = get_weather(config)
    sent = 0
    for user_id in user_ids:
        room_id = get_room(config, token_value, user_id)
        send_room(config, token_value, room_id, body)
        sent += 1
        if mark_sent and state is not None and today is not None:
            user_time = config.get("weather_users", {}).get(user_id, {}).get("time", config.get("default_weather_time", "05:00"))
            state.setdefault("last_sent", {})[user_id] = sent_key(today, user_time)
        time.sleep(0.2)
    if mark_sent and state is not None:
        save_json(config["state_path"], state)
    return sent

def main():
    config = load_json(CONFIG_PATH, None)
    if not config:
        raise SystemExit(f"missing config: {CONFIG_PATH}")
    if len(sys.argv) >= 2 and sys.argv[1] == "--send-now":
        users = sys.argv[2:] or list(config.get("weather_users", {}).keys())
        print(f"sent weather forecast to {send_weather_to_users(config, users)} room(s)")
        return
    users, state, today = due_users(config)
    if not users:
        print("weather schedule: no due users")
        return
    print(f"sent weather forecast to {send_weather_to_users(config, users, True, state, today)} room(s)")

if __name__ == "__main__":
    main()

赋予执行权限并检查语法:

chmod 700 /root/data/docker_data/synapse/scripts/matrix-bot-weather.py
python3 -m py_compile /root/data/docker_data/synapse/scripts/matrix-bot-weather.py

五、运行安装脚本

现在运行安装脚本,创建账号、保存 token、生成配置:

/root/data/docker_data/synapse/scripts/install-matrix-bot.py

正常输出类似:

created @bot:666666.xyz
bot=@bot:666666.xyz
target=@honey:666666.xyz
config=/root/data/docker_data/synapse/scripts/matrix-bot-weather.json

如果机器人已经存在,则会显示:

reused @bot:666666.xyz

六、手动发送测试

先手动发一次天气,确认 token、房间和天气 API 都正常:

/root/data/docker_data/synapse/scripts/matrix-bot-weather.py --send-now

成功时会输出:

sent weather forecast to 1 room(s)

此时目标用户应该能在 Matrix 客户端里看到 @bot:666666.xyz 发来的天气预报。

消息内容大致是:

中国上海天气预报

今天 2026-05-25: 阴,18-26℃,降水概率 20% ,降水量 0.0 mm,最大风速 15 km/h
明天 2026-05-26: 小雨,19-24℃,降水概率 60% ,降水量 3.2 mm,最大风速 18 km/h
后天 2026-05-27: 局部多云,18-28℃,降水概率 10% ,降水量 0.0 mm,最大风速 16 km/h

数据源:Open-Meteo。
发送“天气”可立即查询,发送“设置天气时间 07:30”可修改推送时间。

具体天气以 Open-Meteo 当天返回为准。

七、配置天气调度与聊天同步

这一节把机器人变成真正可交互的联系人。天气推送使用 OpenRC 常驻服务,每 20 秒检查一次所有用户的设置时间;聊天同步也使用 OpenRC 常驻服务,每 20 秒处理一次邀请和命令。这样用户刚发完“设置天气时间 07:30”,到点后就由后台服务自动推送,不依赖 cron 正好踩中某一分钟。

先创建聊天同步脚本:

vi /root/data/docker_data/synapse/scripts/matrix-bot-sync.py

写入:

#!/usr/bin/env python3
import datetime as dt
import hashlib
import html
import importlib.util
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request

CONFIG_PATH = "/root/data/docker_data/synapse/scripts/matrix-bot-weather.json"
WEATHER_SCRIPT_PATH = "/root/data/docker_data/synapse/scripts/matrix-bot-weather.py"
WELCOME = """你好,我是天气机器人。

你可以这样使用我:

天气
  立即查看今天和未来两天天气

设置天气时间 07:30
  设置每天自动推送时间

天气时间
  查看当前推送时间

关闭天气
  停止每日天气推送

开启天气
  恢复每日天气推送

帮助
  再次查看这份说明"""

def load_json(path, default):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return default

def save_json(path, data, mode=0o600):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")
    os.chmod(tmp, mode)
    os.replace(tmp, path)

def request(method, url, token=None, data=None, timeout=30):
    headers = {}
    body = None
    if token:
        headers["Authorization"] = f"Bearer {token}"
    if data is not None:
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8", errors="replace")
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace")
        try:
            payload = json.loads(raw)
        except Exception:
            payload = {"raw": raw}
        return e.code, payload
    except Exception as e:
        return 0, {"error": str(e)}

def quote(value):
    return urllib.parse.quote(value, safe="")

def token(config):
    with open(config["token_path"], "r", encoding="utf-8") as f:
        return f.read().strip()

def local_user(config, user_id):
    return user_id.endswith(":" + config.get("server_name", ""))

def send_room(config, token_value, room_id, body):
    txn = "bot-reply-" + dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S")
    txn += "-" + hashlib.sha1((room_id + body).encode("utf-8")).hexdigest()[:10]
    html_body = "<br>".join(html.escape(line) for line in body.splitlines())
    content = {"msgtype": "m.text", "body": body, "format": "org.matrix.custom.html", "formatted_body": html_body}
    status, payload = request("PUT", f"{config['homeserver']}/_matrix/client/v3/rooms/{quote(room_id)}/send/m.room.message/{txn}", token_value, content)
    if status not in (200, 201):
        print(f"send failed {room_id}: HTTP {status} {payload}")

def enable_user(config, user_id, room_id=None):
    settings = config.setdefault("weather_users", {}).setdefault(user_id, {})
    settings.setdefault("enabled", True)
    settings.setdefault("time", config.get("default_weather_time", "05:00"))
    if room_id:
        config.setdefault("direct_rooms", {})[user_id] = room_id

def import_weather_module():
    spec = importlib.util.spec_from_file_location("matrix_bot_weather", WEATHER_SCRIPT_PATH)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def handle_command(config, token_value, room_id, sender, body):
    text = body.strip()
    lower = text.lower()
    enable_user(config, sender, room_id)
    if text in ("帮助", "help", "菜单"):
        send_room(config, token_value, room_id, WELCOME)
    elif text == "天气":
        weather = import_weather_module()
        send_room(config, token_value, room_id, weather.get_weather(config))
    elif text == "天气时间":
        s = config["weather_users"][sender]
        status = "已开启" if s.get("enabled", True) else "已关闭"
        send_room(config, token_value, room_id, f"你的天气推送{status}\n当前时间:每天 {s.get('time', config.get('default_weather_time', '05:00'))}\n城市:{config.get('city')}。")
    elif text == "关闭天气":
        config["weather_users"][sender]["enabled"] = False
        send_room(config, token_value, room_id, "已关闭每日天气推送。发送“开启天气”可以恢复。")
    elif text == "开启天气":
        config["weather_users"][sender]["enabled"] = True
        send_room(config, token_value, room_id, f"已开启每日天气推送。当前时间:每天 {config['weather_users'][sender].get('time', config.get('default_weather_time', '05:00'))}。")
    else:
        m = re.fullmatch(r"设置天气时间\s+([0-2]?\d):([0-5]\d)", text)
        if m:
            hour = int(m.group(1))
            minute = int(m.group(2))
            if hour > 23:
                send_room(config, token_value, room_id, "时间格式不对。示例:设置天气时间 07:30")
            else:
                config["weather_users"][sender]["time"] = f"{hour:02d}:{minute:02d}"
                config["weather_users"][sender]["enabled"] = True
                send_room(config, token_value, room_id, f"已设置成功:每天 {hour:02d}:{minute:02d} 给你推送天气预报。")
        elif lower in ("hi", "hello", "你好"):
            send_room(config, token_value, room_id, WELCOME)
        else:
            send_room(config, token_value, room_id, "我没看懂。可以发送“帮助”查看全部命令,或发送:设置天气时间 07:30")
    save_json(CONFIG_PATH, config)

def invite_sender(config, invite):
    events = invite.get("invite_state", {}).get("events", [])
    for event in events:
        if event.get("type") == "m.room.member" and event.get("state_key") == config.get("bot_user_id"):
            return event.get("sender")
    return None

def main():
    config = load_json(CONFIG_PATH, None)
    if not config:
        raise SystemExit(f"missing config: {CONFIG_PATH}")
    state = load_json(config.get("sync_state_path"), {})
    token_value = token(config)
    params = {"timeout": 0}
    if state.get("since"):
        params["since"] = state["since"]
    status, payload = request("GET", f"{config['homeserver']}/_matrix/client/v3/sync?{urllib.parse.urlencode(params)}", token_value, timeout=35)
    if status != 200:
        raise SystemExit(f"sync failed: HTTP {status} {payload}")
    for room_id, invite in payload.get("rooms", {}).get("invite", {}).items():
        sender = invite_sender(config, invite)
        if config.get("allow_local_users_only", True) and (not sender or not local_user(config, sender)):
            print(f"skip non-local invite {room_id} from {sender}")
            continue
        join_status, join_payload = request("POST", f"{config['homeserver']}/_matrix/client/v3/rooms/{quote(room_id)}/join", token_value, {})
        if join_status in (200, 201):
            enable_user(config, sender, room_id)
            send_room(config, token_value, room_id, WELCOME)
        else:
            print(f"join failed {room_id}: HTTP {join_status} {join_payload}")
    first_sync = not state.get("since")
    if not first_sync:
        for room_id, room in payload.get("rooms", {}).get("join", {}).items():
            for event in room.get("timeline", {}).get("events", []):
                if event.get("type") != "m.room.message":
                    continue
                sender = event.get("sender")
                if sender == config.get("bot_user_id") or not sender or not local_user(config, sender):
                    continue
                content = event.get("content", {})
                if content.get("msgtype") != "m.text":
                    continue
                handle_command(config, token_value, room_id, sender, content.get("body", ""))
    state["since"] = payload.get("next_batch", state.get("since"))
    save_json(config.get("sync_state_path"), state)
    save_json(CONFIG_PATH, config)
    print("sync ok")

if __name__ == "__main__":
    main()

检查脚本:

chmod 700 /root/data/docker_data/synapse/scripts/matrix-bot-sync.py
python3 -m py_compile /root/data/docker_data/synapse/scripts/matrix-bot-sync.py

创建天气包装脚本:

vi /root/data/docker_data/synapse/scripts/run-matrix-bot-weather.sh

写入:

#!/bin/sh
LOCK=/tmp/matrix-bot-weather.lock
if mkdir "$LOCK" 2>/dev/null; then
  trap 'rmdir "$LOCK"' EXIT
  /root/data/docker_data/synapse/scripts/matrix-bot-weather.py >> /var/log/matrix-bot-weather.log 2>&1
fi

创建聊天同步包装脚本:

vi /root/data/docker_data/synapse/scripts/run-matrix-bot-sync.sh

写入:

#!/bin/sh
LOCK=/tmp/matrix-bot-sync.lock
if mkdir "$LOCK" 2>/dev/null; then
  trap 'rmdir "$LOCK"' EXIT
  /root/data/docker_data/synapse/scripts/matrix-bot-sync.py >> /var/log/matrix-bot-sync.log 2>&1
fi

设置权限:

chmod 700 /root/data/docker_data/synapse/scripts/run-matrix-bot-weather.sh
chmod 700 /root/data/docker_data/synapse/scripts/run-matrix-bot-sync.sh

再创建两个循环脚本。天气循环:

vi /root/data/docker_data/synapse/scripts/run-matrix-bot-weather-loop.sh

写入:

#!/bin/sh
while true; do
  /root/data/docker_data/synapse/scripts/run-matrix-bot-weather.sh
  sleep 20
done

聊天同步循环:

vi /root/data/docker_data/synapse/scripts/run-matrix-bot-sync-loop.sh

写入:

#!/bin/sh
while true; do
  /root/data/docker_data/synapse/scripts/run-matrix-bot-sync.sh
  sleep 20
done

设置权限:

chmod 700 /root/data/docker_data/synapse/scripts/run-matrix-bot-weather-loop.sh
chmod 700 /root/data/docker_data/synapse/scripts/run-matrix-bot-sync-loop.sh

创建天气 OpenRC 服务:

vi /etc/init.d/matrix-bot-weather

写入:

#!/sbin/openrc-run
name="Matrix bot weather scheduler"
description="Send scheduled weather forecasts for Matrix bot users"
command="/root/data/docker_data/synapse/scripts/run-matrix-bot-weather-loop.sh"
command_background=true
pidfile="/run/matrix-bot-weather.pid"
output_log="/var/log/matrix-bot-weather-loop.log"
error_log="/var/log/matrix-bot-weather-loop.log"
depend() {
  need net
  after docker
}

创建聊天同步 OpenRC 服务:

vi /etc/init.d/matrix-bot-sync

写入:

#!/sbin/openrc-run
name="Matrix bot sync"
description="Poll Matrix events for bot invites and weather commands"
command="/root/data/docker_data/synapse/scripts/run-matrix-bot-sync-loop.sh"
command_background=true
pidfile="/run/matrix-bot-sync.pid"
output_log="/var/log/matrix-bot-sync-loop.log"
error_log="/var/log/matrix-bot-sync-loop.log"
depend() {
  need net
  after docker
}

启动并加入开机自启:

chmod 755 /etc/init.d/matrix-bot-weather
chmod 755 /etc/init.d/matrix-bot-sync
rc-update add matrix-bot-weather default
rc-update add matrix-bot-sync default
rc-service matrix-bot-weather restart
rc-service matrix-bot-sync restart

确认服务已经运行:

rc-service matrix-bot-weather status
rc-service matrix-bot-sync status
tail -n 20 /var/log/matrix-bot-weather.log
tail -n 20 /var/log/matrix-bot-sync.log

做一次真正的到点测试。先在 Matrix 客户端里给机器人发:

设置天气时间 09:40

09:40 换成当前时间的下一分钟。机器人应该先回复:

已设置成功:每天 09:40 给你推送天气预报。

然后等到这个时间点。天气服务每 20 秒检查一次,正常情况下几十秒内会自动推送天气预报。服务器上可以同时观察:

tail -f /var/log/matrix-bot-weather.log

看到类似输出就说明后台服务自己完成了定时发送:

sent weather forecast to 1 room(s)
weather schedule: no due users

state 文件也会记录这个用户当天这个时间已经发送:

cat /root/data/docker_data/synapse/scripts/matrix-bot-weather.state.json

示例:

"last_sent": {
  "@honey:666666.xyz": "2026-05-26 09:40"
}

这里记录的是“日期 + 推送时间”,不是简单的“今天已发送”。所以同一天临时改一个新的测试时间,到了新时间仍然会推送;同一个时间当天不会重复刷屏。

八、增加服务器监控提醒

天气预报按用户自己的设置时间发送,监控提醒更适合“有事才吱声”。这里增加一个独立脚本,每 5 分钟检查一次:

  • 磁盘空间预警:检查 //var/lib/docker,默认使用率 80% 警告、90% 严重,或者可用空间低于 10GiB 警告。
  • Docker 容器异常通知:检查关键容器是否存在、是否运行、健康检查是否 unhealthy,以及重启次数是否增加。
  • TURN / LiveKit 状态检测:检查 coturn、LiveKit、LiveKit JWT、Element Call Web 相关容器、端口和 HTTP 探针。

创建监控配置:

vi /root/data/docker_data/synapse/scripts/matrix-bot-monitor.json

写入示例:

{
  "homeserver": "http://127.0.0.1:8008",
  "token_path": "/root/.config/matrix-bot/access_token",
  "state_path": "/root/data/docker_data/synapse/scripts/matrix-bot-monitor.state.json",
  "bot_weather_state_path": "/root/data/docker_data/synapse/scripts/matrix-bot-weather.state.json",
  "target_users": ["@honey:666666.xyz"],
  "target_rooms": [],
  "disk": {
    "mounts": ["/", "/var/lib/docker"],
    "warn_percent": 80,
    "critical_percent": 90,
    "min_free_gb": 10
  },
  "docker": {
    "expected_containers": [
      "matrix-synapse",
      "nginx-proxy-manager",
      "web-call",
      "matrix-livekit-jwt-service",
      "coturn",
      "ntfy",
      "livekit-server",
      "postgres16"
    ]
  },
  "realtime": {
    "containers": [
      "coturn",
      "livekit-server",
      "matrix-livekit-jwt-service",
      "web-call"
    ],
    "tcp_ports": [3478, 7880, 8170, 8181],
    "udp_ports": [3478],
    "http_probes": [
      {
        "name": "LiveKit JWT",
        "url": "http://127.0.0.1:8170/healthz",
        "ok": [200]
      },
      {
        "name": "LiveKit API",
        "url": "http://127.0.0.1:7880/",
        "ok": [200, 404]
      },
      {
        "name": "Element Call Web",
        "url": "http://127.0.0.1:8181/",
        "ok": [200, 301, 302]
      }
    ]
  }
}

如果你的 LiveKit 容器实际叫 livekit-server-test,就把配置里的 livekit-server 改成实际容器名。其他容器名也一样,以 docker ps --format '{{.Names}}' 为准。

创建监控脚本:

vi /root/data/docker_data/synapse/scripts/matrix-bot-monitor.py

核心逻辑建议按下面这个结构写:

#!/usr/bin/env python3
import datetime as dt
import hashlib
import html
import json
import os
import socket
import subprocess
import time
import urllib.parse
import urllib.request

CONFIG_PATH = "/root/data/docker_data/synapse/scripts/matrix-bot-monitor.json"

def load_json(path, default):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return default

def save_json(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")
    os.chmod(tmp, 0o600)
    os.replace(tmp, path)

def run(cmd):
    return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)

def request(method, url, token=None, data=None, timeout=20):
    headers = {}
    body = None
    if token:
        headers["Authorization"] = f"Bearer {token}"
    if data is not None:
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8", errors="replace")
            if not raw:
                return resp.status, {}
            try:
                return resp.status, json.loads(raw)
            except Exception:
                return resp.status, {"raw": raw[:200]}
    except Exception as e:
        return 0, {"error": str(e)}

def quote(value):
    return urllib.parse.quote(value, safe="")

def check_disks(config):
    issues = []
    disk = config["disk"]
    for mount in disk["mounts"]:
        proc = run(["df", "-P", "-B1", mount])
        if proc.returncode != 0:
            issues.append(f"[disk] {mount}: df failed")
            continue
        parts = proc.stdout.strip().splitlines()[-1].split()
        used_percent = int(parts[4].rstrip("%"))
        free_gb = int(parts[3]) / 1024 / 1024 / 1024
        if used_percent >= disk["critical_percent"]:
            issues.append(f"[disk:critical] {parts[5]} used={used_percent}% free={free_gb:.1f}GiB")
        elif used_percent >= disk["warn_percent"] or free_gb < disk["min_free_gb"]:
            issues.append(f"[disk:warning] {parts[5]} used={used_percent}% free={free_gb:.1f}GiB")
    return issues

def docker_inspect(names):
    result = {}
    for name in names:
        proc = run(["docker", "inspect", "--format", "{{json .}}", name])
        if proc.returncode != 0:
            result[name] = {"missing": True}
            continue
        data = json.loads(proc.stdout)
        state = data.get("State", {})
        health = state.get("Health") or {}
        result[name] = {
            "missing": False,
            "running": bool(state.get("Running")),
            "status": state.get("Status", "unknown"),
            "health": health.get("Status", "none"),
            "restart_count": int(data.get("RestartCount", 0)),
        }
    return result

def check_docker(config, state):
    issues = []
    previous = state.get("docker_restart_counts", {})
    current = {}
    for name, info in docker_inspect(config["docker"]["expected_containers"]).items():
        if info.get("missing"):
            issues.append(f"[docker] {name}: missing")
            continue
        current[name] = info["restart_count"]
        if not info["running"]:
            issues.append(f"[docker] {name}: not running, status={info['status']}")
        if info["health"] == "unhealthy":
            issues.append(f"[docker] {name}: health=unhealthy")
        old = previous.get(name)
        if old is not None and info["restart_count"] > old:
            issues.append(f"[docker] {name}: restart count {old} -> {info['restart_count']}")
    state["docker_restart_counts"] = current
    return issues

def tcp_open(port):
    try:
        with socket.create_connection(("127.0.0.1", int(port)), timeout=3):
            return True
    except Exception:
        return False

def udp_listening(port):
    proc = run(["ss", "-lun"])
    return proc.returncode == 0 and any(f":{port}" in line for line in proc.stdout.splitlines())

def check_realtime(config):
    issues = []
    rt = config["realtime"]
    for name, info in docker_inspect(rt["containers"]).items():
        if info.get("missing"):
            issues.append(f"[realtime] {name}: missing")
        elif not info["running"]:
            issues.append(f"[realtime] {name}: not running, status={info['status']}")
    for port in rt["tcp_ports"]:
        if not tcp_open(port):
            issues.append(f"[realtime] tcp/{port}: not listening")
    for port in rt["udp_ports"]:
        if not udp_listening(port):
            issues.append(f"[realtime] udp/{port}: not listening")
    for probe in rt["http_probes"]:
        status, payload = request("GET", probe["url"], timeout=5)
        if status not in probe["ok"]:
            issues.append(f"[realtime] {probe['name']}: HTTP {status} {payload}")
    return issues

def send_room(config, token, room_id, body):
    txn = "server-monitor-" + dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S")
    txn += "-" + hashlib.sha1((room_id + body).encode("utf-8")).hexdigest()[:10]
    html_body = "<br>".join(html.escape(line) for line in body.splitlines())
    content = {
        "msgtype": "m.text",
        "body": body,
        "format": "org.matrix.custom.html",
        "formatted_body": html_body,
    }
    url = f"{config['homeserver']}/_matrix/client/v3/rooms/{quote(room_id)}/send/m.room.message/{txn}"
    status, payload = request("PUT", url, token, content)
    if status not in (200, 201):
        raise RuntimeError(f"send failed: HTTP {status} {payload}")

def send_alert(config, token, state, body):
    rooms = set(config.get("target_rooms", []))
    weather_state = load_json(config.get("bot_weather_state_path", ""), {})
    for user_id, room_id in weather_state.get("direct_rooms", {}).items():
        if user_id in config["target_users"]:
            rooms.add(room_id)
    for room_id in sorted(rooms):
        send_room(config, token, room_id, body)
        time.sleep(0.2)

def main():
    config = load_json(CONFIG_PATH, None)
    if not config:
        raise SystemExit(f"missing config: {CONFIG_PATH}")
    state = load_json(config["state_path"], {})
    with open(config["token_path"], "r", encoding="utf-8") as f:
        token = f.read().strip()

    issues = sorted(set(check_disks(config) + check_docker(config, state) + check_realtime(config)))
    previous = set(state.get("active_issues", []))
    current = set(issues)
    new_issues = sorted(current - previous)
    recovered = sorted(previous - current)

    state["active_issues"] = issues
    state["last_check"] = dt.datetime.now().isoformat(timespec="seconds")

    if new_issues or recovered:
        lines = [f"服务器监控提醒 {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", ""]
        if new_issues:
            lines.append("新增异常:")
            lines.extend(f"- {x}" for x in new_issues)
        if recovered:
            if new_issues:
                lines.append("")
            lines.append("已恢复:")
            lines.extend(f"- {x}" for x in recovered)
        send_alert(config, token, state, "\n".join(lines))

    save_json(config["state_path"], state)
    print(f"monitor ok: active={len(issues)} new={len(new_issues)} recovered={len(recovered)}")

if __name__ == "__main__":
    main()

设置权限并手动测试:

chmod 700 /root/data/docker_data/synapse/scripts/matrix-bot-monitor.py
python3 -m py_compile /root/data/docker_data/synapse/scripts/matrix-bot-monitor.py
/root/data/docker_data/synapse/scripts/matrix-bot-monitor.py

正常时输出类似:

monitor ok: active=0 new=0 recovered=0

如果第一次运行时已有异常,会立刻给 Matrix 目标房间发一条“新增异常”。后续同一异常不会反复刷屏;只有出现新异常或旧异常恢复时,才会再次提醒。

九、配置每 5 分钟监控一次

创建包装脚本:

vi /root/data/docker_data/synapse/scripts/run-matrix-bot-monitor.sh

写入:

#!/bin/sh
LOCK=/tmp/matrix-bot-monitor.lock

if mkdir "$LOCK" 2>/dev/null; then
  trap 'rmdir "$LOCK"' EXIT
  /root/data/docker_data/synapse/scripts/matrix-bot-monitor.py >> /var/log/matrix-bot-monitor.log 2>&1
fi

设置权限:

chmod 700 /root/data/docker_data/synapse/scripts/run-matrix-bot-monitor.sh

编辑 root crontab:

vi /etc/crontabs/root

加入:

*/5 * * * * /root/data/docker_data/synapse/scripts/run-matrix-bot-monitor.sh

重启 crond

rc-service crond restart

手动跑一次包装脚本,确认日志正常:

/root/data/docker_data/synapse/scripts/run-matrix-bot-monitor.sh
tail -n 20 /var/log/matrix-bot-monitor.log

十、聊天命令和用户自助设置

普通用户可以主动邀请机器人:

@bot:666666.xyz

机器人只自动接受本服用户邀请,也就是 :666666.xyz 结尾的用户。首次加入后会自动发送帮助说明:

你好,我是天气机器人。

你可以这样使用我:

天气
  立即查看今天和未来两天天气

设置天气时间 07:30
  设置每天自动推送时间

天气时间
  查看当前推送时间

关闭天气
  停止每日天气推送

开启天气
  恢复每日天气推送

帮助
  再次查看这份说明

普通用户只会获得天气功能,不会收到服务器监控提醒。监控提醒由 matrix-bot-monitor.jsontarget_users 单独控制,例如只发给管理员:

"target_users": [
  "@black:jgaga.tk",
  "@honey:jgaga.tk",
  "@admin2:jgaga.tk"
]

改完白名单后不要只看配置文件,最好发一次无损测试告警,确认每个目标用户都能建立私聊并收到提醒:

/root/data/docker_data/synapse/scripts/matrix-bot-monitor.py --test-alert
python3 -m json.tool /root/data/docker_data/synapse/scripts/matrix-bot-monitor.state.json

天气用户则保存在 matrix-bot-weather.jsonweather_users 里,每个用户可以有自己的推送时间:

"weather_users": {
  "@honey:666666.xyz": {
    "enabled": true,
    "time": "05:00"
  },
  "@black:666666.xyz": {
    "enabled": true,
    "time": "07:30"
  }
}

如果只是给固定管理员开通默认天气提醒,当前 jgaga.tk 这套生产栈默认保留:

"target_users": [
  "@honey:jgaga.tk",
  "@black:jgaga.tk",
  "@admin2:jgaga.tk"
]

target_users 只用于初始化默认用户;真实的开关、时间和城市仍以 weather_users 为准。不要把定时发送重新改回只遍历 target_users,否则用户自己发送“设置天气时间”和“设置城市”后,后台推送不会按个人配置生效。

十一、手动维护用户配置

一般不需要手动维护,用户直接邀请 @bot:666666.xyz 后会自动加入 weather_users。如果需要管理员直接改配置,可以编辑:

vi /root/data/docker_data/synapse/scripts/matrix-bot-weather.json

示例:

"weather_users": {
  "@honey:666666.xyz": {
    "enabled": true,
    "time": "05:00"
  },
  "@other:666666.xyz": {
    "enabled": true,
    "time": "07:30"
  }
}

保存后可以手动测试立即发送:

/root/data/docker_data/synapse/scripts/matrix-bot-weather.py --send-now

十二、关于加密房间

这个脚本使用的是普通 Matrix Client-Server API,没有实现端到端加密设备管理,也没有处理 Olm/Megolm 密钥。

因此建议让机器人使用未加密私聊或未加密房间。上面的脚本没有实现 E2EE,机器人即使能加入加密房间,也不能像正常客户端那样解密和处理消息。

如果旧版本已经把某个用户的天气私聊建成了加密房间,不要尝试原地关闭加密。Matrix 加密状态不能撤回,正确做法是让脚本检测 m.room.encryption,离开旧房间并重新创建未加密私聊,再更新 direct_rooms 映射。

如果一定要在端到端加密房间里使用机器人,需要换成支持 E2EE 的机器人框架,例如 matrix-nio[e2e],并额外处理设备信任、密钥存储和恢复。

十三、排查命令

查看日志:

tail -n 100 /var/log/matrix-bot-weather.log
tail -n 100 /var/log/matrix-bot-sync.log
tail -n 100 /var/log/matrix-bot-monitor.log

检查脚本语法:

python3 -m py_compile /root/data/docker_data/synapse/scripts/matrix-bot-weather.py
python3 -m py_compile /root/data/docker_data/synapse/scripts/matrix-bot-sync.py
python3 -m py_compile /root/data/docker_data/synapse/scripts/matrix-bot-monitor.py

确认机器人 token 文件存在:

ls -l /root/.config/matrix-bot/access_token

确认 Synapse API 正常:

curl -sS -o /tmp/synapse_versions.json -w 'http_code=%{http_code}\n' \
  http://127.0.0.1:8008/_matrix/client/versions
rm -f /tmp/synapse_versions.json

确认天气和聊天同步服务正常运行:

rc-service matrix-bot-weather status
rc-service matrix-bot-sync status
ps -ef | grep matrix-bot-weather | grep -v grep
ps -ef | grep matrix-bot-sync | grep -v grep

确认监控 cron 正常运行:

rc-service crond status
grep -n 'matrix-bot-monitor' /etc/crontabs/root

如果要立即发一次天气测试,可以直接运行:

/root/data/docker_data/synapse/scripts/matrix-bot-weather.py --send-now

如果要测试“定时是否真的生效”,不要只跑 --send-now。应该在 Matrix 客户端里把天气时间设置为下一分钟,然后观察 /var/log/matrix-bot-weather.log,确认是 matrix-bot-weather 服务自己到点发送。

天气脚本按每个用户配置的时间判断是否到点,并在 state 文件里按“日期 + 时间”记录已经发送,避免重复推送。聊天命令和天气推送是两条后台循环服务,监控提醒仍然是独立 cron;普通用户改天气时间不会影响监控提醒。