refactor: make Ask a regular ACTION link instead of fixed route

Remove hardcoded /ask/ routes (core/urls.py), /api/ask/ (api_urls.py),
ask_views.py, and ask.html input page. Ask is now a normal Link record
(alias='ask', link_type=ACTION) created by data migration 0053, so it
behaves exactly like other action links: go/ask/问题 → catch-all →
execute_action → 302 to Discord, editable/deletable in the Links UI.

Tests updated: ask covered as ACTION link (redirect/dedup/empty-query).
This commit is contained in:
OpenClaw Sub-agent
2026-08-01 20:43:29 +10:00
parent 50b5c8ef9c
commit f2f8192fba
6 changed files with 74 additions and 500 deletions
-5
View File
@@ -7,7 +7,6 @@ from django.views.generic import TemplateView
from django.http import FileResponse, Http404
from links.views import LinkDetailView, LinkUpdateView, CustomLinkView
from links.file_views import PublicFileView, import_image_view
from links.ask_views import ask_page as ask_page_view
from django.urls import path, include, re_path
from django.conf.urls.i18n import i18n_patterns
import os
@@ -61,10 +60,6 @@ urlpatterns = [
# JBOT AI Robot Face SPA
path('jbot/', include('jbot.urls')),
# Ask — 手机一键提问(必须在 links.urls 的 alias catch-all 之前)
path('ask/', ask_page_view, name='ask-page'),
path('ask/<str:question>/', ask_page_view, name='ask-page-q'),
# Import external image by URL — /import/images/<path:image_url> (also plural alias)
path('import/images/<path:image_url>', import_image_view, name='import-image'),
path('imports/images/<path:image_url>', import_image_view, name='imports-image'),
-2
View File
@@ -5,7 +5,6 @@ from . import post_views
from . import api_views
from . import file_views
from . import bookmark_views
from . import ask_views
# Create a router and register our viewsets with it
router = DefaultRouter(trailing_slash=False)
@@ -19,7 +18,6 @@ router.register('bookmarks', bookmark_views.BookmarkViewSet, basename='api-bookm
# The API URLs are determined automatically by the router
urlpatterns = [
path('', include(router.urls)),
path('ask/', ask_views.ask_api, name='api-ask'),
path('images/', include('links.image_urls')),
]
-101
View File
@@ -1,101 +0,0 @@
"""
Ask view — 手机一键提问入口(方案 B:投递到 Discord)。
用户打开 http://go/ask/我的问题 即可把问题直接发到 Discord #general 并 @小黑,
小黑在 Discord 里原生回复(含工具调用过程、长文排版),无需打开 Discord 操作。
调用链:手机浏览器 → links Django → Discord Webhook#general)→ 小黑回复在 Discord。
Webhook URL 通过环境变量 DISCORD_WEBHOOK_URL 配置(K8s deployment 注入)。
页面同时提供「打开 Discord」按钮(https universal link 直达 #general 频道)。
发送逻辑复用 links/actions.py(动作链接共用同一套 webhook 发送)。
"""
import logging
import os
from django.http import JsonResponse
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, execute_action, make_dedup_key, send_discord_message
logger = logging.getLogger(__name__)
DISCORD_BOT_ID = os.environ.get('DISCORD_BOT_ID', '1467845106831855796')
# 用 https universal link(手机上唤起 APP,桌面上打开网页版),
# 比 discord:// custom scheme 兼容性更好(后者在桌面浏览器不可用)。
DISCORD_DEEPLINK = os.environ.get(
'DISCORD_DEEPLINK',
'https://discord.com/channels/1467846046590959798/1467846047089954952',
)
def ask_page(request, question=None):
"""提问入口。
- 带问题(/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': '',
'discord_deeplink': DISCORD_DEEPLINK,
})
@csrf_exempt
@require_POST
def ask_api(request):
"""POST /api/ask/ body: {"q": "问题"} → {"text": "提示文案"}"""
import json as _json
try:
data = _json.loads(request.body or b'{}')
except Exception:
return JsonResponse({'error': '无效的请求体'}, status=400)
question = (data.get('q') or '').strip()
if not question:
return JsonResponse({'error': '问题不能为空'}, status=400)
if len(question) > MAX_MSG_LEN:
return JsonResponse({'error': '问题太长了(最多 1900 字)'}, status=400)
try:
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'})
@@ -0,0 +1,44 @@
"""
Ask 已从固定路由改为普通 ACTION Linkalias='ask')。
访问 go/ask/问题 → alias catch-all → redirect_to_original → ACTION 分支
→ execute_action(discord_send) → 302 跳转 Discord。与用户手动创建的
动作链接行为完全一致,可在 Links 界面编辑/删除。
message_template 使用 <@{bot_id}> 让 Discord 消息 @小黑,bot_id 与
原 ask_views 默认值一致(1467845106831855796)。
"""
from django.db import migrations
DISCORD_BOT_ID = '1467845106831855796'
def create_ask_action_link(apps, schema_editor):
Link = apps.get_model('links', 'Link')
Link.objects.get_or_create(
alias='ask',
defaults={
'link_type': 'ACTION',
'action_config': {
'action_type': 'discord_send',
'message_template': f'<@{DISCORD_BOT_ID}> {{query}}',
},
'description': '一键提问小黑:go/ask/问题(发到 Discord #general',
},
)
def remove_ask_action_link(apps, schema_editor):
Link = apps.get_model('links', 'Link')
Link.objects.filter(alias='ask', link_type='ACTION').delete()
class Migration(migrations.Migration):
dependencies = [
('links', '0052_link_action_config_alter_link_link_type'),
]
operations = [
migrations.RunPython(create_ask_action_link, remove_ask_action_link),
]
-386
View File
@@ -1,386 +0,0 @@
{% 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>问小黑</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;
--card-2: #1d1d29;
--text: #ececf1;
--muted: #9a9aa8;
--accent: #7c6cff;
--accent-2: #4facfe;
--bubble-user: linear-gradient(135deg, #7c6cff 0%, #4facfe 100%);
--danger: #ff6b6b;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
display: flex;
flex-direction: column;
max-width: 640px;
margin: 0 auto;
height: 100dvh;
}
header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid rgba(255,255,255,.06);
background: rgba(11,11,18,.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 10;
}
.avatar {
width: 38px; height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 18px; color: #fff;
box-shadow: 0 4px 14px rgba(124,108,255,.35);
flex-shrink: 0;
}
.title { font-size: 16px; font-weight: 700; letter-spacing: .3px; }
.subtitle { font-size: 12px; color: var(--muted); margin-top: 1px; }
.dot { width: 5px; height: 5px; border-radius: 50%; background: #3ddc84; display: inline-block; margin-right: 5px; }
main {
flex: 1;
overflow-y: auto;
padding: 18px 16px 12px;
display: flex;
flex-direction: column;
gap: 14px;
scroll-behavior: smooth;
}
.bubble {
max-width: 86%;
padding: 11px 14px;
border-radius: 16px;
font-size: 15px;
line-height: 1.65;
word-break: break-word;
animation: pop .22s ease;
}
@keyframes pop { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.bubble.user {
align-self: flex-end;
background: var(--bubble-user);
color: #fff;
border-bottom-right-radius: 5px;
font-weight: 500;
}
.bubble.bot {
align-self: flex-start;
background: var(--card);
border: 1px solid rgba(255,255,255,.07);
border-bottom-left-radius: 5px;
}
.bubble.bot pre {
background: #0d0d16;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
padding: 10px 12px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
margin: 8px 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.bubble.bot code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 5px; }
.bubble.bot pre code { background: none; padding: 0; }
.bubble.bot a { color: var(--accent-2); text-decoration: underline; }
.bubble.bot strong { color: #fff; }
.bubble.bot h1, .bubble.bot h2, .bubble.bot h3 { font-size: 15px; margin: 8px 0 4px; color: #fff; }
.bubble.bot ul, .bubble.bot ol { padding-left: 20px; margin: 6px 0; }
.bubble.bot blockquote { border-left: 3px solid var(--accent); padding-left: 10px; color: var(--muted); margin: 6px 0; }
.bubble.bot hr { border: none; border-top: 1px solid rgba(255,255,255,.1); margin: 10px 0; }
.bubble.bot table { border-collapse: collapse; margin: 8px 0; font-size: 13px; }
.bubble.bot th, .bubble.bot td { border: 1px solid rgba(255,255,255,.15); padding: 5px 9px; }
.typing { display: inline-flex; gap: 5px; align-items: center; padding: 4px 2px; }
.typing span {
width: 7px; height: 7px; border-radius: 50%;
background: var(--muted);
animation: blink 1.2s infinite;
}
.typing span:nth-child(2) { animation-delay: .2s; }
.typing span:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%, 80%, 100% { opacity: .25; transform: scale(.85); } 40% { opacity: 1; transform: scale(1); } }
.welcome {
text-align: center;
color: var(--muted);
font-size: 14px;
padding: 60px 24px 30px;
line-height: 1.8;
}
.welcome .big { font-size: 40px; margin-bottom: 12px; }
.welcome .hint { background: var(--card); border: 1px solid rgba(255,255,255,.07); border-radius: 10px; padding: 8px 14px; display: inline-block; font-size: 12.5px; color: var(--muted); margin-top: 16px; }
.error-banner {
align-self: stretch;
background: rgba(255,107,107,.1);
border: 1px solid rgba(255,107,107,.3);
color: var(--danger);
border-radius: 12px;
padding: 12px 14px;
font-size: 14px;
line-height: 1.6;
}
footer {
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255,255,255,.06);
background: rgba(11,11,18,.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
gap: 10px;
align-items: flex-end;
}
#input {
flex: 1;
background: var(--card);
border: 1px solid rgba(255,255,255,.1);
border-radius: 22px;
padding: 11px 16px;
color: var(--text);
font-size: 15px;
line-height: 1.4;
resize: none;
outline: none;
max-height: 120px;
font-family: inherit;
transition: border-color .2s;
}
#input:focus { border-color: var(--accent); }
#input::placeholder { color: #5c5c6e; }
#send {
width: 42px; height: 42px;
border-radius: 50%;
border: none;
background: var(--bubble-user);
color: #fff;
font-size: 17px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 14px rgba(124,108,255,.35);
transition: transform .12s, opacity .2s;
flex-shrink: 0;
}
#send:active { transform: scale(.92); }
#send:disabled { opacity: .45; box-shadow: none; }
.retry {
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.15);
color: var(--text);
border-radius: 18px;
padding: 6px 16px;
font-size: 13px;
cursor: pointer;
margin-top: 8px;
}
.open-discord {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 10px;
align-self: flex-start;
background: linear-gradient(135deg, #5865F2 0%, #7c6cff 100%);
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 14.5px;
padding: 11px 20px;
border-radius: 24px;
box-shadow: 0 4px 16px rgba(88,101,242,.35);
animation: pop .25s ease;
}
.open-discord:active { transform: scale(.96); }
::-webkit-scrollbar { width: 0; height: 0; }
</style>
</head>
<body>
<header>
<div class="avatar"></div>
<div>
<div class="title">问小黑</div>
<div class="subtitle"><span class="dot"></span>Hermes Agent · 在线</div>
</div>
</header>
<main id="main">
<div class="welcome" id="welcome">
<div class="big">🖤</div>
直接输入问题,小黑马上回答。<br>
比如「今天墨尔本天气怎么样」
<div class="hint">也可以直接在链接后加问题:<br>go/ask/你的问题</div>
</div>
</main>
<footer>
<textarea id="input" rows="1" placeholder="问小黑点什么…" enterkeyhint="send"></textarea>
<button id="send" title="发送"></button>
</footer>
<script>
const main = document.getElementById('main');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
const welcome = document.getElementById('welcome');
const initialQuestion = "{{ initial_question|escapejs }}";
const discordDeepLink = "{{ discord_deeplink|escapejs }}";
// ── 极简 markdown 渲染(转义后处理换行/粗体/代码/链接)──
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function mdToHtml(md) {
let out = '';
const lines = md.replace(/\r\n/g, '\n').split('\n');
let inCode = false, codeBuf = [];
const flushCode = () => {
if (codeBuf.length) {
out += '<pre><code>' + escapeHtml(codeBuf.join('\n')) + '</code></pre>';
codeBuf = [];
}
};
for (const raw of lines) {
const t = raw.trim();
if (t.startsWith('```')) {
if (inCode) { flushCode(); inCode = false; }
else { flushCode(); inCode = true; }
continue;
}
if (inCode) { codeBuf.push(raw); continue; }
if (!raw.trim()) { out += '<br>'; continue; }
if (/^#{1,6}\s/.test(raw)) {
const lvl = raw.match(/^#+/)[0].length;
out += `<h${Math.min(lvl,3)}>${inline(raw.replace(/^#+\s*/, ''))}</h${Math.min(lvl,3)}>`;
continue;
}
if (/^[-*]\s+/.test(raw)) { out += `<li>${inline(raw.replace(/^[-*]\s+/, ''))}</li>`; continue; }
if (/^\d+\.\s+/.test(raw)) { out += `<li>${inline(raw.replace(/^\d+\.\s+/, ''))}</li>`; continue; }
if (/^>\s?/.test(raw)) { out += `<blockquote>${inline(raw.replace(/^>\s?/, ''))}</blockquote>`; continue; }
if (/^---+$/.test(raw)) { out += '<hr>'; continue; }
if (/^\|.*\|$/.test(raw)) { out += `<div>${inline(raw)}</div>`; continue; }
out += `<p>${inline(raw)}</p>`;
}
flushCode();
return out;
}
function inline(s) {
let h = escapeHtml(s);
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
return h;
}
function addBubble(text, who) {
welcome && welcome.remove();
const div = document.createElement('div');
div.className = 'bubble ' + who;
if (who === 'bot') div.innerHTML = mdToHtml(text);
else div.textContent = text;
main.appendChild(div);
main.scrollTop = main.scrollHeight;
return div;
}
function addTyping() {
const div = document.createElement('div');
div.className = 'bubble bot';
div.innerHTML = '<div class="typing"><span></span><span></span><span></span></div>';
main.appendChild(div);
main.scrollTop = main.scrollHeight;
return div;
}
function addError(msg) {
const div = document.createElement('div');
div.className = 'error-banner';
div.textContent = '⚠️ ' + msg;
main.appendChild(div);
main.scrollTop = main.scrollHeight;
return div;
}
function addDiscordButton() {
const a = document.createElement('a');
a.className = 'open-discord';
a.href = discordDeepLink;
a.textContent = '📲 打开 Discord 看回复';
main.appendChild(a);
main.scrollTop = main.scrollHeight;
return a;
}
let busy = false;
async function ask(question) {
if (busy) return;
busy = true;
sendBtn.disabled = true;
input.value = '';
autoResize();
addBubble(question, 'user');
const typing = addTyping();
try {
const resp = await fetch('/api/ask/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: question }),
});
const data = await resp.json();
typing.remove();
if (!resp.ok || data.error) {
addError(data.error || '请求失败,请重试');
} else {
addBubble(data.text, 'bot');
addDiscordButton();
}
} catch (e) {
typing.remove();
addError('网络错误,请检查链接后重试');
} finally {
busy = false;
sendBtn.disabled = false;
input.focus();
}
}
function autoResize() {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
}
input.addEventListener('input', autoResize);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const q = input.value.trim();
if (q && !busy) ask(q);
}
});
sendBtn.addEventListener('click', () => {
const q = input.value.trim();
if (q && !busy) ask(q);
});
// 从 URL 读取问题(?q= 或 path 参数)
let autoQ = initialQuestion || '';
if (!autoQ) {
const params = new URLSearchParams(location.search);
autoQ = (params.get('q') || '').trim();
}
if (autoQ) ask(autoQ);
</script>
</body>
</html>
+30 -6
View File
@@ -174,10 +174,29 @@ class TestRedirectAction:
assert resp['Location'] == 'https://example.com/path'
# ── /ask/ 路由(带问题 = 发送+跳转;不带 = 输入页)─────────────────────────
# ── ask 作为普通 ACTION Linkalias='ask',非固定路由)────────────────────────
class TestAskRoute:
def test_ask_with_question_redirects(self, monkeypatch):
class TestAskActionLink:
"""Ask 不再有固定路由:它是一条 link_type=ACTION 的普通 Linkalias='ask'),
由 data migration 0053 创建。访问 go/ask/问题 走 alias catch-all →
redirect_to_original → ACTION 分支,与其他动作链接行为一致。"""
def _create_ask_link(self, db):
# data migration 0053 已创建 alias='ask';测试用 get_or_create 复用
link, _ = Link.objects.get_or_create(
alias='ask',
defaults={
'link_type': Link.LinkType.ACTION,
'action_config': {
'action_type': 'discord_send',
'message_template': '<@1467845106831855796> {query}',
},
},
)
return link
def test_ask_with_question_redirects(self, db, monkeypatch):
self._create_ask_link(db)
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
client = Client()
with patch('links.actions.requests.post') as mock_post:
@@ -186,8 +205,10 @@ class TestAskRoute:
assert resp['Location'].startswith('https://discord.com/channels/')
body = mock_post.call_args.kwargs['json']['content']
assert '今天有什么新闻' in body
assert '<@1467845106831855796>' in body
def test_ask_with_question_dedup(self, monkeypatch):
def test_ask_with_question_dedup(self, db, monkeypatch):
self._create_ask_link(db)
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
client = Client()
with patch('links.actions.requests.post') as mock_post:
@@ -195,8 +216,11 @@ class TestAskRoute:
client.get('/ask/相同问题/')
assert mock_post.call_count == 1
def test_ask_empty_renders_input_page(self):
def test_ask_empty_query_renders_error_not_input_page(self, db):
self._create_ask_link(db)
client = Client()
resp = client.get('/ask/')
# 无参数时 ACTION 链接渲染错误提示页(不再有 ask.html 输入页)
assert resp.status_code == 200
assert '问小黑' in resp.content.decode()
assert '缺少参数' in resp.content.decode()
assert 'ask.html' not in resp.content.decode()