feat: import smsreceiver workerized code with full README

This commit is contained in:
OpenClaw Agent
2026-03-23 04:08:27 +08:00
commit 56179e6a75
21 changed files with 2725 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
import os
import json
import sqlite3
import time
import requests
D1_DATABASE_ID = os.getenv('D1_DATABASE_ID', '')
CF_API_TOKEN = os.getenv('CF_API_TOKEN', '')
CF_ACCOUNT_ID = os.getenv('CF_ACCOUNT_ID', '')
SQLITE_PATH = os.getenv('SQLITE_PATH', 'sms_receiver_go.db')
BATCH_SIZE = int(os.getenv('BATCH_SIZE', '50'))
if not D1_DATABASE_ID or not CF_API_TOKEN or not CF_ACCOUNT_ID:
raise SystemExit('Missing env: D1_DATABASE_ID / CF_API_TOKEN / CF_ACCOUNT_ID')
api = f"https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/d1/database/{D1_DATABASE_ID}/query"
headers = {"Authorization": f"Bearer {CF_API_TOKEN}", "Content-Type": "application/json"}
conn = sqlite3.connect(SQLITE_PATH)
conn.row_factory = sqlite3.Row
TABLES = ['sms_messages', 'receive_logs']
def post_query(sql, params=None):
payload = {"sql": sql}
if params is not None:
payload["params"] = params
r = requests.post(api, headers=headers, data=json.dumps(payload))
r.raise_for_status()
def migrate_table(table):
cur = conn.cursor()
cur.execute(f"SELECT * FROM {table}")
rows = cur.fetchall()
print(f"{table}: {len(rows)} rows")
batch = 0
for row in rows:
cols = row.keys()
placeholders = ','.join(['?'] * len(cols))
sql = f"INSERT INTO {table} ({','.join(cols)}) VALUES ({placeholders})"
params = [row[c] for c in cols]
post_query(sql, params)
batch += 1
if batch >= BATCH_SIZE:
time.sleep(0.2)
batch = 0
for table in TABLES:
migrate_table(table)
print('done')

1531
worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
worker/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "smsreceiver-worker-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"hono": "^4.6.10"
},
"devDependencies": {
"typescript": "^5.6.3",
"wrangler": "^4.7.0"
}
}

31
worker/schema.sql Normal file
View File

@@ -0,0 +1,31 @@
CREATE TABLE sms_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_number TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
device_info TEXT,
sim_info TEXT,
sign_verified INTEGER,
ip_address TEXT,
created_at TEXT
);
CREATE TABLE receive_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_number TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
sign TEXT,
sign_valid INTEGER,
ip_address TEXT,
status TEXT NOT NULL,
error_message TEXT,
created_at TEXT
);
CREATE INDEX idx_messages_from ON sms_messages(from_number);
CREATE INDEX idx_messages_timestamp ON sms_messages(timestamp);
CREATE INDEX idx_messages_created ON sms_messages(created_at);
CREATE INDEX idx_logs_created ON receive_logs(created_at);
CREATE INDEX idx_logs_status ON receive_logs(status);

47
worker/src/index.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Hono } from 'hono'
import { authRoutes } from './routes/auth'
import { messagesRoutes } from './routes/messages'
import { logsRoutes } from './routes/logs'
import { receiveRoutes } from './routes/receive'
import { statsRoutes } from './routes/stats'
export type Env = {
DB: D1Database
ADMIN_USER: string
ADMIN_PASS_HASH: string
SESSION_SECRET: string
API_TOKEN: string
HMAC_SECRET: string
SIGN_VERIFY: string
SIGN_MAX_AGE_MS: string
CORS_ORIGIN: string
}
const app = new Hono<{ Bindings: Env }>()
app.use('*', async (c, next) => {
const reqOrigin = c.req.header('Origin')
const allowOrigin = reqOrigin || c.env.CORS_ORIGIN || '*'
c.header('Access-Control-Allow-Origin', allowOrigin)
c.header('Vary', 'Origin')
c.header('Access-Control-Allow-Credentials', 'true')
c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
c.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
if (c.req.method === 'OPTIONS') {
return c.body(null, 204)
}
await next()
})
app.route('/api/auth', authRoutes)
app.route('/api/messages', messagesRoutes)
app.route('/api/logs', logsRoutes)
app.route('/api/receive', receiveRoutes)
app.route('/api/stats', statsRoutes)
app.get('/api/health', (c) => c.json({ ok: true }))
export default app

View File

@@ -0,0 +1,10 @@
import { createMiddleware } from 'hono/factory'
import type { Env } from '../index'
import { verifySessionCookie } from '../utils/session'
export const requireAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
const cookie = c.req.header('Cookie') || ''
const ok = verifySessionCookie(cookie, c.env.SESSION_SECRET)
if (!ok) return c.json({ success: false, error: '未授权' }, 401)
await next()
})

33
worker/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { createSessionCookie, clearSessionCookie, hashPassword } from '../utils/session'
export const authRoutes = new Hono<{ Bindings: Env }>()
authRoutes.post('/login', async (c) => {
const body = await c.req.json().catch(() => ({})) as any
const username = body.username || ''
const password = body.password || ''
if (!username || !password) {
return c.json({ success: false, error: '缺少用户名或密码' }, 400)
}
if (username !== c.env.ADMIN_USER) {
return c.json({ success: false, error: '用户名或密码错误' }, 401)
}
const passHash = hashPassword(password, c.env.SESSION_SECRET)
if (passHash !== c.env.ADMIN_PASS_HASH) {
return c.json({ success: false, error: '用户名或密码错误' }, 401)
}
const cookie = createSessionCookie(username, c.env.SESSION_SECRET)
c.header('Set-Cookie', cookie)
return c.json({ success: true })
})
authRoutes.post('/logout', async (c) => {
c.header('Set-Cookie', clearSessionCookie())
return c.json({ success: true })
})

29
worker/src/routes/logs.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { requireAuth } from '../middlewares/auth'
export const logsRoutes = new Hono<{ Bindings: Env }>()
logsRoutes.get('/', requireAuth, async (c) => {
const page = Math.max(1, Number(c.req.query('page') || 1))
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') || 20)))
const status = (c.req.query('status') || '').trim()
const offset = (page - 1) * limit
let where = ''
const params: any[] = []
if (status) {
where = 'WHERE status = ?'
params.push(status)
}
const totalStmt = c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM receive_logs ${where}`)
const total = (await totalStmt.bind(...params).first() as any)?.cnt || 0
const stmt = c.env.DB.prepare(
`SELECT * FROM receive_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
)
const rows = await stmt.bind(...params, limit, offset).all()
return c.json({ success: true, data: rows.results || [], total, page, limit })
})

View File

@@ -0,0 +1,70 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { requireAuth } from '../middlewares/auth'
export const messagesRoutes = new Hono<{ Bindings: Env }>()
messagesRoutes.get('/', requireAuth, async (c) => {
const page = Math.max(1, Number(c.req.query('page') || 1))
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') || 20)))
const q = (c.req.query('q') || '').trim()
const from = (c.req.query('from') || '').trim()
const startTs = Number(c.req.query('start_ts') || 0)
const endTs = Number(c.req.query('end_ts') || 0)
const offset = (page - 1) * limit
const whereParts: string[] = []
const params: any[] = []
if (from) {
whereParts.push('from_number = ?')
params.push(from)
}
if (q) {
whereParts.push('(from_number LIKE ? OR content LIKE ?)')
params.push(`%${q}%`, `%${q}%`)
}
if (startTs > 0) {
whereParts.push('timestamp >= ?')
params.push(startTs)
}
if (endTs > 0) {
whereParts.push('timestamp <= ?')
params.push(endTs)
}
const where = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const totalStmt = c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM sms_messages ${where}`)
const total = (await totalStmt.bind(...params).first() as any)?.cnt || 0
const stmt = c.env.DB.prepare(
`SELECT * FROM sms_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`
)
const rows = await stmt.bind(...params, limit, offset).all()
const fromRows = await c.env.DB.prepare(
`SELECT DISTINCT from_number FROM sms_messages ORDER BY from_number`
).all()
return c.json({
success: true,
data: rows.results || [],
total,
page,
limit,
from_numbers: (fromRows.results || []).map((r: any) => r.from_number),
})
})
messagesRoutes.get('/:id', requireAuth, async (c) => {
const id = c.req.param('id')
const stmt = c.env.DB.prepare(`SELECT * FROM sms_messages WHERE id = ?`)
const row = await stmt.bind(id).first()
if (!row) return c.json({ success: false, error: '消息不存在' }, 404)
return c.json({ success: true, data: row })
})

View File

@@ -0,0 +1,99 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { getClientIP, nowMs } from '../utils/common'
import { verifySign } from '../utils/sign'
import { insertMessageAndLog } from '../utils/db'
export const receiveRoutes = new Hono<{ Bindings: Env }>()
receiveRoutes.post('/', async (c) => {
const contentType = c.req.header('content-type') || ''
let form: Record<string, string> = {}
if (contentType.includes('application/json')) {
const body = (await c.req.json().catch(() => ({}))) as any
form = { ...body }
} else {
const body = await c.req.parseBody()
for (const [k, v] of Object.entries(body)) {
form[k] = Array.isArray(v) ? String(v[0]) : String(v ?? '')
}
}
const from = form.from || ''
const msg = form.content || ''
if (!from || !msg) {
return c.json({ success: false, error: `缺少必填参数 (from: '${from}', content: '${msg}')` }, 400)
}
const timestamp = form.timestamp ? Number(form.timestamp) : nowMs()
const sign = form.sign || ''
const device = form.device || ''
const sim = form.sim || ''
const token = c.req.query('token') || form.token || ''
const ip = getClientIP(c.req.raw.headers)
let signValid: number | null = 1
let status = 'success'
let errorMessage: string | null = null
if (c.env.SIGN_VERIFY === 'true' && token) {
if (c.env.API_TOKEN && token !== c.env.API_TOKEN) {
signValid = 0
status = 'error'
errorMessage = '无效的 token'
} else if (!c.env.HMAC_SECRET) {
// 对齐旧版token 存在但 secret 为空时,跳过签名验证
signValid = 1
} else {
const maxAge = Number(c.env.SIGN_MAX_AGE_MS || 300000)
const now = nowMs()
const diff = now - timestamp
if (diff > maxAge) {
signValid = 0
status = 'error'
errorMessage = `时间戳过期(差异: ${(diff / 1000).toFixed(1)} 秒)`
} else {
const valid = verifySign(timestamp, sign, c.env.HMAC_SECRET)
signValid = valid ? 1 : 0
if (!valid) {
status = 'error'
errorMessage = '签名不匹配'
}
}
}
}
const nowIso = new Date().toISOString()
const messageRow = {
from_number: from,
content: msg,
timestamp,
device_info: device || null,
sim_info: sim || null,
sign_verified: signValid,
ip_address: ip,
created_at: nowIso,
}
const logRow = {
from_number: from,
content: msg,
timestamp,
sign: sign || null,
sign_valid: signValid,
ip_address: ip,
status,
error_message: errorMessage,
created_at: nowIso,
}
try {
await insertMessageAndLog(c.env, messageRow, logRow)
} catch {
return c.json({ success: false, error: '保存消息失败' }, 500)
}
return c.json({ success: true, message: '短信已接收' })
})

View File

@@ -0,0 +1,24 @@
import { Hono } from 'hono'
import type { Env } from '../index'
import { requireAuth } from '../middlewares/auth'
export const statsRoutes = new Hono<{ Bindings: Env }>()
statsRoutes.get('/', requireAuth, async (c) => {
const totalRow = await c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM sms_messages`).first<{ cnt: number }>()
const todayRow = await c.env.DB.prepare(`
SELECT COUNT(*) as cnt
FROM sms_messages
WHERE datetime(timestamp/1000, 'unixepoch') >= date('now')
`).first<{ cnt: number }>()
const failRow = await c.env.DB.prepare(`SELECT COUNT(*) as cnt FROM receive_logs WHERE status != 'success'`).first<{ cnt: number }>()
return c.json({
success: true,
data: {
total: totalRow?.cnt || 0,
today: todayRow?.cnt || 0,
failed: failRow?.cnt || 0,
},
})
})

View File

@@ -0,0 +1,9 @@
export function getClientIP(headers: Headers) {
const forwarded = headers.get('X-Forwarded-For')
if (forwarded) return forwarded.split(',')[0].trim()
return headers.get('CF-Connecting-IP') || ''
}
export function nowMs() {
return Date.now()
}

40
worker/src/utils/db.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Env } from '../index'
export async function insertMessageAndLog(env: Env, msg: any, log: any) {
const stmtMsg = env.DB.prepare(
`INSERT INTO sms_messages (from_number, content, timestamp, device_info, sim_info, sign_verified, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
const stmtLog = env.DB.prepare(
`INSERT INTO receive_logs (from_number, content, timestamp, sign, sign_valid, ip_address, status, error_message, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
const now = new Date().toISOString()
const batch = env.DB.batch([
stmtMsg.bind(
msg.from_number,
msg.content,
msg.timestamp,
msg.device_info,
msg.sim_info,
msg.sign_verified,
msg.ip_address,
msg.created_at || now
),
stmtLog.bind(
log.from_number,
log.content,
log.timestamp,
log.sign,
log.sign_valid,
log.ip_address,
log.status,
log.error_message,
log.created_at || now
),
])
const results = await batch
return results
}

View File

@@ -0,0 +1,39 @@
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto'
const COOKIE_NAME = 'sms_session'
export function hashPassword(password: string, secret: string) {
const h = createHmac('sha256', secret)
h.update(password)
return h.digest('hex')
}
export function createSessionCookie(username: string, secret: string) {
const nonce = randomBytes(16).toString('hex')
const payload = `${username}.${Date.now()}.${nonce}`
const sig = createHmac('sha256', secret).update(payload).digest('hex')
return `${COOKIE_NAME}=${payload}.${sig}; HttpOnly; Path=/; SameSite=None; Secure`
}
export function clearSessionCookie() {
return `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=None; Secure`
}
export function verifySessionCookie(cookieHeader: string, secret: string) {
const cookie = parseCookie(cookieHeader, COOKIE_NAME)
if (!cookie) return false
const parts = cookie.split('.')
if (parts.length < 4) return false
const sig = parts.pop() as string
const payload = parts.join('.')
const expected = createHmac('sha256', secret).update(payload).digest('hex')
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}
function parseCookie(header: string, key: string) {
const items = header.split(';').map((v) => v.trim())
for (const item of items) {
if (item.startsWith(key + '=')) return item.slice(key.length + 1)
}
return ''
}

14
worker/src/utils/sign.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createHmac } from 'node:crypto'
export function generateSign(timestamp: number, secret: string) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = createHmac('sha256', secret)
hmac.update(stringToSign)
const base64 = hmac.digest('base64')
return encodeURIComponent(base64)
}
export function verifySign(timestamp: number, sign: string, secret: string) {
const expected = generateSign(timestamp, secret)
return expected === sign
}

10
worker/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"]
}
}

19
worker/wrangler.toml Normal file
View File

@@ -0,0 +1,19 @@
name = "smsreceiver-worker-api"
main = "src/index.ts"
compatibility_date = "2026-03-22"
compatibility_flags = ["nodejs_compat"]
[vars]
ADMIN_USER = "admin"
ADMIN_PASS_HASH = "b8e450efa9c66b2b50ec8a4d7eb51213d7fa3395e107575881ad999ee92744b5"
SESSION_SECRET = "1e81b5f9e5a695eba01e996b14871db8899b08e111cf8252df8aa4c91d1c7144"
API_TOKEN = "default_token"
HMAC_SECRET = ""
SIGN_VERIFY = "true"
SIGN_MAX_AGE_MS = "3600000"
CORS_ORIGIN = "*"
[[d1_databases]]
binding = "DB"
database_name = "smsreceiver"
database_id = "50bd73ba-218c-4eb6-948a-9014a8892575"