kimaki 0.4.10 → 0.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/discordBot.js +204 -106
- package/dist/escape-backticks.test.js +286 -1
- package/dist/tools.js +3 -2
- package/package.json +1 -1
- package/src/discordBot.ts +249 -130
- package/src/escape-backticks.test.ts +302 -1
- package/src/tools.ts +6 -3
package/dist/discordBot.js
CHANGED
|
@@ -22,6 +22,50 @@ import { isAbortError } from './utils.js';
|
|
|
22
22
|
import { setGlobalDispatcher, Agent } from 'undici';
|
|
23
23
|
// disables the automatic 5 minutes abort after no body
|
|
24
24
|
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
|
|
25
|
+
export const OPENCODE_SYSTEM_MESSAGE = `
|
|
26
|
+
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
27
|
+
|
|
28
|
+
After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
|
|
29
|
+
|
|
30
|
+
bunx critique web
|
|
31
|
+
|
|
32
|
+
you can also show latest commit changes using
|
|
33
|
+
|
|
34
|
+
bunx critique web HEAD~1
|
|
35
|
+
|
|
36
|
+
do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
37
|
+
|
|
38
|
+
## markdown
|
|
39
|
+
|
|
40
|
+
discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
|
|
41
|
+
|
|
42
|
+
the max heading level is 3, so do not use ####
|
|
43
|
+
|
|
44
|
+
headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
|
|
45
|
+
|
|
46
|
+
## tables
|
|
47
|
+
|
|
48
|
+
discord does NOT support markdown gfm tables.
|
|
49
|
+
|
|
50
|
+
so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
|
|
51
|
+
|
|
52
|
+
\`\`\`
|
|
53
|
+
Item Qty Price
|
|
54
|
+
---------- --- -----
|
|
55
|
+
Apples 10 $5
|
|
56
|
+
Oranges 3 $2
|
|
57
|
+
\`\`\`
|
|
58
|
+
|
|
59
|
+
Using code blocks will make the content use monospaced font so that space will be aligned correctly
|
|
60
|
+
|
|
61
|
+
IMPORTANT: add enough space characters to align the table! otherwise the content will not look good and will be difficult to understand for the user
|
|
62
|
+
|
|
63
|
+
code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
|
|
64
|
+
|
|
65
|
+
## diagrams
|
|
66
|
+
|
|
67
|
+
you can create diagrams wrapping them in code blocks too.
|
|
68
|
+
`;
|
|
25
69
|
const discordLogger = createLogger('DISCORD');
|
|
26
70
|
const voiceLogger = createLogger('VOICE');
|
|
27
71
|
const opencodeLogger = createLogger('OPENCODE');
|
|
@@ -85,7 +129,7 @@ async function createUserAudioLogStream(guildId, channelId) {
|
|
|
85
129
|
}
|
|
86
130
|
}
|
|
87
131
|
// Set up voice handling for a connection (called once per connection)
|
|
88
|
-
async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
|
|
132
|
+
async function setupVoiceHandling({ connection, guildId, channelId, appId, discordClient, }) {
|
|
89
133
|
voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
|
|
90
134
|
// Check if this voice channel has an associated directory
|
|
91
135
|
const channelDirRow = getDatabase()
|
|
@@ -183,8 +227,24 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
|
|
|
183
227
|
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
|
|
184
228
|
genAiWorker.sendTextInput(text);
|
|
185
229
|
},
|
|
186
|
-
onError(error) {
|
|
230
|
+
async onError(error) {
|
|
187
231
|
voiceLogger.error('GenAI worker error:', error);
|
|
232
|
+
const textChannelRow = getDatabase()
|
|
233
|
+
.prepare(`SELECT cd2.channel_id FROM channel_directories cd1
|
|
234
|
+
JOIN channel_directories cd2 ON cd1.directory = cd2.directory
|
|
235
|
+
WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`)
|
|
236
|
+
.get(channelId);
|
|
237
|
+
if (textChannelRow) {
|
|
238
|
+
try {
|
|
239
|
+
const textChannel = await discordClient.channels.fetch(textChannelRow.channel_id);
|
|
240
|
+
if (textChannel?.isTextBased() && 'send' in textChannel) {
|
|
241
|
+
await textChannel.send(`⚠️ Voice session error: ${error}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
voiceLogger.error('Failed to send error to text channel:', e);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
188
248
|
},
|
|
189
249
|
});
|
|
190
250
|
// Stop any existing GenAI worker before storing new one
|
|
@@ -451,60 +511,20 @@ async function getOpenPort() {
|
|
|
451
511
|
async function sendThreadMessage(thread, content) {
|
|
452
512
|
const MAX_LENGTH = 2000;
|
|
453
513
|
content = escapeBackticksInCodeBlocks(content);
|
|
454
|
-
|
|
455
|
-
if (
|
|
456
|
-
|
|
514
|
+
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
|
|
515
|
+
if (chunks.length > 1) {
|
|
516
|
+
discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
|
|
457
517
|
}
|
|
458
|
-
// Use marked's lexer to tokenize markdown content
|
|
459
|
-
const lexer = new Lexer();
|
|
460
|
-
const tokens = lexer.lex(content);
|
|
461
|
-
const chunks = [];
|
|
462
|
-
let currentChunk = '';
|
|
463
|
-
// Process each token and add to chunks
|
|
464
|
-
for (const token of tokens) {
|
|
465
|
-
const tokenText = token.raw || '';
|
|
466
|
-
// If adding this token would exceed limit and we have content, flush current chunk
|
|
467
|
-
if (currentChunk && currentChunk.length + tokenText.length > MAX_LENGTH) {
|
|
468
|
-
chunks.push(currentChunk);
|
|
469
|
-
currentChunk = '';
|
|
470
|
-
}
|
|
471
|
-
// If this single token is longer than MAX_LENGTH, split it
|
|
472
|
-
if (tokenText.length > MAX_LENGTH) {
|
|
473
|
-
if (currentChunk) {
|
|
474
|
-
chunks.push(currentChunk);
|
|
475
|
-
currentChunk = '';
|
|
476
|
-
}
|
|
477
|
-
let remainingText = tokenText;
|
|
478
|
-
while (remainingText.length > MAX_LENGTH) {
|
|
479
|
-
// Try to split at a newline if possible
|
|
480
|
-
let splitIndex = MAX_LENGTH;
|
|
481
|
-
const newlineIndex = remainingText.lastIndexOf('\n', MAX_LENGTH - 1);
|
|
482
|
-
if (newlineIndex > MAX_LENGTH * 0.7) {
|
|
483
|
-
splitIndex = newlineIndex + 1;
|
|
484
|
-
}
|
|
485
|
-
chunks.push(remainingText.slice(0, splitIndex));
|
|
486
|
-
remainingText = remainingText.slice(splitIndex);
|
|
487
|
-
}
|
|
488
|
-
currentChunk = remainingText;
|
|
489
|
-
}
|
|
490
|
-
else {
|
|
491
|
-
currentChunk += tokenText;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
// Add any remaining content
|
|
495
|
-
if (currentChunk) {
|
|
496
|
-
chunks.push(currentChunk);
|
|
497
|
-
}
|
|
498
|
-
// Send all chunks
|
|
499
|
-
discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
|
|
500
518
|
let firstMessage;
|
|
501
519
|
for (let i = 0; i < chunks.length; i++) {
|
|
502
520
|
const chunk = chunks[i];
|
|
503
|
-
if (!chunk)
|
|
521
|
+
if (!chunk) {
|
|
504
522
|
continue;
|
|
523
|
+
}
|
|
505
524
|
const message = await thread.send(chunk);
|
|
506
|
-
if (i === 0)
|
|
525
|
+
if (i === 0) {
|
|
507
526
|
firstMessage = message;
|
|
527
|
+
}
|
|
508
528
|
}
|
|
509
529
|
return firstMessage;
|
|
510
530
|
}
|
|
@@ -621,6 +641,77 @@ export function escapeBackticksInCodeBlocks(markdown) {
|
|
|
621
641
|
}
|
|
622
642
|
return result;
|
|
623
643
|
}
|
|
644
|
+
export function splitMarkdownForDiscord({ content, maxLength, }) {
|
|
645
|
+
if (content.length <= maxLength) {
|
|
646
|
+
return [content];
|
|
647
|
+
}
|
|
648
|
+
const lexer = new Lexer();
|
|
649
|
+
const tokens = lexer.lex(content);
|
|
650
|
+
const lines = [];
|
|
651
|
+
for (const token of tokens) {
|
|
652
|
+
if (token.type === 'code') {
|
|
653
|
+
const lang = token.lang || '';
|
|
654
|
+
lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false });
|
|
655
|
+
const codeLines = token.text.split('\n');
|
|
656
|
+
for (const codeLine of codeLines) {
|
|
657
|
+
lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false });
|
|
658
|
+
}
|
|
659
|
+
lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true });
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
const rawLines = token.raw.split('\n');
|
|
663
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
664
|
+
const isLast = i === rawLines.length - 1;
|
|
665
|
+
const text = isLast ? rawLines[i] : rawLines[i] + '\n';
|
|
666
|
+
if (text) {
|
|
667
|
+
lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const chunks = [];
|
|
673
|
+
let currentChunk = '';
|
|
674
|
+
let currentLang = null;
|
|
675
|
+
for (const line of lines) {
|
|
676
|
+
const wouldExceed = currentChunk.length + line.text.length > maxLength;
|
|
677
|
+
if (wouldExceed && currentChunk) {
|
|
678
|
+
if (currentLang !== null) {
|
|
679
|
+
currentChunk += '```\n';
|
|
680
|
+
}
|
|
681
|
+
chunks.push(currentChunk);
|
|
682
|
+
if (line.isClosingFence && currentLang !== null) {
|
|
683
|
+
currentChunk = '';
|
|
684
|
+
currentLang = null;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
688
|
+
const lang = line.lang;
|
|
689
|
+
currentChunk = '```' + lang + '\n';
|
|
690
|
+
if (!line.isOpeningFence) {
|
|
691
|
+
currentChunk += line.text;
|
|
692
|
+
}
|
|
693
|
+
currentLang = lang;
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
currentChunk = line.text;
|
|
697
|
+
currentLang = null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
currentChunk += line.text;
|
|
702
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
703
|
+
currentLang = line.lang;
|
|
704
|
+
}
|
|
705
|
+
else if (line.isClosingFence) {
|
|
706
|
+
currentLang = null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (currentChunk) {
|
|
711
|
+
chunks.push(currentChunk);
|
|
712
|
+
}
|
|
713
|
+
return chunks;
|
|
714
|
+
}
|
|
624
715
|
/**
|
|
625
716
|
* Escape Discord formatting characters to prevent breaking code blocks and inline code
|
|
626
717
|
*/
|
|
@@ -837,24 +928,27 @@ function getToolOutputToDisplay(part) {
|
|
|
837
928
|
if (part.state.status === 'error') {
|
|
838
929
|
return part.state.error || 'Unknown error';
|
|
839
930
|
}
|
|
840
|
-
if (part.tool === 'todowrite') {
|
|
841
|
-
const todos = part.state.input?.todos || [];
|
|
842
|
-
return todos
|
|
843
|
-
.map((todo) => {
|
|
844
|
-
let statusIcon = '▢';
|
|
845
|
-
if (todo.status === 'in_progress') {
|
|
846
|
-
statusIcon = '●';
|
|
847
|
-
}
|
|
848
|
-
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
849
|
-
statusIcon = '■';
|
|
850
|
-
}
|
|
851
|
-
return `\`${statusIcon}\` ${todo.content}`;
|
|
852
|
-
})
|
|
853
|
-
.filter(Boolean)
|
|
854
|
-
.join('\n');
|
|
855
|
-
}
|
|
856
931
|
return '';
|
|
857
932
|
}
|
|
933
|
+
function formatTodoList(part) {
|
|
934
|
+
if (part.type !== 'tool' || part.tool !== 'todowrite')
|
|
935
|
+
return '';
|
|
936
|
+
const todos = part.state.input?.todos || [];
|
|
937
|
+
if (todos.length === 0)
|
|
938
|
+
return '';
|
|
939
|
+
return todos
|
|
940
|
+
.map((todo, i) => {
|
|
941
|
+
const num = `${i + 1}.`;
|
|
942
|
+
if (todo.status === 'in_progress') {
|
|
943
|
+
return `${num} **${todo.content}**`;
|
|
944
|
+
}
|
|
945
|
+
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
946
|
+
return `${num} ~~${todo.content}~~`;
|
|
947
|
+
}
|
|
948
|
+
return `${num} ${todo.content}`;
|
|
949
|
+
})
|
|
950
|
+
.join('\n');
|
|
951
|
+
}
|
|
858
952
|
function formatPart(part) {
|
|
859
953
|
if (part.type === 'text') {
|
|
860
954
|
return part.text || '';
|
|
@@ -877,14 +971,31 @@ function formatPart(part) {
|
|
|
877
971
|
return `◼︎ snapshot ${part.snapshot}`;
|
|
878
972
|
}
|
|
879
973
|
if (part.type === 'tool') {
|
|
974
|
+
if (part.tool === 'todowrite') {
|
|
975
|
+
return formatTodoList(part);
|
|
976
|
+
}
|
|
880
977
|
if (part.state.status !== 'completed' && part.state.status !== 'error') {
|
|
881
978
|
return '';
|
|
882
979
|
}
|
|
883
980
|
const summaryText = getToolSummaryText(part);
|
|
884
981
|
const outputToDisplay = getToolOutputToDisplay(part);
|
|
885
|
-
let toolTitle =
|
|
886
|
-
if (
|
|
887
|
-
toolTitle =
|
|
982
|
+
let toolTitle = '';
|
|
983
|
+
if (part.state.status === 'error') {
|
|
984
|
+
toolTitle = 'error';
|
|
985
|
+
}
|
|
986
|
+
else if (part.tool === 'bash') {
|
|
987
|
+
const command = part.state.input?.command || '';
|
|
988
|
+
const isSingleLine = !command.includes('\n');
|
|
989
|
+
const hasBackticks = command.includes('`');
|
|
990
|
+
if (isSingleLine && command.length <= 120 && !hasBackticks) {
|
|
991
|
+
toolTitle = `\`${command}\``;
|
|
992
|
+
}
|
|
993
|
+
else {
|
|
994
|
+
toolTitle = part.state.title ? `*${part.state.title}*` : '';
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
else if (part.state.title) {
|
|
998
|
+
toolTitle = `*${part.state.title}*`;
|
|
888
999
|
}
|
|
889
1000
|
const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : '';
|
|
890
1001
|
const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
@@ -1265,6 +1376,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1265
1376
|
path: { id: session.id },
|
|
1266
1377
|
body: {
|
|
1267
1378
|
parts,
|
|
1379
|
+
system: OPENCODE_SYSTEM_MESSAGE,
|
|
1268
1380
|
},
|
|
1269
1381
|
signal: abortController.signal,
|
|
1270
1382
|
});
|
|
@@ -1861,52 +1973,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1861
1973
|
await command.editReply(`Resumed session "${sessionTitle}" in ${thread.toString()}`);
|
|
1862
1974
|
// Send initial message to thread
|
|
1863
1975
|
await sendThreadMessage(thread, `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
|
|
1864
|
-
//
|
|
1865
|
-
|
|
1976
|
+
// Collect all assistant parts first, then only render the last 30
|
|
1977
|
+
const allAssistantParts = [];
|
|
1866
1978
|
for (const message of messages) {
|
|
1867
|
-
if (message.info.role === '
|
|
1868
|
-
// Render user messages
|
|
1869
|
-
const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
|
|
1870
|
-
const userTexts = userParts
|
|
1871
|
-
.map((p) => {
|
|
1872
|
-
if (p.type === 'text') {
|
|
1873
|
-
return p.text;
|
|
1874
|
-
}
|
|
1875
|
-
return '';
|
|
1876
|
-
})
|
|
1877
|
-
.filter((t) => t.trim());
|
|
1878
|
-
const userText = userTexts.join('\n\n');
|
|
1879
|
-
if (userText) {
|
|
1880
|
-
// Escape backticks in user messages to prevent formatting issues
|
|
1881
|
-
const escapedText = escapeDiscordFormatting(userText);
|
|
1882
|
-
await sendThreadMessage(thread, `**User:**\n${escapedText}`);
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
else if (message.info.role === 'assistant') {
|
|
1886
|
-
// Render assistant parts
|
|
1887
|
-
const partsToRender = [];
|
|
1979
|
+
if (message.info.role === 'assistant') {
|
|
1888
1980
|
for (const part of message.parts) {
|
|
1889
1981
|
const content = formatPart(part);
|
|
1890
1982
|
if (content.trim()) {
|
|
1891
|
-
|
|
1983
|
+
allAssistantParts.push({ id: part.id, content });
|
|
1892
1984
|
}
|
|
1893
1985
|
}
|
|
1894
|
-
if (partsToRender.length > 0) {
|
|
1895
|
-
const combinedContent = partsToRender
|
|
1896
|
-
.map((p) => p.content)
|
|
1897
|
-
.join('\n\n');
|
|
1898
|
-
const discordMessage = await sendThreadMessage(thread, combinedContent);
|
|
1899
|
-
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
1900
|
-
const transaction = getDatabase().transaction((parts) => {
|
|
1901
|
-
for (const part of parts) {
|
|
1902
|
-
stmt.run(part.id, discordMessage.id, thread.id);
|
|
1903
|
-
}
|
|
1904
|
-
});
|
|
1905
|
-
transaction(partsToRender);
|
|
1906
|
-
}
|
|
1907
1986
|
}
|
|
1908
|
-
messageCount++;
|
|
1909
1987
|
}
|
|
1988
|
+
const partsToRender = allAssistantParts.slice(-30);
|
|
1989
|
+
const skippedCount = allAssistantParts.length - partsToRender.length;
|
|
1990
|
+
if (skippedCount > 0) {
|
|
1991
|
+
await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`);
|
|
1992
|
+
}
|
|
1993
|
+
if (partsToRender.length > 0) {
|
|
1994
|
+
const combinedContent = partsToRender
|
|
1995
|
+
.map((p) => p.content)
|
|
1996
|
+
.join('\n\n');
|
|
1997
|
+
const discordMessage = await sendThreadMessage(thread, combinedContent);
|
|
1998
|
+
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
1999
|
+
const transaction = getDatabase().transaction((parts) => {
|
|
2000
|
+
for (const part of parts) {
|
|
2001
|
+
stmt.run(part.id, discordMessage.id, thread.id);
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
transaction(partsToRender);
|
|
2005
|
+
}
|
|
2006
|
+
const messageCount = messages.length;
|
|
1910
2007
|
await sendThreadMessage(thread, `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
|
|
1911
2008
|
}
|
|
1912
2009
|
catch (error) {
|
|
@@ -2245,6 +2342,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2245
2342
|
guildId: newState.guild.id,
|
|
2246
2343
|
channelId: voiceChannel.id,
|
|
2247
2344
|
appId: currentAppId,
|
|
2345
|
+
discordClient,
|
|
2248
2346
|
});
|
|
2249
2347
|
// Handle connection state changes
|
|
2250
2348
|
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test, expect } from 'vitest';
|
|
2
2
|
import { Lexer } from 'marked';
|
|
3
|
-
import { escapeBackticksInCodeBlocks } from './discordBot.js';
|
|
3
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discordBot.js';
|
|
4
4
|
test('escapes single backticks in code blocks', () => {
|
|
5
5
|
const input = '```js\nconst x = `hello`\n```';
|
|
6
6
|
const result = escapeBackticksInCodeBlocks(input);
|
|
@@ -123,3 +123,288 @@ const x = \\\`hello\\\`
|
|
|
123
123
|
"
|
|
124
124
|
`);
|
|
125
125
|
});
|
|
126
|
+
test('splitMarkdownForDiscord returns single chunk for short content', () => {
|
|
127
|
+
const result = splitMarkdownForDiscord({
|
|
128
|
+
content: 'Hello world',
|
|
129
|
+
maxLength: 100,
|
|
130
|
+
});
|
|
131
|
+
expect(result).toMatchInlineSnapshot(`
|
|
132
|
+
[
|
|
133
|
+
"Hello world",
|
|
134
|
+
]
|
|
135
|
+
`);
|
|
136
|
+
});
|
|
137
|
+
test('splitMarkdownForDiscord splits at line boundaries', () => {
|
|
138
|
+
const result = splitMarkdownForDiscord({
|
|
139
|
+
content: 'Line 1\nLine 2\nLine 3\nLine 4',
|
|
140
|
+
maxLength: 15,
|
|
141
|
+
});
|
|
142
|
+
expect(result).toMatchInlineSnapshot(`
|
|
143
|
+
[
|
|
144
|
+
"Line 1
|
|
145
|
+
Line 2
|
|
146
|
+
",
|
|
147
|
+
"Line 3
|
|
148
|
+
Line 4",
|
|
149
|
+
]
|
|
150
|
+
`);
|
|
151
|
+
});
|
|
152
|
+
test('splitMarkdownForDiscord preserves code blocks when not split', () => {
|
|
153
|
+
const result = splitMarkdownForDiscord({
|
|
154
|
+
content: '```js\nconst x = 1\n```',
|
|
155
|
+
maxLength: 100,
|
|
156
|
+
});
|
|
157
|
+
expect(result).toMatchInlineSnapshot(`
|
|
158
|
+
[
|
|
159
|
+
"\`\`\`js
|
|
160
|
+
const x = 1
|
|
161
|
+
\`\`\`",
|
|
162
|
+
]
|
|
163
|
+
`);
|
|
164
|
+
});
|
|
165
|
+
test('splitMarkdownForDiscord adds closing and opening fences when splitting code block', () => {
|
|
166
|
+
const result = splitMarkdownForDiscord({
|
|
167
|
+
content: '```js\nline1\nline2\nline3\nline4\n```',
|
|
168
|
+
maxLength: 20,
|
|
169
|
+
});
|
|
170
|
+
expect(result).toMatchInlineSnapshot(`
|
|
171
|
+
[
|
|
172
|
+
"\`\`\`js
|
|
173
|
+
line1
|
|
174
|
+
line2
|
|
175
|
+
\`\`\`
|
|
176
|
+
",
|
|
177
|
+
"\`\`\`js
|
|
178
|
+
line3
|
|
179
|
+
line4
|
|
180
|
+
\`\`\`
|
|
181
|
+
",
|
|
182
|
+
]
|
|
183
|
+
`);
|
|
184
|
+
});
|
|
185
|
+
test('splitMarkdownForDiscord handles code block with language', () => {
|
|
186
|
+
const result = splitMarkdownForDiscord({
|
|
187
|
+
content: '```typescript\nconst a = 1\nconst b = 2\n```',
|
|
188
|
+
maxLength: 30,
|
|
189
|
+
});
|
|
190
|
+
expect(result).toMatchInlineSnapshot(`
|
|
191
|
+
[
|
|
192
|
+
"\`\`\`typescript
|
|
193
|
+
const a = 1
|
|
194
|
+
\`\`\`
|
|
195
|
+
",
|
|
196
|
+
"\`\`\`typescript
|
|
197
|
+
const b = 2
|
|
198
|
+
\`\`\`
|
|
199
|
+
",
|
|
200
|
+
]
|
|
201
|
+
`);
|
|
202
|
+
});
|
|
203
|
+
test('splitMarkdownForDiscord handles mixed content with code blocks', () => {
|
|
204
|
+
const result = splitMarkdownForDiscord({
|
|
205
|
+
content: 'Text before\n```js\ncode\n```\nText after',
|
|
206
|
+
maxLength: 25,
|
|
207
|
+
});
|
|
208
|
+
expect(result).toMatchInlineSnapshot(`
|
|
209
|
+
[
|
|
210
|
+
"Text before
|
|
211
|
+
\`\`\`js
|
|
212
|
+
code
|
|
213
|
+
\`\`\`
|
|
214
|
+
",
|
|
215
|
+
"Text after",
|
|
216
|
+
]
|
|
217
|
+
`);
|
|
218
|
+
});
|
|
219
|
+
test('splitMarkdownForDiscord handles code block without language', () => {
|
|
220
|
+
const result = splitMarkdownForDiscord({
|
|
221
|
+
content: '```\nline1\nline2\n```',
|
|
222
|
+
maxLength: 12,
|
|
223
|
+
});
|
|
224
|
+
expect(result).toMatchInlineSnapshot(`
|
|
225
|
+
[
|
|
226
|
+
"\`\`\`
|
|
227
|
+
line1
|
|
228
|
+
\`\`\`
|
|
229
|
+
",
|
|
230
|
+
"\`\`\`
|
|
231
|
+
line2
|
|
232
|
+
\`\`\`
|
|
233
|
+
",
|
|
234
|
+
]
|
|
235
|
+
`);
|
|
236
|
+
});
|
|
237
|
+
test('splitMarkdownForDiscord handles multiple consecutive code blocks', () => {
|
|
238
|
+
const result = splitMarkdownForDiscord({
|
|
239
|
+
content: '```js\nfoo\n```\n```py\nbar\n```',
|
|
240
|
+
maxLength: 20,
|
|
241
|
+
});
|
|
242
|
+
expect(result).toMatchInlineSnapshot(`
|
|
243
|
+
[
|
|
244
|
+
"\`\`\`js
|
|
245
|
+
foo
|
|
246
|
+
\`\`\`
|
|
247
|
+
\`\`\`py
|
|
248
|
+
\`\`\`
|
|
249
|
+
",
|
|
250
|
+
"\`\`\`py
|
|
251
|
+
bar
|
|
252
|
+
\`\`\`
|
|
253
|
+
",
|
|
254
|
+
]
|
|
255
|
+
`);
|
|
256
|
+
});
|
|
257
|
+
test('splitMarkdownForDiscord handles empty code block', () => {
|
|
258
|
+
const result = splitMarkdownForDiscord({
|
|
259
|
+
content: 'before\n```\n```\nafter',
|
|
260
|
+
maxLength: 50,
|
|
261
|
+
});
|
|
262
|
+
expect(result).toMatchInlineSnapshot(`
|
|
263
|
+
[
|
|
264
|
+
"before
|
|
265
|
+
\`\`\`
|
|
266
|
+
\`\`\`
|
|
267
|
+
after",
|
|
268
|
+
]
|
|
269
|
+
`);
|
|
270
|
+
});
|
|
271
|
+
test('splitMarkdownForDiscord handles content exactly at maxLength', () => {
|
|
272
|
+
const result = splitMarkdownForDiscord({
|
|
273
|
+
content: '12345678901234567890',
|
|
274
|
+
maxLength: 20,
|
|
275
|
+
});
|
|
276
|
+
expect(result).toMatchInlineSnapshot(`
|
|
277
|
+
[
|
|
278
|
+
"12345678901234567890",
|
|
279
|
+
]
|
|
280
|
+
`);
|
|
281
|
+
});
|
|
282
|
+
test('splitMarkdownForDiscord handles code block only', () => {
|
|
283
|
+
const result = splitMarkdownForDiscord({
|
|
284
|
+
content: '```ts\nconst x = 1\n```',
|
|
285
|
+
maxLength: 15,
|
|
286
|
+
});
|
|
287
|
+
expect(result).toMatchInlineSnapshot(`
|
|
288
|
+
[
|
|
289
|
+
"\`\`\`ts
|
|
290
|
+
\`\`\`
|
|
291
|
+
",
|
|
292
|
+
"\`\`\`ts
|
|
293
|
+
const x = 1
|
|
294
|
+
\`\`\`
|
|
295
|
+
",
|
|
296
|
+
]
|
|
297
|
+
`);
|
|
298
|
+
});
|
|
299
|
+
test('splitMarkdownForDiscord handles code block at start with text after', () => {
|
|
300
|
+
const result = splitMarkdownForDiscord({
|
|
301
|
+
content: '```js\ncode\n```\nSome text after',
|
|
302
|
+
maxLength: 20,
|
|
303
|
+
});
|
|
304
|
+
expect(result).toMatchInlineSnapshot(`
|
|
305
|
+
[
|
|
306
|
+
"\`\`\`js
|
|
307
|
+
code
|
|
308
|
+
\`\`\`
|
|
309
|
+
",
|
|
310
|
+
"Some text after",
|
|
311
|
+
]
|
|
312
|
+
`);
|
|
313
|
+
});
|
|
314
|
+
test('splitMarkdownForDiscord handles text before code block at end', () => {
|
|
315
|
+
const result = splitMarkdownForDiscord({
|
|
316
|
+
content: 'Some text before\n```js\ncode\n```',
|
|
317
|
+
maxLength: 25,
|
|
318
|
+
});
|
|
319
|
+
expect(result).toMatchInlineSnapshot(`
|
|
320
|
+
[
|
|
321
|
+
"Some text before
|
|
322
|
+
\`\`\`js
|
|
323
|
+
\`\`\`
|
|
324
|
+
",
|
|
325
|
+
"\`\`\`js
|
|
326
|
+
code
|
|
327
|
+
\`\`\`
|
|
328
|
+
",
|
|
329
|
+
]
|
|
330
|
+
`);
|
|
331
|
+
});
|
|
332
|
+
test('splitMarkdownForDiscord handles very long line inside code block', () => {
|
|
333
|
+
const result = splitMarkdownForDiscord({
|
|
334
|
+
content: '```js\nshort\nveryverylonglinethatexceedsmaxlength\nshort\n```',
|
|
335
|
+
maxLength: 25,
|
|
336
|
+
});
|
|
337
|
+
expect(result).toMatchInlineSnapshot(`
|
|
338
|
+
[
|
|
339
|
+
"\`\`\`js
|
|
340
|
+
short
|
|
341
|
+
\`\`\`
|
|
342
|
+
",
|
|
343
|
+
"\`\`\`js
|
|
344
|
+
veryverylonglinethatexceedsmaxlength
|
|
345
|
+
\`\`\`
|
|
346
|
+
",
|
|
347
|
+
"\`\`\`js
|
|
348
|
+
short
|
|
349
|
+
\`\`\`
|
|
350
|
+
",
|
|
351
|
+
]
|
|
352
|
+
`);
|
|
353
|
+
});
|
|
354
|
+
test('splitMarkdownForDiscord handles realistic long markdown with code block', () => {
|
|
355
|
+
const content = `Here is some explanation text before the code.
|
|
356
|
+
|
|
357
|
+
\`\`\`typescript
|
|
358
|
+
export function calculateTotal(items: Item[]): number {
|
|
359
|
+
let total = 0
|
|
360
|
+
for (const item of items) {
|
|
361
|
+
total += item.price * item.quantity
|
|
362
|
+
}
|
|
363
|
+
return total
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function formatCurrency(amount: number): string {
|
|
367
|
+
return new Intl.NumberFormat('en-US', {
|
|
368
|
+
style: 'currency',
|
|
369
|
+
currency: 'USD',
|
|
370
|
+
}).format(amount)
|
|
371
|
+
}
|
|
372
|
+
\`\`\`
|
|
373
|
+
|
|
374
|
+
And here is some text after the code block.`;
|
|
375
|
+
const result = splitMarkdownForDiscord({
|
|
376
|
+
content,
|
|
377
|
+
maxLength: 200,
|
|
378
|
+
});
|
|
379
|
+
expect(result).toMatchInlineSnapshot(`
|
|
380
|
+
[
|
|
381
|
+
"Here is some explanation text before the code.
|
|
382
|
+
|
|
383
|
+
\`\`\`typescript
|
|
384
|
+
export function calculateTotal(items: Item[]): number {
|
|
385
|
+
let total = 0
|
|
386
|
+
for (const item of items) {
|
|
387
|
+
\`\`\`
|
|
388
|
+
",
|
|
389
|
+
"\`\`\`typescript
|
|
390
|
+
total += item.price * item.quantity
|
|
391
|
+
}
|
|
392
|
+
return total
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function formatCurrency(amount: number): string {
|
|
396
|
+
return new Intl.NumberFormat('en-US', {
|
|
397
|
+
style: 'currency',
|
|
398
|
+
\`\`\`
|
|
399
|
+
",
|
|
400
|
+
"\`\`\`typescript
|
|
401
|
+
currency: 'USD',
|
|
402
|
+
}).format(amount)
|
|
403
|
+
}
|
|
404
|
+
\`\`\`
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
And here is some text after the code block.",
|
|
408
|
+
]
|
|
409
|
+
`);
|
|
410
|
+
});
|