diff --git a/package.json b/package.json index d970567..a34daa3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "tsx --test src/**/*.test.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/src/index.ts b/src/index.ts index bcdc0e7..a31d0f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,24 +4,7 @@ import { createWebhookHandler } from './webhook/handler.js'; import { sendMessage, markAsRead, startTyping, sendReaction, shareContactCard, getChat, renameGroupChat, setGroupChatIcon, removeParticipant } from './linq/client.js'; import { chat, getGroupChatAction, getTextForEffect, generateImage } from './claude/client.js'; import { getUserProfile, addMessage } from './state/conversation.js'; - -// Clean up LLM response formatting quirks before sending -function cleanResponse(text: string): string { - return text - // Turn newline-dash into inline dash (e.g., "foo\n - bar" → "foo - bar") - .replace(/\n\s*-\s*/g, ' - ') - // Remove markdown underlines/italics (_text_ → text) - .replace(/(?(); @@ -156,23 +139,27 @@ app.post( } if (finalText || generatedImage || groupChatIcon) { - // Split into multiple messages first, then clean each one - // (must split before cleaning, or the --- delimiter gets mangled) - const messages = finalText ? finalText.split('---').map(m => cleanResponse(m)).filter(m => m.length > 0) : []; + const isIMessage = service === 'iMessage'; + + // Split into multiple messages first, then process each one + // (must split before processing, or the --- delimiter gets mangled) + const rawMessages = finalText ? finalText.split('---').filter(m => m.trim().length > 0) : []; + const processedMessages = rawMessages.map(m => processResponse(m, isIMessage)); // If the incoming message was a reply, continue the thread by replying to that message const replyTo = incomingReplyTo ? { message_id: messageId } : undefined; // Send text messages first (before generating image) - if (messages.length > 0) { - for (let i = 0; i < messages.length; i++) { - const isLastMessage = i === messages.length - 1; + if (processedMessages.length > 0) { + for (let i = 0; i < processedMessages.length; i++) { + const { text: msgText, decorations } = processedMessages[i]; + const isLastMessage = i === processedMessages.length - 1; // Only apply effect to the last text message (if no image coming) const messageEffect = (isLastMessage && !generatedImage) ? effect ?? undefined : undefined; // Only thread the first message const messageReplyTo = (i === 0) ? replyTo : undefined; - await sendMessage(chatId, messages[i], messageEffect, messageReplyTo); + await sendMessage(chatId, msgText, messageEffect, messageReplyTo, undefined, decorations.length > 0 ? decorations : undefined); // Add a natural delay between messages (except after the last one) if (!isLastMessage) { @@ -180,7 +167,7 @@ app.post( await new Promise(resolve => setTimeout(resolve, delay)); } } - console.log(`[timing] sendMessage (${messages.length} text msg${messages.length !== 1 ? 's' : ''}): ${Date.now() - start}ms`); + console.log(`[timing] sendMessage (${processedMessages.length} text msg${processedMessages.length !== 1 ? 's' : ''}): ${Date.now() - start}ms`); } // Now generate and send image if requested diff --git a/src/linq/client.ts b/src/linq/client.ts index 653342f..eb24a07 100644 --- a/src/linq/client.ts +++ b/src/linq/client.ts @@ -70,6 +70,15 @@ export type BubbleEffect = 'slam' | 'loud' | 'gentle' | 'invisible_ink'; export type MessageEffect = { type: 'screen' | 'bubble'; name: string }; export type ReplyTo = { message_id: string; part_index?: number }; +export type TextDecorationStyle = 'bold' | 'italic' | 'strikethrough' | 'underline'; +export type TextDecorationAnimation = 'big' | 'small' | 'shake' | 'nod' | 'explode' | 'ripple' | 'bloom' | 'jitter'; + +export type TextDecoration = { + range: [number, number]; + style?: TextDecorationStyle; + animation?: TextDecorationAnimation; +}; + export interface SendMessageResponse { chat_id: string; message: { @@ -85,7 +94,7 @@ export interface MediaAttachment { url: string; } -export async function sendMessage(chatId: string, text: string, effect?: MessageEffect, replyTo?: ReplyTo, media?: MediaAttachment[]): Promise { +export async function sendMessage(chatId: string, text: string, effect?: MessageEffect, replyTo?: ReplyTo, media?: MediaAttachment[], textDecorations?: TextDecoration[]): Promise { if (!API_TOKEN) { throw new Error('LINQ_API_TOKEN not configured'); } @@ -99,10 +108,14 @@ export async function sendMessage(chatId: string, text: string, effect?: Message console.log(`[linq] Sending message to chat ${chatId}${extras.length ? ` with ${extras.join(', ')}` : ''}`); // Build message parts: text first, then any media - const parts: Array<{ type: string; value?: string; url?: string }> = []; + const parts: Array<{ type: string; value?: string; url?: string; text_decorations?: TextDecoration[] }> = []; if (text) { - parts.push({ type: 'text', value: text }); + const textPart: { type: string; value: string; text_decorations?: TextDecoration[] } = { type: 'text', value: text }; + if (textDecorations && textDecorations.length > 0) { + textPart.text_decorations = textDecorations; + } + parts.push(textPart); } if (media) { diff --git a/src/text/decorations.test.ts b/src/text/decorations.test.ts new file mode 100644 index 0000000..ec9b64b --- /dev/null +++ b/src/text/decorations.test.ts @@ -0,0 +1,248 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseDecorations, processResponse, cleanResponse } from './decorations.js'; + +describe('parseDecorations', () => { + it('returns plain text unchanged', () => { + const result = parseDecorations('hello world'); + assert.equal(result.text, 'hello world'); + assert.deepEqual(result.decorations, []); + }); + + it('parses a single style decoration', () => { + const result = parseDecorations('{bold:hello} world'); + assert.equal(result.text, 'hello world'); + assert.deepEqual(result.decorations, [ + { range: [0, 5], style: 'bold' }, + ]); + }); + + it('parses a single animation decoration', () => { + const result = parseDecorations('oh {shake:no}!'); + assert.equal(result.text, 'oh no!'); + assert.deepEqual(result.decorations, [ + { range: [3, 5], animation: 'shake' }, + ]); + }); + + it('parses multiple decorations in one string', () => { + const result = parseDecorations('{bold:Hello} {shake:world}'); + assert.equal(result.text, 'Hello world'); + assert.deepEqual(result.decorations, [ + { range: [0, 5], style: 'bold' }, + { range: [6, 11], animation: 'shake' }, + ]); + }); + + it('handles all style types', () => { + const styles = ['bold', 'italic', 'strikethrough', 'underline'] as const; + for (const style of styles) { + const result = parseDecorations(`{${style}:text}`); + assert.equal(result.text, 'text'); + assert.equal(result.decorations.length, 1); + assert.equal(result.decorations[0].style, style); + assert.deepEqual(result.decorations[0].range, [0, 4]); + } + }); + + it('handles all animation types', () => { + const animations = ['big', 'small', 'shake', 'nod', 'explode', 'ripple', 'bloom', 'jitter'] as const; + for (const anim of animations) { + const result = parseDecorations(`{${anim}:text}`); + assert.equal(result.text, 'text'); + assert.equal(result.decorations.length, 1); + assert.equal(result.decorations[0].animation, anim); + assert.deepEqual(result.decorations[0].range, [0, 4]); + } + }); + + it('computes correct ranges with text before and after', () => { + const result = parseDecorations('hey u really {explode:killed it} today'); + assert.equal(result.text, 'hey u really killed it today'); + assert.deepEqual(result.decorations, [ + { range: [13, 22], animation: 'explode' }, + ]); + }); + + it('handles multiple decorations with varying gap text', () => { + const result = parseDecorations('a {bold:b} c {italic:d} e'); + assert.equal(result.text, 'a b c d e'); + assert.deepEqual(result.decorations, [ + { range: [2, 3], style: 'bold' }, + { range: [6, 7], style: 'italic' }, + ]); + }); + + it('handles decoration at end of string', () => { + const result = parseDecorations('check this {shake:out}'); + assert.equal(result.text, 'check this out'); + assert.deepEqual(result.decorations, [ + { range: [11, 14], animation: 'shake' }, + ]); + }); + + it('handles decoration at start of string', () => { + const result = parseDecorations('{big:WOW} thats cool'); + assert.equal(result.text, 'WOW thats cool'); + assert.deepEqual(result.decorations, [ + { range: [0, 3], animation: 'big' }, + ]); + }); + + it('handles entire string as one decoration', () => { + const result = parseDecorations('{explode:BOOM}'); + assert.equal(result.text, 'BOOM'); + assert.deepEqual(result.decorations, [ + { range: [0, 4], animation: 'explode' }, + ]); + }); + + it('handles adjacent decorations with no gap', () => { + const result = parseDecorations('{bold:hello}{italic:world}'); + assert.equal(result.text, 'helloworld'); + assert.deepEqual(result.decorations, [ + { range: [0, 5], style: 'bold' }, + { range: [5, 10], style: 'italic' }, + ]); + }); + + it('drops overlapping markers (first wins)', () => { + // Construct a scenario where markers overlap — this would be unusual + // but the parser should handle it gracefully + const result = parseDecorations('{bold:a{italic:b}c}'); + // {bold:...} matches first and captures "a{italic:b}c" minus the closing } + // Wait — the regex is [^}]+ so it stops at the first } + // {bold:a{italic:b} matches {bold: then "a{italic:b" then } + // Actually no — let me think. regex: \{bold:([^}]+)\} + // Input: {bold:a{italic:b}c} + // The [^}]+ will match "a{italic:b" (stops at first }) + // So {bold:a{italic:b} is the full match, with content "a{italic:b" + // Then "c}" is leftover text + assert.equal(result.text, 'a{italic:bc}'); + assert.equal(result.decorations.length, 1); + assert.equal(result.decorations[0].style, 'bold'); + }); + + it('ignores unknown decoration names', () => { + const result = parseDecorations('{unknown:text}'); + assert.equal(result.text, '{unknown:text}'); + assert.deepEqual(result.decorations, []); + }); + + it('handles empty content gracefully', () => { + // {bold:} won't match because [^}]+ requires at least one char + const result = parseDecorations('{bold:}'); + assert.equal(result.text, '{bold:}'); + assert.deepEqual(result.decorations, []); + }); + + it('preserves emoji in content', () => { + const result = parseDecorations('{shake:🔥🔥🔥}'); + assert.equal(result.text, '🔥🔥🔥'); + assert.equal(result.decorations.length, 1); + assert.equal(result.decorations[0].animation, 'shake'); + }); + + it('handles multiple same-type decorations', () => { + const result = parseDecorations('{bold:one} and {bold:two}'); + assert.equal(result.text, 'one and two'); + assert.deepEqual(result.decorations, [ + { range: [0, 3], style: 'bold' }, + { range: [8, 11], style: 'bold' }, + ]); + }); +}); + +describe('processResponse', () => { + describe('iMessage (isIMessage = true)', () => { + it('parses decorations and returns them', () => { + const result = processResponse('thats {bold:insane}', true); + assert.equal(result.text, 'thats insane'); + assert.deepEqual(result.decorations, [ + { range: [6, 12], style: 'bold' }, + ]); + }); + + it('cleans up whitespace quirks while preserving decorations', () => { + const result = processResponse(' {shake:hello} world ', true); + // parseDecorations → text: ' hello world ', decoration at [2,7] + // trim leading 2 spaces → 'hello world', range adjusted to [0,5] + assert.equal(result.text, 'hello world'); + assert.deepEqual(result.decorations, [ + { range: [0, 5], animation: 'shake' }, + ]); + }); + + it('returns no decorations for plain text', () => { + const result = processResponse('just normal text', true); + assert.equal(result.text, 'just normal text'); + assert.deepEqual(result.decorations, []); + }); + + it('handles animation decorations', () => { + const result = processResponse('{explode:BOOM}', true); + assert.equal(result.text, 'BOOM'); + assert.deepEqual(result.decorations, [ + { range: [0, 4], animation: 'explode' }, + ]); + }); + }); + + describe('SMS/RCS (isIMessage = false)', () => { + it('strips decoration syntax and returns plain text', () => { + const result = processResponse('thats {bold:insane}', false); + assert.equal(result.text, 'thats insane'); + assert.deepEqual(result.decorations, []); + }); + + it('strips animation syntax too', () => { + const result = processResponse('{shake:EARTHQUAKE}', false); + assert.equal(result.text, 'EARTHQUAKE'); + assert.deepEqual(result.decorations, []); + }); + + it('strips multiple decorations', () => { + const result = processResponse('{bold:hello} {shake:world}', false); + assert.equal(result.text, 'hello world'); + assert.deepEqual(result.decorations, []); + }); + + it('also strips markdown formatting', () => { + const result = processResponse('**bold** and *italic*', false); + assert.equal(result.text, 'bold and italic'); + assert.deepEqual(result.decorations, []); + }); + + it('strips decorations AND markdown together', () => { + const result = processResponse('**hey** {shake:whoa}', false); + assert.equal(result.text, 'hey whoa'); + assert.deepEqual(result.decorations, []); + }); + }); +}); + +describe('cleanResponse', () => { + it('strips markdown bold', () => { + assert.equal(cleanResponse('**hello**'), 'hello'); + }); + + it('strips markdown italic', () => { + assert.equal(cleanResponse('*hello*'), 'hello'); + }); + + it('strips markdown underline/italic', () => { + assert.equal(cleanResponse('_hello_'), 'hello'); + }); + + it('collapses multiple spaces', () => { + assert.equal(cleanResponse('hello world'), 'hello world'); + }); + + it('trims whitespace', () => { + assert.equal(cleanResponse(' hello '), 'hello'); + }); + + it('converts newline-dash to inline dash', () => { + assert.equal(cleanResponse('hello\n - world'), 'hello - world'); + }); +}); diff --git a/src/text/decorations.ts b/src/text/decorations.ts new file mode 100644 index 0000000..7e4f708 --- /dev/null +++ b/src/text/decorations.ts @@ -0,0 +1,122 @@ +import { TextDecoration } from '../linq/client.js'; + +// All decoration names (styles + animations) for the {name:content} syntax +const DECORATION_STYLES = ['bold', 'italic', 'strikethrough', 'underline'] as const; +const DECORATION_ANIMATIONS = ['big', 'small', 'shake', 'nod', 'explode', 'ripple', 'bloom', 'jitter'] as const; +const ALL_DECORATIONS = [...DECORATION_STYLES, ...DECORATION_ANIMATIONS]; + +// Parse {decoration:content} markup into plain text + text_decorations array +// Used for iMessage only — SMS/RCS uses cleanResponse() which strips everything +export function parseDecorations(input: string): { text: string; decorations: TextDecoration[] } { + // Phase 1: Find all {decoration:content} markers in original text + interface Marker { + fullStart: number; + fullEnd: number; + contentStart: number; + contentEnd: number; + decoration: TextDecoration['style'] | TextDecoration['animation']; + isStyle: boolean; + } + + const markers: Marker[] = []; + + for (const name of ALL_DECORATIONS) { + const regex = new RegExp(`\\{${name}:([^}]+)\\}`, 'g'); + let match; + while ((match = regex.exec(input)) !== null) { + markers.push({ + fullStart: match.index, + fullEnd: match.index + match[0].length, + contentStart: match.index + name.length + 2, // skip {name: + contentEnd: match.index + match[0].length - 1, // skip } + decoration: name as TextDecoration['style'] | TextDecoration['animation'], + isStyle: (DECORATION_STYLES as readonly string[]).includes(name), + }); + } + } + + // Sort by position, remove overlapping markers (first match wins) + markers.sort((a, b) => a.fullStart - b.fullStart); + const validMarkers: Marker[] = []; + let lastEnd = -1; + for (const m of markers) { + if (m.fullStart >= lastEnd) { + validMarkers.push(m); + lastEnd = m.fullEnd; + } + } + + // Phase 2: Build clean text and compute decoration ranges + let cleanText = ''; + const decorations: TextDecoration[] = []; + let i = 0; + let markerIdx = 0; + + while (i < input.length) { + if (markerIdx < validMarkers.length && i === validMarkers[markerIdx].fullStart) { + const marker = validMarkers[markerIdx]; + const content = input.slice(marker.contentStart, marker.contentEnd); + const decoStart = cleanText.length; + cleanText += content; + const decoEnd = cleanText.length; + + const deco: TextDecoration = { range: [decoStart, decoEnd] }; + if (marker.isStyle) { + deco.style = marker.decoration as TextDecoration['style']; + } else { + deco.animation = marker.decoration as TextDecoration['animation']; + } + decorations.push(deco); + + i = marker.fullEnd; + markerIdx++; + } else { + cleanText += input[i]; + i++; + } + } + + return { text: cleanText, decorations }; +} + +// Clean up LLM response formatting quirks before sending (SMS/RCS) +export function cleanResponse(text: string): string { + return text + .replace(/\n\s*-\s*/g, ' - ') + .replace(/(? 0 + ? decorations.map(d => ({ + ...d, + range: [d.range[0] - trimmedLeading, d.range[1] - trimmedLeading] as [number, number], + })).filter(d => d.range[1] > 0) + : decorations; + + return { text: cleaned, decorations: adjustedDecorations }; + } + + // SMS/RCS: strip everything including decoration syntax + let stripped = text; + for (const name of ALL_DECORATIONS) { + stripped = stripped.replace(new RegExp(`\\{${name}:([^}]+)\\}`, 'g'), '$1'); + } + return { text: cleanResponse(stripped), decorations: [] }; +}