mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
feat: action links send+redirect to Discord, no result page; 10-min dedup window
This commit is contained in:
+40
-6
@@ -12,11 +12,15 @@ action_config 结构:
|
||||
}
|
||||
|
||||
webhook URL 从环境变量 DISCORD_WEBHOOK_URL 读取(K8s secret links-discord-webhook)。
|
||||
|
||||
防重:传入 dedup_key 时,同一 key 在 DEDUP_TTL 秒内只发送一次(发送成功后写入缓存)。
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,6 +30,15 @@ ACTION_DISCORD_SEND = 'discord_send'
|
||||
# Discord 单条消息上限 2000 字符,留出余量
|
||||
MAX_MSG_LEN = 1900
|
||||
|
||||
# 防重窗口:同一内容 10 分钟内不重复发送
|
||||
DEDUP_TTL = 600
|
||||
|
||||
|
||||
def make_dedup_key(namespace: str, *parts: str) -> str:
|
||||
"""生成防重 key:namespace + 各部分的 md5(避免超长/特殊字符问题)。"""
|
||||
raw = '|'.join(str(p) for p in parts if p)
|
||||
return f'dedup:{namespace}:{hashlib.md5(raw.encode("utf-8")).hexdigest()}'
|
||||
|
||||
|
||||
def send_discord_message(message: str) -> None:
|
||||
"""通过 Discord webhook 发送消息到配置的频道。"""
|
||||
@@ -49,10 +62,18 @@ def render_template(template: str, query: str) -> str:
|
||||
return template.replace('{query}', query)
|
||||
|
||||
|
||||
def execute_action(action_config: dict, query: str) -> dict:
|
||||
"""执行动作,返回 {success, message, discord_deeplink}。"""
|
||||
def execute_action(action_config: dict, query: str, dedup_key: str = '') -> dict:
|
||||
"""执行动作,返回 {success, message, discord_deeplink, deduped}。
|
||||
|
||||
dedup_key 非空时启用防重:同一 key 在 DEDUP_TTL 秒内只发送一次;
|
||||
发送成功才占位,失败会释放(允许重试)。
|
||||
"""
|
||||
config = action_config or {}
|
||||
action_type = config.get('action_type', '')
|
||||
deeplink = os.environ.get(
|
||||
'DISCORD_DEEPLINK',
|
||||
'https://discord.com/channels/1467846046590959798/1467846047089954952',
|
||||
)
|
||||
|
||||
if action_type == ACTION_DISCORD_SEND:
|
||||
if not query.strip():
|
||||
@@ -60,21 +81,34 @@ def execute_action(action_config: dict, query: str) -> dict:
|
||||
'success': False,
|
||||
'message': '链接缺少参数:请在别名后面加上你的问题(go/别名/问题)',
|
||||
'discord_deeplink': '',
|
||||
'deduped': False,
|
||||
}
|
||||
# 防重:同 key 在窗口内已发送过 → 不重复发送,直接算成功(跳转 Discord)
|
||||
if dedup_key and not cache.add(dedup_key, '1', DEDUP_TTL):
|
||||
return {
|
||||
'success': True,
|
||||
'message': '已发送过,10 分钟内不重复发送',
|
||||
'discord_deeplink': deeplink,
|
||||
'deduped': True,
|
||||
}
|
||||
template = config.get('message_template', '@小黑 {query}')
|
||||
message = render_template(template, query)
|
||||
try:
|
||||
send_discord_message(message)
|
||||
except Exception:
|
||||
if dedup_key:
|
||||
cache.delete(dedup_key) # 失败释放防重占位,允许重试
|
||||
raise
|
||||
return {
|
||||
'success': True,
|
||||
'message': '已投递到 Discord,回复会出现在 #general',
|
||||
'discord_deeplink': os.environ.get(
|
||||
'DISCORD_DEEPLINK',
|
||||
'https://discord.com/channels/1467846046590959798/1467846047089954952',
|
||||
),
|
||||
'discord_deeplink': deeplink,
|
||||
'deduped': False,
|
||||
}
|
||||
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'未知的动作类型: {action_type}',
|
||||
'discord_deeplink': '',
|
||||
'deduped': False,
|
||||
}
|
||||
|
||||
+38
-5
@@ -15,11 +15,11 @@ import logging
|
||||
import os
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .actions import MAX_MSG_LEN, send_discord_message
|
||||
from .actions import MAX_MSG_LEN, execute_action, make_dedup_key, send_discord_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,11 +33,37 @@ DISCORD_DEEPLINK = os.environ.get(
|
||||
|
||||
|
||||
def ask_page(request, question=None):
|
||||
"""渲染提问页。question 可为 URL path 段或 ?q= 参数。"""
|
||||
"""提问入口。
|
||||
|
||||
- 带问题(/ask/问题 或 ?q=问题):直接发消息到 Discord 并 302 跳转,
|
||||
不显示页面;10 分钟内同一问题不重复发送。
|
||||
- 不带问题(/ask/):渲染移动端输入页(JS 调 /api/ask/ 发送)。
|
||||
"""
|
||||
if question is None:
|
||||
question = request.GET.get('q', '')
|
||||
question = (question or '').strip()
|
||||
|
||||
if question:
|
||||
dedup_key = make_dedup_key('ask', question)
|
||||
try:
|
||||
result = execute_action(
|
||||
{'action_type': 'discord_send', 'message_template': f'<@{DISCORD_BOT_ID}> {{query}}'},
|
||||
question,
|
||||
dedup_key=dedup_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('[ASK] failed: %s', e)
|
||||
result = {'success': False, 'message': str(e), 'discord_deeplink': '', 'deduped': False}
|
||||
if result.get('success') and result.get('discord_deeplink'):
|
||||
return redirect(result['discord_deeplink'])
|
||||
return render(request, 'links/action_result.html', {
|
||||
'result': result,
|
||||
'query': question,
|
||||
'link': None,
|
||||
})
|
||||
|
||||
return render(request, 'links/ask.html', {
|
||||
'initial_question': question,
|
||||
'initial_question': '',
|
||||
'discord_deeplink': DISCORD_DEEPLINK,
|
||||
})
|
||||
|
||||
@@ -60,9 +86,16 @@ def ask_api(request):
|
||||
return JsonResponse({'error': '问题太长了(最多 1900 字)'}, status=400)
|
||||
|
||||
try:
|
||||
send_discord_message(f'<@{DISCORD_BOT_ID}> {question}')
|
||||
dedup_key = make_dedup_key('ask', question)
|
||||
result = execute_action(
|
||||
{'action_type': 'discord_send', 'message_template': f'<@{DISCORD_BOT_ID}> {{query}}'},
|
||||
question,
|
||||
dedup_key=dedup_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('[ASK] post failed: %s', e)
|
||||
return JsonResponse({'error': str(e)}, status=502)
|
||||
|
||||
if result.get('deduped'):
|
||||
return JsonResponse({'text': '10 分钟内已发送过相同问题,不重复发送'})
|
||||
return JsonResponse({'text': '已投递到 Discord,小黑回复会出现在 #general'})
|
||||
|
||||
+7
-4
@@ -6,7 +6,7 @@ from django.db.models import F, Count, Q, Case, When, Value, IntegerField, Sum
|
||||
from django.db.models.functions import TruncDate
|
||||
from .models import Link, ClickLog, LinkChangeLog, Page, Post, SiteSettings
|
||||
from .forms import LinkForm, PageForm
|
||||
from .actions import execute_action
|
||||
from .actions import execute_action, make_dedup_key
|
||||
from django.core.cache import cache
|
||||
import json
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
@@ -344,14 +344,17 @@ def redirect_to_original(request, alias, param=None):
|
||||
cache.set(click_key, 1, timeout=172800)
|
||||
|
||||
try:
|
||||
# 动作链接(ACTION):执行动作(如发 Discord 消息)并显示结果页
|
||||
# 动作链接(ACTION):执行动作(如发 Discord 消息),成功直接跳转 Discord
|
||||
if link.link_type == Link.LinkType.ACTION:
|
||||
query = param or request.GET.get('q', '') or ''
|
||||
dedup_key = make_dedup_key('action', link.alias, query)
|
||||
try:
|
||||
result = execute_action(link.action_config, query)
|
||||
result = execute_action(link.action_config, query, dedup_key=dedup_key)
|
||||
except Exception as e:
|
||||
logger.warning('[ACTION] %s failed: %s', link.alias, e)
|
||||
result = {'success': False, 'message': str(e), 'discord_deeplink': ''}
|
||||
result = {'success': False, 'message': str(e), 'discord_deeplink': '', 'deduped': False}
|
||||
if result.get('success') and result.get('discord_deeplink'):
|
||||
return redirect(result['discord_deeplink'])
|
||||
return render(request, 'links/action_result.html', {
|
||||
'result': result,
|
||||
'query': query,
|
||||
|
||||
@@ -62,14 +62,20 @@ assert 'value="discord_send"' in html or 'discord_send' in html, '编辑页未
|
||||
assert '@小黑 {query}' in html, '编辑页未预填 message template'
|
||||
print('✅ 3. 编辑页正确预填动作配置')
|
||||
|
||||
# 4. 访问动作链接 → 发 Discord + 结果页
|
||||
resp = client.get(f'/{ALIAS}/你好墨尔本/')
|
||||
assert resp.status_code == 200, f'访问链接 {resp.status_code}'
|
||||
body = resp.content.decode()
|
||||
assert '已投递到 Discord' in body, '结果页缺少成功提示'
|
||||
assert '打开 Discord' in body, '结果页缺少 Discord 按钮'
|
||||
assert '你好墨尔本' in body, '结果页缺少 query 显示'
|
||||
print('✅ 4. 访问链接返回结果页,消息已发送到 Discord(含 @小黑 你好墨尔本)')
|
||||
# 4. 访问动作链接 → 发 Discord + 302 跳转(不渲染页面)
|
||||
with patch('links.actions.send_discord_message') as mock_send:
|
||||
resp = client.get(f'/{ALIAS}/你好墨尔本/')
|
||||
assert resp.status_code == 302, f'应 302 跳转: {resp.status_code}'
|
||||
assert resp['Location'].startswith('https://discord.com/channels/'), resp['Location']
|
||||
mock_send.assert_called_once_with('@小黑 你好墨尔本')
|
||||
print('✅ 4. 访问链接 302 跳转 Discord,消息已发送(不发页面)')
|
||||
|
||||
# 4b. 防重:10 分钟内同一问题刷新不重复发送
|
||||
with patch('links.actions.send_discord_message') as mock_send:
|
||||
resp = client.get(f'/{ALIAS}/你好墨尔本/') # 刷新/重复访问
|
||||
assert resp.status_code == 302
|
||||
mock_send.assert_not_called(), '防重窗口内不应再次发送'
|
||||
print('✅ 4b. 10 分钟防重生效:刷新不重复发送')
|
||||
|
||||
# 5. 无参数访问 → 提示缺参数,不发送空消息
|
||||
with patch('links.actions.send_discord_message') as mock_send:
|
||||
|
||||
+54
-6
@@ -121,22 +121,41 @@ class TestLinkFormActionValidation:
|
||||
# ── redirect_to_original execution ────────────────────────────────────────────
|
||||
|
||||
class TestRedirectAction:
|
||||
def test_action_link_executes_and_renders_result(self, action_link):
|
||||
def test_action_link_executes_and_redirects(self, action_link):
|
||||
"""成功:发送消息并 302 跳转 Discord,不渲染页面。"""
|
||||
client = Client()
|
||||
with patch('links.actions.send_discord_message') as mock_send:
|
||||
resp = client.get(f'/testaction/今天有什么新闻/')
|
||||
assert resp.status_code == 200
|
||||
resp = client.get('/testaction/今天有什么新闻/')
|
||||
assert resp.status_code == 302
|
||||
assert resp['Location'].startswith('https://discord.com/channels/')
|
||||
mock_send.assert_called_once_with('@小黑 今天有什么新闻')
|
||||
assert 'links/action_result.html' in [t.name for t in resp.templates]
|
||||
assert '今天有什么新闻' in resp.content.decode()
|
||||
|
||||
def test_action_link_with_query_param(self, action_link):
|
||||
client = Client()
|
||||
with patch('links.actions.send_discord_message') as mock_send:
|
||||
resp = client.get('/testaction/', {'q': '用查询参数提问'})
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 302
|
||||
mock_send.assert_called_once_with('@小黑 用查询参数提问')
|
||||
|
||||
def test_action_link_dedup_same_query_in_window(self, action_link, monkeypatch):
|
||||
"""10 分钟内同一 query 只发一次,刷新不重复发送。"""
|
||||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||||
client = Client()
|
||||
with patch('links.actions.requests.post') as mock_post:
|
||||
resp1 = client.get('/testaction/相同问题/')
|
||||
resp2 = client.get('/testaction/相同问题/') # 刷新/重复访问
|
||||
assert resp1.status_code == 302
|
||||
assert resp2.status_code == 302
|
||||
assert mock_post.call_count == 1, '同一问题 10 分钟内只应发送一次'
|
||||
|
||||
def test_action_link_different_query_not_deduped(self, action_link, monkeypatch):
|
||||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||||
client = Client()
|
||||
with patch('links.actions.requests.post') as mock_post:
|
||||
client.get('/testaction/问题甲/')
|
||||
client.get('/testaction/问题乙/')
|
||||
assert mock_post.call_count == 2, '不同问题应各自发送'
|
||||
|
||||
def test_action_link_webhook_failure_shows_error(self, action_link, monkeypatch):
|
||||
import requests as req_lib
|
||||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||||
@@ -145,6 +164,7 @@ class TestRedirectAction:
|
||||
resp = client.get('/testaction/测试/')
|
||||
assert resp.status_code == 200
|
||||
assert '发送到 Discord 失败' in resp.content.decode()
|
||||
assert '打开 Discord' not in resp.content.decode()
|
||||
|
||||
def test_normal_link_unaffected(self, db):
|
||||
Link.objects.create(alias='plainlink', link_type=Link.LinkType.LINK, original_url='https://example.com/path')
|
||||
@@ -152,3 +172,31 @@ class TestRedirectAction:
|
||||
resp = client.get('/plainlink/')
|
||||
assert resp.status_code in (301, 302)
|
||||
assert resp['Location'] == 'https://example.com/path'
|
||||
|
||||
|
||||
# ── /ask/ 路由(带问题 = 发送+跳转;不带 = 输入页)─────────────────────────
|
||||
|
||||
class TestAskRoute:
|
||||
def test_ask_with_question_redirects(self, monkeypatch):
|
||||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||||
client = Client()
|
||||
with patch('links.actions.requests.post') as mock_post:
|
||||
resp = client.get('/ask/今天有什么新闻/')
|
||||
assert resp.status_code == 302
|
||||
assert resp['Location'].startswith('https://discord.com/channels/')
|
||||
body = mock_post.call_args.kwargs['json']['content']
|
||||
assert '今天有什么新闻' in body
|
||||
|
||||
def test_ask_with_question_dedup(self, monkeypatch):
|
||||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||||
client = Client()
|
||||
with patch('links.actions.requests.post') as mock_post:
|
||||
client.get('/ask/相同问题/')
|
||||
client.get('/ask/相同问题/')
|
||||
assert mock_post.call_count == 1
|
||||
|
||||
def test_ask_empty_renders_input_page(self):
|
||||
client = Client()
|
||||
resp = client.get('/ask/')
|
||||
assert resp.status_code == 200
|
||||
assert '问小黑' in resp.content.decode()
|
||||
|
||||
Reference in New Issue
Block a user