diff --git a/links/actions.py b/links/actions.py new file mode 100644 index 0000000..7039eb4 --- /dev/null +++ b/links/actions.py @@ -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': '', + } diff --git a/links/ask_views.py b/links/ask_views.py index 392b71c..696b28d 100644 --- a/links/ask_views.py +++ b/links/ask_views.py @@ -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) diff --git a/links/forms.py b/links/forms.py index 5079336..f9702c2 100644 --- a/links/forms.py +++ b/links/forms.py @@ -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 diff --git a/links/migrations/0052_link_action_config_alter_link_link_type.py b/links/migrations/0052_link_action_config_alter_link_link_type.py new file mode 100644 index 0000000..2e3881a --- /dev/null +++ b/links/migrations/0052_link_action_config_alter_link_link_type.py @@ -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), + ), + ] diff --git a/links/models.py b/links/models.py index 33ce6e3..dffde38 100644 --- a/links/models.py +++ b/links/models.py @@ -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) diff --git a/links/templates/links/action_result.html b/links/templates/links/action_result.html new file mode 100644 index 0000000..4f32f78 --- /dev/null +++ b/links/templates/links/action_result.html @@ -0,0 +1,58 @@ +{% load static %} + + + + + + + {{ link.alias }} · 已执行 + + + + +
+
{% if result.success %}✅{% else %}⚠️{% endif %}
+
{{ result.message }}
+ {% if query %}
{{ query }}
{% endif %} + {% if result.success and result.discord_deeplink %} + 📲 打开 Discord 看回复 + {% endif %} + ← 返回 +
+ + diff --git a/links/templates/links/link_form.html b/links/templates/links/link_form.html index 5c240df..bddd08b 100644 --- a/links/templates/links/link_form.html +++ b/links/templates/links/link_form.html @@ -43,6 +43,9 @@ + @@ -65,16 +68,31 @@ {% trans "Example: https://google.com/search?q={query, default=hello}" %}

+
+

+ {% 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." %} +

+

+ {% trans "Example: go/news/今天有什么新闻 → sends \"@小黑 今天有什么新闻\" to Discord." %} +

+
{% csrf_token %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} {% for field in form %} {% if field.name != 'link_type' %} -