mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +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)。
|
webhook URL 从环境变量 DISCORD_WEBHOOK_URL 读取(K8s secret links-discord-webhook)。
|
||||||
|
|
||||||
|
防重:传入 dedup_key 时,同一 key 在 DEDUP_TTL 秒内只发送一次(发送成功后写入缓存)。
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +30,15 @@ ACTION_DISCORD_SEND = 'discord_send'
|
|||||||
# Discord 单条消息上限 2000 字符,留出余量
|
# Discord 单条消息上限 2000 字符,留出余量
|
||||||
MAX_MSG_LEN = 1900
|
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:
|
def send_discord_message(message: str) -> None:
|
||||||
"""通过 Discord webhook 发送消息到配置的频道。"""
|
"""通过 Discord webhook 发送消息到配置的频道。"""
|
||||||
@@ -49,10 +62,18 @@ def render_template(template: str, query: str) -> str:
|
|||||||
return template.replace('{query}', query)
|
return template.replace('{query}', query)
|
||||||
|
|
||||||
|
|
||||||
def execute_action(action_config: dict, query: str) -> dict:
|
def execute_action(action_config: dict, query: str, dedup_key: str = '') -> dict:
|
||||||
"""执行动作,返回 {success, message, discord_deeplink}。"""
|
"""执行动作,返回 {success, message, discord_deeplink, deduped}。
|
||||||
|
|
||||||
|
dedup_key 非空时启用防重:同一 key 在 DEDUP_TTL 秒内只发送一次;
|
||||||
|
发送成功才占位,失败会释放(允许重试)。
|
||||||
|
"""
|
||||||
config = action_config or {}
|
config = action_config or {}
|
||||||
action_type = config.get('action_type', '')
|
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 action_type == ACTION_DISCORD_SEND:
|
||||||
if not query.strip():
|
if not query.strip():
|
||||||
@@ -60,21 +81,34 @@ def execute_action(action_config: dict, query: str) -> dict:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'message': '链接缺少参数:请在别名后面加上你的问题(go/别名/问题)',
|
'message': '链接缺少参数:请在别名后面加上你的问题(go/别名/问题)',
|
||||||
'discord_deeplink': '',
|
'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}')
|
template = config.get('message_template', '@小黑 {query}')
|
||||||
message = render_template(template, query)
|
message = render_template(template, query)
|
||||||
|
try:
|
||||||
send_discord_message(message)
|
send_discord_message(message)
|
||||||
|
except Exception:
|
||||||
|
if dedup_key:
|
||||||
|
cache.delete(dedup_key) # 失败释放防重占位,允许重试
|
||||||
|
raise
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '已投递到 Discord,回复会出现在 #general',
|
'message': '已投递到 Discord,回复会出现在 #general',
|
||||||
'discord_deeplink': os.environ.get(
|
'discord_deeplink': deeplink,
|
||||||
'DISCORD_DEEPLINK',
|
'deduped': False,
|
||||||
'https://discord.com/channels/1467846046590959798/1467846047089954952',
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': f'未知的动作类型: {action_type}',
|
'message': f'未知的动作类型: {action_type}',
|
||||||
'discord_deeplink': '',
|
'discord_deeplink': '',
|
||||||
|
'deduped': False,
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-5
@@ -15,11 +15,11 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.http import JsonResponse
|
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.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_POST
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,11 +33,37 @@ DISCORD_DEEPLINK = os.environ.get(
|
|||||||
|
|
||||||
|
|
||||||
def ask_page(request, question=None):
|
def ask_page(request, question=None):
|
||||||
"""渲染提问页。question 可为 URL path 段或 ?q= 参数。"""
|
"""提问入口。
|
||||||
|
|
||||||
|
- 带问题(/ask/问题 或 ?q=问题):直接发消息到 Discord 并 302 跳转,
|
||||||
|
不显示页面;10 分钟内同一问题不重复发送。
|
||||||
|
- 不带问题(/ask/):渲染移动端输入页(JS 调 /api/ask/ 发送)。
|
||||||
|
"""
|
||||||
if question is None:
|
if question is None:
|
||||||
question = request.GET.get('q', '')
|
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', {
|
return render(request, 'links/ask.html', {
|
||||||
'initial_question': question,
|
'initial_question': '',
|
||||||
'discord_deeplink': DISCORD_DEEPLINK,
|
'discord_deeplink': DISCORD_DEEPLINK,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,9 +86,16 @@ def ask_api(request):
|
|||||||
return JsonResponse({'error': '问题太长了(最多 1900 字)'}, status=400)
|
return JsonResponse({'error': '问题太长了(最多 1900 字)'}, status=400)
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.warning('[ASK] post failed: %s', e)
|
logger.warning('[ASK] post failed: %s', e)
|
||||||
return JsonResponse({'error': str(e)}, status=502)
|
return JsonResponse({'error': str(e)}, status=502)
|
||||||
|
|
||||||
|
if result.get('deduped'):
|
||||||
|
return JsonResponse({'text': '10 分钟内已发送过相同问题,不重复发送'})
|
||||||
return JsonResponse({'text': '已投递到 Discord,小黑回复会出现在 #general'})
|
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 django.db.models.functions import TruncDate
|
||||||
from .models import Link, ClickLog, LinkChangeLog, Page, Post, SiteSettings
|
from .models import Link, ClickLog, LinkChangeLog, Page, Post, SiteSettings
|
||||||
from .forms import LinkForm, PageForm
|
from .forms import LinkForm, PageForm
|
||||||
from .actions import execute_action
|
from .actions import execute_action, make_dedup_key
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
import json
|
import json
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
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)
|
cache.set(click_key, 1, timeout=172800)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 动作链接(ACTION):执行动作(如发 Discord 消息)并显示结果页
|
# 动作链接(ACTION):执行动作(如发 Discord 消息),成功直接跳转 Discord
|
||||||
if link.link_type == Link.LinkType.ACTION:
|
if link.link_type == Link.LinkType.ACTION:
|
||||||
query = param or request.GET.get('q', '') or ''
|
query = param or request.GET.get('q', '') or ''
|
||||||
|
dedup_key = make_dedup_key('action', link.alias, query)
|
||||||
try:
|
try:
|
||||||
result = execute_action(link.action_config, query)
|
result = execute_action(link.action_config, query, dedup_key=dedup_key)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('[ACTION] %s failed: %s', link.alias, 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', {
|
return render(request, 'links/action_result.html', {
|
||||||
'result': result,
|
'result': result,
|
||||||
'query': query,
|
'query': query,
|
||||||
|
|||||||
@@ -62,14 +62,20 @@ assert 'value="discord_send"' in html or 'discord_send' in html, '编辑页未
|
|||||||
assert '@小黑 {query}' in html, '编辑页未预填 message template'
|
assert '@小黑 {query}' in html, '编辑页未预填 message template'
|
||||||
print('✅ 3. 编辑页正确预填动作配置')
|
print('✅ 3. 编辑页正确预填动作配置')
|
||||||
|
|
||||||
# 4. 访问动作链接 → 发 Discord + 结果页
|
# 4. 访问动作链接 → 发 Discord + 302 跳转(不渲染页面)
|
||||||
resp = client.get(f'/{ALIAS}/你好墨尔本/')
|
with patch('links.actions.send_discord_message') as mock_send:
|
||||||
assert resp.status_code == 200, f'访问链接 {resp.status_code}'
|
resp = client.get(f'/{ALIAS}/你好墨尔本/')
|
||||||
body = resp.content.decode()
|
assert resp.status_code == 302, f'应 302 跳转: {resp.status_code}'
|
||||||
assert '已投递到 Discord' in body, '结果页缺少成功提示'
|
assert resp['Location'].startswith('https://discord.com/channels/'), resp['Location']
|
||||||
assert '打开 Discord' in body, '结果页缺少 Discord 按钮'
|
mock_send.assert_called_once_with('@小黑 你好墨尔本')
|
||||||
assert '你好墨尔本' in body, '结果页缺少 query 显示'
|
print('✅ 4. 访问链接 302 跳转 Discord,消息已发送(不发页面)')
|
||||||
print('✅ 4. 访问链接返回结果页,消息已发送到 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. 无参数访问 → 提示缺参数,不发送空消息
|
# 5. 无参数访问 → 提示缺参数,不发送空消息
|
||||||
with patch('links.actions.send_discord_message') as mock_send:
|
with patch('links.actions.send_discord_message') as mock_send:
|
||||||
|
|||||||
+54
-6
@@ -121,22 +121,41 @@ class TestLinkFormActionValidation:
|
|||||||
# ── redirect_to_original execution ────────────────────────────────────────────
|
# ── redirect_to_original execution ────────────────────────────────────────────
|
||||||
|
|
||||||
class TestRedirectAction:
|
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()
|
client = Client()
|
||||||
with patch('links.actions.send_discord_message') as mock_send:
|
with patch('links.actions.send_discord_message') as mock_send:
|
||||||
resp = client.get(f'/testaction/今天有什么新闻/')
|
resp = client.get('/testaction/今天有什么新闻/')
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 302
|
||||||
|
assert resp['Location'].startswith('https://discord.com/channels/')
|
||||||
mock_send.assert_called_once_with('@小黑 今天有什么新闻')
|
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):
|
def test_action_link_with_query_param(self, action_link):
|
||||||
client = Client()
|
client = Client()
|
||||||
with patch('links.actions.send_discord_message') as mock_send:
|
with patch('links.actions.send_discord_message') as mock_send:
|
||||||
resp = client.get('/testaction/', {'q': '用查询参数提问'})
|
resp = client.get('/testaction/', {'q': '用查询参数提问'})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 302
|
||||||
mock_send.assert_called_once_with('@小黑 用查询参数提问')
|
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):
|
def test_action_link_webhook_failure_shows_error(self, action_link, monkeypatch):
|
||||||
import requests as req_lib
|
import requests as req_lib
|
||||||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||||||
@@ -145,6 +164,7 @@ class TestRedirectAction:
|
|||||||
resp = client.get('/testaction/测试/')
|
resp = client.get('/testaction/测试/')
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert '发送到 Discord 失败' in resp.content.decode()
|
assert '发送到 Discord 失败' in resp.content.decode()
|
||||||
|
assert '打开 Discord' not in resp.content.decode()
|
||||||
|
|
||||||
def test_normal_link_unaffected(self, db):
|
def test_normal_link_unaffected(self, db):
|
||||||
Link.objects.create(alias='plainlink', link_type=Link.LinkType.LINK, original_url='https://example.com/path')
|
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/')
|
resp = client.get('/plainlink/')
|
||||||
assert resp.status_code in (301, 302)
|
assert resp.status_code in (301, 302)
|
||||||
assert resp['Location'] == 'https://example.com/path'
|
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