457 lines
26 KiB
Python
457 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hmac
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import smtplib
|
|
import ssl
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from email.message import EmailMessage
|
|
from typing import Literal
|
|
from uuid import uuid4
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
from fastapi import Depends, HTTPException
|
|
|
|
from .auth import digest
|
|
from .db import decode, encode
|
|
from .domain import ACTIONS, parse_date
|
|
from .models import EmailCode, TelegramCode
|
|
from .money import format_money
|
|
from .notification_config import EmailConfigInput, NotificationConfigs, TelegramConfigInput
|
|
|
|
logger = logging.getLogger('simkeep.notifications')
|
|
SCHEDULE_FIELDS = ('timezone','time','offsets','overdue')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
public_url: str = ''
|
|
secure_cookies: bool = False
|
|
telegram_token: str = ''
|
|
telegram_username: str = ''
|
|
smtp_host: str = ''
|
|
smtp_port: int = 587
|
|
smtp_user: str = ''
|
|
smtp_password: str = ''
|
|
smtp_from: str = ''
|
|
smtp_mode: str = 'starttls'
|
|
|
|
@classmethod
|
|
def from_env(cls):
|
|
mode = os.getenv('SIMKEEP_SMTP_MODE','starttls').lower()
|
|
if mode not in ('starttls','ssl','plain'):
|
|
raise ValueError('SIMKEEP_SMTP_MODE 须为 starttls、ssl 或 plain')
|
|
return cls(
|
|
public_url=os.getenv('SIMKEEP_PUBLIC_URL','').rstrip('/'),
|
|
secure_cookies=os.getenv('SIMKEEP_SECURE_COOKIES','0')=='1',
|
|
telegram_token=os.getenv('SIMKEEP_TELEGRAM_TOKEN',''),
|
|
telegram_username=os.getenv('SIMKEEP_TELEGRAM_USERNAME','').lstrip('@'),
|
|
smtp_host=os.getenv('SIMKEEP_SMTP_HOST',''),
|
|
smtp_port=int(os.getenv('SIMKEEP_SMTP_PORT','465' if mode=='ssl' else '587')),
|
|
smtp_user=os.getenv('SIMKEEP_SMTP_USER',''),
|
|
smtp_password=os.getenv('SIMKEEP_SMTP_PASSWORD',''),
|
|
smtp_from=os.getenv('SIMKEEP_SMTP_FROM',os.getenv('SIMKEEP_SMTP_USER','')),
|
|
smtp_mode=mode,
|
|
)
|
|
|
|
def configured(self, channel):
|
|
if channel == 'telegram':
|
|
return bool(self.telegram_token)
|
|
return bool(self.smtp_host and self.smtp_from and (not self.smtp_user or self.smtp_password))
|
|
|
|
def status(self):
|
|
# Only capability flags are public; destinations and secrets stay on the server.
|
|
return {'telegram':{'configured':self.configured('telegram')},'email':{'configured':self.configured('email')}}
|
|
|
|
|
|
class Transport:
|
|
def __init__(self, config):
|
|
self.config = config
|
|
self.username = config.telegram_username
|
|
|
|
async def telegram(self, method, payload):
|
|
async with httpx.AsyncClient(timeout=20) as client:
|
|
response = await client.post(f'https://api.telegram.org/bot{self.config.telegram_token}/{method}',json=payload)
|
|
if response.status_code != 200:
|
|
raise RuntimeError('Telegram 暂时无法发送')
|
|
result = response.json()
|
|
if not result.get('ok'):
|
|
raise RuntimeError('Telegram 暂时无法发送')
|
|
return result['result']
|
|
|
|
async def bot_username(self):
|
|
if not self.username:
|
|
self.username = (await self.telegram('getMe',{}))['username']
|
|
if not re.fullmatch(r'[A-Za-z0-9_]{5,32}', self.username):
|
|
raise RuntimeError('Bot 用户名无效')
|
|
return self.username
|
|
|
|
async def updates(self, offset):
|
|
return await self.telegram('getUpdates',{'offset':offset,'timeout':0,'allowed_updates':['message']})
|
|
|
|
def smtp(self, destination=None, text=''):
|
|
context = ssl.create_default_context()
|
|
if self.config.smtp_mode == 'ssl':
|
|
connection = smtplib.SMTP_SSL(self.config.smtp_host,self.config.smtp_port,timeout=20,context=context)
|
|
else:
|
|
connection = smtplib.SMTP(self.config.smtp_host,self.config.smtp_port,timeout=20)
|
|
with connection:
|
|
connection.ehlo_or_helo_if_needed()
|
|
if self.config.smtp_mode == 'starttls':
|
|
connection.starttls(context=context)
|
|
connection.ehlo()
|
|
if self.config.smtp_user:
|
|
connection.login(self.config.smtp_user,self.config.smtp_password)
|
|
if destination is None:
|
|
return
|
|
message = EmailMessage()
|
|
message['Subject'] = '续卡 SIMKEEP · 续期提醒' if '验证码' not in text else '续卡 SIMKEEP · 验证邮箱'
|
|
message['From'] = self.config.smtp_from
|
|
message['To'] = destination
|
|
message.set_content(text)
|
|
refused = connection.send_message(message)
|
|
if refused:
|
|
raise RuntimeError('邮箱服务暂时无法发送')
|
|
|
|
async def send(self, channel, destination, text):
|
|
if channel == 'telegram':
|
|
await self.telegram('sendMessage',{'chat_id':destination,'text':text[:4000],'disable_web_page_preview':True})
|
|
else:
|
|
await asyncio.to_thread(self.smtp,destination,text)
|
|
|
|
|
|
def destination(settings, channel):
|
|
if channel == 'telegram':
|
|
return settings.get('chatId','') if settings.get('telegramVerified') else ''
|
|
return settings.get('emailAddress','') if settings.get('emailVerified') else ''
|
|
|
|
|
|
class Notifier:
|
|
def __init__(self, db, config, transport=None):
|
|
self.db = db
|
|
self.config = config
|
|
self.transport = transport or Transport(config)
|
|
self.configs = NotificationConfigs(db)
|
|
|
|
def transport_for(self, owner, config=None):
|
|
config = config or self.configs.load(owner, self.config)
|
|
return self.transport if config == self.config else Transport(config)
|
|
|
|
def recover(self):
|
|
with self.db.connect(write=True) as conn:
|
|
conn.execute("UPDATE notification_jobs SET status='pending',error='发送中断,等待重试' WHERE status='processing'")
|
|
|
|
def enqueue(self, now=None):
|
|
now = now or datetime.now(timezone.utc)
|
|
timestamp = now.timestamp()
|
|
with self.db.connect(write=True) as conn:
|
|
for person in conn.execute('SELECT id,settings FROM users').fetchall():
|
|
config = self.configs.load(person['id'], self.config, conn)
|
|
settings = decode(person['settings'])
|
|
local = now.astimezone(ZoneInfo(settings['timezone']))
|
|
if local.strftime('%H:%M') < settings['time']:
|
|
continue
|
|
day = local.date()
|
|
for row in conn.execute('SELECT document FROM cards WHERE user_id=?',(person['id'],)).fetchall():
|
|
card = decode(row['document'])
|
|
if card['archived']:
|
|
continue
|
|
for rule in card['rules']:
|
|
remaining = (parse_date(rule['dueDate'])-day).days
|
|
if remaining not in [0,*settings['offsets']] and not (remaining < 0 and settings['overdue']):
|
|
continue
|
|
payload = {'dueDate':rule['dueDate'],'day':day.isoformat(),'remaining':remaining,'schedule':{k:settings[k] for k in SCHEDULE_FIELDS}}
|
|
for channel in ('telegram','email'):
|
|
target = destination(settings,channel)
|
|
if not settings[channel] or not target or not config.configured(channel):
|
|
continue
|
|
key = ':'.join((person['id'],card['id'],rule['id'],str(rule['version']),channel,day.isoformat()))
|
|
values = (str(uuid4()),key,person['id'],card['id'],rule['id'],rule['version'],channel,target,encode(payload),timestamp,timestamp)
|
|
conn.execute('INSERT OR IGNORE INTO notification_jobs(id,dedupe_key,user_id,card_id,rule_id,rule_version,channel,destination,payload,ready_at,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',values)
|
|
# Restore only unsent, cancelled jobs. Sent keys remain final even after settings change.
|
|
conn.execute("UPDATE notification_jobs SET status='pending',attempts=0,error=NULL,destination=?,payload=?,ready_at=? WHERE dedupe_key=? AND status='cancelled' AND sent_at IS NULL",(target,encode(payload),timestamp,key))
|
|
|
|
def valid(self, conn, job, now):
|
|
person = conn.execute('SELECT settings FROM users WHERE id=?',(job['user_id'],)).fetchone()
|
|
row = conn.execute('SELECT document FROM cards WHERE id=? AND user_id=?',(job['card_id'],job['user_id'])).fetchone()
|
|
if not person or not row:
|
|
return None
|
|
settings, card, payload = decode(person[0]), decode(row[0]), decode(job['payload'])
|
|
rule = next((r for r in card['rules'] if r['id']==job['rule_id']),None)
|
|
if card['archived'] or not rule or rule['version']!=job['rule_version'] or rule['dueDate']!=payload['dueDate']:
|
|
return None
|
|
if not settings[job['channel']] or destination(settings,job['channel'])!=job['destination']:
|
|
return None
|
|
if {k:settings[k] for k in SCHEDULE_FIELDS}!=payload['schedule']:
|
|
return None
|
|
if now.astimezone(ZoneInfo(settings['timezone'])).date().isoformat()!=payload['day']:
|
|
return None
|
|
return card, rule, payload
|
|
|
|
def message(self, card, rule, payload):
|
|
remaining = payload['remaining']
|
|
timing = f'还有 {remaining} 天' if remaining > 0 else '今天到期' if remaining == 0 else f'已逾期 {-remaining} 天'
|
|
action = ACTIONS[rule['action']]
|
|
phone = f"号码尾号 {re.sub(r'\s','',card['phone'])[-4:]}" if card['phone'].startswith('+') else '数据卡 / 未填写号码'
|
|
cost = rule.get('cost')
|
|
balance = next((item for item in card.get('balances',[]) if cost and item['currency']==cost['currency']),None)
|
|
money = f"续期金额:{format_money(cost)}\n"
|
|
if balance is not None:
|
|
money += f"同币种余额:{format_money(balance)}\n"
|
|
return f"续卡 SIMKEEP · 续期提醒\n\n{card['name']} · {card['provider']}\n{phone}\n{action} · {timing}\n到期日期:{rule['dueDate']}\n{money}操作说明:{rule['instructions'] or '请按运营商的要求完成续期'}\n\n完成操作后,请在续卡记录完成,更新下次续期日期。\n{self.config.public_url}/#renewals"
|
|
|
|
async def deliver(self, now=None):
|
|
now = now or datetime.now(timezone.utc)
|
|
timestamp = now.timestamp()
|
|
with self.db.connect(write=True) as conn:
|
|
# Expire stale dates even when their channel is currently unavailable.
|
|
for person in conn.execute('SELECT id,settings FROM users').fetchall():
|
|
local_day = now.astimezone(ZoneInfo(decode(person['settings'])['timezone'])).date().isoformat()
|
|
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND status='pending' AND json_extract(payload,'$.day')<>?",(person['id'],local_day))
|
|
configurations = {}
|
|
pending = []
|
|
# Apply availability before the batch limit, including per-account services.
|
|
for row in conn.execute("SELECT * FROM notification_jobs WHERE status='pending' AND ready_at<=? ORDER BY ready_at", (timestamp,)):
|
|
owner = row['user_id']
|
|
if owner not in configurations:
|
|
configurations[owner] = self.configs.load(owner, self.config, conn)
|
|
if configurations[owner].configured(row['channel']):
|
|
pending.append(dict(row))
|
|
if len(pending) == 50:
|
|
break
|
|
for job in pending:
|
|
with self.db.connect(write=True) as conn:
|
|
config = self.configs.load(job['user_id'], self.config, conn)
|
|
if not config.configured(job['channel']):
|
|
continue
|
|
current = conn.execute("SELECT * FROM notification_jobs WHERE id=? AND status='pending'",(job['id'],)).fetchone()
|
|
if not current:
|
|
continue
|
|
valid = self.valid(conn,current,now)
|
|
if not valid:
|
|
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE id=?",(job['id'],))
|
|
continue
|
|
attempts = current['attempts']+1
|
|
conn.execute("UPDATE notification_jobs SET status='processing',attempts=? WHERE id=?",(attempts,job['id']))
|
|
try:
|
|
await self.transport_for(job['user_id'],config).send(job['channel'],job['destination'],self.message(*valid))
|
|
except Exception:
|
|
# Raw transport exceptions can contain credentials. Persist only a safe, actionable message.
|
|
error = '发送失败,请检查服务配置或接收地址' if attempts>=5 else '发送失败,稍后自动重试'
|
|
with self.db.connect(write=True) as conn:
|
|
conn.execute("UPDATE notification_jobs SET status=?,error=?,ready_at=? WHERE id=? AND status='processing'",('failed' if attempts>=5 else 'pending',error,timestamp+60*2**(attempts-1),job['id']))
|
|
else:
|
|
with self.db.connect(write=True) as conn:
|
|
# An edit during the network call cannot recall an accepted message; record the actual outcome.
|
|
conn.execute("UPDATE notification_jobs SET status='sent',sent_at=?,error=NULL WHERE id=?",(timestamp,job['id']))
|
|
|
|
def process_update(self, update, user_id=None, token=None):
|
|
message = update.get('message',{})
|
|
chat = message.get('chat',{})
|
|
match = re.fullmatch(r'/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]+)',message.get('text','').strip())
|
|
if chat.get('type')!='private' or not isinstance(chat.get('id'),int) or not match:
|
|
return False
|
|
chat_id = str(chat['id'])
|
|
with self.db.connect(write=True) as conn:
|
|
binding = conn.execute("SELECT * FROM bindings WHERE token_hash=? AND kind='telegram' AND destination IS NULL AND expires_at>?",(digest(match[1]),time.time())).fetchone()
|
|
if not binding:
|
|
return False
|
|
expected = token if token is not None else self.configs.load(user_id, self.config, conn).telegram_token if user_id else self.config.telegram_token
|
|
current = self.configs.load(binding['user_id'], self.config, conn)
|
|
if (user_id is not None and binding['user_id'] != user_id) or current.telegram_token != expected:
|
|
return False
|
|
for row in conn.execute('SELECT id,settings FROM users'):
|
|
existing = decode(row['settings'])
|
|
same_bot = self.configs.load(row['id'],self.config,conn).telegram_token == current.telegram_token
|
|
if same_bot and row['id']!=binding['user_id'] and existing.get('telegramVerified') and existing.get('chatId')==chat_id:
|
|
return False
|
|
row = conn.execute('SELECT settings FROM users WHERE id=?',(binding['user_id'],)).fetchone()
|
|
settings = decode(row[0])
|
|
settings.update(chatId=chat_id,telegramVerified=True)
|
|
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),binding['user_id']))
|
|
conn.execute('UPDATE bindings SET destination=? WHERE token_hash=?',(chat_id,binding['token_hash']))
|
|
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(binding['user_id'],))
|
|
return True
|
|
|
|
async def poll(self):
|
|
with self.db.connect() as conn:
|
|
people = [row['id'] for row in conn.execute('SELECT id FROM users')]
|
|
personal = [(owner,self.configs.load(owner,self.config,conn)) for owner in people]
|
|
targets = [(owner,config) for owner,config in personal if config.telegram_token and config.telegram_token != self.config.telegram_token]
|
|
if self.config.configured('telegram'):
|
|
targets.append((None,self.config))
|
|
semaphore = asyncio.Semaphore(4)
|
|
|
|
async def guarded(owner, config):
|
|
async with semaphore:
|
|
try:
|
|
await self.poll_bot(owner, config)
|
|
except Exception:
|
|
logger.warning('Telegram 绑定暂时不可用,请检查本账号的 Bot 配置与网络')
|
|
await asyncio.gather(*(guarded(owner,config) for owner,config in targets))
|
|
|
|
async def poll_bot(self, owner, config):
|
|
key = f'telegram_offset:{owner}:{digest(config.telegram_token)}' if owner else 'telegram_offset'
|
|
with self.db.connect() as conn:
|
|
row = conn.execute('SELECT value FROM system_state WHERE key=?', (key,)).fetchone()
|
|
offset = int(row[0]) if row else 0
|
|
updates = await self.transport_for(owner,config).updates(offset)
|
|
for update in updates:
|
|
self.process_update(update,user_id=owner,token=config.telegram_token)
|
|
with self.db.connect(write=True) as conn:
|
|
conn.execute('INSERT OR REPLACE INTO system_state VALUES(?,?)',(key,str(update['update_id']+1)))
|
|
|
|
async def run(self):
|
|
self.recover()
|
|
while True:
|
|
try:
|
|
await self.poll()
|
|
except Exception:
|
|
logger.warning('Telegram 绑定暂时不可用,请检查 Bot 配置与网络')
|
|
try:
|
|
self.enqueue()
|
|
await self.deliver()
|
|
with self.db.connect(write=True) as conn:
|
|
conn.execute('DELETE FROM bindings WHERE expires_at<?',(time.time(),))
|
|
except Exception:
|
|
logger.warning('续期调度暂时失败,将在下一轮重试')
|
|
await asyncio.sleep(5)
|
|
|
|
def install_routes(self, app, session, result, rate):
|
|
def require_config(channel, owner):
|
|
config = self.configs.load(owner,self.config)
|
|
if not config.configured(channel):
|
|
raise HTTPException(503,'请先配置本账号的 Telegram Bot' if channel=='telegram' else '请先配置本账号的邮件服务')
|
|
return self.transport_for(owner,config)
|
|
|
|
@app.get('/api/notifications/config')
|
|
def read_config(person=Depends(session)):
|
|
return {'services':self.configs.status(person['id'],self.config)}
|
|
|
|
@app.put('/api/notifications/config/telegram')
|
|
def save_telegram_config(payload:TelegramConfigInput,person=Depends(session)):
|
|
self.configs.save(person['id'],'telegram',payload,self.config)
|
|
return result(person['id'])
|
|
|
|
@app.put('/api/notifications/config/email')
|
|
def save_email_config(payload:EmailConfigInput,person=Depends(session)):
|
|
self.configs.save(person['id'],'email',payload,self.config)
|
|
return result(person['id'])
|
|
|
|
@app.delete('/api/notifications/config/{channel}')
|
|
def clear_config(channel:Literal['email','telegram'],person=Depends(session)):
|
|
self.configs.clear(person['id'],channel,self.config)
|
|
return result(person['id'])
|
|
|
|
@app.post('/api/notifications/config/{channel}/check')
|
|
async def check_config(channel:Literal['email','telegram'],person=Depends(session)):
|
|
transport = require_config(channel,person['id'])
|
|
rate.check(('config_check',channel,person['id']),5,600)
|
|
try:
|
|
if channel == 'telegram':
|
|
username = await transport.bot_username()
|
|
return {'checked':True,'botUsername':username}
|
|
await asyncio.to_thread(transport.smtp)
|
|
except Exception:
|
|
message = '无法连接 Telegram Bot,请检查 Token 和服务器网络' if channel=='telegram' else 'SMTP 连接或认证失败,请检查服务器、端口、安全方式及密码 / 授权码'
|
|
raise HTTPException(503,message) from None
|
|
return {'checked':True}
|
|
|
|
@app.post('/api/notifications/telegram/binding')
|
|
async def telegram_binding(person=Depends(session)):
|
|
transport = require_config('telegram',person['id'])
|
|
token = getattr(transport,'config',self.config).telegram_token
|
|
rate.check(('telegram_binding',person['id']),3,60)
|
|
try:
|
|
username = await transport.bot_username()
|
|
except Exception:
|
|
raise HTTPException(503,'无法连接 Telegram Bot,请检查本账号的配置') from None
|
|
code = secrets.token_urlsafe(24)
|
|
with self.db.connect(write=True) as conn:
|
|
if self.configs.load(person['id'],self.config,conn).telegram_token != token:
|
|
raise HTTPException(409,'Bot 配置已更改,请重新开始绑定')
|
|
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'",(person['id'],))
|
|
conn.execute('INSERT INTO bindings(token_hash,user_id,kind,expires_at,created_at) VALUES(?,?,?,?,?)',(digest(code),person['id'],'telegram',time.time()+600,time.time()))
|
|
return {'code':code,'url':f'https://t.me/{username}?start={code}','expiresIn':600}
|
|
|
|
@app.post('/api/notifications/telegram/binding/check')
|
|
def check_telegram_binding(payload:TelegramCode,person=Depends(session)):
|
|
with self.db.connect() as conn:
|
|
binding = conn.execute("SELECT destination FROM bindings WHERE token_hash=? AND user_id=? AND kind='telegram' AND expires_at>?",(digest(payload.code),person['id'],time.time())).fetchone()
|
|
status = 'expired' if not binding else 'bound' if binding['destination'] else 'waiting'
|
|
return result(person['id'],bindingStatus=status)
|
|
|
|
@app.delete('/api/notifications/telegram/binding')
|
|
def unbind_telegram(person=Depends(session)):
|
|
with self.db.connect(write=True) as conn:
|
|
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
|
settings.update(chatId='',telegramVerified=False,telegram=False)
|
|
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id']))
|
|
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'",(person['id'],))
|
|
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(person['id'],))
|
|
return result(person['id'])
|
|
|
|
@app.post('/api/notifications/email/verify')
|
|
async def verify_email(person=Depends(session)):
|
|
transport = require_config('email',person['id'])
|
|
rate.check(('email_verify',person['id']),3,600)
|
|
code = f'{secrets.randbelow(1000000):06d}'
|
|
nonce = secrets.token_urlsafe(24)
|
|
token_hash = digest(nonce+code)
|
|
with self.db.connect(write=True) as conn:
|
|
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
|
address = settings['emailAddress']
|
|
if not address:
|
|
raise HTTPException(422,'请先保存接收邮箱')
|
|
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='email'",(person['id'],))
|
|
conn.execute('INSERT INTO bindings(token_hash,user_id,kind,destination,expires_at,created_at) VALUES(?,?,?,?,?,?)',(token_hash,person['id'],'email',encode({'address':address,'nonce':nonce}),time.time()+600,time.time()))
|
|
try:
|
|
await transport.send('email',address,f'续卡 SIMKEEP · 验证邮箱\n\n验证码:{code}\n10 分钟内有效。请在通知设置输入验证码,确认这是你的接收邮箱。')
|
|
except Exception:
|
|
with self.db.connect(write=True) as conn:
|
|
conn.execute('DELETE FROM bindings WHERE token_hash=?',(token_hash,))
|
|
raise HTTPException(503,'验证码发送失败,请检查邮件服务配置') from None
|
|
return {'sent':True,'expiresIn':600}
|
|
|
|
@app.post('/api/notifications/email/confirm')
|
|
def confirm_email(payload:EmailCode,person=Depends(session)):
|
|
rate.check(('email_confirm',person['id']),20,600)
|
|
valid = False
|
|
with self.db.connect(write=True) as conn:
|
|
binding = conn.execute("SELECT * FROM bindings WHERE user_id=? AND kind='email' ORDER BY created_at DESC LIMIT 1",(person['id'],)).fetchone()
|
|
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
|
if binding and binding['expires_at']>time.time() and binding['attempts']<5:
|
|
stored = decode(binding['destination'])
|
|
valid = stored['address']==settings['emailAddress'] and hmac.compare_digest(binding['token_hash'],digest(stored['nonce']+payload.code))
|
|
if valid:
|
|
settings['emailVerified'] = True
|
|
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id']))
|
|
conn.execute('DELETE FROM bindings WHERE token_hash=?',(binding['token_hash'],))
|
|
else:
|
|
conn.execute('UPDATE bindings SET attempts=attempts+1 WHERE token_hash=?',(binding['token_hash'],))
|
|
if not valid:
|
|
raise HTTPException(422,'验证码不正确或已失效,请重新发送')
|
|
return result(person['id'])
|
|
|
|
@app.post('/api/notifications/test/{channel}')
|
|
async def test_notification(channel:Literal['email','telegram'],person=Depends(session)):
|
|
transport = require_config(channel,person['id'])
|
|
rate.check(('notification_test',person['id']),5,600)
|
|
with self.db.connect() as conn:
|
|
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
|
target = destination(settings,channel)
|
|
if not target:
|
|
raise HTTPException(422,'请先验证邮箱或绑定 Telegram')
|
|
try:
|
|
await transport.send(channel,target,'续卡 SIMKEEP · 测试提醒\n\n通知渠道已连接。开启提醒并保存设置后,续期提醒将按你选择的时间发送。')
|
|
except Exception:
|
|
raise HTTPException(503,'测试提醒发送失败,请检查服务配置或接收地址') from None
|
|
return {'sent':True}
|