mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
feat: add Action link type — configurable send-to-Discord links with UI form
This commit is contained in:
@@ -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
@@ -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
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 中提取参数名
|
||||
|
||||
@@ -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 应该有一条 @小黑 你好墨尔本')
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user