SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,740 @@
// 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['k'], () => 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<string, unknown> }> {
return segs.map((s): { type: string; data: Record<string, unknown> } => {
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: <Type className="h-4 w-4" /> },
{ k: 'at', label: '@', icon: <AtSign className="h-4 w-4" /> },
{ k: 'image', label: '图片', icon: <ImageIcon className="h-4 w-4" /> },
{ k: 'face', label: '表情', icon: <Smile className="h-4 w-4" /> },
{ k: 'reply', label: '回复', icon: <Quote className="h-4 w-4" /> },
{ k: 'record', label: '语音', icon: <Mic className="h-4 w-4" /> },
{ k: 'video', label: '视频', icon: <Film className="h-4 w-4" /> },
];
const ADVANCED: { k: SegBody['k']; label: string; icon: React.ReactNode }[] = [
{ k: 'file', label: '文件', icon: <FileText className="h-4 w-4" /> },
{ k: 'mface', label: '商城表情', icon: <Sticker className="h-4 w-4" /> },
{ k: 'poke', label: '戳一戳', icon: <Hand className="h-4 w-4" /> },
{ k: 'json', label: 'JSON 卡片', icon: <Code2 className="h-4 w-4" /> },
{ k: 'xml', label: 'XML 卡片', icon: <Code2 className="h-4 w-4" /> },
{ k: 'markdown', label: 'Markdown', icon: <Code2 className="h-4 w-4" /> },
{ k: 'node', label: '合并转发节点', icon: <Forward className="h-4 w-4" /> },
];
/** 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<SegBody['k'], string> = {
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<number | null>(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.
<div
className={
nested
? 'flex flex-col gap-2'
: 'flex flex-col gap-2 border border-border/50 bg-muted/20 p-2'
}
style={nested ? undefined : { borderRadius: canvasRadius }}
>
{segments.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">
{mode === 'forward'
? '空转发 — 点下方添加转发节点'
: '空消息 — 点下方「添加段」开始'}
</p>
)}
{segments.map((s, i) => (
<div
key={s._id}
onDragOver={(e) => 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. */}
<div className="mb-1 flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5">
<span
draggable
// stopPropagation keeps a nested (forward-node) builder's drag
// from reaching the parent row; onDragEnd lives here because the
// grip — not the row — is now the drag source.
onDragStart={(e) => {
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"
>
<GripVertical className="h-3.5 w-3.5" />
</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{LABELS[s.k]}
</span>
</span>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => move(i, i - 1)}
disabled={i === 0}
className="rounded px-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
title="上移"
>
↑
</button>
<button
type="button"
onClick={() => move(i, i + 1)}
disabled={i === segments.length - 1}
className="rounded px-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
title="下移"
>
↓
</button>
<button
type="button"
onClick={() => remove(i)}
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<SegmentEditor
seg={s}
onChange={(n) => patch(i, n)}
uin={uin}
groupId={groupId}
depth={depth}
/>
</div>
))}
<div className="relative">
<button
type="button"
onClick={() => setShowAdd((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-border/70 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" /> 添加段
</button>
{showAdd && (
<div className="absolute z-30 mt-1.5 w-64 rounded-xl border border-border/60 bg-popover p-2 shadow-lg">
<div className="grid grid-cols-4 gap-1">
{primaryOptions.map((c) => (
<button
key={c.k}
type="button"
onClick={() => add(c.k)}
className="flex flex-col items-center gap-1 rounded-sm p-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{c.icon}
{c.label}
</button>
))}
</div>
{secondaryOptions.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer list-none px-1 text-xs text-muted-foreground hover:text-foreground">
<span className="inline-flex items-center gap-1">
<ChevronDown className="h-3 w-3" /> 进阶段
</span>
</summary>
<div className="mt-1 grid grid-cols-4 gap-1">
{secondaryOptions.map((c) => (
<button
key={c.k}
type="button"
onClick={() => add(c.k)}
className="flex flex-col items-center gap-1 rounded-sm p-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{c.icon}
{c.label}
</button>
))}
</div>
</details>
)}
</div>
)}
</div>
</div>
);
}
function AtEditor({
seg,
onChange,
uin,
groupId,
}: {
seg: Extract<Seg, { k: 'at' }>;
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 (
<Picker
ariaLabel="@某人"
value={seg.qq}
onChange={(v) => 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 (
<textarea
value={seg.text}
onChange={(e) => onChange({ ...seg, text: e.target.value })}
placeholder="文本内容"
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40"
/>
);
case 'at':
return <AtEditor seg={seg} onChange={onChange} uin={uin} groupId={groupId} />;
case 'image':
return (
<FileSource
role="image"
value={seg.file}
onChange={(v) => onChange({ ...seg, file: v })}
/>
);
case 'record':
return (
<FileSource
role="record"
value={seg.file}
onChange={(v) => onChange({ ...seg, file: v })}
/>
);
case 'video':
return (
<FileSource
role="video"
value={seg.file}
onChange={(v) => onChange({ ...seg, file: v })}
/>
);
case 'file':
return (
<div className="flex flex-col gap-1.5">
<FileSource
role="file"
value={seg.file}
onChange={(v) => onChange({ ...seg, file: v })}
/>
<Input
value={seg.name}
onChange={(e) => onChange({ ...seg, name: e.target.value })}
placeholder="文件名(可选)"
/>
</div>
);
case 'face':
return <FaceGrid value={seg.id} onChange={(v) => onChange({ ...seg, id: v })} />;
case 'reply':
return (
<Input
value={seg.id}
onChange={(e) =>
onChange({ ...seg, id: e.target.value.replace(/[^\d-]/g, '') })
}
placeholder="回复的消息 id"
className="font-mono"
/>
);
case 'poke':
return (
<Input
value={seg.type}
onChange={(e) =>
onChange({ ...seg, type: e.target.value.replace(/[^\d]/g, '') })
}
placeholder="戳一戳类型(默认 1)"
className="font-mono"
/>
);
case 'json':
return (
<textarea
value={seg.data}
onChange={(e) => onChange({ ...seg, data: e.target.value })}
placeholder='JSON 卡片原文 {"app":…}'
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
/>
);
case 'xml':
return (
<textarea
value={seg.data}
onChange={(e) => onChange({ ...seg, data: e.target.value })}
placeholder="XML 卡片原文 <msg>…"
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
/>
);
case 'markdown':
return (
<textarea
value={seg.content}
onChange={(e) => onChange({ ...seg, content: e.target.value })}
placeholder="Markdown 内容"
className="min-h-16 w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:border-ring"
/>
);
case 'mface':
return (
<div className="grid grid-cols-2 gap-1.5">
<Input
value={seg.emoji_id}
onChange={(e) => onChange({ ...seg, emoji_id: e.target.value })}
placeholder="emoji_id"
className="font-mono text-xs"
/>
<Input
value={seg.emoji_package_id}
onChange={(e) => onChange({ ...seg, emoji_package_id: e.target.value })}
placeholder="emoji_package_id"
className="font-mono text-xs"
/>
<Input
value={seg.key}
onChange={(e) => onChange({ ...seg, key: e.target.value })}
placeholder="key"
className="font-mono text-xs"
/>
<Input
value={seg.summary}
onChange={(e) => onChange({ ...seg, summary: e.target.value })}
placeholder="summary(可选)"
className="text-xs"
/>
</div>
);
case 'node':
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-1.5">
<Input
value={seg.user_id}
onChange={(e) =>
onChange({ ...seg, user_id: e.target.value.replace(/[^\d]/g, '') })
}
placeholder="发送者 QQ"
className="font-mono text-xs"
/>
<Input
value={seg.nickname}
onChange={(e) => onChange({ ...seg, nickname: e.target.value })}
placeholder="发送者昵称"
className="text-xs"
/>
</div>
<div>
<span className="mb-1 block text-xs text-muted-foreground">
节点内容(可再嵌套)
</span>
{depth < 3 ? (
<MessageBuilder
segments={seg.content}
onChange={(c) => onChange({ ...seg, content: c })}
uin={uin}
groupId={groupId}
depth={depth + 1}
mode="node-content"
/>
) : (
<p className="text-xs text-destructive">嵌套层级过深</p>
)}
</div>
</div>
);
}
}
/** A compact human-readable preview line of a built message. */
export function previewSegments(segs: Seg[]): string {
return segs
.map((s) => {
switch (s.k) {
case 'text':
return s.text;
case 'at':
return s.qq === 'all' ? '@全体' : `@${s.qq}`;
case 'image':
return '[图片]';
case 'face':
return `[表情${s.id}]`;
case 'reply':
return '[回复]';
case 'record':
return '[语音]';
case 'video':
return '[视频]';
case 'file':
return `[文件${s.name ? ':' + s.name : ''}]`;
case 'mface':
return '[商城表情]';
case 'poke':
return '[戳一戳]';
case 'json':
return '[JSON卡片]';
case 'xml':
return '[XML卡片]';
case 'markdown':
return '[Markdown]';
case 'node':
return `[转发:${s.nickname || s.user_id}]`;
}
})
.join('');
}
/** True if the message contains forward nodes (→ send via the forward action). */
export function hasNodes(segs: Seg[]): boolean {
return segs.some((s) => s.k === 'node');
}
/** Per-segment completeness check (recurses into node content). Returns the
* first problem string, or null. The forward "all nodes" rule is enforced
* separately at the top level (only the forward action's `messages` array must
* be all-nodes; a node's own content is a normal message). */
export function validateSegs(segs: Seg[]): string | null {
for (const s of segs) {
switch (s.k) {
case 'at':
if (!s.qq.trim()) {
return '@ 段未选择对象';
}
break;
case 'image':
if (!s.file.trim()) {
return '图片段未指定来源';
}
break;
case 'record':
if (!s.file.trim()) {
return '语音段未指定来源';
}
break;
case 'video':
if (!s.file.trim()) {
return '视频段未指定来源';
}
break;
case 'file':
if (!s.file.trim()) {
return '文件段未指定来源';
}
break;
case 'reply':
if (!s.id.trim()) {
return '回复段缺少消息 id';
}
break;
case 'node': {
if (!s.user_id.trim()) {
return '转发节点缺少发送者 QQ';
}
if (s.content.length === 0) {
return '转发节点内容为空';
}
const inner = validateSegs(s.content);
if (inner) {
return inner;
}
break;
}
default:
break;
}
}
return null;
}