File size: 10,103 Bytes
bb65265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633f62a
bb65265
 
 
 
 
 
633f62a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb65265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
#!/usr/bin/env python3
"""
aichat 訊息中繼(REST)。自架,不經過任何第三方轉發站。

    POST /api/aichat/put                      送一則訊息
    GET  /api/aichat/poll?channel=&since=&wait= 長輪詢取回後續
    GET  /api/aichat/peek?channel=&limit=     不阻塞地抓最近幾則(測試/除錯用)
    GET  /api/aichat/health                   存活與統計

為什麼要有這個
--------------
原本訊息是經過 ntfy.sh 之類的**公開 anyone-can-read 轉發站**:內文明文、topic 誰都能猜,
而且免費站台會限速、會把跑太多的來源 IP 直接封掉且不會自己恢復。訊息走別人的機器,
也就等於把對話交出去。

現在自己架一個:資料只落在這個 Space(SQLite),走 HTTPS,需要 HF token 才能進。
「不會漏掉」也不再靠對方的善意:

* 每則訊息有單調遞增的 ``id``。接收端記住最後收到的 ``id``,重連帶 ``since=<id>``,
  伺服器精確地從那裡接續 —— 不是「重送我沒收到的」,是「從沒斷過的那裡接」。
* 送出端(bridge / 網頁)自己寫磁碟 outbox 才送出,失敗只是延後。
* 就算游標太舊被修剪,bridge 端還有整篇快照(history.json)可以補齊。

## 路由
挂在 app.py 的 FastAPI 上(``app.include_router(relay.router)``),不動 app.py 既有的
``/``、``/files``、``/download``、``/ws``。前綴 ``/api/aichat`` 跟那些都不撞。

## 授權
Space 本身是 private,HF 的閘門已經擋掉沒 token 的人。這個 Space 的 AICHAT_TOKEN 有設定
時,還要再對一次 ``X-Aichat-Token``(雙層)。
"""

from __future__ import annotations

import asyncio
import json
import os
import re
import sqlite3
import time
from pathlib import Path

from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

router = APIRouter(prefix="/api/aichat", tags=["aichat"])

VERSION = 2

# ---------------------------------------------------------------- CORS
# 網頁在 aichatv(static Space)、中繼在 testzerogpu2,兩者不同 host → 瀏覽器一定
# 會先送 OPTIONS 預檢。這裡放行指定來源,**不是**萬用 *(萬用 * 不能帶
# Authorization 標頭,實際等於不能用)。
ALLOW_ORIGINS = [
    o.strip() for o in os.environ.get(
        "AICHAT_ORIGINS",
        "https://hellosun-aichatv.static.hf.space,"
        "https://huggingface.co,"
        "http://localhost:8000,http://127.0.0.1:8000"
    ).split(",") if o.strip()
]


def install_cors(app) -> None:
    app.add_middleware(
        CORSMiddleware,
        allow_origins=ALLOW_ORIGINS,
        allow_credentials=False,
        allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
        allow_headers=["Authorization", "Content-Type", "X-Aichat-Token"],
        max_age=600,
    )

# ---------------------------------------------------------------- 設定
DB_PATH = Path(os.environ.get("AICHAT_DB", "/home/user/data/aichat_relay.db"))
SHARED_TOKEN = os.environ.get("AICHAT_TOKEN", "").strip()

KEEP_PER_CHANNEL = int(os.environ.get("AICHAT_KEEP", "2000"))   # 每個 channel 留幾則
CHANNEL_MAX = 64
CHANNEL_RE = re.compile(r"^[A-Za-z0-9._~-]{1,%d}$" % CHANNEL_MAX)
PAYLOAD_MAX = 200_000          # 單則最多 200 KB(附件切片後仍在範圍內)
MAX_WAIT = 25                  # 長輪詢最多吊幾秒:HF 的 proxy 逾時前一定要回


# ---------------------------------------------------------------- 儲存
def _connect() -> sqlite3.Connection:
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    # Space 重啟後檔案還在(重建才會沒),所以 WAL + 自動建表
    con = sqlite3.connect(DB_PATH, timeout=10, check_same_thread=False)
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("PRAGMA synchronous=NORMAL")
    con.execute(
        "CREATE TABLE IF NOT EXISTS msgs ("
        " id INTEGER PRIMARY KEY AUTOINCREMENT,"
        " channel TEXT NOT NULL,"
        " payload TEXT NOT NULL,"
        " ts REAL NOT NULL)"
    )
    con.execute("CREATE INDEX IF NOT EXISTS msgs_ch ON msgs(channel, id)")
    return con


_con: sqlite3.Connection | None = None
_lock = asyncio.Lock()
# channel → 一組等待中的未來;有新訊息就把它們叫醒
_waiters: dict[str, set[asyncio.Future]] = {}


def db() -> sqlite3.Connection:
    global _con
    if _con is None:
        _con = _connect()
    return _con


def check_channel(c: str) -> str:
    if not c or not CHANNEL_RE.match(c):
        raise HTTPException(400, f"channel 只能是 1..{CHANNEL_MAX} 個字元,且只限 A-Za-z0-9._~-")
    return c


def prune(channel: str) -> None:
    con = db()
    row = con.execute("SELECT COUNT(*) FROM msgs WHERE channel=?", (channel,)).fetchone()
    n = row[0] if row else 0
    if n > KEEP_PER_CHANNEL:
        con.execute(
            "DELETE FROM msgs WHERE channel=? AND id <= ("
            "  SELECT id FROM msgs WHERE channel=? ORDER BY id DESC LIMIT 1 OFFSET ?)",
            (channel, channel, KEEP_PER_CHANNEL - 1),
        )
        con.commit()


# ---------------------------------------------------------------- 授權
def authorize(x_aichat_token: str | None) -> None:
    """Space 已經是 private(HF 擋掉沒 token 的)。這裡是第二層,設定了才啟用。"""
    if not SHARED_TOKEN:
        return
    if not x_aichat_token or x_aichat_token != SHARED_TOKEN:
        raise HTTPException(401, "X-Aichat-Token 不對")


# ---------------------------------------------------------------- 路由
@router.get("/health")
async def health() -> JSONResponse:
    con = db()
    total = con.execute("SELECT COUNT(*) FROM msgs").fetchone()[0]
    chans = con.execute("SELECT COUNT(DISTINCT channel) FROM msgs").fetchone()[0]
    oldest = con.execute("SELECT MIN(id) FROM msgs").fetchone()[0]
    newest = con.execute("SELECT MAX(id) FROM msgs").fetchone()[0]
    return JSONResponse({
        "ok": True, "version": VERSION,
        "hub": "self-hosted (no third party)",
        "messages": total, "channels": chans,
        "oldest_id": oldest, "newest_id": newest,
        "shared_token": bool(SHARED_TOKEN),
        "waiters": sum(len(v) for v in _waiters.values()),
    })


@router.post("/put")
async def put(request: Request, x_aichat_token: str | None = Header(None)) -> JSONResponse:
    authorize(x_aichat_token)
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(400, "body 不是合法 JSON")
    channel = check_channel(str(body.get("channel") or ""))
    payload = body.get("payload")
    if not isinstance(payload, str):
        raise HTTPException(400, "payload 必須是字串")
    if len(payload.encode("utf-8")) > PAYLOAD_MAX:
        raise HTTPException(413, f"payload 超過 {PAYLOAD_MAX} bytes")

    con = db()
    cur = con.execute("INSERT INTO msgs(channel, payload, ts) VALUES(?,?,?)",
                      (channel, payload, time.time()))
    con.commit()
    mid = cur.lastrowid
    prune(channel)

    # 叫醒在長輪詢的人
    for fut in list(_waiters.get(channel, ())):
        if not fut.done():
            fut.set_result(True)
    return JSONResponse({"ok": True, "id": mid, "ts": time.time()})


@router.get("/peek")
async def peek(channel: str = Query(...), limit: int = Query(50, ge=1, le=500),
               x_aichat_token: str | None = Header(None)) -> JSONResponse:
    """不阻塞:抓最近 limit 則(給測試與除錯用)。"""
    authorize(x_aichat_token)
    channel = check_channel(channel)
    con = db()
    rows = con.execute(
        "SELECT id, payload, ts FROM msgs WHERE channel=? ORDER BY id DESC LIMIT ?",
        (channel, limit)).fetchall()
    items = [{"id": r[0], "payload": r[1], "ts": r[2]} for r in reversed(rows)]
    return JSONResponse({"items": items, "cursor": items[-1]["id"] if items else 0})


@router.get("/poll")
async def poll(channel: str = Query(...),
               since: int = Query(0, ge=0),
               wait: int = Query(20, ge=0, le=MAX_WAIT),
               x_aichat_token: str | None = Header(None)) -> JSONResponse:
    """長輪詢:等到有新訊息或逾時。回傳的 cursor 請記住,下次帶回來。"""
    authorize(x_aichat_token)
    channel = check_channel(channel)
    wait = max(0, min(wait, MAX_WAIT))

    def fetch() -> list:
        con = db()
        rows = con.execute(
            "SELECT id, payload, ts FROM msgs WHERE channel=? AND id>? ORDER BY id ASC LIMIT 500",
            (channel, since)).fetchall()
        return [{"id": r[0], "payload": r[1], "ts": r[2]} for r in rows]

    items = fetch()
    if items or wait == 0:
        return JSONResponse({"items": items,
                             "cursor": items[-1]["id"] if items else since})

    # 沒有東西 → 掛一個 future,有人送訊息時會被 set_result 叫醒
    fut: asyncio.Future = asyncio.get_running_loop().create_future()
    _waiters.setdefault(channel, set()).add(fut)
    try:
        await asyncio.wait_for(fut, timeout=wait)
    except asyncio.TimeoutError:
        pass
    finally:
        _waiters.get(channel, set()).discard(fut)

    items = fetch()
    return JSONResponse({"items": items,
                         "cursor": items[-1]["id"] if items else since})


@router.delete("/channel/{channel}")
async def wipe(channel: str, x_aichat_token: str | None = Header(None)) -> JSONResponse:
    """清掉某個 channel 的全部訊息(測試重置用)。"""
    authorize(x_aichat_token)
    channel = check_channel(channel)
    con = db()
    con.execute("DELETE FROM msgs WHERE channel=?", (channel,))
    con.commit()
    return JSONResponse({"ok": True, "channel": channel})


@router.on_event("startup")
async def _startup() -> None:
    try:
        db()
    except Exception as e:                      # 磁碟壞掉不該讓整個 Space 掛掉
        print(f"[relay] 資料庫開不起來:{e!r}")
    print(f"[relay] v{VERSION} 就緒  db={DB_PATH}  共享 token={'有' if SHARED_TOKEN else '無'}")