init project
This commit is contained in:
38
.dockerignore
Normal file
38
.dockerignore
Normal file
@@ -0,0 +1,38 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info
|
||||
.eggs
|
||||
dist
|
||||
build
|
||||
.venv
|
||||
venv
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# References (不需要包含在鏡像中)
|
||||
references/
|
||||
|
||||
# Tests (生產鏡像不需要)
|
||||
tests/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Store (運行時掛載)
|
||||
store/
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# PROJECT M Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Matrix Server Configuration
|
||||
MATRIX_SERVER=https://your-matrix-server.com
|
||||
MATRIX_USER_ID=@your-bot:your-matrix-server.com
|
||||
MATRIX_PASSWORD=your-secure-password
|
||||
|
||||
# Device Configuration
|
||||
MATRIX_DEVICE_ID=PROJECT_M_BOT
|
||||
MATRIX_DEVICE_NAME=ProjectMBot
|
||||
|
||||
# OpenClaw Configuration
|
||||
OPENCLAW_JSON_PATH=~/.openclaw/openclaw.json
|
||||
|
||||
# Bot Greeting Message
|
||||
BOT_GREETING_MESSAGE=你好!我係 PROJECT M Bot,已成功加入房間。有咩需要可以搵管理員幫手!
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Store folder (E2EE keys)
|
||||
store/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
57
AGENTS.md
Normal file
57
AGENTS.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# PROJECT M - OpenClaw Matrix Bot
|
||||
|
||||
> AI Agent 專用文檔,描述項目結構和開發指南
|
||||
|
||||
## 項目概述
|
||||
|
||||
| 屬性 | 內容 |
|
||||
|------|------|
|
||||
| 代號 | PROJECT M |
|
||||
| 類型 | Matrix Bot |
|
||||
| 核心功能 | 自動登錄、Token 同步、邀請響應、問候發送 |
|
||||
|
||||
## 目錄結構
|
||||
|
||||
```
|
||||
openclaw-matrix-uplife-bot/
|
||||
├── src/ # 源代碼
|
||||
│ ├── main.py # 主入口
|
||||
│ ├── config.py # 配置管理
|
||||
│ └── services/
|
||||
│ ├── matrix_service.py # Matrix 服務
|
||||
│ └── token_manager.py # Token 管理器
|
||||
├── tests/ # 單元測試
|
||||
├── references/ # 參考代碼(只讀)
|
||||
│ └── matrix-bot-chat-reference/
|
||||
├── docker-compose.yml # Docker 配置
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 關鍵配置
|
||||
|
||||
- **Token JSON Key**: `channels.matrix.accessToken`
|
||||
- **OpenClaw 路徑**: `~/.openclaw/openclaw.json`
|
||||
|
||||
## 開發命令
|
||||
|
||||
```bash
|
||||
# 啟動
|
||||
docker compose up -d
|
||||
|
||||
# 停止
|
||||
docker compose down
|
||||
|
||||
# 測試
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## 安全協議
|
||||
|
||||
1. 更新 Token 前**必須**備份
|
||||
2. 使用沙箱測試
|
||||
3. 密碼僅通過環境變量傳入
|
||||
|
||||
## 參考資料
|
||||
|
||||
- `references/matrix-bot-chat-reference/` - 舊版 Matrix Bot 代碼
|
||||
- [matrix-nio 文檔](https://matrix-nio.readthedocs.io/)
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 設置工作目錄
|
||||
WORKDIR /app
|
||||
|
||||
# 複製依賴文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安裝依賴
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 複製源代碼
|
||||
COPY src/ ./src/
|
||||
|
||||
# 設置環境變量
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# 運行 Bot
|
||||
CMD ["python", "-m", "src.main"]
|
||||
113
README.md
Normal file
113
README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# PROJECT M - Matrix Bot
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://matrix.org/)
|
||||
[](https://docs.docker.com/compose/)
|
||||
|
||||
> Matrix Bot for OpenClaw integration - Auto-join rooms and sync access tokens.
|
||||
|
||||
## 功能特點
|
||||
|
||||
- 🔐 **自動登錄**:使用配置的帳號密碼登錄 Matrix Server
|
||||
- 🔄 **Token 同步**:自動更新 `~/.openclaw/openclaw.json` 中的 access token
|
||||
- 📬 **邀請響應**:自動接受房間邀請並加入
|
||||
- 👋 **問候消息**:加入房間後自動發送問候信息
|
||||
- 🐳 **Docker 部署**:支持 Docker Compose 一鍵啟動
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 1. 配置環境變量
|
||||
|
||||
```bash
|
||||
# 複製環境變量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 編輯配置
|
||||
nano .env
|
||||
```
|
||||
|
||||
必要配置項:
|
||||
```env
|
||||
MATRIX_SERVER=https://your-matrix-server.com
|
||||
MATRIX_USER_ID=@your-bot:your-matrix-server.com
|
||||
MATRIX_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
### 2. 使用 Docker Compose 啟動
|
||||
|
||||
```bash
|
||||
# 構建並啟動
|
||||
docker compose up -d
|
||||
|
||||
# 查看日誌
|
||||
docker compose logs -f
|
||||
|
||||
# 停止
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 3. 本地開發運行
|
||||
|
||||
```bash
|
||||
# 創建虛擬環境
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# 安裝依賴
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 運行
|
||||
python -m src.main
|
||||
```
|
||||
|
||||
## 項目結構
|
||||
|
||||
```
|
||||
openclaw-matrix-uplife-bot/
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # 主入口
|
||||
│ ├── config.py # 配置管理
|
||||
│ └── services/
|
||||
│ ├── __init__.py
|
||||
│ ├── matrix_service.py # Matrix 服務
|
||||
│ └── token_manager.py # Token 管理器
|
||||
├── tests/ # 測試
|
||||
├── store/ # E2EE 密鑰存儲
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 安全協議
|
||||
|
||||
### Token 更新安全
|
||||
|
||||
1. **強制備份**:更新前必須創建帶時間戳的備份
|
||||
2. **驗證機制**:確認備份文件存在後才進行更新
|
||||
3. **錯誤回滾**:更新失敗時可從備份恢復
|
||||
|
||||
### 環境變量
|
||||
|
||||
- 敏感信息(密碼)僅通過環境變量傳入
|
||||
- `.env` 文件已加入 `.gitignore`
|
||||
- 禁止在代碼中硬編碼密碼
|
||||
|
||||
## 測試
|
||||
|
||||
```bash
|
||||
# 運行所有測試
|
||||
pytest tests/ -v
|
||||
|
||||
# 運行特定測試
|
||||
pytest tests/test_token_manager.py -v
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
**PROJECT M** - OpenClaw Matrix Integration Bot
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
project-m-bot:
|
||||
build: .
|
||||
container_name: project-m-bot
|
||||
restart: unless-stopped
|
||||
|
||||
# 環境變量(從 .env 文件讀取)
|
||||
environment:
|
||||
- MATRIX_SERVER=${MATRIX_SERVER}
|
||||
- MATRIX_USER_ID=${MATRIX_USER_ID}
|
||||
- MATRIX_PASSWORD=${MATRIX_PASSWORD}
|
||||
- MATRIX_DEVICE_ID=${MATRIX_DEVICE_ID:-PROJECT_M_BOT}
|
||||
- MATRIX_DEVICE_NAME=${MATRIX_DEVICE_NAME:-ProjectMBot}
|
||||
- BOT_GREETING_MESSAGE=${BOT_GREETING_MESSAGE:-你好!我係 PROJECT M Bot,已成功加入房間。}
|
||||
# 容器內路徑
|
||||
- OPENCLAW_JSON_PATH=/root/.openclaw/openclaw.json
|
||||
|
||||
# 掛載卷
|
||||
volumes:
|
||||
# 掛載 openclaw 配置目錄(用於更新 token)
|
||||
- ~/.openclaw:/root/.openclaw
|
||||
# 掛載 store 目錄(用於持久化 E2EE 密鑰)
|
||||
- ./store:/app/store
|
||||
|
||||
# 網絡配置
|
||||
networks:
|
||||
- project-m-network
|
||||
|
||||
networks:
|
||||
project-m-network:
|
||||
driver: bridge
|
||||
1
references/matrix-bot-chat-reference
Submodule
1
references/matrix-bot-chat-reference
Submodule
Submodule references/matrix-bot-chat-reference added at 972a538356
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# PROJECT M Dependencies
|
||||
# Matrix Protocol Client
|
||||
matrix-nio[e2e]>=0.25.0
|
||||
|
||||
# Async File Operations
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# Environment Variables
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# PROJECT M - Matrix Bot
|
||||
81
src/config.py
Normal file
81
src/config.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# config.py - PROJECT M 配置管理模組
|
||||
"""
|
||||
Configuration management for PROJECT M Matrix Bot.
|
||||
Loads settings from environment variables with sensible defaults.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 載入 .env 文件
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
"""PROJECT M Bot 配置類"""
|
||||
|
||||
# Matrix Server 配置
|
||||
MATRIX_SERVER: str = os.getenv("MATRIX_SERVER", "https://matrix.org")
|
||||
MATRIX_USER_ID: str = os.getenv("MATRIX_USER_ID", "")
|
||||
MATRIX_PASSWORD: str = os.getenv("MATRIX_PASSWORD", "")
|
||||
|
||||
# 設備配置
|
||||
DEVICE_ID: str = os.getenv("MATRIX_DEVICE_ID", "PROJECT_M_BOT")
|
||||
DEVICE_NAME: str = os.getenv("MATRIX_DEVICE_NAME", "ProjectMBot")
|
||||
|
||||
# OpenClaw 配置
|
||||
OPENCLAW_JSON_PATH: str = os.getenv(
|
||||
"OPENCLAW_JSON_PATH",
|
||||
str(Path.home() / ".openclaw" / "openclaw.json")
|
||||
)
|
||||
|
||||
# Access Token 的 JSON Key 路徑
|
||||
# 格式: channels.matrix.accessToken
|
||||
TOKEN_JSON_KEY: str = "channels.matrix.accessToken"
|
||||
|
||||
# Bot 問候信息
|
||||
GREETING_MESSAGE: str = os.getenv(
|
||||
"BOT_GREETING_MESSAGE",
|
||||
"你好!我係 PROJECT M Bot,已成功加入房間。有咩需要可以搵管理員幫手!"
|
||||
)
|
||||
|
||||
# 存儲路徑(E2EE 密鑰)
|
||||
STORE_PATH: str = os.getenv("STORE_PATH", "store")
|
||||
|
||||
@classmethod
|
||||
def validate(cls) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
驗證必要的配置是否已設置
|
||||
|
||||
Returns:
|
||||
tuple[bool, list[str]]: (是否有效, 缺失的配置列表)
|
||||
"""
|
||||
missing = []
|
||||
|
||||
if not cls.MATRIX_SERVER:
|
||||
missing.append("MATRIX_SERVER")
|
||||
if not cls.MATRIX_USER_ID:
|
||||
missing.append("MATRIX_USER_ID")
|
||||
if not cls.MATRIX_PASSWORD:
|
||||
missing.append("MATRIX_PASSWORD")
|
||||
|
||||
return len(missing) == 0, missing
|
||||
|
||||
@classmethod
|
||||
def get_openclaw_path(cls) -> Path:
|
||||
"""
|
||||
獲取 openclaw.json 的完整路徑
|
||||
|
||||
Returns:
|
||||
Path: openclaw.json 的路徑對象
|
||||
"""
|
||||
path_str = cls.OPENCLAW_JSON_PATH
|
||||
# 展開 ~ 為用戶主目錄
|
||||
if path_str.startswith("~"):
|
||||
return Path(path_str).expanduser()
|
||||
return Path(path_str)
|
||||
|
||||
|
||||
# 創建全局配置實例
|
||||
config = Config()
|
||||
81
src/main.py
Normal file
81
src/main.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# main.py - PROJECT M 主入口
|
||||
"""
|
||||
PROJECT M Matrix Bot - Main Entry Point
|
||||
|
||||
This bot:
|
||||
1. Logs into Matrix server
|
||||
2. Updates access token in ~/.openclaw/openclaw.json
|
||||
3. Listens for room invitations
|
||||
4. Auto-joins and sends greeting messages
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from src.config import config
|
||||
from src.services.matrix_service import MatrixService
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函數"""
|
||||
print("=" * 50)
|
||||
print("PROJECT M - Matrix Bot")
|
||||
print("=" * 50)
|
||||
|
||||
# 驗證配置
|
||||
is_valid, missing = config.validate()
|
||||
if not is_valid:
|
||||
print(f"[ERROR] 缺少必要配置: {', '.join(missing)}")
|
||||
print("[ERROR] 請設置環境變量或創建 .env 文件")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] Matrix Server: {config.MATRIX_SERVER}")
|
||||
print(f"[INFO] User ID: {config.MATRIX_USER_ID}")
|
||||
print(f"[INFO] OpenClaw Path: {config.get_openclaw_path()}")
|
||||
print("-" * 50)
|
||||
|
||||
# 創建 Matrix 服務
|
||||
matrix_service = MatrixService()
|
||||
|
||||
# 設置信號處理
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def shutdown():
|
||||
"""優雅關閉"""
|
||||
print("\n[INFO] 正在關閉...")
|
||||
await matrix_service.close()
|
||||
print("[INFO] 已關閉")
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""處理終止信號"""
|
||||
asyncio.create_task(shutdown())
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
# 登錄
|
||||
success = await matrix_service.login()
|
||||
if not success:
|
||||
print("[ERROR] 登錄失敗,請檢查配置")
|
||||
sys.exit(1)
|
||||
|
||||
print("[INFO] 登錄成功!開始監聽邀請...")
|
||||
print("[INFO] 按 Ctrl+C 退出")
|
||||
print("-" * 50)
|
||||
|
||||
# 開始同步循環
|
||||
await matrix_service.sync_forever()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[INFO] 收到中斷信號")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 發生錯誤: {e}")
|
||||
finally:
|
||||
await matrix_service.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
1
src/services/__init__.py
Normal file
1
src/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services module
|
||||
270
src/services/matrix_service.py
Normal file
270
src/services/matrix_service.py
Normal file
@@ -0,0 +1,270 @@
|
||||
# matrix_service.py - Matrix 連接服務
|
||||
"""
|
||||
Matrix Bot Service for PROJECT M.
|
||||
Handles login, sync, invite callbacks, and message sending.
|
||||
|
||||
Based on reference implementation from:
|
||||
references/matrix-bot-chat-reference/services/matrix_service.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Optional
|
||||
from nio import (
|
||||
AsyncClient,
|
||||
LoginResponse,
|
||||
InviteMemberEvent,
|
||||
ClientConfig,
|
||||
)
|
||||
|
||||
from src.config import config
|
||||
from src.services.token_manager import TokenManager
|
||||
|
||||
|
||||
class MatrixService:
|
||||
"""
|
||||
Matrix Bot 核心服務
|
||||
|
||||
功能:
|
||||
- 登錄 Matrix Server
|
||||
- 同步消息
|
||||
- 處理房間邀請
|
||||
- 發送問候消息
|
||||
- 更新 openclaw.json 中的 access token
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
"""單例模式"""
|
||||
if cls._instance is None:
|
||||
cls._instance = super(MatrixService, cls).__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 Matrix 服務"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# 確保存儲目錄存在
|
||||
if not os.path.exists(config.STORE_PATH):
|
||||
os.makedirs(config.STORE_PATH)
|
||||
|
||||
# 創建客戶端配置
|
||||
client_config = ClientConfig(
|
||||
store_sync_tokens=True,
|
||||
encryption_enabled=False # PROJECT M 不需要 E2EE
|
||||
)
|
||||
|
||||
# 提取用戶名(從 @user:server.com 格式)
|
||||
username = config.MATRIX_USER_ID
|
||||
if username.startswith("@"):
|
||||
username = username.split(":")[0][1:]
|
||||
|
||||
# 初始化客戶端
|
||||
self.client = AsyncClient(
|
||||
config.MATRIX_SERVER,
|
||||
username,
|
||||
device_id=config.DEVICE_ID,
|
||||
store_path=config.STORE_PATH,
|
||||
config=client_config,
|
||||
)
|
||||
|
||||
self.is_logged_in = False
|
||||
self.welcomed_rooms = set()
|
||||
|
||||
# Token 管理器
|
||||
self.token_manager = TokenManager(
|
||||
json_path=config.get_openclaw_path(),
|
||||
token_key=config.TOKEN_JSON_KEY
|
||||
)
|
||||
|
||||
# 添加邀請回調
|
||||
self.client.add_event_callback(
|
||||
self.invite_callback,
|
||||
InviteMemberEvent
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
print("[MatrixService] 初始化完成")
|
||||
|
||||
async def login(self) -> bool:
|
||||
"""
|
||||
登錄 Matrix Server 並更新 openclaw.json 中的 token
|
||||
|
||||
Returns:
|
||||
是否登錄成功
|
||||
"""
|
||||
if self.is_logged_in:
|
||||
print("[MatrixService] 已經登錄")
|
||||
return True
|
||||
|
||||
try:
|
||||
print(f"[MatrixService] 正在登錄 {config.MATRIX_SERVER}...")
|
||||
|
||||
response = await self.client.login(
|
||||
password=config.MATRIX_PASSWORD,
|
||||
device_name=config.DEVICE_NAME
|
||||
)
|
||||
|
||||
if isinstance(response, LoginResponse):
|
||||
self.is_logged_in = True
|
||||
print(f"[MatrixService] 登錄成功!User ID: {response.user_id}")
|
||||
print(f"[MatrixService] Access Token: {response.access_token[:30]}...")
|
||||
|
||||
# 更新 openclaw.json 中的 token
|
||||
await self._update_openclaw_token(response.access_token)
|
||||
|
||||
return True
|
||||
else:
|
||||
print(f"[MatrixService] 登錄失敗: {response}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[MatrixService] 登錄過程出錯: {e}")
|
||||
return False
|
||||
|
||||
async def _update_openclaw_token(self, access_token: str) -> bool:
|
||||
"""
|
||||
更新 openclaw.json 中的 access token
|
||||
|
||||
Args:
|
||||
access_token: 新的 access token
|
||||
|
||||
Returns:
|
||||
是否更新成功
|
||||
"""
|
||||
print("[MatrixService] 準備更新 openclaw.json...")
|
||||
|
||||
# 檢查文件是否存在
|
||||
openclaw_path = config.get_openclaw_path()
|
||||
if not openclaw_path.exists():
|
||||
print(f"[MatrixService] openclaw.json 不存在: {openclaw_path}")
|
||||
print("[MatrixService] 跳過 token 更新(這在測試環境是正常的)")
|
||||
return False
|
||||
|
||||
# 使用 TokenManager 安全更新
|
||||
success = await self.token_manager.update_access_token(access_token)
|
||||
|
||||
if success:
|
||||
print("[MatrixService] openclaw.json token 更新成功!")
|
||||
else:
|
||||
print("[MatrixService] openclaw.json token 更新失敗")
|
||||
|
||||
return success
|
||||
|
||||
async def invite_callback(self, room, event):
|
||||
"""
|
||||
處理房間邀請事件
|
||||
|
||||
當 Bot 被邀請到房間時:
|
||||
1. 自動加入房間
|
||||
2. 發送問候消息
|
||||
"""
|
||||
try:
|
||||
inviter = event.sender
|
||||
room_id = room.room_id
|
||||
|
||||
print(f"[MatrixService] 收到來自 {inviter} 的邀請,房間: {room_id}")
|
||||
|
||||
# 自動加入房間
|
||||
join_response = await self.client.join(room_id)
|
||||
|
||||
if join_response:
|
||||
print(f"[MatrixService] 已加入房間: {room_id}")
|
||||
|
||||
# 同步以獲取房間狀態
|
||||
await self.client.sync(timeout=5000)
|
||||
|
||||
# 發送問候消息
|
||||
await self.send_greeting(room_id)
|
||||
|
||||
# 記錄已發送問候的房間
|
||||
self.welcomed_rooms.add(room_id)
|
||||
else:
|
||||
print(f"[MatrixService] 加入房間失敗: {room_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[MatrixService] 處理邀請時出錯: {e}")
|
||||
|
||||
async def send_greeting(self, room_id: str) -> bool:
|
||||
"""
|
||||
發送問候消息到指定房間
|
||||
|
||||
Args:
|
||||
room_id: 房間 ID
|
||||
|
||||
Returns:
|
||||
是否發送成功
|
||||
"""
|
||||
return await self.send_message(config.GREETING_MESSAGE, room_id)
|
||||
|
||||
async def send_message(self, message: str, room_id: str) -> bool:
|
||||
"""
|
||||
發送消息到指定房間
|
||||
|
||||
Args:
|
||||
message: 消息內容
|
||||
room_id: 房間 ID
|
||||
|
||||
Returns:
|
||||
是否發送成功
|
||||
"""
|
||||
if not self.is_logged_in:
|
||||
print("[MatrixService] 未登錄,無法發送消息")
|
||||
return False
|
||||
|
||||
content = {
|
||||
"msgtype": "m.text",
|
||||
"body": message
|
||||
}
|
||||
|
||||
try:
|
||||
await self.client.room_send(
|
||||
room_id=room_id,
|
||||
message_type="m.room.message",
|
||||
content=content
|
||||
)
|
||||
print(f"[MatrixService] 消息已發送到 {room_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[MatrixService] 發送消息失敗: {e}")
|
||||
return False
|
||||
|
||||
async def sync_forever(self):
|
||||
"""
|
||||
開始無限同步循環
|
||||
|
||||
這個方法會持續運行,監聽所有事件
|
||||
"""
|
||||
if not self.is_logged_in:
|
||||
print("[MatrixService] 未登錄,無法開始同步")
|
||||
return
|
||||
|
||||
print("[MatrixService] 開始同步循環...")
|
||||
|
||||
try:
|
||||
# 先進行一次同步
|
||||
await self.client.sync(timeout=10000)
|
||||
print("[MatrixService] 初始同步完成")
|
||||
|
||||
# 開始無限同步
|
||||
await self.client.sync_forever(
|
||||
timeout=30000,
|
||||
full_state=True
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[MatrixService] 同步出錯: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""關閉連接"""
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
print("[MatrixService] 連接已關閉")
|
||||
|
||||
|
||||
# 創建全局服務實例
|
||||
matrix_service = MatrixService()
|
||||
294
src/services/token_manager.py
Normal file
294
src/services/token_manager.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# token_manager.py - OpenClaw Token 管理器
|
||||
"""
|
||||
Manages the access token synchronization with openclaw.json.
|
||||
Handles backup creation and safe updates.
|
||||
|
||||
SAFETY PROTOCOLS:
|
||||
1. Always create timestamped backup before any modification
|
||||
2. Verify backup exists before updating
|
||||
3. Never corrupt the original file
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import aiofiles
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""
|
||||
管理 openclaw.json 中的 Matrix access token
|
||||
|
||||
安全協議:
|
||||
- 更新前必須創建備份
|
||||
- 驗證備份存在後才進行更新
|
||||
- 任何錯誤都不會破壞原文件
|
||||
- 備份輪轉:最多保留 MAX_BACKUPS 個備份
|
||||
"""
|
||||
|
||||
# 最大備份文件數量
|
||||
MAX_BACKUPS: int = 5
|
||||
|
||||
def __init__(self, json_path: Path, token_key: str = "channels.matrix.accessToken"):
|
||||
"""
|
||||
初始化 Token 管理器
|
||||
|
||||
Args:
|
||||
json_path: openclaw.json 的路徑
|
||||
token_key: Token 在 JSON 中的 key 路徑(點分隔)
|
||||
"""
|
||||
self.json_path = json_path
|
||||
self.token_key = token_key
|
||||
self._last_backup_path: Optional[Path] = None
|
||||
|
||||
def _generate_backup_filename(self) -> str:
|
||||
"""生成帶時間戳的備份文件名(毫秒級精度避免重複)"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
return f"{self.json_path.name}.{timestamp}.bak"
|
||||
|
||||
def _get_nested_value(self, data: dict, key_path: str) -> Optional[str]:
|
||||
"""
|
||||
從嵌套字典中獲取值
|
||||
|
||||
Args:
|
||||
data: 字典數據
|
||||
key_path: 點分隔的 key 路徑,如 "channels.matrix.accessToken"
|
||||
|
||||
Returns:
|
||||
對應的值,如果不存在則返回 None
|
||||
"""
|
||||
keys = key_path.split(".")
|
||||
current = data
|
||||
|
||||
for key in keys:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return None
|
||||
|
||||
return current if isinstance(current, str) else None
|
||||
|
||||
def _set_nested_value(self, data: dict, key_path: str, value: str) -> dict:
|
||||
"""
|
||||
在嵌套字典中設置值(會自動創建中間層級)
|
||||
|
||||
Args:
|
||||
data: 字典數據
|
||||
key_path: 點分隔的 key 路徑
|
||||
value: 要設置的值
|
||||
|
||||
Returns:
|
||||
更新後的字典
|
||||
"""
|
||||
keys = key_path.split(".")
|
||||
current = data
|
||||
|
||||
# 遍歷到最後一個 key 的父級
|
||||
for key in keys[:-1]:
|
||||
if key not in current:
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
|
||||
# 設置最後一個 key 的值
|
||||
current[keys[-1]] = value
|
||||
return data
|
||||
|
||||
def create_backup(self) -> Optional[Path]:
|
||||
"""
|
||||
創建 openclaw.json 的備份
|
||||
|
||||
Returns:
|
||||
備份文件路徑,如果失敗則返回 None
|
||||
"""
|
||||
if not self.json_path.exists():
|
||||
print(f"[TokenManager] 源文件不存在: {self.json_path}")
|
||||
return None
|
||||
|
||||
backup_filename = self._generate_backup_filename()
|
||||
backup_path = self.json_path.parent / backup_filename
|
||||
|
||||
try:
|
||||
shutil.copy2(self.json_path, backup_path)
|
||||
self._last_backup_path = backup_path
|
||||
print(f"[TokenManager] 備份已創建: {backup_path}")
|
||||
|
||||
# 備份輪轉:刪除超出限制的舊備份
|
||||
self._rotate_backups()
|
||||
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
print(f"[TokenManager] 創建備份失敗: {e}")
|
||||
return None
|
||||
|
||||
def _get_backup_files(self) -> list[Path]:
|
||||
"""
|
||||
獲取所有備份文件,按時間排序(最新到最舊)
|
||||
|
||||
使用文件名中的時間戳排序(格式:YYYYMMDD_HHMMSS_ffffff)
|
||||
這比 mtime 更可靠
|
||||
|
||||
Returns:
|
||||
備份文件列表
|
||||
"""
|
||||
if not self.json_path.parent.exists():
|
||||
return []
|
||||
|
||||
# 匹配備份文件模式:openclaw.json.YYYYMMDD_HHMMSS_ffffff.bak
|
||||
pattern = f"{self.json_path.name}.*.bak"
|
||||
backup_files = list(self.json_path.parent.glob(pattern))
|
||||
|
||||
# 按文件名排序(時間戳在文件名中,字典序即時間序)
|
||||
# 格式:name.YYYYMMDD_HHMMSS_ffffff.bak
|
||||
# 因為時間戳格式固定,字典序 = 時間序
|
||||
backup_files.sort(key=lambda x: x.name, reverse=True)
|
||||
|
||||
return backup_files
|
||||
|
||||
def _rotate_backups(self) -> int:
|
||||
"""
|
||||
輪轉備份文件,刪除超出 MAX_BACKUPS 的舊備份
|
||||
|
||||
Returns:
|
||||
刪除的備份數量
|
||||
"""
|
||||
backup_files = self._get_backup_files()
|
||||
deleted_count = 0
|
||||
|
||||
# 如果備份數量超過限制,刪除最舊的
|
||||
if len(backup_files) > self.MAX_BACKUPS:
|
||||
files_to_delete = backup_files[self.MAX_BACKUPS:]
|
||||
|
||||
for old_backup in files_to_delete:
|
||||
try:
|
||||
old_backup.unlink()
|
||||
print(f"[TokenManager] 已刪除舊備份: {old_backup.name}")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"[TokenManager] 刪除備份失敗: {old_backup.name} - {e}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
def get_backup_count(self) -> int:
|
||||
"""
|
||||
獲取當前備份文件數量
|
||||
|
||||
Returns:
|
||||
備份文件數量
|
||||
"""
|
||||
return len(self._get_backup_files())
|
||||
|
||||
def verify_backup_exists(self, backup_path: Optional[Path] = None) -> bool:
|
||||
"""
|
||||
驗證備份文件是否存在
|
||||
|
||||
Args:
|
||||
backup_path: 備份路徑,如果為 None 則使用上次創建的備份
|
||||
|
||||
Returns:
|
||||
備份是否存在
|
||||
"""
|
||||
path = backup_path or self._last_backup_path
|
||||
if path is None:
|
||||
return False
|
||||
return path.exists()
|
||||
|
||||
async def read_current_token(self) -> Optional[str]:
|
||||
"""
|
||||
讀取當前的 access token
|
||||
|
||||
Returns:
|
||||
當前 token,如果不存在則返回 None
|
||||
"""
|
||||
if not self.json_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
async with aiofiles.open(self.json_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
data = json.loads(content)
|
||||
return self._get_nested_value(data, self.token_key)
|
||||
except Exception as e:
|
||||
print(f"[TokenManager] 讀取 token 失敗: {e}")
|
||||
return None
|
||||
|
||||
async def update_access_token(self, new_token: str) -> bool:
|
||||
"""
|
||||
更新 access token(安全模式)
|
||||
|
||||
安全協議:
|
||||
1. 創建備份
|
||||
2. 驗證備份存在
|
||||
3. 讀取原文件
|
||||
4. 更新 token
|
||||
5. 寫入文件
|
||||
|
||||
Args:
|
||||
new_token: 新的 access token
|
||||
|
||||
Returns:
|
||||
是否更新成功
|
||||
"""
|
||||
# Step 1: 創建備份
|
||||
backup_path = self.create_backup()
|
||||
if backup_path is None:
|
||||
print("[TokenManager] 無法創建備份,中止更新操作")
|
||||
return False
|
||||
|
||||
# Step 2: 驗證備份存在
|
||||
if not self.verify_backup_exists(backup_path):
|
||||
print("[TokenManager] 備份驗證失敗,中止更新操作")
|
||||
return False
|
||||
|
||||
print(f"[TokenManager] 備份驗證通過: {backup_path}")
|
||||
|
||||
try:
|
||||
# Step 3: 讀取原文件
|
||||
async with aiofiles.open(self.json_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
data = json.loads(content)
|
||||
|
||||
# Step 4: 更新 token
|
||||
old_token = self._get_nested_value(data, self.token_key)
|
||||
if old_token:
|
||||
print(f"[TokenManager] 舊 token: {old_token[:20]}...")
|
||||
|
||||
data = self._set_nested_value(data, self.token_key, new_token)
|
||||
print(f"[TokenManager] 新 token: {new_token[:20]}...")
|
||||
|
||||
# Step 5: 寫入文件(保持格式美觀)
|
||||
async with aiofiles.open(self.json_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
print("[TokenManager] Token 更新成功!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TokenManager] 更新失敗: {e}")
|
||||
print(f"[TokenManager] 可以從備份恢復: {backup_path}")
|
||||
return False
|
||||
|
||||
def restore_from_backup(self, backup_path: Optional[Path] = None) -> bool:
|
||||
"""
|
||||
從備份恢復原文件
|
||||
|
||||
Args:
|
||||
backup_path: 備份路徑,如果為 None 則使用上次創建的備份
|
||||
|
||||
Returns:
|
||||
是否恢復成功
|
||||
"""
|
||||
path = backup_path or self._last_backup_path
|
||||
if path is None or not path.exists():
|
||||
print("[TokenManager] 沒有可用的備份")
|
||||
return False
|
||||
|
||||
try:
|
||||
shutil.copy2(path, self.json_path)
|
||||
print(f"[TokenManager] 已從備份恢復: {path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[TokenManager] 恢復失敗: {e}")
|
||||
return False
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests module
|
||||
76
tests/test_config.py
Normal file
76
tests/test_config.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# test_config.py - 配置模組測試
|
||||
"""
|
||||
Tests for Config class.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.config import Config
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Config 測試套件"""
|
||||
|
||||
def test_default_values(self):
|
||||
"""測試默認值"""
|
||||
assert Config.DEVICE_ID == os.getenv("MATRIX_DEVICE_ID", "PROJECT_M_BOT")
|
||||
assert Config.STORE_PATH == os.getenv("STORE_PATH", "store")
|
||||
|
||||
def test_token_json_key(self):
|
||||
"""測試 Token JSON Key 路徑"""
|
||||
assert Config.TOKEN_JSON_KEY == "channels.matrix.accessToken"
|
||||
|
||||
def test_validate_missing_config(self):
|
||||
"""測試配置驗證(缺少配置時)"""
|
||||
# 臨時清空環境變量
|
||||
original_server = os.environ.get("MATRIX_SERVER")
|
||||
original_user = os.environ.get("MATRIX_USER_ID")
|
||||
original_password = os.environ.get("MATRIX_PASSWORD")
|
||||
|
||||
try:
|
||||
os.environ["MATRIX_SERVER"] = ""
|
||||
os.environ["MATRIX_USER_ID"] = ""
|
||||
os.environ["MATRIX_PASSWORD"] = ""
|
||||
|
||||
# 重新載入配置類的值
|
||||
Config.MATRIX_SERVER = ""
|
||||
Config.MATRIX_USER_ID = ""
|
||||
Config.MATRIX_PASSWORD = ""
|
||||
|
||||
is_valid, missing = Config.validate()
|
||||
|
||||
assert is_valid is False
|
||||
assert "MATRIX_SERVER" in missing
|
||||
assert "MATRIX_USER_ID" in missing
|
||||
assert "MATRIX_PASSWORD" in missing
|
||||
finally:
|
||||
# 恢復原值
|
||||
if original_server:
|
||||
os.environ["MATRIX_SERVER"] = original_server
|
||||
if original_user:
|
||||
os.environ["MATRIX_USER_ID"] = original_user
|
||||
if original_password:
|
||||
os.environ["MATRIX_PASSWORD"] = original_password
|
||||
|
||||
def test_get_openclaw_path_with_tilde(self):
|
||||
"""測試 ~ 路徑展開"""
|
||||
Config.OPENCLAW_JSON_PATH = "~/.openclaw/openclaw.json"
|
||||
|
||||
path = Config.get_openclaw_path()
|
||||
|
||||
assert isinstance(path, Path)
|
||||
assert str(path).startswith(str(Path.home()))
|
||||
assert "~" not in str(path)
|
||||
|
||||
def test_get_openclaw_path_absolute(self):
|
||||
"""測試絕對路徑"""
|
||||
Config.OPENCLAW_JSON_PATH = "/tmp/test/openclaw.json"
|
||||
|
||||
path = Config.get_openclaw_path()
|
||||
|
||||
assert path == Path("/tmp/test/openclaw.json")
|
||||
277
tests/test_token_manager.py
Normal file
277
tests/test_token_manager.py
Normal file
@@ -0,0 +1,277 @@
|
||||
# test_token_manager.py - Token 管理器測試
|
||||
"""
|
||||
Tests for TokenManager class.
|
||||
Uses sandbox testing to avoid modifying real files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 需要確保可以導入
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.services.token_manager import TokenManager
|
||||
|
||||
|
||||
class TestTokenManager:
|
||||
"""TokenManager 測試套件"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_json_file(self, tmp_path):
|
||||
"""創建臨時 JSON 文件用於沙箱測試"""
|
||||
json_file = tmp_path / "openclaw.json"
|
||||
test_data = {
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"accessToken": "old_token_12345",
|
||||
"homeserver": "https://matrix.org"
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "telegram_token"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"debug": True
|
||||
}
|
||||
}
|
||||
json_file.write_text(json.dumps(test_data, indent=2))
|
||||
return json_file
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager(self, temp_json_file):
|
||||
"""創建 TokenManager 實例"""
|
||||
return TokenManager(
|
||||
json_path=temp_json_file,
|
||||
token_key="channels.matrix.accessToken"
|
||||
)
|
||||
|
||||
def test_create_backup(self, token_manager, temp_json_file):
|
||||
"""測試備份創建"""
|
||||
backup_path = token_manager.create_backup()
|
||||
|
||||
assert backup_path is not None
|
||||
assert backup_path.exists()
|
||||
assert temp_json_file.name in backup_path.name
|
||||
assert ".bak" in backup_path.name
|
||||
|
||||
# 驗證備份內容與原文件相同
|
||||
original = json.loads(temp_json_file.read_text())
|
||||
backup = json.loads(backup_path.read_text())
|
||||
assert original == backup
|
||||
|
||||
def test_verify_backup_exists(self, token_manager):
|
||||
"""測試備份驗證"""
|
||||
# 未創建備份時應該返回 False
|
||||
assert token_manager.verify_backup_exists() is False
|
||||
|
||||
# 創建備份後應該返回 True
|
||||
token_manager.create_backup()
|
||||
assert token_manager.verify_backup_exists() is True
|
||||
|
||||
def test_get_nested_value(self, token_manager):
|
||||
"""測試嵌套值讀取"""
|
||||
data = {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"level3": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert token_manager._get_nested_value(data, "level1.level2.level3") == "value"
|
||||
assert token_manager._get_nested_value(data, "level1.level2") is None # 不是字符串
|
||||
assert token_manager._get_nested_value(data, "nonexistent") is None
|
||||
|
||||
def test_set_nested_value(self, token_manager):
|
||||
"""測試嵌套值設置"""
|
||||
data = {}
|
||||
|
||||
result = token_manager._set_nested_value(data, "a.b.c", "new_value")
|
||||
|
||||
assert result["a"]["b"]["c"] == "new_value"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_current_token(self, token_manager):
|
||||
"""測試讀取當前 token"""
|
||||
token = await token_manager.read_current_token()
|
||||
|
||||
assert token == "old_token_12345"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_access_token(self, token_manager, temp_json_file):
|
||||
"""測試更新 access token(核心功能)"""
|
||||
new_token = "new_token_67890"
|
||||
|
||||
# 執行更新
|
||||
success = await token_manager.update_access_token(new_token)
|
||||
|
||||
# 驗證成功
|
||||
assert success is True
|
||||
|
||||
# 驗證備份存在
|
||||
assert token_manager.verify_backup_exists() is True
|
||||
|
||||
# 驗證新 token 已寫入
|
||||
updated_data = json.loads(temp_json_file.read_text())
|
||||
assert updated_data["channels"]["matrix"]["accessToken"] == new_token
|
||||
|
||||
# 驗證其他數據未被破壞
|
||||
assert updated_data["channels"]["telegram"]["botToken"] == "telegram_token"
|
||||
assert updated_data["settings"]["debug"] is True
|
||||
|
||||
def test_restore_from_backup(self, token_manager, temp_json_file):
|
||||
"""測試從備份恢復"""
|
||||
# 創建備份
|
||||
backup_path = token_manager.create_backup()
|
||||
|
||||
# 修改原文件
|
||||
temp_json_file.write_text('{"corrupted": true}')
|
||||
|
||||
# 恢復
|
||||
success = token_manager.restore_from_backup()
|
||||
|
||||
assert success is True
|
||||
|
||||
# 驗證恢復結果
|
||||
restored_data = json.loads(temp_json_file.read_text())
|
||||
assert restored_data["channels"]["matrix"]["accessToken"] == "old_token_12345"
|
||||
|
||||
|
||||
class TestTokenManagerEdgeCases:
|
||||
"""TokenManager 邊緣情況測試"""
|
||||
|
||||
def test_backup_nonexistent_file(self, tmp_path):
|
||||
"""測試備份不存在的文件"""
|
||||
manager = TokenManager(
|
||||
json_path=tmp_path / "nonexistent.json",
|
||||
token_key="test"
|
||||
)
|
||||
|
||||
backup_path = manager.create_backup()
|
||||
assert backup_path is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_file(self, tmp_path):
|
||||
"""測試更新不存在的文件"""
|
||||
manager = TokenManager(
|
||||
json_path=tmp_path / "nonexistent.json",
|
||||
token_key="test"
|
||||
)
|
||||
|
||||
success = await manager.update_access_token("new_token")
|
||||
assert success is False
|
||||
|
||||
|
||||
class TestBackupRotation:
|
||||
"""備份輪轉功能測試(沙箱測試)"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_json_file(self, tmp_path):
|
||||
"""創建臨時 JSON 文件"""
|
||||
json_file = tmp_path / "openclaw.json"
|
||||
test_data = {"channels": {"matrix": {"accessToken": "test_token"}}}
|
||||
json_file.write_text(json.dumps(test_data))
|
||||
return json_file
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager(self, temp_json_file):
|
||||
"""創建 TokenManager 實例"""
|
||||
return TokenManager(
|
||||
json_path=temp_json_file,
|
||||
token_key="channels.matrix.accessToken"
|
||||
)
|
||||
|
||||
def test_max_backups_constant(self):
|
||||
"""測試最大備份數量常量"""
|
||||
assert TokenManager.MAX_BACKUPS == 5
|
||||
|
||||
def test_get_backup_files_empty(self, token_manager):
|
||||
"""測試沒有備份時返回空列表"""
|
||||
backups = token_manager._get_backup_files()
|
||||
assert backups == []
|
||||
|
||||
def test_get_backup_files_with_backups(self, token_manager, tmp_path):
|
||||
"""測試獲取備份文件列表"""
|
||||
# 創建幾個備份
|
||||
for i in range(3):
|
||||
token_manager.create_backup()
|
||||
import time
|
||||
time.sleep(0.01) # 確保時間戳不同
|
||||
|
||||
backups = token_manager._get_backup_files()
|
||||
assert len(backups) == 3
|
||||
|
||||
# 確認按文件名排序(時間戳在名稱中,字典序=時間序,最新在前)
|
||||
for i in range(len(backups) - 1):
|
||||
assert backups[i].name > backups[i + 1].name
|
||||
|
||||
def test_rotation_under_limit(self, token_manager):
|
||||
"""測試備份數量未超過限制時不刪除"""
|
||||
# 創建 3 個備份(低於限制)
|
||||
for i in range(3):
|
||||
token_manager.create_backup()
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
assert token_manager.get_backup_count() == 3
|
||||
|
||||
def test_rotation_at_limit(self, token_manager):
|
||||
"""測試備份數量剛好達到限制"""
|
||||
# 創建 5 個備份(剛好等於限制)
|
||||
for i in range(5):
|
||||
token_manager.create_backup()
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
assert token_manager.get_backup_count() == 5
|
||||
|
||||
def test_rotation_over_limit(self, token_manager):
|
||||
"""測試備份數量超過限制時自動刪除最舊的"""
|
||||
# 創建 7 個備份(超過限制)
|
||||
for i in range(7):
|
||||
token_manager.create_backup()
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
# 應該只保留 5 個
|
||||
assert token_manager.get_backup_count() == 5
|
||||
|
||||
def test_rotation_preserves_newest(self, token_manager, temp_json_file):
|
||||
"""測試輪轉保留最新的備份"""
|
||||
backup_paths = []
|
||||
|
||||
# 創建 7 個備份
|
||||
for i in range(7):
|
||||
path = token_manager.create_backup()
|
||||
if path:
|
||||
backup_paths.append(path)
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
# 檢查最新的 5 個備份仍然存在
|
||||
remaining_backups = token_manager._get_backup_files()
|
||||
assert len(remaining_backups) == 5
|
||||
|
||||
# 最新的 5 個應該是最後創建的 5 個
|
||||
newest_5 = backup_paths[-5:]
|
||||
for path in newest_5:
|
||||
assert path.exists(), f"{path} should exist"
|
||||
|
||||
# 最舊的 2 個應該被刪除
|
||||
oldest_2 = backup_paths[:2]
|
||||
for path in oldest_2:
|
||||
assert not path.exists(), f"{path} should be deleted"
|
||||
|
||||
def test_get_backup_count(self, token_manager):
|
||||
"""測試獲取備份數量"""
|
||||
assert token_manager.get_backup_count() == 0
|
||||
|
||||
token_manager.create_backup()
|
||||
assert token_manager.get_backup_count() == 1
|
||||
|
||||
token_manager.create_backup()
|
||||
assert token_manager.get_backup_count() == 2
|
||||
Reference in New Issue
Block a user