feat: add Action link type — configurable send-to-Discord links with UI form

This commit is contained in:
OpenClaw Sub-agent
2026-07-31 22:56:06 +10:00
parent c91112aed9
commit 1c18f2b177
10 changed files with 565 additions and 25 deletions
+80
View File
@@ -0,0 +1,80 @@
"""
Link action execution — 动作链接(link_type=ACTION)的执行逻辑。
访问 go/{alias}/{参数} 时,根据 action_config 执行预设动作。
当前支持的动作:
- discord_send: 通过 Discord webhook 把消息发到指定频道并 @小黑
action_config 结构:
{
"action_type": "discord_send",
"message_template": "@小黑 {query}", # {query} 会被 URL 参数替换
}
webhook URL 从环境变量 DISCORD_WEBHOOK_URL 读取(K8s secret links-discord-webhook)。
"""
import logging
import os
import requests
logger = logging.getLogger(__name__)
# 动作类型常量
ACTION_DISCORD_SEND = 'discord_send'
# Discord 单条消息上限 2000 字符,留出余量
MAX_MSG_LEN = 1900
def send_discord_message(message: str) -> None:
"""通过 Discord webhook 发送消息到配置的频道。"""
webhook_url = os.environ.get('DISCORD_WEBHOOK_URL', '').strip()
if not webhook_url:
raise RuntimeError('服务端未配置 DISCORD_WEBHOOK_URL,无法发送')
content = message[:MAX_MSG_LEN]
try:
resp = requests.post(webhook_url, json={'content': content}, timeout=15)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
logger.warning('[ACTION] discord webhook failed: %s', e)
raise RuntimeError('发送到 Discord 失败,稍后再试')
def render_template(template: str, query: str) -> str:
"""把消息模板中的 {query} 替换为 URL 参数(其余占位符保持原样)。"""
if not template:
return query
return template.replace('{query}', query)
def execute_action(action_config: dict, query: str) -> dict:
"""执行动作,返回 {success, message, discord_deeplink}。"""
config = action_config or {}
action_type = config.get('action_type', '')
if action_type == ACTION_DISCORD_SEND:
if not query.strip():
return {
'success': False,
'message': '链接缺少参数:请在别名后面加上你的问题(go/别名/问题)',
'discord_deeplink': '',
}
template = config.get('message_template', '@小黑 {query}')
message = render_template(template, query)
send_discord_message(message)
return {
'success': True,
'message': '已投递到 Discord,回复会出现在 #general',
'discord_deeplink': os.environ.get(
'DISCORD_DEEPLINK',
'https://discord.com/channels/1467846046590959798/1467846047089954952',
),
}
return {
'success': False,
'message': f'未知的动作类型: {action_type}',
'discord_deeplink': '',
}
+6 -22
View File
@@ -7,18 +7,20 @@ Ask view — 手机一键提问入口(方案 B:投递到 Discord)。
调用链:手机浏览器 → links Django → Discord Webhook#general)→ 小黑回复在 Discord。
Webhook URL 通过环境变量 DISCORD_WEBHOOK_URL 配置(K8s deployment 注入)。
页面同时提供「打开 Discord」按钮(discord:// deep link 直达 #general 频道)。
页面同时提供「打开 Discord」按钮(https universal link 直达 #general 频道)。
发送逻辑复用 links/actions.py(动作链接共用同一套 webhook 发送)。
"""
import logging
import os
import re
import requests
from django.http import JsonResponse
from django.shortcuts import render
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
logger = logging.getLogger(__name__)
DISCORD_BOT_ID = os.environ.get('DISCORD_BOT_ID', '1467845106831855796')
@@ -29,24 +31,6 @@ DISCORD_DEEPLINK = os.environ.get(
'https://discord.com/channels/1467846046590959798/1467846047089954952',
)
# 用 webhook 发消息(限制长度,Discord 单条消息上限 2000 字符)
MAX_MSG_LEN = 1900
def _post_to_discord(question: str) -> None:
"""通过 Discord webhook 把问题发到 #general 并 @小黑。"""
webhook_url = os.environ.get('DISCORD_WEBHOOK_URL', '').strip()
if not webhook_url:
raise RuntimeError('服务端未配置 DISCORD_WEBHOOK_URL,请先配置')
content = f'<@{DISCORD_BOT_ID}> {question}'[:MAX_MSG_LEN]
try:
resp = requests.post(webhook_url, json={'content': content}, timeout=15)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
logger.warning('[ASK] discord webhook failed: %s', e)
raise RuntimeError('发送到 Discord 失败,稍后再试')
def ask_page(request, question=None):
"""渲染提问页。question 可为 URL path 段或 ?q= 参数。"""
@@ -76,7 +60,7 @@ def ask_api(request):
return JsonResponse({'error': '问题太长了(最多 1900 字)'}, status=400)
try:
_post_to_discord(question)
send_discord_message(f'<@{DISCORD_BOT_ID}> {question}')
except Exception as e:
logger.warning('[ASK] post failed: %s', e)
return JsonResponse({'error': str(e)}, status=502)
+35 -1
View File
@@ -51,10 +51,24 @@ class TagInputField(forms.ModelMultipleChoiceField):
class LinkForm(forms.ModelForm):
text = SimpleMDEField()
# 动作链接的配置字段(不入模型,clean 时组装进 action_config JSON
action_type = forms.ChoiceField(
required=False,
choices=[('', _('选择动作')), ('discord_send', _('发 Discord 消息'))],
label=_('Action'),
widget=forms.Select(attrs={'class': 'shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'}),
)
action_message_template = forms.CharField(
required=False,
label=_('Message template'),
help_text=_('消息模板,{query} 会被链接 URL 中的参数替换。例如:@小黑 {query}'),
widget=forms.Textarea(attrs={'rows': 2, 'placeholder': '@小黑 {query}',
'class': 'shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'}),
)
class Meta:
model = Link
fields = ['alias', 'original_url', 'link_type', 'text', 'description', 'tags']
fields = ['alias', 'original_url', 'link_type', 'text', 'description', 'tags', 'action_config']
widgets = {
'link_type': forms.RadioSelect(),
'description': forms.Textarea(attrs={'rows': 3}),
@@ -71,6 +85,11 @@ class LinkForm(forms.ModelForm):
required=False,
widget=forms.SelectMultiple(attrs={'class': 'select2'}),
)
# 编辑已有 action 链接时,从 action_config 预填动作配置字段
if self.instance and self.instance.pk and self.instance.action_config:
cfg = self.instance.action_config
self.fields['action_type'].initial = cfg.get('action_type', '')
self.fields['action_message_template'].initial = cfg.get('message_template', '')
def clean_original_url(self):
url = self.cleaned_data.get('original_url')
@@ -104,11 +123,26 @@ class LinkForm(forms.ModelForm):
link_type = cleaned_data.get('link_type')
original_url = cleaned_data.get('original_url')
text = cleaned_data.get('text')
action_type = cleaned_data.get('action_type')
action_template = (cleaned_data.get('action_message_template') or '').strip()
if link_type == Link.LinkType.LINK and not original_url:
raise forms.ValidationError(_("Original URL is required for Link type."))
elif link_type == Link.LinkType.CUSTOM and not text:
raise forms.ValidationError(_("Text is required for Custom type."))
elif link_type == Link.LinkType.ACTION:
if not action_type:
raise forms.ValidationError(_("Please choose an action for Action type."))
if not action_template:
raise forms.ValidationError(_("Message template is required for Action type."))
# 组装 action_config JSON
cleaned_data['action_config'] = {
'action_type': action_type,
'message_template': action_template,
}
else:
# 非动作类型清空 action_config
cleaned_data['action_config'] = {}
return cleaned_data
@@ -0,0 +1,23 @@
# Generated by Django 5.2.12 on 2026-07-31 12:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0051_add_bookmark_import_dedup'),
]
operations = [
migrations.AddField(
model_name='link',
name='action_config',
field=models.JSONField(blank=True, default=dict, help_text="Action configuration (e.g. {'action_type': 'discord_send', 'message_template': '@小黑 {query}'})"),
),
migrations.AlterField(
model_name='link',
name='link_type',
field=models.CharField(choices=[('LINK', 'Link'), ('CUSTOM', 'Custom'), ('ACTION', 'Action')], default='LINK', max_length=10),
),
]
+6
View File
@@ -20,6 +20,7 @@ class Link(models.Model):
class LinkType(models.TextChoices):
LINK = 'LINK', _('Link')
CUSTOM = 'CUSTOM', _('Custom')
ACTION = 'ACTION', _('Action')
alias = models.SlugField(max_length=100, unique=True)
original_url = models.TextField(blank=True, null=True)
@@ -29,6 +30,11 @@ class Link(models.Model):
choices=LinkType.choices,
default=LinkType.LINK,
)
action_config = models.JSONField(
default=dict,
blank=True,
help_text=_("Action configuration (e.g. {'action_type': 'discord_send', 'message_template': '@小黑 {query}'})"),
)
click_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+58
View File
@@ -0,0 +1,58 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b0b12">
<title>{{ link.alias }} · 已执行</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
<style>
:root {
--bg: #0b0b12; --card: #16161f; --text: #ececf1; --muted: #9a9aa8;
--accent: #7c6cff; --accent-2: #4facfe; --danger: #ff6b6b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100dvh; padding: 24px; text-align: center;
}
.card {
background: var(--card); border: 1px solid rgba(255,255,255,.08); border-radius: 20px;
padding: 40px 28px; max-width: 420px; width: 100%;
animation: pop .3s ease;
}
@keyframes pop { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.icon { font-size: 52px; margin-bottom: 14px; }
.title { font-size: 19px; font-weight: 700; margin-bottom: 8px; }
.msg { color: var(--muted); font-size: 14px; line-height: 1.7; margin-bottom: 6px; word-break: break-word; }
.query {
display: inline-block; background: rgba(255,255,255,.07); border-radius: 10px;
padding: 8px 14px; font-size: 13.5px; color: var(--text); margin: 12px 0 22px; max-width: 100%;
word-break: break-word;
}
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
background: linear-gradient(135deg, #5865F2 0%, #7c6cff 100%); color: #fff;
text-decoration: none; font-weight: 600; font-size: 15px;
padding: 13px 24px; border-radius: 26px; width: 100%;
box-shadow: 0 4px 16px rgba(88,101,242,.35); transition: transform .12s;
}
.btn:active { transform: scale(.96); }
.btn.alt { background: rgba(255,255,255,.08); box-shadow: none; margin-top: 10px; color: var(--text); border: 1px solid rgba(255,255,255,.12); }
.err { color: var(--danger); }
</style>
</head>
<body>
<div class="card">
<div class="icon">{% if result.success %}✅{% else %}⚠️{% endif %}</div>
<div class="title {% if not result.success %}err{% endif %}">{{ result.message }}</div>
{% if query %}<div class="query">{{ query }}</div>{% endif %}
{% if result.success and result.discord_deeplink %}
<a class="btn" href="{{ result.discord_deeplink }}">📲 打开 Discord 看回复</a>
{% endif %}
<a class="btn alt" href="/">← 返回</a>
</div>
</body>
</html>
+76 -2
View File
@@ -43,6 +43,9 @@
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_custom %}text-blue-600 border-b-2 border-blue-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-gray-700 hover:border-gray-300 focus:outline-none" id="custom-tab">
{% trans "Custom" %}
</button>
<button type="button" class="py-2 px-4 text-sm font-medium text-center {% if is_action %}text-purple-600 border-b-2 border-purple-600{% else %}text-gray-500 border-b-2 border-transparent{% endif %} hover:text-purple-700 hover:border-purple-300 focus:outline-none" id="action-tab">
⚡ {% trans "Action" %}
</button>
</div>
</div>
@@ -65,16 +68,31 @@
{% trans "Example: https://google.com/search?q={query, default=hello}" %}
</p>
</div>
<div id="action-description" class="bg-purple-50 border-l-4 border-purple-400 p-4 mb-4 {% if not is_action %}hidden{% endif %}">
<p class="text-sm text-purple-700">
{% trans "Action type executes a predefined action when the link is opened (e.g. send a message to Discord). The text after the alias becomes {query} in the template." %}
</p>
<p class="text-sm text-purple-700 mt-2">
{% trans "Example: go/news/今天有什么新闻 → sends \"@小黑 今天有什么新闻\" to Discord." %}
</p>
</div>
<!-- Form -->
<form method="post" class="space-y-6">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
{% for error in form.non_field_errors %}
<p class="text-sm text-red-700">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<input type="hidden" name="link_type" id="id_link_type" value="{{ form.link_type.value|default:'LINK' }}">
{% for field in form %}
{% if field.name != 'link_type' %}
<!-- Render fields based on link type -->
<div class="space-y-1 {% if field.name == 'original_url' %}link-field{% if is_custom %}hidden{% endif %}{% elif field.name == 'text' %} custom-field {% if not is_custom %}hidden{% endif %}{% endif %}">
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 {% if field.name == 'original_url' and is_custom or field.name == 'text' and not is_custom %}hidden{% endif %} {% if field.name != 'description' %}required-field{% endif %}">
<div class="space-y-1 {% if field.name == 'original_url' %}link-field{% if is_custom %}hidden{% endif %}{% elif field.name == 'text' %} custom-field {% if not is_custom %}hidden{% endif %}{% elif field.name == 'action_type' or field.name == 'action_message_template' %}action-field{% if not is_action %}hidden{% endif %}{% endif %}">
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 {% if field.name == 'original_url' and is_custom or field.name == 'text' and not is_custom or field.name == 'action_type' and not is_action or field.name == 'action_message_template' and not is_action %}hidden{% endif %} {% if field.name != 'description' and field.name != 'action_type' and field.name != 'action_message_template' %}required-field{% endif %}">
{{ field.label }}
</label>
<div class="mt-1">
@@ -93,6 +111,11 @@
class="shadow appearance-none rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
{% if field.field.required %}required{% endif %}>
{% endif %}
{% elif field.name == 'action_type' or field.name == 'action_message_template' %}
<!-- Render action config fields only for action links -->
{% if is_action or is_new %}
{{ field }}
{% endif %}
{% elif field.name == 'alias' and form.instance.pk %}
<!-- Render alias field as read-only when editing -->
<input type="text"
@@ -171,10 +194,13 @@
document.addEventListener('DOMContentLoaded', function() {
const linkTab = document.getElementById('link-tab');
const customTab = document.getElementById('custom-tab');
const actionTab = document.getElementById('action-tab');
const linkFields = document.querySelectorAll('.link-field');
const customFields = document.querySelectorAll('.custom-field');
const actionFields = document.querySelectorAll('.action-field');
const linkDescription = document.getElementById('link-description');
const customDescription = document.getElementById('custom-description');
const actionDescription = document.getElementById('action-description');
const linkTypeInput = document.getElementById('id_link_type');
const textField = document.querySelector('.custom-field');
let simplemde = null;
@@ -184,6 +210,8 @@
linkTab.classList.remove('text-gray-500', 'border-transparent');
customTab.classList.remove('text-blue-600', 'border-blue-600');
customTab.classList.add('text-gray-500', 'border-transparent');
actionTab.classList.remove('text-purple-600', 'border-purple-600');
actionTab.classList.add('text-gray-500', 'border-transparent');
linkFields.forEach(field => {
field.classList.remove('hidden');
const label = field.querySelector('label');
@@ -194,8 +222,14 @@
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
actionFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
linkDescription.classList.remove('hidden');
customDescription.classList.add('hidden');
actionDescription.classList.add('hidden');
linkTypeInput.value = 'LINK';
textField.classList.add('hidden');
}
@@ -205,6 +239,8 @@
customTab.classList.remove('text-gray-500', 'border-transparent');
linkTab.classList.remove('text-blue-600', 'border-blue-600');
linkTab.classList.add('text-gray-500', 'border-transparent');
actionTab.classList.remove('text-purple-600', 'border-purple-600');
actionTab.classList.add('text-gray-500', 'border-transparent');
customFields.forEach(field => {
field.classList.remove('hidden');
const label = field.querySelector('label');
@@ -215,13 +251,48 @@
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
actionFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
customDescription.classList.remove('hidden');
linkDescription.classList.add('hidden');
actionDescription.classList.add('hidden');
linkTypeInput.value = 'CUSTOM';
textField.classList.remove('hidden');
initializeSimpleMDE();
}
function showActionFields() {
actionTab.classList.add('text-purple-600', 'border-purple-600');
actionTab.classList.remove('text-gray-500', 'border-transparent');
linkTab.classList.remove('text-blue-600', 'border-blue-600');
linkTab.classList.add('text-gray-500', 'border-transparent');
customTab.classList.remove('text-blue-600', 'border-blue-600');
customTab.classList.add('text-gray-500', 'border-transparent');
actionFields.forEach(field => {
field.classList.remove('hidden');
const label = field.querySelector('label');
if (label) label.classList.remove('hidden');
});
linkFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
customFields.forEach(field => {
field.classList.add('hidden');
const label = field.querySelector('label');
if (label) label.classList.add('hidden');
});
actionDescription.classList.remove('hidden');
linkDescription.classList.add('hidden');
customDescription.classList.add('hidden');
linkTypeInput.value = 'ACTION';
textField.classList.add('hidden');
}
function initializeSimpleMDE() {
if (!simplemde) {
var textArea = document.getElementById('id_text');
@@ -280,10 +351,13 @@
linkTab.addEventListener('click', showLinkFields);
customTab.addEventListener('click', showCustomFields);
actionTab.addEventListener('click', showActionFields);
// Initialize form state
if (linkTypeInput.value === 'CUSTOM') {
showCustomFields();
} else if (linkTypeInput.value === 'ACTION') {
showActionFields();
} else {
showLinkFields();
}
+20
View File
@@ -6,6 +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 django.core.cache import cache
import json
from django.core.serializers.json import DjangoJSONEncoder
@@ -166,6 +167,10 @@ class LinkCreateView(CreateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['is_new'] = True
# 新建表单:根据当前表单值判断(提交失败重渲染时保持选中状态)
form = context.get('form')
context['is_custom'] = bool(form and form['link_type'].value() == Link.LinkType.CUSTOM)
context['is_action'] = bool(form and form['link_type'].value() == Link.LinkType.ACTION)
return context
def form_valid(self, form):
@@ -229,6 +234,7 @@ class LinkUpdateView(UpdateView):
context = super().get_context_data(**kwargs)
link = self.get_object()
context['is_custom'] = link.link_type == Link.LinkType.CUSTOM
context['is_action'] = link.link_type == Link.LinkType.ACTION
return context
def form_valid(self, form):
@@ -338,6 +344,20 @@ def redirect_to_original(request, alias, param=None):
cache.set(click_key, 1, timeout=172800)
try:
# 动作链接(ACTION):执行动作(如发 Discord 消息)并显示结果页
if link.link_type == Link.LinkType.ACTION:
query = param or request.GET.get('q', '') or ''
try:
result = execute_action(link.action_config, query)
except Exception as e:
logger.warning('[ACTION] %s failed: %s', link.alias, e)
result = {'success': False, 'message': str(e), 'discord_deeplink': ''}
return render(request, 'links/action_result.html', {
'result': result,
'query': query,
'link': link,
})
# 如果是模板 URL 并且提供了参数
if param:
# 从 URL 中提取参数名
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""端到端测试:模拟用户在 UI 创建 ACTION 链接 → 访问链接 → 验证 Discord 发送 + 结果页。"""
import os
import sys
import re
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
os.environ['DISCORD_WEBHOOK_URL'] = os.environ.get('DISCORD_WEBHOOK_URL', '')
if not os.environ['DISCORD_WEBHOOK_URL']:
print('❌ 需要 DISCORD_WEBHOOK_URL 环境变量')
sys.exit(1)
django.setup()
from django.test import Client
from django.contrib.auth import get_user_model
from unittest.mock import patch
from links.models import Link
# 创建/获取测试用户并登录(links 管理页需要认证)
User = get_user_model()
if not User.objects.filter(username='e2e_tester').exists():
User.objects.create_user(username='e2e_tester', password='e2e_pass_123')
client = Client()
client.force_login(User.objects.get(username='e2e_tester'))
ALIAS = 'e2eask'
# 1. 打开创建页(links 的创建页在 /create/
resp = client.get('/create/')
assert resp.status_code == 200, f'创建页 {resp.status_code}'
html = resp.content.decode()
assert '⚡ Action' in html, '缺少 Action tab'
assert 'id="action-tab"' in html, '缺少 action-tab 元素'
print('✅ 1. 创建页加载,Action tab 存在')
# 2. 提取 csrf 并模拟表单提交(Action 类型)
m = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', html)
assert m, '找不到 csrf token'
csrf = m.group(1)
resp = client.post('/create/', {
'csrfmiddlewaretoken': csrf,
'alias': ALIAS,
'link_type': 'ACTION',
'action_type': 'discord_send',
'action_message_template': '@小黑 {query}',
'description': '端到端测试动作链接',
}, follow=True)
assert resp.status_code == 200, f'创建提交 {resp.status_code}'
link = Link.objects.get(alias=ALIAS)
assert link.link_type == 'ACTION', f'link_type={link.link_type}'
assert link.action_config == {'action_type': 'discord_send', 'message_template': '@小黑 {query}'}, link.action_config
print('✅ 2. 表单创建成功,action_config 正确保存:', link.action_config)
# 3. 编辑页预填验证
resp = client.get(f'/link/{link.pk}/edit/')
html = resp.content.decode()
assert 'value="discord_send"' in html or 'discord_send' in html, '编辑页未预填 action_type'
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(含 @小黑 你好墨尔本)')
# 5. 无参数访问 → 提示缺参数,不发送空消息
with patch('links.actions.send_discord_message') as mock_send:
resp = client.get(f'/{ALIAS}/')
assert resp.status_code == 200
assert '链接缺少参数' in resp.content.decode(), '缺少参数提示未出现'
mock_send.assert_not_called(), '空参数不应发送消息'
print('✅ 5. 无参数访问提示缺参数,不发送空消息')
# 6. 表单校验:ACTION 缺模板 → 报错
resp = client.post('/create/', {
'csrfmiddlewaretoken': csrf,
'alias': 'badaction',
'link_type': 'ACTION',
'action_type': 'discord_send',
'action_message_template': '',
}, follow=True)
assert 'Message template is required' in resp.content.decode(), '缺少模板未报错'
print('✅ 6. ACTION 缺模板正确报错')
# 7. 普通链接不受影响
resp = client.post('/create/', {
'csrfmiddlewaretoken': csrf,
'alias': 'e2eplain',
'link_type': 'LINK',
'original_url': 'https://example.com',
}, follow=True)
assert resp.status_code == 200
plain = Link.objects.get(alias='e2eplain')
assert plain.action_config == {}, f'普通链接 action_config 应为空: {plain.action_config}'
print('✅ 7. 普通链接创建正常,action_config 清空')
# 清理测试数据
Link.objects.filter(alias__in=['e2eask', 'e2eplain']).delete()
print('\n🎉 端到端测试全部通过!检查 Discord #general 应该有一条 @小黑 你好墨尔本')
+154
View File
@@ -0,0 +1,154 @@
"""
Tests for Action links (link_type=ACTION, e.g. send-to-Discord).
Covers: model persistence, form validation/assembly, and the
redirect_to_original execution path (with mocked webhook).
"""
import pytest
from django.test import Client
from unittest.mock import patch
from links.models import Link
from links.forms import LinkForm
from links.actions import render_template, execute_action
from links import views
@pytest.fixture
def action_link(db):
return Link.objects.create(
alias='testaction',
link_type=Link.LinkType.ACTION,
action_config={
'action_type': 'discord_send',
'message_template': '@小黑 {query}',
},
)
# ── render_template ──────────────────────────────────────────────────────────
class TestRenderTemplate:
def test_replaces_query_placeholder(self):
assert render_template('@小黑 {query}', '今天天气') == '@小黑 今天天气'
def test_empty_template_returns_query(self):
assert render_template('', 'hello') == 'hello'
def test_template_without_placeholder_keeps_query_appended(self):
# 模板里没有 {query} 时,至少保留 query 内容(用默认拼接)
assert render_template('@小黑', 'hi') == '@小黑'
# ── execute_action ────────────────────────────────────────────────────────────
class TestExecuteAction:
@patch('links.actions.send_discord_message')
def test_discord_send_success(self, mock_send):
result = execute_action(
{'action_type': 'discord_send', 'message_template': '@小黑 {query}'},
'帮我查天气',
)
mock_send.assert_called_once_with('@小黑 帮我查天气')
assert result['success'] is True
assert 'discord_deeplink' in result
@patch('links.actions.send_discord_message')
def test_unknown_action_type(self, mock_send):
result = execute_action({'action_type': 'teleport'}, 'x')
mock_send.assert_not_called()
assert result['success'] is False
@patch('links.actions.send_discord_message')
def test_empty_query_does_not_send(self, mock_send):
result = execute_action(
{'action_type': 'discord_send', 'message_template': '@小黑 {query}'},
' ',
)
mock_send.assert_not_called()
assert result['success'] is False
assert '缺少参数' in result['message']
@patch('links.actions.send_discord_message', side_effect=RuntimeError('boom'))
def test_webhook_failure_propagates(self, mock_send):
with pytest.raises(RuntimeError):
execute_action({'action_type': 'discord_send', 'message_template': '{query}'}, 'x')
# ── LinkForm validation ───────────────────────────────────────────────────────
class TestLinkFormActionValidation:
def _form(self, **overrides):
data = {
'alias': 'formaction',
'link_type': Link.LinkType.ACTION,
'action_type': 'discord_send',
'action_message_template': '@小黑 {query}',
'description': '',
}
data.update(overrides)
return LinkForm(data)
def test_valid_action_form_assembles_config(self, db):
form = self._form()
assert form.is_valid(), form.errors
assert form.cleaned_data['action_config'] == {
'action_type': 'discord_send',
'message_template': '@小黑 {query}',
}
def test_action_requires_type(self, db):
form = self._form(action_type='')
assert not form.is_valid()
assert 'Please choose an action' in str(form.errors)
def test_action_requires_template(self, db):
form = self._form(action_message_template='')
assert not form.is_valid()
assert 'Message template is required' in str(form.errors)
def test_link_type_clears_action_config(self, db):
form = self._form(link_type=Link.LinkType.LINK, original_url='https://example.com')
assert form.is_valid(), form.errors
assert form.cleaned_data['action_config'] == {}
def test_link_type_requires_url(self, db):
form = self._form(link_type=Link.LinkType.LINK, original_url='')
assert not form.is_valid()
assert 'Original URL is required' in str(form.errors)
# ── redirect_to_original execution ────────────────────────────────────────────
class TestRedirectAction:
def test_action_link_executes_and_renders_result(self, action_link):
client = Client()
with patch('links.actions.send_discord_message') as mock_send:
resp = client.get(f'/testaction/今天有什么新闻/')
assert resp.status_code == 200
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
mock_send.assert_called_once_with('@小黑 用查询参数提问')
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')
client = Client()
with patch('links.actions.requests.post', side_effect=req_lib.exceptions.RequestException('connection refused')):
resp = client.get('/testaction/测试/')
assert resp.status_code == 200
assert '发送到 Discord 失败' 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')
client = Client()
resp = client.get('/plainlink/')
assert resp.status_code in (301, 302)
assert resp['Location'] == 'https://example.com/path'