// MessageBuilder — a visual OneBot message composer so you never hand-write a // segment array. Common segments are first-class (text / @ / image / face / // reply / record / video); advanced ones fold away (file / mface / poke / json // / xml / markdown / forward-node). `node` nests a full builder (merged // forward). Rows reorder by drag. Emits the live OneBot segment array up. import { useState } from 'react'; import { AtSign, ChevronDown, Code2, FileText, Film, Forward, GripVertical, Hand, Image as ImageIcon, Mic, Plus, Quote, Smile, Sticker, Trash2, Type, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Picker } from '@/components/ui/picker'; import { FaceGrid } from '@/components/ui/face-grid'; import { FileSource } from '@/components/debug/file-source'; import { useGroupMembers } from '@/hooks/use-debug-contacts'; // ── segment model ── // `_id` is a stable React key tied to a segment's identity (so editing one field // doesn't remount the row and lose focus / inner control state); it is NOT // serialised. SegBody is the discriminated union; Seg adds the id. type SegBody = | { k: 'text'; text: string } | { k: 'at'; qq: string } | { k: 'image'; file: string } | { k: 'face'; id: string } | { k: 'reply'; id: string } | { k: 'record'; file: string } | { k: 'video'; file: string } | { k: 'file'; file: string; name: string } | { k: 'mface'; emoji_id: string; emoji_package_id: string; key: string; summary: string } | { k: 'poke'; type: string } | { k: 'json'; data: string } | { k: 'xml'; data: string } | { k: 'markdown'; content: string } | { k: 'node'; user_id: string; nickname: string; content: Seg[] }; export type Seg = SegBody & { _id: string }; export type MessageBuilderMode = 'auto' | 'message' | 'forward' | 'node-content'; let segSeq = 0; const nid = () => `s${segSeq++}`; const BLANK: Record SegBody> = { text: () => ({ k: 'text', text: '' }), at: () => ({ k: 'at', qq: '' }), image: () => ({ k: 'image', file: '' }), face: () => ({ k: 'face', id: '' }), reply: () => ({ k: 'reply', id: '' }), record: () => ({ k: 'record', file: '' }), video: () => ({ k: 'video', file: '' }), file: () => ({ k: 'file', file: '', name: '' }), mface: () => ({ k: 'mface', emoji_id: '', emoji_package_id: '', key: '', summary: '' }), poke: () => ({ k: 'poke', type: '1' }), json: () => ({ k: 'json', data: '' }), xml: () => ({ k: 'xml', data: '' }), markdown: () => ({ k: 'markdown', content: '' }), node: () => ({ k: 'node', user_id: '', nickname: '', content: [] }), }; /** A fresh segment with a stable id. */ export function newSeg(k: SegBody['k']): Seg { return { ...BLANK[k](), _id: nid() } as Seg; } /** Serialise the builder model to a OneBot v11 segment array (drops `_id`). */ export function toOneBot(segs: Seg[]): Array<{ type: string; data: Record }> { return segs.map((s): { type: string; data: Record } => { switch (s.k) { case 'text': return { type: 'text', data: { text: s.text } }; case 'at': return { type: 'at', data: { qq: s.qq } }; case 'image': return { type: 'image', data: { file: s.file } }; case 'face': return { type: 'face', data: { id: Number(s.id) || 0 } }; case 'reply': return { type: 'reply', data: { id: Number(s.id) || 0 } }; case 'record': return { type: 'record', data: { file: s.file } }; case 'video': return { type: 'video', data: { file: s.file } }; case 'file': return { type: 'file', data: { file: s.file, name: s.name } }; case 'mface': return { type: 'mface', data: { emoji_id: s.emoji_id, emoji_package_id: s.emoji_package_id, key: s.key, summary: s.summary, }, }; case 'poke': return { type: 'poke', data: { type: Number(s.type) || 1 } }; case 'json': return { type: 'json', data: { data: s.data } }; case 'xml': return { type: 'xml', data: { data: s.data } }; case 'markdown': return { type: 'markdown', data: { content: s.content } }; case 'node': return { type: 'node', data: { user_id: Number(s.user_id) || 0, nickname: s.nickname, content: toOneBot(s.content), }, }; } }); } const COMMON: { k: SegBody['k']; label: string; icon: React.ReactNode }[] = [ { k: 'text', label: '文本', icon: }, { k: 'at', label: '@', icon: }, { k: 'image', label: '图片', icon: }, { k: 'face', label: '表情', icon: }, { k: 'reply', label: '回复', icon: }, { k: 'record', label: '语音', icon: }, { k: 'video', label: '视频', icon: }, ]; const ADVANCED: { k: SegBody['k']; label: string; icon: React.ReactNode }[] = [ { k: 'file', label: '文件', icon: }, { k: 'mface', label: '商城表情', icon: }, { k: 'poke', label: '戳一戳', icon: }, { k: 'json', label: 'JSON 卡片', icon: }, { k: 'xml', label: 'XML 卡片', icon: }, { k: 'markdown', label: 'Markdown', icon: }, { k: 'node', label: '合并转发节点', icon: }, ]; /** Segment kinds exposed by the add menu for a builder context. */ export function messageBuilderSegmentKinds( mode: MessageBuilderMode, segments: Seg[], ): SegBody['k'][] { const all = [...COMMON, ...ADVANCED].map((item) => item.k); if (mode === 'auto') { return all; } if (mode === 'message') { return all.filter((kind) => kind !== 'node'); } if (mode === 'forward') { return ['node']; } // A forward node may contain either an ordinary message or another list of // nodes, never a mixture. Window shake is also rejected anywhere inside a // forward payload. Lock the menu to the branch selected by the first child. const forwardSafe = all.filter((kind) => kind !== 'poke'); if (segments.length === 0) { return forwardSafe; } if (segments.every((segment) => segment.k === 'node')) { return ['node']; } if (segments.every((segment) => segment.k !== 'node')) { return forwardSafe.filter((kind) => kind !== 'node'); } return []; } const LABELS: Record = { text: '文本', at: '@', image: '图片', face: '表情', reply: '回复', record: '语音', video: '视频', file: '文件', mface: '商城表情', poke: '戳一戳', json: 'JSON', xml: 'XML', markdown: 'Markdown', node: '转发节点', }; export function MessageBuilder({ segments, onChange, uin, groupId, depth = 0, mode = 'auto', }: { segments: Seg[]; onChange: (segs: Seg[]) => void; uin: string; /** group context for @ member picking (empty for private targets). */ groupId: string; depth?: number; mode?: MessageBuilderMode; }) { const [showAdd, setShowAdd] = useState(false); const [dragFrom, setDragFrom] = useState(null); const add = (k: SegBody['k']) => { onChange([...segments, newSeg(k)]); setShowAdd(false); }; const patch = (i: number, next: Seg) => onChange(segments.map((s, j) => (j === i ? next : s))); const remove = (i: number) => onChange(segments.filter((_, j) => j !== i)); const move = (from: number, to: number) => { if (to < 0 || to >= segments.length || from === to) { return; } const next = segments.slice(); const [it] = next.splice(from, 1); next.splice(to, 0, it); onChange(next); }; // Keep the top-level canvas concentric with its rows at any operator-selected // corner radius. At the default 12px radius this is 20px → 12px over the 8px // inset. Nested forward builders deliberately avoid another canvas surface: // cards-inside-cards add visual noise and cannot remain concentric with a // parent row once the operator selects a compact radius. const nested = depth > 0; const canvasRadius = 'calc(var(--radius) + 0.5rem)'; const rowRadius = depth === 0 ? 'var(--radius)' : depth === 1 ? 'max(0px, calc(var(--radius) - 0.25rem))' : 'max(0px, calc(var(--radius) - 0.5rem))'; const allowedKinds = new Set(messageBuilderSegmentKinds(mode, segments)); const commonOptions = COMMON.filter((item) => allowedKinds.has(item.k)); const advancedOptions = ADVANCED.filter((item) => allowedKinds.has(item.k)); const primaryOptions = mode === 'forward' ? advancedOptions : commonOptions; const secondaryOptions = mode === 'forward' ? [] : advancedOptions; return ( // The root is a bounded compose canvas, so its empty hint reads as an // intentional empty state rather than a stray line. Nested builders inherit // the surrounding node surface instead of drawing another card within it.
{segments.length === 0 && (

{mode === 'forward' ? '空转发 — 点下方添加转发节点' : '空消息 — 点下方「添加段」开始'}

)} {segments.map((s, i) => (
e.preventDefault()} onDrop={(e) => { e.stopPropagation(); if (dragFrom !== null) { move(dragFrom, i); } setDragFrom(null); }} className="border border-border/50 bg-card/60 p-2" style={{ borderRadius: rowRadius }} > {/* Drag is scoped to the grip handle only — making the whole row draggable hijacked text selection inside the segment inputs (you couldn't select part of a pasted URL without dragging the card). The row stays the drop target; its body is fully selectable. */}
{ e.stopPropagation(); e.dataTransfer.setData('text/plain', String(i)); e.dataTransfer.effectAllowed = 'move'; setDragFrom(i); }} onDragEnd={() => setDragFrom(null)} title="拖动此处排序" aria-label="拖动排序" className="flex shrink-0 cursor-grab rounded-md p-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground active:cursor-grabbing" > {LABELS[s.k]}
patch(i, n)} uin={uin} groupId={groupId} depth={depth} />
))}
{showAdd && (
{primaryOptions.map((c) => ( ))}
{secondaryOptions.length > 0 && (
进阶段
{secondaryOptions.map((c) => ( ))}
)}
)}
); } function AtEditor({ seg, onChange, uin, groupId, }: { seg: Extract; onChange: (s: Seg) => void; uin: string; groupId: string; }) { const { items, loading, error, refresh } = useGroupMembers(uin, groupId); const options = [{ value: 'all', label: '@全体成员', sub: 'all' }, ...items]; return ( onChange({ ...seg, qq: v })} options={options} loading={loading} error={error} onRefresh={refresh} placeholder={groupId ? '选择成员 / @全体' : '输入 QQ 号(私聊无成员)'} validateRaw={(s) => s === 'all' || /^\d{3,}$/.test(s)} /> ); } function SegmentEditor({ seg, onChange, uin, groupId, depth, }: { seg: Seg; onChange: (s: Seg) => void; uin: string; groupId: string; depth: number; }) { switch (seg.k) { case 'text': return (