Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 13 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<!\w)_([^_]+)_(?!\w)/g, '$1')
// Remove markdown bold (**text** → text)
.replace(/\*\*([^*]+)\*\*/g, '$1')
// Remove stray asterisks used for emphasis
.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, '$1')
// Clean up multiple spaces
.replace(/ +/g, ' ')
// Clean up extra newlines (but preserve intentional double-newlines for --- splits)
.replace(/\n{3,}/g, '\n\n')
.trim();
}
import { cleanResponse, processResponse } from './text/decorations.js';

// Track message count per chat for contact card sharing
const chatMessageCount = new Map<string, number>();
Expand Down Expand Up @@ -156,31 +139,35 @@ 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) {
const delay = 400 + Math.random() * 400; // 400-800ms feels natural
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
Expand Down
19 changes: 16 additions & 3 deletions src/linq/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -85,7 +94,7 @@ export interface MediaAttachment {
url: string;
}

export async function sendMessage(chatId: string, text: string, effect?: MessageEffect, replyTo?: ReplyTo, media?: MediaAttachment[]): Promise<SendMessageResponse> {
export async function sendMessage(chatId: string, text: string, effect?: MessageEffect, replyTo?: ReplyTo, media?: MediaAttachment[], textDecorations?: TextDecoration[]): Promise<SendMessageResponse> {
if (!API_TOKEN) {
throw new Error('LINQ_API_TOKEN not configured');
}
Expand All @@ -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) {
Expand Down
248 changes: 248 additions & 0 deletions src/text/decorations.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading