mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(lyrics): send full lyrics chunked under TeamSpeak message cap (#116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8743a904f
commit
81b8953d52
4 files changed
+212
-2
No files matched your search
@@ -210,3 +210,64 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
|
|||||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
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<string>;
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-2
@@ -13,6 +13,7 @@ import {
|
|||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
|
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
@@ -395,7 +396,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const response = await this.executeCommand(parsed, msg);
|
const response = await this.executeCommand(parsed, msg);
|
||||||
if (response) {
|
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) {
|
} catch (err) {
|
||||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
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 provider = this.getProviderFor(song.platform);
|
||||||
const lyrics = await provider.getLyrics(song.id);
|
const lyrics = await provider.getLyrics(song.id);
|
||||||
if (lyrics.length === 0) return "No lyrics available";
|
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")}`;
|
return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user