From 81b8953d5255306c6e0f5997790a8866771e81a6 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 4 Jul 2026 14:26:51 +0800 Subject: [PATCH] fix(lyrics): send full lyrics chunked under TeamSpeak message cap (#116) Co-Authored-By: Claude Opus 4.8 --- src/bot/instance.test.ts | 61 +++++++++++++++++++++++++++++++ src/bot/instance.ts | 12 +++++-- src/bot/text-chunk.test.ts | 67 ++++++++++++++++++++++++++++++++++ src/bot/text-chunk.ts | 74 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/bot/text-chunk.test.ts create mode 100644 src/bot/text-chunk.ts diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index e745258..35dff90 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -210,3 +210,64 @@ describe("BotInstance.handleTextMessage — command permission gate", () => { expect(ctx.executeCommand).toHaveBeenCalledTimes(1); }); }); + +describe("BotInstance.handleTextMessage — response chunking (#116)", () => { + it("splits a long command response into multiple sends, each under the byte cap", async () => { + const ctx = makeGateCtx({ adminGroups: [] }); + const longResponse = Array.from( + { length: 200 }, + (_, i) => `歌词 line number ${i} with some content`, + ).join("\n"); + ctx.executeCommand = vi.fn(async () => longResponse); + + await handleTextMessage.call(ctx, makeMsg("!lyrics")); + + const calls = ctx.tsClient.sendTextMessage.mock.calls; + expect(calls.length).toBeGreaterThan(1); + for (const [chunk] of calls) { + expect(Buffer.byteLength(chunk as string, "utf8")).toBeLessThanOrEqual(900); + } + }); + + it("sends a short command response as a single message", async () => { + const ctx = makeGateCtx({ adminGroups: [] }); + ctx.executeCommand = vi.fn(async () => "short reply"); + + await handleTextMessage.call(ctx, makeMsg("!lyrics")); + + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith("short reply"); + }); +}); + +const cmdLyrics = (BotInstance.prototype as any).cmdLyrics as ( + this: unknown, +) => Promise; + +describe("BotInstance.cmdLyrics — full lyrics (#116)", () => { + it("returns ALL lyric lines, not just the first 10", async () => { + const lyricLines = Array.from({ length: 30 }, (_, i) => ({ + time: i, + text: `lyric line ${i}`, + })); + const ctx: any = { + queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) }, + getProviderFor: () => ({ getLyrics: vi.fn(async () => lyricLines) }), + }; + + const out = await cmdLyrics.call(ctx); + + for (const l of lyricLines) { + expect(out).toContain(l.text); + } + expect(out.startsWith("Lyrics for Song:")).toBe(true); + }); + + it("returns 'No lyrics available' when the provider has none", async () => { + const ctx: any = { + queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) }, + getProviderFor: () => ({ getLyrics: vi.fn(async () => []) }), + }; + expect(await cmdLyrics.call(ctx)).toBe("No lyrics available"); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index f0bb312..2f64b32 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -13,6 +13,7 @@ import { type ParsedCommand, } from "./commands.js"; import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; +import { splitTextIntoChunks } from "./text-chunk.js"; import type { Logger } from "../logger.js"; import type { BotDatabase, ProfileConfig } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; @@ -395,7 +396,11 @@ export class BotInstance extends EventEmitter { try { const response = await this.executeCommand(parsed, msg); if (response) { - await this.tsClient.sendTextMessage(response); + // A single long reply (e.g. full lyrics) would exceed TeamSpeak's + // per-message byte cap, so split it and send the chunks in order. + for (const chunk of splitTextIntoChunks(response)) { + await this.tsClient.sendTextMessage(chunk); + } } } catch (err) { this.logger.error({ err, command: parsed.name }, "Command execution error"); @@ -1064,7 +1069,10 @@ export class BotInstance extends EventEmitter { const provider = this.getProviderFor(song.platform); const lyrics = await provider.getLyrics(song.id); if (lyrics.length === 0) return "No lyrics available"; - const lines = lyrics.slice(0, 10).map((l) => l.text); + // Include the FULL lyrics (the send path chunks them under the message + // cap). Cap only to avoid pathological spam — far above any normal song. + const MAX_LYRIC_LINES = 200; + const lines = lyrics.slice(0, MAX_LYRIC_LINES).map((l) => l.text); return `Lyrics for ${song.name}:\n${lines.join("\n")}`; } diff --git a/src/bot/text-chunk.test.ts b/src/bot/text-chunk.test.ts new file mode 100644 index 0000000..d62c6bc --- /dev/null +++ b/src/bot/text-chunk.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { splitTextIntoChunks } from "./text-chunk.js"; + +const bytes = (s: string) => Buffer.byteLength(s, "utf8"); + +describe("splitTextIntoChunks", () => { + it("returns a single chunk for a short string", () => { + const chunks = splitTextIntoChunks("hello world", 900); + expect(chunks).toEqual(["hello world"]); + }); + + it("splits a multi-line string longer than maxBytes into multiple chunks on line boundaries", () => { + const lines = Array.from({ length: 50 }, (_, i) => `line number ${i}`); + const text = lines.join("\n"); + const chunks = splitTextIntoChunks(text, 60); + + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) { + expect(bytes(c)).toBeLessThanOrEqual(60); + } + // No hard-split of any line occurred, so rejoining with "\n" is lossless. + expect(chunks.join("\n")).toBe(text); + }); + + it("bounds by BYTES not chars: multibyte (Chinese) content stays under the cap", () => { + // Each Chinese char is 3 bytes in UTF-8. 40 chars/line = 120 bytes/line. + const lines = Array.from({ length: 10 }, () => "歌词".repeat(20)); + const text = lines.join("\n"); + const chunks = splitTextIntoChunks(text, 150); + + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) { + expect(bytes(c)).toBeLessThanOrEqual(150); + } + expect(chunks.join("\n")).toBe(text); + }); + + it("hard-splits a single over-long line so no chunk exceeds the cap", () => { + const longLine = "a".repeat(500); + const chunks = splitTextIntoChunks(longLine, 100); + + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) { + expect(bytes(c)).toBeLessThanOrEqual(100); + } + // Content is preserved (hard-split introduces split points, not \n). + expect(chunks.join("")).toBe(longLine); + }); + + it("never splits a multibyte character across a hard-split boundary", () => { + // 200 Chinese chars = 600 bytes on ONE line, cap 40 bytes. + const longLine = "歌".repeat(200); + const chunks = splitTextIntoChunks(longLine, 40); + + for (const c of chunks) { + expect(bytes(c)).toBeLessThanOrEqual(40); + // A clean re-decode: every chunk is valid UTF-8 with no replacement char. + expect(c.includes("�")).toBe(false); + } + expect(chunks.join("")).toBe(longLine); + }); + + it("preserves blank lines within a single chunk", () => { + const text = "a\n\nb"; + expect(splitTextIntoChunks(text, 900)).toEqual([text]); + }); +}); diff --git a/src/bot/text-chunk.ts b/src/bot/text-chunk.ts new file mode 100644 index 0000000..63d1b37 --- /dev/null +++ b/src/bot/text-chunk.ts @@ -0,0 +1,74 @@ +/** + * Split `text` into chunks whose UTF-8 byte length never exceeds `maxBytes`. + * + * TeamSpeak enforces a per-message byte cap (~1024 bytes), and the send path + * does no chunking, so a long single reply (e.g. full song lyrics) would be + * truncated or rejected. This packs whole lines greedily, breaking BETWEEN + * lines. When a single line is itself longer than `maxBytes`, it is hard-split + * on UTF-8 character boundaries so no chunk ever exceeds the cap and no + * multibyte character is ever cut in half. + * + * Content is preserved on rejoin, modulo the split points: chunks split only on + * newline boundaries rejoin losslessly with `chunks.join("\n")`; a hard-split + * long line rejoins with `chunks.join("")`. + * + * @param text The full message text. + * @param maxBytes Max UTF-8 bytes per chunk (default 900 — under TS's ~1024 cap + * with headroom for protocol framing/escaping). + */ +export function splitTextIntoChunks(text: string, maxBytes = 900): string[] { + const chunks: string[] = []; + let current = ""; + + const flush = (): void => { + if (current !== "") { + chunks.push(current); + current = ""; + } + }; + + for (const rawLine of text.split("\n")) { + const pieces = + Buffer.byteLength(rawLine, "utf8") > maxBytes + ? hardSplitByBytes(rawLine, maxBytes) + : [rawLine]; + + for (const piece of pieces) { + const candidate = current === "" ? piece : `${current}\n${piece}`; + if (Buffer.byteLength(candidate, "utf8") <= maxBytes) { + current = candidate; + } else { + // current is guaranteed non-empty here: pieces never exceed maxBytes, + // so an empty `current` always accepts the next piece above. + flush(); + current = piece; + } + } + } + + flush(); + return chunks; +} + +/** + * Break a single line into pieces each ≤ `maxBytes` UTF-8 bytes, never cutting + * a character (iterates code points, so surrogate pairs stay intact). + */ +function hardSplitByBytes(line: string, maxBytes: number): string[] { + const pieces: string[] = []; + let current = ""; + let currentBytes = 0; + + for (const ch of line) { + const chBytes = Buffer.byteLength(ch, "utf8"); + if (currentBytes + chBytes > maxBytes && current !== "") { + pieces.push(current); + current = ""; + currentBytes = 0; + } + current += ch; + currentBytes += chBytes; + } + if (current !== "") pieces.push(current); + return pieces; +}