feat: action links send+redirect to Discord, no result page; 10-min dedup window

This commit is contained in:
OpenClaw Sub-agent
2026-07-31 23:37:36 +10:00
parent 56b391109f
commit 7d89e91319
5 changed files with 154 additions and 30 deletions
+40 -6
View File
@@ -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:
"""生成防重 keynamespace + 各部分的 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
View File
@@ -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
View File
@@ -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,
+14 -8
View File
@@ -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
View File
@@ -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()