+
{{ field.label }}
@@ -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' %}
+
+ {% if is_action or is_new %}
+ {{ field }}
+ {% endif %}
{% elif field.name == 'alias' and form.instance.pk %}
{
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();
}
diff --git a/links/views.py b/links/views.py
index 1187102..97ff49d 100644
--- a/links/views.py
+++ b/links/views.py
@@ -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 中提取参数名
diff --git a/scripts/e2e_action_links.py b/scripts/e2e_action_links.py
new file mode 100644
index 0000000..6d9a9cc
--- /dev/null
+++ b/scripts/e2e_action_links.py
@@ -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 应该有一条 @小黑 你好墨尔本')
diff --git a/tests/test_actions.py b/tests/test_actions.py
new file mode 100644
index 0000000..c7a042a
--- /dev/null
+++ b/tests/test_actions.py
@@ -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'