HelloSun commited on
Commit
bb65265
·
verified ·
1 Parent(s): a9e3d42

aichat 自架 REST 中繼(SQLite 游標續傳,不經過第三方轉發站)

Browse files
Files changed (1) hide show
  1. relay.py +234 -0
relay.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ aichat 訊息中繼(REST)。自架,不經過任何第三方轉發站。
4
+
5
+ POST /api/aichat/put 送一則訊息
6
+ GET /api/aichat/poll?channel=&since=&wait= 長輪詢取回後續
7
+ GET /api/aichat/peek?channel=&limit= 不阻塞地抓最近幾則(測試/除錯用)
8
+ GET /api/aichat/health 存活與統計
9
+
10
+ 為什麼要有這個
11
+ --------------
12
+ 原本訊息是經過 ntfy.sh 之類的**公開 anyone-can-read 轉發站**:內文明文、topic 誰都能猜,
13
+ 而且免費站台會限速、會把跑太多的來源 IP 直接封掉且不會自己恢復。訊息走別人的機器,
14
+ 也就等於把對話交出去。
15
+
16
+ 現在自己架一個:資料只落在這個 Space(SQLite),走 HTTPS,需要 HF token 才能進。
17
+ 「不會漏掉」也不再靠對方的善意:
18
+
19
+ * 每則訊息有單調遞增的 ``id``。接收端記住最後收到的 ``id``,重連帶 ``since=<id>``,
20
+ 伺服器精確地從那裡接續 —— 不是「重送我沒收到的」,是「從沒斷過的那裡接」。
21
+ * 送出端(bridge / 網頁)自己寫磁碟 outbox 才送出,失敗只是延後。
22
+ * 就算游標太舊被修剪,bridge 端還有整篇快照(history.json)可以補齊。
23
+
24
+ ## 路由
25
+ 挂在 app.py 的 FastAPI 上(``app.include_router(relay.router)``),不動 app.py 既有的
26
+ ``/``、``/files``、``/download``、``/ws``。前綴 ``/api/aichat`` 跟那些都不撞。
27
+
28
+ ## 授權
29
+ Space 本身是 private,HF 的閘門已經擋掉沒 token 的人。這個 Space 的 AICHAT_TOKEN 有設定
30
+ 時,還要再對一次 ``X-Aichat-Token``(雙層)。
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import json
37
+ import os
38
+ import re
39
+ import sqlite3
40
+ import time
41
+ from pathlib import Path
42
+
43
+ from fastapi import APIRouter, Header, HTTPException, Query, Request
44
+ from fastapi.responses import JSONResponse
45
+
46
+ router = APIRouter(prefix="/api/aichat", tags=["aichat"])
47
+
48
+ VERSION = 2
49
+
50
+ # ---------------------------------------------------------------- 設定
51
+ DB_PATH = Path(os.environ.get("AICHAT_DB", "/home/user/data/aichat_relay.db"))
52
+ SHARED_TOKEN = os.environ.get("AICHAT_TOKEN", "").strip()
53
+
54
+ KEEP_PER_CHANNEL = int(os.environ.get("AICHAT_KEEP", "2000")) # 每個 channel 留幾則
55
+ CHANNEL_MAX = 64
56
+ CHANNEL_RE = re.compile(r"^[A-Za-z0-9._~-]{1,%d}$" % CHANNEL_MAX)
57
+ PAYLOAD_MAX = 200_000 # 單則最多 200 KB(附件切片後仍在範圍內)
58
+ MAX_WAIT = 25 # 長輪詢最多吊幾秒:HF 的 proxy 逾時前一定要回
59
+
60
+
61
+ # ---------------------------------------------------------------- 儲存
62
+ def _connect() -> sqlite3.Connection:
63
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
64
+ # Space 重啟後檔案還在(重建才會沒),所以 WAL + 自動建表
65
+ con = sqlite3.connect(DB_PATH, timeout=10, check_same_thread=False)
66
+ con.execute("PRAGMA journal_mode=WAL")
67
+ con.execute("PRAGMA synchronous=NORMAL")
68
+ con.execute(
69
+ "CREATE TABLE IF NOT EXISTS msgs ("
70
+ " id INTEGER PRIMARY KEY AUTOINCREMENT,"
71
+ " channel TEXT NOT NULL,"
72
+ " payload TEXT NOT NULL,"
73
+ " ts REAL NOT NULL)"
74
+ )
75
+ con.execute("CREATE INDEX IF NOT EXISTS msgs_ch ON msgs(channel, id)")
76
+ return con
77
+
78
+
79
+ _con: sqlite3.Connection | None = None
80
+ _lock = asyncio.Lock()
81
+ # channel → 一組等待中的未來;有新訊息就把它們叫醒
82
+ _waiters: dict[str, set[asyncio.Future]] = {}
83
+
84
+
85
+ def db() -> sqlite3.Connection:
86
+ global _con
87
+ if _con is None:
88
+ _con = _connect()
89
+ return _con
90
+
91
+
92
+ def check_channel(c: str) -> str:
93
+ if not c or not CHANNEL_RE.match(c):
94
+ raise HTTPException(400, f"channel 只能是 1..{CHANNEL_MAX} 個字元,且只限 A-Za-z0-9._~-")
95
+ return c
96
+
97
+
98
+ def prune(channel: str) -> None:
99
+ con = db()
100
+ row = con.execute("SELECT COUNT(*) FROM msgs WHERE channel=?", (channel,)).fetchone()
101
+ n = row[0] if row else 0
102
+ if n > KEEP_PER_CHANNEL:
103
+ con.execute(
104
+ "DELETE FROM msgs WHERE channel=? AND id <= ("
105
+ " SELECT id FROM msgs WHERE channel=? ORDER BY id DESC LIMIT 1 OFFSET ?)",
106
+ (channel, channel, KEEP_PER_CHANNEL - 1),
107
+ )
108
+ con.commit()
109
+
110
+
111
+ # ---------------------------------------------------------------- 授權
112
+ def authorize(x_aichat_token: str | None) -> None:
113
+ """Space 已經是 private(HF 擋掉沒 token 的)。這裡是第二層,設定了才啟用。"""
114
+ if not SHARED_TOKEN:
115
+ return
116
+ if not x_aichat_token or x_aichat_token != SHARED_TOKEN:
117
+ raise HTTPException(401, "X-Aichat-Token 不對")
118
+
119
+
120
+ # ---------------------------------------------------------------- 路由
121
+ @router.get("/health")
122
+ async def health() -> JSONResponse:
123
+ con = db()
124
+ total = con.execute("SELECT COUNT(*) FROM msgs").fetchone()[0]
125
+ chans = con.execute("SELECT COUNT(DISTINCT channel) FROM msgs").fetchone()[0]
126
+ oldest = con.execute("SELECT MIN(id) FROM msgs").fetchone()[0]
127
+ newest = con.execute("SELECT MAX(id) FROM msgs").fetchone()[0]
128
+ return JSONResponse({
129
+ "ok": True, "version": VERSION,
130
+ "hub": "self-hosted (no third party)",
131
+ "messages": total, "channels": chans,
132
+ "oldest_id": oldest, "newest_id": newest,
133
+ "shared_token": bool(SHARED_TOKEN),
134
+ "waiters": sum(len(v) for v in _waiters.values()),
135
+ })
136
+
137
+
138
+ @router.post("/put")
139
+ async def put(request: Request, x_aichat_token: str | None = Header(None)) -> JSONResponse:
140
+ authorize(x_aichat_token)
141
+ try:
142
+ body = await request.json()
143
+ except Exception:
144
+ raise HTTPException(400, "body 不是合法 JSON")
145
+ channel = check_channel(str(body.get("channel") or ""))
146
+ payload = body.get("payload")
147
+ if not isinstance(payload, str):
148
+ raise HTTPException(400, "payload 必須是字串")
149
+ if len(payload.encode("utf-8")) > PAYLOAD_MAX:
150
+ raise HTTPException(413, f"payload 超過 {PAYLOAD_MAX} bytes")
151
+
152
+ con = db()
153
+ cur = con.execute("INSERT INTO msgs(channel, payload, ts) VALUES(?,?,?)",
154
+ (channel, payload, time.time()))
155
+ con.commit()
156
+ mid = cur.lastrowid
157
+ prune(channel)
158
+
159
+ # 叫醒在長輪詢的人
160
+ for fut in list(_waiters.get(channel, ())):
161
+ if not fut.done():
162
+ fut.set_result(True)
163
+ return JSONResponse({"ok": True, "id": mid, "ts": time.time()})
164
+
165
+
166
+ @router.get("/peek")
167
+ async def peek(channel: str = Query(...), limit: int = Query(50, ge=1, le=500),
168
+ x_aichat_token: str | None = Header(None)) -> JSONResponse:
169
+ """不阻塞:抓最近 limit 則(給測試與除錯用)。"""
170
+ authorize(x_aichat_token)
171
+ channel = check_channel(channel)
172
+ con = db()
173
+ rows = con.execute(
174
+ "SELECT id, payload, ts FROM msgs WHERE channel=? ORDER BY id DESC LIMIT ?",
175
+ (channel, limit)).fetchall()
176
+ items = [{"id": r[0], "payload": r[1], "ts": r[2]} for r in reversed(rows)]
177
+ return JSONResponse({"items": items, "cursor": items[-1]["id"] if items else 0})
178
+
179
+
180
+ @router.get("/poll")
181
+ async def poll(channel: str = Query(...),
182
+ since: int = Query(0, ge=0),
183
+ wait: int = Query(20, ge=0, le=MAX_WAIT),
184
+ x_aichat_token: str | None = Header(None)) -> JSONResponse:
185
+ """長輪詢:等到有新訊息或逾時。回傳的 cursor 請記住,下次帶回來。"""
186
+ authorize(x_aichat_token)
187
+ channel = check_channel(channel)
188
+ wait = max(0, min(wait, MAX_WAIT))
189
+
190
+ def fetch() -> list:
191
+ con = db()
192
+ rows = con.execute(
193
+ "SELECT id, payload, ts FROM msgs WHERE channel=? AND id>? ORDER BY id ASC LIMIT 500",
194
+ (channel, since)).fetchall()
195
+ return [{"id": r[0], "payload": r[1], "ts": r[2]} for r in rows]
196
+
197
+ items = fetch()
198
+ if items or wait == 0:
199
+ return JSONResponse({"items": items,
200
+ "cursor": items[-1]["id"] if items else since})
201
+
202
+ # 沒有東西 → 掛一個 future,有人送訊息時會被 set_result 叫醒
203
+ fut: asyncio.Future = asyncio.get_running_loop().create_future()
204
+ _waiters.setdefault(channel, set()).add(fut)
205
+ try:
206
+ await asyncio.wait_for(fut, timeout=wait)
207
+ except asyncio.TimeoutError:
208
+ pass
209
+ finally:
210
+ _waiters.get(channel, set()).discard(fut)
211
+
212
+ items = fetch()
213
+ return JSONResponse({"items": items,
214
+ "cursor": items[-1]["id"] if items else since})
215
+
216
+
217
+ @router.delete("/channel/{channel}")
218
+ async def wipe(channel: str, x_aichat_token: str | None = Header(None)) -> JSONResponse:
219
+ """清掉某個 channel 的全部訊息(測試重置用)。"""
220
+ authorize(x_aichat_token)
221
+ channel = check_channel(channel)
222
+ con = db()
223
+ con.execute("DELETE FROM msgs WHERE channel=?", (channel,))
224
+ con.commit()
225
+ return JSONResponse({"ok": True, "channel": channel})
226
+
227
+
228
+ @router.on_event("startup")
229
+ async def _startup() -> None:
230
+ try:
231
+ db()
232
+ except Exception as e: # 磁碟壞掉不該讓整個 Space 掛掉
233
+ print(f"[relay] 資料庫開不起來:{e!r}")
234
+ print(f"[relay] v{VERSION} 就緒 db={DB_PATH} 共享 token={'有' if SHARED_TOKEN else '無'}")