diff --git a/.zed/settings.json b/.zed/settings.json index c0f29b9..4a185c2 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -3,58 +3,58 @@ // For a full list of overridable settings, and general information on folder-specific settings, // see the documentation: https://zed.dev/docs/configuring-zed#settings-files { - "lsp": { - "deno": { - "settings": { + "lsp": { "deno": { - "enable": true, + "settings": { + "deno": { + "enable": true + } + } }, - }, + "json-language-server": { + "settings": { + "json": { + "schemas": [ + { + "fileMatch": ["deno.json", "deno.jsonc"], + "url": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json" + }, + { + "fileMatch": ["package.json"], + "url": "https://www.schemastore.org/package" + } + ] + } + } + } }, - "json-language-server": { - "settings": { - "json": { - "schemas": [ - { - "fileMatch": ["deno.json", "deno.jsonc"], - "url": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", - }, - { - "fileMatch": ["package.json"], - "url": "https://www.schemastore.org/package", - }, - ], + "languages": { + "JavaScript": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!vtsls", + "!eslint" + ], + "formatter": "language_server" }, - }, - }, - }, - "languages": { - "JavaScript": { - "language_servers": [ - "deno", - "!typescript-language-server", - "!vtsls", - "!eslint", - ], - "formatter": "language_server", - }, - "TypeScript": { - "language_servers": [ - "deno", - "!typescript-language-server", - "!vtsls", - "!eslint", - ], - "formatter": "language_server", - }, - "TSX": { - "language_servers": [ - "deno", - "!typescript-language-server", - "!vtsls", - "!eslint", - ], - "formatter": "language_server", - }, - }, + "TypeScript": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!vtsls", + "!eslint" + ], + "formatter": "language_server" + }, + "TSX": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!vtsls", + "!eslint" + ], + "formatter": "language_server" + } + } } diff --git a/AGENTS.md b/AGENTS.md index c3dbeb5..1fb9653 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,12 @@ # AGENTS.md Deno-first monorepo with: + - root app (`main.ts`, `lib/`, `scripts/`) - widget app (`widget/`, Svelte 5 + Vite) ## Root Commands (run from repo root) + - Typecheck: `deno check --allow-import main.ts` - Lint: `deno lint` - Format check: `deno fmt --check` @@ -17,6 +19,7 @@ Deno-first monorepo with: - Build widget assets: `scripts/build-widget.bash` ## Widget Commands (run from `widget/`) + - Dev server: `deno task dev` (long-running) - Typecheck: `deno task check` - Lint: `deno task lint` @@ -26,12 +29,14 @@ Deno-first monorepo with: - Preview: `deno task preview` (long-running) ## Verification Order + 1. Typecheck 2. Lint 3. Tests 4. Build (if relevant) Minimum gates: + - Root-only: `deno check --allow-import main.ts` + `deno lint` + `AI_TOKEN="test" deno test -A` - Widget-only: `deno task check` + `deno task lint` + `deno task build` diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 238660f..20fc863 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,9 +1,11 @@ # Don't be a Dick Code of Conduct ## Introduction + In the interest of simplifying the typical process of determining what a proper Code of Conduct should consist of, I've come up with the simplest, clearest, and most concise Code of Conduct ever. It is licensed under the "Do What The Fuck You Want To" Public License. ## Full Text of the Code of Conduct + The full text of the Don't be a Dick Code of Conduct is as follows: > If at anytime you choose to do something that a rational person of average intelligence could reasonably consider your actions as "Being a dick", you are in violation of this code of conduct. @@ -11,22 +13,29 @@ The full text of the Don't be a Dick Code of Conduct is as follows: ## Potential Questions and Concerns ### Is this a joke? + No. ### This doesn't codify the differences between proper and improper conduct -Yes it does. Let's face it, people that harass others know they're being dicks. Everyone in modern society has heard of the Golden Rule. There's a version of the Golden Rule in every major religion and therefore exists as common knowledge in every modern society in the World. *People know what dickish behavior is* even if they don't want to admit it. + +Yes it does. Let's face it, people that harass others know they're being dicks. Everyone in modern society has heard of the Golden Rule. There's a version of the Golden Rule in every major religion and therefore exists as common knowledge in every modern society in the World. _People know what dickish behavior is_ even if they don't want to admit it. ### I think you're making light of codes of conduct -Absolutely not. But I guess I am making light of the bikeshedding that takes place when considering codes of conduct. Instead of arguing over and over about what should or should not exist in a Code of Conduct, we should trust that people understand when they're being a dick or not. If someone violates the Code of Conduct, boot them without prejudice. Boot them with zero tolerance. Boot them from your conference/ project/ workplace/ whatever and move on. + +Absolutely not. But I guess I am making light of the bikeshedding that takes place when considering codes of conduct. Instead of arguing over and over about what should or should not exist in a Code of Conduct, we should trust that people understand when they're being a dick or not. If someone violates the Code of Conduct, boot them without prejudice. Boot them with zero tolerance. Boot them from your conference/ project/ workplace/ whatever and move on. ### How do I enforce this Code of Conduct? + Have some guts and do it. Verify the complaint. Determine if you think the accused person has been a dick. Then, confront the person, tell them they're a dick and that they need to go away. ### I don't believe in Codes of Conduct. They're for wimps and whiners + Then you're a dick. ## How to Use This CoC in Your Project + If you'd like to use the "Don't Be a Dick" Code of Conduct in your own project, you can do so by copying the contents of [the Code of Conduct](https://github.com/karlgroves/dontbeadick?tab=readme-ov-file#full-text-of-the-code-of-conduct) into a `CODE_OF_CONDUCT.md` file in the root of your repository. You may also link back to this repository as the source. ## Contributor Guidelines + All contributors are expected to adhere to the [Don't Be a Dick Code of Conduct](https://github.com/karlgroves/dontbeadick?tab=readme-ov-file#full-text-of-the-code-of-conduct). Please read it before participating in discussions, opening issues, or submitting pull requests. diff --git a/deno.json b/deno.json index 667f692..7c80f1b 100644 --- a/deno.json +++ b/deno.json @@ -1,56 +1,56 @@ { - "compilerOptions": { - "strict": true - }, - "lint": { - "include": [ - "*.ts", - "lib/" - ], - "rules": { - "tags": [ - "recommended" - ] + "compilerOptions": { + "strict": true + }, + "lint": { + "include": [ + "*.ts", + "lib/" + ], + "rules": { + "tags": [ + "recommended" + ] + } + }, + "fmt": { + "include": [ + "*.ts", + "lib/" + ], + "useTabs": false, + "lineWidth": 80, + "indentWidth": 4, + "semiColons": true, + "singleQuote": true, + "proseWrap": "preserve" + }, + "nodeModulesDir": "auto", + "imports": { + "@google/genai": "npm:@google/genai@^1.44.0", + "@opentelemetry/auto-instrumentations-node": "npm:@opentelemetry/auto-instrumentations-node@^0.71.0", + "@opentelemetry/sdk-node": "npm:@opentelemetry/sdk-node@^0.213.0", + "@std/assert": "jsr:@std/assert@^1.0.19", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/fs": "jsr:@std/fs@^1.0.23", + "ai": "npm:ai@^6.0.116", + "@ai-sdk/google": "npm:@ai-sdk/google@^3.0.43", + "@ai-sdk/openai-compatible": "npm:@ai-sdk/openai-compatible@^2.0.35", + "@libsql/client": "npm:@libsql/client@^0.17.0", + "@deno-library/logger": "jsr:@deno-library/logger@^1.2.0", + "drizzle-kit": "npm:drizzle-kit@^0.31.9", + "drizzle-orm": "npm:drizzle-orm@^0.45.1", + "grammy": "https://deno.land/x/grammy@v1.38.2/mod.ts", + "@grammyjs/runner": "https://deno.land/x/grammy_runner@v2.0.3/mod.ts", + "grammy_types": "https://deno.land/x/grammy_types@v3.21.0/mod.ts", + "isomorphic-dompurify": "npm:isomorphic-dompurify@^3.0.0", + "ky": "npm:ky@^1.14.3", + "grammy_ratelimiter": "https://deno.land/x/grammy_ratelimiter@v1.2.1/mod.ts", + "@grammyjs/i18n": "https://deno.land/x/grammy_i18n@v1.1.0/mod.ts", + "langfuse-vercel": "npm:langfuse-vercel@^3.38.6", + "remark-html": "npm:remark-html@^16.0.1", + "remark-parse": "npm:remark-parse@^11.0.0", + "unified": "npm:unified@^11.0.5", + "zod": "npm:zod@^4.3.6" } - }, - "fmt": { - "include": [ - "*.ts", - "lib/" - ], - "useTabs": false, - "lineWidth": 80, - "indentWidth": 4, - "semiColons": true, - "singleQuote": true, - "proseWrap": "preserve" - }, - "nodeModulesDir": "auto", - "imports": { - "@google/genai": "npm:@google/genai@^1.44.0", - "@opentelemetry/auto-instrumentations-node": "npm:@opentelemetry/auto-instrumentations-node@^0.71.0", - "@opentelemetry/sdk-node": "npm:@opentelemetry/sdk-node@^0.213.0", - "@std/assert": "jsr:@std/assert@^1.0.19", - "@std/encoding": "jsr:@std/encoding@^1.0.10", - "@std/fs": "jsr:@std/fs@^1.0.23", - "ai": "npm:ai@^6.0.116", - "@ai-sdk/google": "npm:@ai-sdk/google@^3.0.43", - "@ai-sdk/openai-compatible": "npm:@ai-sdk/openai-compatible@^2.0.35", - "@libsql/client": "npm:@libsql/client@^0.17.0", - "@deno-library/logger": "jsr:@deno-library/logger@^1.2.0", - "drizzle-kit": "npm:drizzle-kit@^0.31.9", - "drizzle-orm": "npm:drizzle-orm@^0.45.1", - "grammy": "https://deno.land/x/grammy@v1.38.2/mod.ts", - "@grammyjs/runner": "https://deno.land/x/grammy_runner@v2.0.3/mod.ts", - "grammy_types": "https://deno.land/x/grammy_types@v3.21.0/mod.ts", - "isomorphic-dompurify": "npm:isomorphic-dompurify@^3.0.0", - "ky": "npm:ky@^1.14.3", - "grammy_ratelimiter": "https://deno.land/x/grammy_ratelimiter@v1.2.1/mod.ts", - "@grammyjs/i18n": "https://deno.land/x/grammy_i18n@v1.1.0/mod.ts", - "langfuse-vercel": "npm:langfuse-vercel@^3.38.6", - "remark-html": "npm:remark-html@^16.0.1", - "remark-parse": "npm:remark-parse@^11.0.0", - "unified": "npm:unified@^11.0.5", - "zod": "npm:zod@^4.3.6" - } } diff --git a/drizzle/0005_history_v3_threads.sql b/drizzle/0005_history_v3_threads.sql new file mode 100644 index 0000000..630d7d8 --- /dev/null +++ b/drizzle/0005_history_v3_threads.sql @@ -0,0 +1,27 @@ +ALTER TABLE `chat_messages` ADD `thread_id` text; +--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `thread_root_message_id` integer; +--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `thread_parent_message_id` integer; +--> statement-breakpoint +ALTER TABLE `chat_messages` ADD `thread_source` text; +--> statement-breakpoint +UPDATE `chat_messages` +SET + `thread_parent_message_id` = `reply_to_id`, + `thread_root_message_id` = CASE + WHEN `reply_to_id` IS NOT NULL THEN `reply_to_id` + ELSE `message_id` + END, + `thread_id` = 'legacy:' || CAST(CASE + WHEN `reply_to_id` IS NOT NULL THEN `reply_to_id` + ELSE `message_id` + END AS text), + `thread_source` = CASE + WHEN `reply_to_id` IS NOT NULL THEN 'legacy_reply' + ELSE 'legacy_single' + END +WHERE `thread_id` IS NULL; +--> statement-breakpoint +CREATE INDEX `chat_messages_chat_id_thread_id_message_id_idx` +ON `chat_messages` (`chat_id`, `thread_id`, `message_id`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d95815d..ea34525 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1772809200000, "tag": "0004_fix_messages_to_pass", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1772895600000, + "tag": "0005_history_v3_threads", + "breakpoints": true } ] } diff --git a/lib/ai/chat-context.ts b/lib/ai/chat-context.ts new file mode 100644 index 0000000..a202113 --- /dev/null +++ b/lib/ai/chat-context.ts @@ -0,0 +1,96 @@ +import { ChatMessage, Member } from '../memory.ts'; + +interface ChatPromptAdditionParams { + chatType: string; + isComments: boolean; + privateChatPromptAddition?: string; + commentsPromptAddition?: string; + groupChatPromptAddition?: string; +} + +interface BuildChatInfoBlockParams { + nowText: string; + chatType: string; + chatTitle?: string; + userFirstName?: string; + userUsername?: string; + activeMembers?: Member[]; + notes?: string[]; + memory?: string; + includeNotes?: boolean; + includeMemory?: boolean; +} + +export function isTelegramCommentsHistory(history: ChatMessage[]): boolean { + return history.some((message) => + message.info.forward_origin?.type === 'channel' && + message.info.from?.first_name === 'Telegram' + ); +} + +export function buildChatPromptAddition( + params: ChatPromptAdditionParams, +): string { + const { + chatType, + isComments, + privateChatPromptAddition, + commentsPromptAddition, + groupChatPromptAddition, + } = params; + + if (chatType === 'private') { + return privateChatPromptAddition ?? ''; + } + + if (isComments) { + return commentsPromptAddition ?? ''; + } + + return groupChatPromptAddition ?? ''; +} + +export function buildChatInfoBlock(params: BuildChatInfoBlockParams): string { + const { + nowText, + chatType, + chatTitle, + userFirstName, + userUsername, + activeMembers = [], + notes = [], + memory, + includeNotes = false, + includeMemory = false, + } = params; + + let text = `Date and time right now: ${nowText}`; + + if (chatType === 'private') { + text += `\nЛичный чат с ${userFirstName ?? 'User'} (@${ + userUsername ?? 'unknown' + })`; + } else if (activeMembers.length > 0) { + const prettyMembersList = activeMembers.map((member) => { + let line = `- ${member.first_name}`; + if (member.username) { + line += ` (@${member.username})`; + } + return line; + }).join('\n'); + + text += `\nChat: ${ + chatTitle ?? 'Unknown chat' + }, Active members:\n${prettyMembersList}`; + } + + if (includeNotes && notes.length > 0) { + text += `\n\nChat notes:\n${notes.join('\n')}`; + } + + if (includeMemory && memory) { + text += `\n\nMY OWN PERSONAL NOTES AND MEMORY:\n${memory}`; + } + + return text; +} diff --git a/lib/ai/chat-context_test.ts b/lib/ai/chat-context_test.ts new file mode 100644 index 0000000..4ded604 --- /dev/null +++ b/lib/ai/chat-context_test.ts @@ -0,0 +1,83 @@ +import { assertEquals } from '@std/assert'; +import { + buildChatInfoBlock, + buildChatPromptAddition, + isTelegramCommentsHistory, +} from './chat-context.ts'; +import { ChatMessage, Member } from '../memory.ts'; +import { Message, User } from 'grammy_types'; + +function createMessageWithForwardFromTelegram(): ChatMessage { + return { + id: 1, + text: '', + isMyself: false, + info: { + forward_origin: { type: 'channel' }, + from: { first_name: 'Telegram' }, + } as unknown as Message, + }; +} + +function createMember(first_name: string, username?: string): Member { + return { + id: 1, + first_name, + username, + description: '', + info: {} as unknown as User, + lastUse: Date.now(), + }; +} + +Deno.test('isTelegramCommentsHistory detects telegram comments', () => { + const history: ChatMessage[] = [createMessageWithForwardFromTelegram()]; + assertEquals(isTelegramCommentsHistory(history), true); +}); + +Deno.test('buildChatPromptAddition resolves by chat mode', () => { + assertEquals( + buildChatPromptAddition({ + chatType: 'private', + isComments: false, + privateChatPromptAddition: 'private', + commentsPromptAddition: 'comments', + groupChatPromptAddition: 'group', + }), + 'private', + ); + assertEquals( + buildChatPromptAddition({ + chatType: 'group', + isComments: true, + privateChatPromptAddition: 'private', + commentsPromptAddition: 'comments', + groupChatPromptAddition: 'group', + }), + 'comments', + ); +}); + +Deno.test('buildChatInfoBlock renders active members and notes', () => { + const members: Member[] = [ + createMember('Ann', 'ann'), + createMember('Bob'), + ]; + + const text = buildChatInfoBlock({ + nowText: 'now', + chatType: 'group', + chatTitle: 'Test chat', + activeMembers: members, + notes: ['n1'], + memory: 'm1', + includeNotes: true, + includeMemory: true, + }); + + assertEquals(text.includes('Date and time right now: now'), true); + assertEquals(text.includes('Chat: Test chat, Active members:'), true); + assertEquals(text.includes('- Ann (@ann)'), true); + assertEquals(text.includes('Chat notes:\nn1'), true); + assertEquals(text.includes('MY OWN PERSONAL NOTES AND MEMORY:\nm1'), true); +}); diff --git a/lib/ai/chat-generation.ts b/lib/ai/chat-generation.ts new file mode 100644 index 0000000..6a42897 --- /dev/null +++ b/lib/ai/chat-generation.ts @@ -0,0 +1,64 @@ +export type ReplyMethod = 'json_actions' | 'plain_text_reactions'; + +export type GenerationFallbackLevel = + | 'full' + | 'short_history' + | 'short_history_no_notes'; + +export type GenerationAttemptPlan = { + level: GenerationFallbackLevel; + historyLimit: number; + includeBotNotes: boolean; +}; + +export function resolveReplyMethod( + configuredMethod: string | undefined, +): ReplyMethod { + if (configuredMethod === 'json_actions') { + return 'json_actions'; + } + if (configuredMethod === 'plain_text_reactions') { + return 'plain_text_reactions'; + } + + return 'json_actions'; +} + +export function splitTextByTwoLines(text: string): string[] { + return text + .split(/\r?\n\s*\r?\n+/) + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.length > 0); +} + +export function getGenerationFallbackPlans( + messagesToPass: number, +): GenerationAttemptPlan[] { + const shortHistoryLimit = Math.max(2, Math.floor(messagesToPass / 2)); + + return [ + { + level: 'full', + historyLimit: messagesToPass, + includeBotNotes: true, + }, + { + level: 'short_history', + historyLimit: shortHistoryLimit, + includeBotNotes: true, + }, + { + level: 'short_history_no_notes', + historyLimit: shortHistoryLimit, + includeBotNotes: false, + }, + ]; +} + +export function resolveCustomPrompt( + configured: string | undefined, + fallback: string, +): string { + const trimmed = configured?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : fallback; +} diff --git a/lib/ai/chat-generation_test.ts b/lib/ai/chat-generation_test.ts new file mode 100644 index 0000000..fecee1f --- /dev/null +++ b/lib/ai/chat-generation_test.ts @@ -0,0 +1,44 @@ +import { assertEquals } from '@std/assert'; +import { + getGenerationFallbackPlans, + resolveCustomPrompt, + resolveReplyMethod, + splitTextByTwoLines, +} from './chat-generation.ts'; + +Deno.test('resolveReplyMethod falls back to json_actions', () => { + assertEquals(resolveReplyMethod(undefined), 'json_actions'); + assertEquals(resolveReplyMethod('unknown'), 'json_actions'); + assertEquals( + resolveReplyMethod('plain_text_reactions'), + 'plain_text_reactions', + ); +}); + +Deno.test('splitTextByTwoLines splits by blank lines', () => { + assertEquals(splitTextByTwoLines('a\nb\n\n c\n\n'), ['a\nb', 'c']); +}); + +Deno.test('splitTextByTwoLines keeps metadata block with text', () => { + assertEquals( + splitTextByTwoLines( + '\n{"target_ref":"t0"}\n\nreply', + ), + ['\n{"target_ref":"t0"}\n\nreply'], + ); +}); + +Deno.test('getGenerationFallbackPlans uses short history second stage', () => { + const plans = getGenerationFallbackPlans(9); + assertEquals(plans.map((p) => p.level), [ + 'full', + 'short_history', + 'short_history_no_notes', + ]); + assertEquals(plans[1].historyLimit, 4); +}); + +Deno.test('resolveCustomPrompt trims configured value', () => { + assertEquals(resolveCustomPrompt(' custom ', 'fallback'), 'custom'); + assertEquals(resolveCustomPrompt(' ', 'fallback'), 'fallback'); +}); diff --git a/lib/ai/schema.ts b/lib/ai/schema.ts index a86197a..c971ef2 100644 --- a/lib/ai/schema.ts +++ b/lib/ai/schema.ts @@ -10,7 +10,7 @@ export const chatEntrySchema = z.object({ message: 'Reply action must include text', }); -const reactionEntrySchema = z.object({ +export const reactionEntrySchema = z.object({ type: z.literal('react'), react: z.string(), target_ref: z.string().regex(/^t\d+$/).optional(), @@ -25,6 +25,9 @@ export const chatResponseSchema = z.array(chatActionSchema); export const chatActionsToolInputSchema = z.object({ entries: chatResponseSchema, }); +export const chatReactionsToolInputSchema = z.object({ + entries: z.array(reactionEntrySchema), +}); const legacyChatEntrySchema = z.object({ text: z.string().optional(), diff --git a/lib/ai/target-refs.ts b/lib/ai/target-refs.ts index 6ecbedd..5833395 100644 --- a/lib/ai/target-refs.ts +++ b/lib/ai/target-refs.ts @@ -1,6 +1,11 @@ import { ModelMessage } from 'ai'; import { ChatMessage } from '../memory.ts'; +const HISTORY_META_OPEN = ''; +const HISTORY_META_CLOSE = ''; +const historyMetaPrefixRegex = + /^\s*\r?\n([\s\S]*?)\r?\n<\/slusha_meta>/; + export interface TargetRef { ref: string; messageId: number; @@ -88,10 +93,46 @@ export function annotateHistoryWithTargetRefs( ); const annotateText = (text: string): string => { - return text.replace(/^\[m(\d+)\]/, (full, id) => { - const ref = refByMessageId.get(Number(id)); - return ref ? `[${ref}]${full}` : full; - }); + const metaMatch = text.match(historyMetaPrefixRegex); + if (!metaMatch) { + return text; + } + + let parsedMeta: Record; + try { + const parsed = JSON.parse(metaMatch[1]); + if (!parsed || typeof parsed !== 'object') { + return text; + } + parsedMeta = parsed as Record; + } catch { + return text; + } + + const messageId = parsedMeta.message_id; + const resolvedMessageId = typeof messageId === 'number' + ? messageId + : typeof messageId === 'string' && /^\d+$/.test(messageId) + ? Number(messageId) + : undefined; + if (typeof resolvedMessageId !== 'number') { + return text; + } + + const targetRef = refByMessageId.get(resolvedMessageId); + if (!targetRef || parsedMeta.target_ref === targetRef) { + return text; + } + + const nextMeta: Record = { + ...parsedMeta, + target_ref: targetRef, + }; + + const nextMetaBlock = `${HISTORY_META_OPEN}\n${ + JSON.stringify(nextMeta) + }\n${HISTORY_META_CLOSE}`; + return `${nextMetaBlock}${text.slice(metaMatch[0].length)}`; }; return history.map((entry) => { diff --git a/lib/ai/target-refs_test.ts b/lib/ai/target-refs_test.ts index 3225a98..a90cdb3 100644 --- a/lib/ai/target-refs_test.ts +++ b/lib/ai/target-refs_test.ts @@ -51,12 +51,20 @@ Deno.test('buildTargetRefsPrompt returns fallback when no targets', () => { assertStringIncludes(prompt, 'There are no explicit targets right now'); }); -Deno.test('annotateHistoryWithTargetRefs annotates m-id prefixes', () => { +Deno.test('annotateHistoryWithTargetRefs annotates history meta block', () => { const messages: ModelMessage[] = [ - { role: 'user', content: '[m42][u1:@alice] Alice:\nhi' }, + { + role: 'user', + content: + '\n{"message_id":42,"author":{"id":"1","tag":"@alice","name":"Alice"},"date":"2026-03-08 05:58:00 +03:00"}\n\nhi', + }, { role: 'assistant', - content: [{ type: 'text', text: '[m41] ok' }], + content: [{ + type: 'text', + text: + '\n{"kind":"history_message_meta","message_id":41,"author_id":"943542647","author_tag":"@sl_chatbot"}\n\nok', + }], }, ]; @@ -72,11 +80,12 @@ Deno.test('annotateHistoryWithTargetRefs annotates m-id prefixes', () => { assertEquals(annotated[0], { role: 'user', - content: '[t0][m42][u1:@alice] Alice:\nhi', + content: + '\n{"message_id":42,"author":{"id":"1","tag":"@alice","name":"Alice"},"date":"2026-03-08 05:58:00 +03:00","target_ref":"t0"}\n\nhi', }); assertEquals( (annotated[1].content as Array<{ type: string; text: string }>)[0] .text, - '[t1][m41] ok', + '\n{"kind":"history_message_meta","message_id":41,"author_id":"943542647","author_tag":"@sl_chatbot","target_ref":"t1"}\n\nok', ); }); diff --git a/lib/config.ts b/lib/config.ts index 25fbded..90745b8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -24,6 +24,11 @@ const matcherSchema = z.union([ ]); const thinkingLevelSchema = z.enum(['minimal', 'low', 'medium', 'high']); +const replyMethodSchema = z.enum([ + 'json_actions', + 'plain_text_reactions', +]); +const historyVersionSchema = z.enum(['v2', 'v3']); const googleThinkingConfigSchema = z.object({ thinkingLevel: thinkingLevelSchema.optional(), thinkingBudget: z.number().int().min(0).max(65536).optional(), @@ -71,10 +76,10 @@ export const configSchema = z.object({ prompt: z.string().max(20000), dumbPrompt: z.string().max(10000).optional(), /** - * When false, bot will not expect JSON array output and will - * generate a single plain text message instead. Reactions are disabled. + * Selects chat reply strategy for message generation and tool usage. */ - useJsonResponses: z.boolean().default(true), + replyMethod: replyMethodSchema.optional(), + historyVersion: historyVersionSchema.default('v2'), /** * Optional alternative pre-prompt for dumb models that don't output JSON */ @@ -87,6 +92,14 @@ export const configSchema = z.object({ * Smart-mode final prompt (expects JSON array response) */ finalPrompt: z.string().max(20000), + /** + * Optional override for send_chat_actions tool description. + */ + chatActionsToolDescription: z.string().max(20000).optional(), + /** + * Optional override for send_chat_reactions tool description in plain_text_reactions mode. + */ + chatReactionsToolDescription: z.string().max(20000).optional(), /** * Optional alternative final prompt for dumb models (plain text) */ @@ -154,7 +167,7 @@ export const configSchema = z.object({ 'gemini-3.1-flash-lite-preview', ]), maxNotesToStore: boundedPositiveInt(1, 200).default(5), - maxMessagesToStore: boundedPositiveInt(1, 2000).default(100), + maxMessagesToStore: boundedPositiveInt(1, 10000).default(100), chatLastUseNotes: boundedPositiveInt(1, 100).default(3), chatLastUseMemory: boundedPositiveInt(1, 100).default(2), responseDelay: z.number().min(0).max(120).default(1), @@ -174,7 +187,8 @@ const chatOverrideAiSchema = z.object({ commentsPromptAddition: configSchema.shape.ai.shape.commentsPromptAddition .optional(), hateModePrompt: configSchema.shape.ai.shape.hateModePrompt.optional(), - useJsonResponses: configSchema.shape.ai.shape.useJsonResponses.optional(), + replyMethod: configSchema.shape.ai.shape.replyMethod.optional(), + historyVersion: configSchema.shape.ai.shape.historyVersion.optional(), messagesToPass: configSchema.shape.ai.shape.messagesToPass.optional(), messageMaxLength: configSchema.shape.ai.shape.messageMaxLength.optional(), includeAttachmentsInHistory: configSchema.shape.ai.shape diff --git a/lib/db/schema.ts b/lib/db/schema.ts index e23480d..086671a 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -86,6 +86,12 @@ export const chatMessages = sqliteTable('chat_messages', { replyToText: text('reply_to_text'), replyToIsMyself: integer('reply_to_is_myself', { mode: 'boolean' }), replyToInfo: text('reply_to_info'), + threadId: text('thread_id'), + threadRootMessageId: integer('thread_root_message_id', { mode: 'number' }), + threadParentMessageId: integer('thread_parent_message_id', { + mode: 'number', + }), + threadSource: text('thread_source'), info: text('info').notNull(), }, (table) => ({ pk: primaryKey({ columns: [table.chatId, table.messageId] }), diff --git a/lib/default-config.ts b/lib/default-config.ts index f83034b..ddf952f 100644 --- a/lib/default-config.ts +++ b/lib/default-config.ts @@ -28,6 +28,12 @@ const prompt = 'Your character is Слюша. She is cute and dumb.'; const finalPrompt = 'Answer must be concise. Return only JSON array of typed actions using target_ref from Reply Target Map.'; +const chatActionsToolDescription = + 'Submit Telegram actions once per turn. Return entries where each item is either {"type":"reply","text":"...","target_ref":"tN"} or {"type":"react","react":"❤","target_ref":"tN"}. Use target_ref values from Reply Target Map. If target_ref is omitted, action applies to the triggering message.'; + +const chatReactionsToolDescription = + 'Submit Telegram reactions once per turn. Return entries containing only {"type":"react","react":"❤","target_ref":"tN"}. Do not include reply text.'; + const notesPrompt = 'Напиши краткое обзор важных событий в трех-пяти пунктах без нумирации. Твой ответ должен содержать только пункты событий чата.'; @@ -76,7 +82,8 @@ const defaultConfig = { startMessage: 'Привет! Я Слюша, бот-гений.', ai: { model: 'gemini-3.1-flash-lite-preview', - useJsonResponses: true, + replyMethod: 'json_actions', + historyVersion: 'v2', notesModel: 'gemini-3.1-flash-lite-preview', memoryModel: 'gemini-3.1-flash-lite-preview', prePrompt, @@ -98,6 +105,8 @@ const defaultConfig = { memoryPrompt, memoryPromptRepeat, finalPrompt, + chatActionsToolDescription, + chatReactionsToolDescription, dumbFinalPrompt: 'Ответь одним коротким сообщением простым текстом.', temperature: 0.55, topK: 32, diff --git a/lib/history.ts b/lib/history.ts index 848f310..9e33a66 100644 --- a/lib/history.ts +++ b/lib/history.ts @@ -19,6 +19,8 @@ interface HistoryOptions { attachments?: boolean; resolveReplyThread?: boolean; includeReactions?: boolean; + historyVersion?: 'v2' | 'v3'; + activeMessageId?: number; } interface HistoryCandidate { @@ -26,6 +28,15 @@ interface HistoryCandidate { rootIndex: number; } +const HISTORY_META_OPEN = ''; +const HISTORY_META_CLOSE = ''; + +function buildHistoryMetadataBlock(metadata: Record): string { + return `${HISTORY_META_OPEN}\n${ + JSON.stringify(metadata) + }\n${HISTORY_META_CLOSE}`; +} + function collectReplyThread( history: ChatMessage[], msg: ChatMessage, @@ -100,6 +111,133 @@ export function selectHistoryCandidates( return selected; } +function sameThread(left: ChatMessage, right: ChatMessage): boolean { + if (left.threadId && right.threadId) { + return left.threadId === right.threadId; + } + + if (left.threadRootMessageId && right.threadRootMessageId) { + return left.threadRootMessageId === right.threadRootMessageId; + } + + return false; +} + +function hasThreadMetadata(history: ChatMessage[]): boolean { + return history.some((msg) => + Boolean(msg.threadId) || typeof msg.threadRootMessageId === 'number' + ); +} + +export function selectHistoryCandidatesV3( + history: ChatMessage[], + options: { + maxRootMessages?: number; + activeMessageId?: number; + }, +): HistoryCandidate[] { + if (!hasThreadMetadata(history)) { + return selectHistoryCandidates(history, { + resolveReplyThread: true, + maxRootMessages: options.maxRootMessages, + }); + } + + const selected: HistoryCandidate[] = []; + const seen = new Set(); + + const activeThreadAnchor = typeof options.activeMessageId === 'number' + ? history.find((msg) => msg.id === options.activeMessageId) + : undefined; + + let fallbackAnchor: ChatMessage | undefined; + if (!activeThreadAnchor) { + for (let i = history.length - 1; i >= 0; i--) { + const candidate = history[i]; + if (!candidate.isMyself) { + fallbackAnchor = candidate; + break; + } + } + } + + const effectiveAnchor = activeThreadAnchor ?? fallbackAnchor; + const anchorTopicId = typeof effectiveAnchor?.info.message_thread_id === + 'number' + ? effectiveAnchor.info.message_thread_id + : undefined; + const scopedToTelegramTopic = typeof anchorTopicId === 'number'; + + function isInScopedTopic(msg: ChatMessage): boolean { + if (!scopedToTelegramTopic) { + return true; + } + + return msg.info.message_thread_id === anchorTopicId; + } + + const maxRootMessages = options.maxRootMessages; + const activeThreadBudget = typeof maxRootMessages === 'number' + ? Math.max(1, Math.floor(maxRootMessages * 0.7)) + : undefined; + + let activeThreadTaken = 0; + if (effectiveAnchor) { + for (let i = history.length - 1; i >= 0; i--) { + const msg = history[i]; + if (seen.has(msg.id)) { + continue; + } + if (!isInScopedTopic(msg)) { + continue; + } + if (!scopedToTelegramTopic && !sameThread(msg, effectiveAnchor)) { + continue; + } + if ( + typeof activeThreadBudget === 'number' && + activeThreadTaken >= activeThreadBudget + ) { + break; + } + + selected.push({ + msg, + rootIndex: i, + }); + seen.add(msg.id); + activeThreadTaken += 1; + } + } + + let rootMessagesProcessed = 0; + for (let i = history.length - 1; i >= 0; i--) { + const msg = history[i]; + if (!isInScopedTopic(msg)) { + continue; + } + + if ( + typeof maxRootMessages === 'number' && + rootMessagesProcessed >= maxRootMessages + ) { + break; + } + rootMessagesProcessed += 1; + + if (seen.has(msg.id)) { + continue; + } + selected.push({ + msg, + rootIndex: i, + }); + seen.add(msg.id); + } + + return selected; +} + type PrintType = (name: keyof Message, msg: Message) => string; function getAttachmentDefault(name: keyof Message) { @@ -243,11 +381,21 @@ function getUnsupportedContentFields(msgInfo: Message): string[] { ); } -function getTimeString(date: Date): string { +function getDateString(date: Date): string { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${hours}:${minutes}`; + const offsetMinutes = -date.getTimezoneOffset(); + const offsetSign = offsetMinutes >= 0 ? '+' : '-'; + const absOffsetMinutes = Math.abs(offsetMinutes); + const offsetHours = String(Math.floor(absOffsetMinutes / 60)).padStart(2, '0'); + const offsetMins = String(absOffsetMinutes % 60).padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${offsetSign}${offsetHours}:${offsetMins}`; } async function constructMsg( @@ -331,100 +479,102 @@ async function constructMsg( // const prettyInputMessage = JSON.stringify(inputMessage, null, 2); // const prettyInputMessage = JSON.stringify(inputMessage); - let prettyInputMessage = ''; - const authorId = typeof msg.info.from?.id === 'number' ? msg.info.from.id.toString() : 'unknown'; const authorTag = user.username ?? firstName; + const messageDate = typeof msg.info.date === 'number' + ? getDateString(new Date(msg.info.date * 1000)) + : undefined; const replyTargetId = msg.replyTo?.id; - - prettyInputMessage += `[m${msg.id}][u${authorId}:${authorTag}]`; + const messageMeta: Record = { + message_id: msg.id, + author: { + id: authorId, + tag: authorTag, + name: firstName, + }, + }; + if (messageDate) { + messageMeta.date = messageDate; + } if (typeof replyTargetId === 'number') { - prettyInputMessage += `[reply_to:m${replyTargetId}]`; + messageMeta.reply_to_message_id = replyTargetId; } - prettyInputMessage += ' '; - - if (characterName && msg.isMyself) { - prettyInputMessage += `${characterName}`; - if (user.username) { - prettyInputMessage += ` (${user.username})`; - } - - prettyInputMessage += ` [${ - getTimeString(new Date(msg.info.date * 1000)) - }]`; - - prettyInputMessage += `:\n`; + if (msg.threadId) { + messageMeta.thread_id = msg.threadId; } - - if (!msg.isMyself) { - prettyInputMessage += `${user.name}`; - if (user.username) { - prettyInputMessage += ` (${user.username})`; - } - - if (replyTo) { - prettyInputMessage += ` `; - } - - if (msg.info.forward_origin) { - const prettyJsonObject = JSON.stringify(removeFieldsWithSuffixes( - msg.info.forward_origin, - )); - - if (user.name === 'Telegram') { - prettyInputMessage += - ` `; - } else { - prettyInputMessage += ` `; - } - } - - if (msg.info.via_bot) { - prettyInputMessage += - ` `; - } - - if (msg.info.quote?.text) { - prettyInputMessage += `\n `; - } - - prettyInputMessage += ` [${ - getTimeString(new Date(msg.info.date * 1000)) - }]`; - - prettyInputMessage += `:\n`; + if (typeof msg.threadRootMessageId === 'number') { + messageMeta.thread_root_message_id = msg.threadRootMessageId; + } + if (typeof msg.threadParentMessageId === 'number') { + messageMeta.thread_parent_message_id = msg.threadParentMessageId; + } + if (msg.threadSource) { + messageMeta.thread_source = msg.threadSource; + } + if (replyTo) { + messageMeta.reply_to_author_tag = replyTo; + } + if (msg.info.forward_origin) { + messageMeta.forward_origin = removeFieldsWithSuffixes( + msg.info.forward_origin, + ); + } + if (msg.info.via_bot?.username) { + messageMeta.via_bot = `@${msg.info.via_bot.username}`; + } + if (msg.info.quote?.text) { + messageMeta.quote_text = msg.info.quote.text; } + let prettyInputMessage = ''; + if (characterName && msg.isMyself) { + messageMeta.character = characterName; + } prettyInputMessage += `${text.trim()}`; if ( includeReactions && msg.reactions && Object.keys(msg.reactions).length > 0 ) { - const parts: string[] = []; + const reactions: Array> = []; for (const rec of Object.values(msg.reactions)) { - let label = ''; - if (rec.type === 'emoji' && rec.emoji) label = rec.emoji; - else if (rec.type === 'custom' && rec.customEmojiId) { - label = `custom:${rec.customEmojiId}`; - } else continue; - - if (rec.by && rec.by.length > 0) { - const users = rec.by.map((u) => - u.username ? `@${u.username}` : u.name - ).join(', '); - parts.push(`${label} by ${users}`); - } else if (rec.count && rec.count > 0) { - parts.push(`${label} x${rec.count}`); + if (rec.type === 'emoji' && rec.emoji) { + reactions.push({ + type: 'emoji', + emoji: rec.emoji, + count: rec.count, + by: rec.by.map((user) => + user.username ? `@${user.username}` : user.name + ), + }); + continue; + } + + if (rec.type === 'custom' && rec.customEmojiId) { + reactions.push({ + type: 'custom', + custom_emoji_id: rec.customEmojiId, + count: rec.count, + by: rec.by.map((user) => + user.username ? `@${user.username}` : user.name + ), + }); } } - if (parts.length > 0) { - prettyInputMessage += ` `; + + if (reactions.length > 0) { + messageMeta.reactions = reactions; } } + const messageBody = prettyInputMessage.trim(); + prettyInputMessage = buildHistoryMetadataBlock(messageMeta); + if (messageBody.length > 0) { + prettyInputMessage += `\n${messageBody}`; + } + parts.push({ type: 'text', text: prettyInputMessage, @@ -458,35 +608,61 @@ async function constructMsg( } as ModelMessage; } -export async function makeHistoryV2( +interface BuildHistoryContextOptions { + mode: 'chat' | 'notes'; + symbolLimit: number; + messagesLimit: number; + bytesLimit: number; + attachments?: boolean; + resolveReplyThread?: boolean; + includeReactions?: boolean; + characterName?: string; + historyVersion?: 'v2' | 'v3'; + activeMessageId?: number; +} + +export async function buildHistoryContext( botInfo: { token: string; id: number }, api: Api, logger: Logger, history: ChatMessage[], - options: HistoryOptions, + options: BuildHistoryContextOptions, ): Promise { - const { messagesLimit, bytesLimit, symbolLimit } = options; - const resolveReplies = options.resolveReplyThread ?? true; + const { mode, messagesLimit, bytesLimit, symbolLimit } = options; + const resolveReplies = mode === 'chat' + ? (options.resolveReplyThread ?? true) + : false; let totalBytes = 0; let totalAttachments = 0; const prompt: ModelMessage[] = []; + let textPart = ''; - const candidates = selectHistoryCandidates(history, { - resolveReplyThread: resolveReplies, - }); + const candidates = mode === 'chat' && options.historyVersion === 'v3' + ? selectHistoryCandidatesV3(history, { + maxRootMessages: undefined, + activeMessageId: options.activeMessageId, + }) + : selectHistoryCandidates(history, { + resolveReplyThread: resolveReplies, + maxRootMessages: mode === 'notes' ? messagesLimit : undefined, + }); for (const candidate of candidates) { const msg = candidate.msg; - let attachAttachments = options.attachments ?? true; - // If message is too old, don't attach attachments - if (candidate.rootIndex < history.length - 10 || totalAttachments > 2) { - attachAttachments = false; + let attachAttachments = false; + if (mode === 'chat') { + attachAttachments = options.attachments ?? true; + if ( + candidate.rootIndex < history.length - 10 || + totalAttachments > 2 + ) { + attachAttachments = false; + } } let msgRes; - try { msgRes = await constructMsg( api, @@ -496,11 +672,14 @@ export async function makeHistoryV2( symbolLimit, attachments: attachAttachments, includeReactions: options.includeReactions, + characterName: options.characterName, }, ); } catch (error) { logger.error( - 'Could not construct replied message', + mode === 'chat' + ? 'Could not construct replied message' + : 'Could not construct message', { error, messageId: msg.id, @@ -508,95 +687,27 @@ export async function makeHistoryV2( hasText: Boolean(msg.text), textLength: msg.text?.length ?? 0, supportedFields: getSupportedContentFields(msg.info), - unsupportedFields: getUnsupportedContentFields( - msg.info, - ), + unsupportedFields: getUnsupportedContentFields(msg.info), }, ); continue; } - if (Array.isArray(msgRes.content)) { - const attachmentsPart = msgRes.content.find((m) => - m.type === 'file' || m.type === 'image' - ); - - totalAttachments += attachmentsPart ? 1 : 0; - } - - const size = JSON.stringify(msgRes).length; - - if (totalBytes + size >= bytesLimit) { - // logger.info( - // `Skipping old messages because prompt is too big ${size} (${ - // totalBytes + size - // } > ${bytesLimit})`, - // ); - break; - } - - prompt.push(msgRes); - totalBytes += size; - } - - // Reverse messages to make them in chronological order - prompt.reverse(); - - // Remove old messages to fit the limit - prompt.splice(0, prompt.length - messagesLimit); - - return prompt; -} - -interface NotesHistoryOptions { - symbolLimit: number; - messagesLimit: number; - bytesLimit: number; - characterName?: string; -} - -export async function makeNotesHistory( - botInfo: { token: string; id: number }, - api: Api, - logger: Logger, - history: ChatMessage[], - options: NotesHistoryOptions, -): Promise { - const { messagesLimit, bytesLimit, symbolLimit, characterName } = options; - - let totalBytes = 0; - let textPart = ''; - - const candidates = selectHistoryCandidates(history, { - resolveReplyThread: false, - maxRootMessages: messagesLimit, - }); + if (mode === 'chat') { + if (Array.isArray(msgRes.content)) { + const attachmentsPart = msgRes.content.find((m) => + m.type === 'file' || m.type === 'image' + ); + totalAttachments += attachmentsPart ? 1 : 0; + } - for (const candidate of candidates) { - const msg = candidate.msg; + const size = JSON.stringify(msgRes).length; + if (totalBytes + size >= bytesLimit) { + break; + } - let msgRes; - try { - msgRes = await constructMsg( - api, - botInfo, - msg, - { - symbolLimit, - attachments: false, - characterName, - }, - ); - } catch (error) { - logger.error('Could not construct message', { - error, - messageId: msg.id, - isMyself: msg.isMyself, - hasText: Boolean(msg.text), - textLength: msg.text?.length ?? 0, - supportedFields: getSupportedContentFields(msg.info), - unsupportedFields: getUnsupportedContentFields(msg.info), - }); + prompt.push(msgRes); + totalBytes += size; continue; } @@ -611,28 +722,111 @@ export async function makeNotesHistory( } const size = JSON.stringify(content).length; - if (totalBytes + size >= bytesLimit) { logger.info( `Skipping old messages because prompt is too big ${size} (${ totalBytes + size } > ${bytesLimit})`, ); - break; } textPart += content; - textPart += '\n--- ---\n'; - totalBytes += size; } - return [{ - role: 'user', - content: textPart, - }]; + if (mode === 'notes') { + return [{ + role: 'user', + content: textPart, + }]; + } + + prompt.reverse(); + prompt.splice(0, prompt.length - messagesLimit); + return prompt; +} + +export function makeHistoryV2( + botInfo: { token: string; id: number }, + api: Api, + logger: Logger, + history: ChatMessage[], + options: HistoryOptions, +): Promise { + return buildHistoryContext( + botInfo, + api, + logger, + history, + { + mode: 'chat', + symbolLimit: options.symbolLimit, + messagesLimit: options.messagesLimit, + bytesLimit: options.bytesLimit, + attachments: options.attachments, + resolveReplyThread: options.resolveReplyThread, + includeReactions: options.includeReactions, + historyVersion: 'v2', + activeMessageId: options.activeMessageId, + }, + ); +} + +export function makeHistoryV3( + botInfo: { token: string; id: number }, + api: Api, + logger: Logger, + history: ChatMessage[], + options: HistoryOptions, +): Promise { + return buildHistoryContext( + botInfo, + api, + logger, + history, + { + mode: 'chat', + symbolLimit: options.symbolLimit, + messagesLimit: options.messagesLimit, + bytesLimit: options.bytesLimit, + attachments: options.attachments, + resolveReplyThread: options.resolveReplyThread, + includeReactions: options.includeReactions, + historyVersion: 'v3', + activeMessageId: options.activeMessageId, + }, + ); +} + +interface NotesHistoryOptions { + symbolLimit: number; + messagesLimit: number; + bytesLimit: number; + characterName?: string; +} + +export function makeNotesHistory( + botInfo: { token: string; id: number }, + api: Api, + logger: Logger, + history: ChatMessage[], + options: NotesHistoryOptions, +): Promise { + return buildHistoryContext( + botInfo, + api, + logger, + history, + { + mode: 'notes', + symbolLimit: options.symbolLimit, + messagesLimit: options.messagesLimit, + bytesLimit: options.bytesLimit, + characterName: options.characterName, + }, + ); } async function getAttachments( diff --git a/lib/history_test.ts b/lib/history_test.ts index 888459a..e17541c 100644 --- a/lib/history_test.ts +++ b/lib/history_test.ts @@ -2,7 +2,7 @@ import { assertEquals } from '@std/assert'; import { Message } from 'grammy_types'; import { ReplyMessage } from './telegram/helpers.ts'; import { ChatMessage, ReplyTo } from './memory.ts'; -import { selectHistoryCandidates } from './history.ts'; +import { selectHistoryCandidates, selectHistoryCandidatesV3 } from './history.ts'; function createReplyTo(id: number): ReplyTo { return { @@ -13,7 +13,17 @@ function createReplyTo(id: number): ReplyTo { }; } -function createMessage(id: number, replyToId?: number): ChatMessage { +function createMessage( + id: number, + replyToId?: number, + options?: { + threadId?: string; + threadRootMessageId?: number; + messageThreadId?: number; + fromId?: number; + date?: number; + }, +): ChatMessage { return { id, text: `m${id}`, @@ -21,7 +31,17 @@ function createMessage(id: number, replyToId?: number): ChatMessage { replyTo: typeof replyToId === 'number' ? createReplyTo(replyToId) : undefined, - info: {} as unknown as Message, + threadId: options?.threadId, + threadRootMessageId: options?.threadRootMessageId, + info: { + from: { + id: options?.fromId ?? 1, + is_bot: false, + first_name: 'User', + }, + date: options?.date ?? id, + message_thread_id: options?.messageThreadId, + } as unknown as Message, }; } @@ -54,3 +74,287 @@ Deno.test('selectHistoryCandidates respects maxRootMessages', () => { assertEquals(selected.map((m) => m.msg.id), [3, 2]); }); + +Deno.test('selectHistoryCandidatesV3 prioritizes active thread', () => { + const history: ChatMessage[] = [ + createMessage(1, undefined, { + threadId: 'thread:1', + threadRootMessageId: 1, + fromId: 11, + }), + createMessage(2, undefined, { + threadId: 'thread:2', + threadRootMessageId: 2, + fromId: 22, + }), + createMessage(3, 1, { + threadId: 'thread:1', + threadRootMessageId: 1, + fromId: 33, + }), + createMessage(4, undefined, { + threadId: 'thread:2', + threadRootMessageId: 2, + fromId: 22, + }), + createMessage(5, 3, { + threadId: 'thread:1', + threadRootMessageId: 1, + fromId: 44, + }), + ]; + + const selected = selectHistoryCandidatesV3(history, {}); + + assertEquals(selected.map((m) => m.msg.id), [5, 3, 1, 4, 2]); +}); + +Deno.test('selectHistoryCandidatesV3 falls back to V2 without metadata', () => { + const history: ChatMessage[] = [ + createMessage(1), + createMessage(2, 1), + createMessage(3), + createMessage(4, 2), + ]; + + const selected = selectHistoryCandidatesV3(history, {}); + assertEquals(selected.map((m) => m.msg.id), [4, 2, 1, 3]); +}); + +Deno.test( + 'selectHistoryCandidatesV3 keeps continuation thread with interleaving chatter', + () => { + const history: ChatMessage[] = [ + createMessage(10, undefined, { + threadId: 'thread:10', + threadRootMessageId: 10, + fromId: 100, + }), + createMessage(11, undefined, { + threadId: 'thread:99', + threadRootMessageId: 99, + fromId: 200, + }), + createMessage(12, 10, { + threadId: 'thread:10', + threadRootMessageId: 10, + fromId: 300, + }), + createMessage(13, undefined, { + threadId: 'thread:10', + threadRootMessageId: 10, + fromId: 300, + }), + createMessage(14, undefined, { + threadId: 'thread:99', + threadRootMessageId: 99, + fromId: 200, + }), + createMessage(15, 13, { + threadId: 'thread:10', + threadRootMessageId: 10, + fromId: 400, + }), + ]; + + const selected = selectHistoryCandidatesV3(history, { + maxRootMessages: 5, + }); + + assertEquals(selected.slice(0, 3).map((m) => m.msg.id), [15, 13, 12]); + }, +); + +Deno.test( + 'selectHistoryCandidatesV3 groups difficult group reply thread over meme noise', + () => { + const history: ChatMessage[] = [ + // userA root thread + createMessage(101, undefined, { + threadId: 'thread:101', + threadRootMessageId: 101, + fromId: 10, + }), + // userB disconnected meme thread + createMessage(102, undefined, { + threadId: 'thread:102', + threadRootMessageId: 102, + fromId: 20, + }), + createMessage(103, undefined, { + threadId: 'thread:102', + threadRootMessageId: 102, + fromId: 20, + }), + // userC replies to userA + createMessage(104, 101, { + threadId: 'thread:101', + threadRootMessageId: 101, + fromId: 30, + }), + // userC follow-up without explicit telegram reply but same internal thread + createMessage(105, undefined, { + threadId: 'thread:101', + threadRootMessageId: 101, + fromId: 30, + }), + // more meme noise + createMessage(106, undefined, { + threadId: 'thread:102', + threadRootMessageId: 102, + fromId: 20, + }), + // userD and userF reply into userC branch + createMessage(107, 105, { + threadId: 'thread:101', + threadRootMessageId: 101, + fromId: 40, + }), + createMessage(108, 105, { + threadId: 'thread:101', + threadRootMessageId: 101, + fromId: 50, + }), + ]; + + const selected = selectHistoryCandidatesV3(history, {}); + + assertEquals(selected.map((m) => m.msg.id), [ + 108, + 107, + 105, + 104, + 101, + 106, + 103, + 102, + ]); + }, +); + +Deno.test( + 'selectHistoryCandidatesV3 keeps most recent hard thread context under budget', + () => { + const history: ChatMessage[] = [ + createMessage(201, undefined, { + threadId: 'thread:201', + threadRootMessageId: 201, + fromId: 10, + }), + createMessage(202, undefined, { + threadId: 'thread:202', + threadRootMessageId: 202, + fromId: 20, + }), + createMessage(203, undefined, { + threadId: 'thread:202', + threadRootMessageId: 202, + fromId: 20, + }), + createMessage(204, 201, { + threadId: 'thread:201', + threadRootMessageId: 201, + fromId: 30, + }), + createMessage(205, undefined, { + threadId: 'thread:201', + threadRootMessageId: 201, + fromId: 30, + }), + createMessage(206, undefined, { + threadId: 'thread:202', + threadRootMessageId: 202, + fromId: 20, + }), + createMessage(207, 205, { + threadId: 'thread:201', + threadRootMessageId: 201, + fromId: 40, + }), + createMessage(208, 205, { + threadId: 'thread:201', + threadRootMessageId: 201, + fromId: 50, + }), + ]; + + const selected = selectHistoryCandidatesV3(history, { + maxRootMessages: 6, + }); + + assertEquals(selected.map((m) => m.msg.id), [208, 207, 205, 204, 206, 203]); + }, +); + +Deno.test( + 'selectHistoryCandidatesV3 scopes to active telegram topic when anchor provided', + () => { + const history: ChatMessage[] = [ + createMessage(300, undefined, { + threadId: 'thread:300', + threadRootMessageId: 300, + messageThreadId: 77, + fromId: 10, + }), + createMessage(301, undefined, { + threadId: 'thread:301', + threadRootMessageId: 301, + messageThreadId: 88, + fromId: 20, + }), + createMessage(302, 300, { + threadId: 'thread:300', + threadRootMessageId: 300, + messageThreadId: 77, + fromId: 30, + }), + createMessage(303, undefined, { + threadId: 'thread:301', + threadRootMessageId: 301, + messageThreadId: 88, + fromId: 20, + }), + createMessage(304, undefined, { + threadId: 'thread:300', + threadRootMessageId: 300, + messageThreadId: 77, + fromId: 40, + }), + ]; + + const selected = selectHistoryCandidatesV3(history, { + activeMessageId: 304, + }); + + assertEquals(selected.map((m) => m.msg.id), [304, 302, 300]); + }, +); + +Deno.test( + 'selectHistoryCandidatesV3 falls back to latest non-bot anchor when active message is missing', + () => { + const history: ChatMessage[] = [ + createMessage(401, undefined, { + threadId: 'thread:401', + threadRootMessageId: 401, + fromId: 10, + }), + createMessage(402, undefined, { + threadId: 'thread:402', + threadRootMessageId: 402, + fromId: 20, + }), + createMessage(403, 401, { + threadId: 'thread:401', + threadRootMessageId: 401, + fromId: 30, + }), + ]; + + const selected = selectHistoryCandidatesV3(history, { + activeMessageId: 999999, + }); + + assertEquals(selected.map((m) => m.msg.id), [403, 401, 402]); + }, +); diff --git a/lib/memory.ts b/lib/memory.ts index a42c70c..534a9cc 100644 --- a/lib/memory.ts +++ b/lib/memory.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, inArray } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; import { Chat as TgChat, Message, User } from 'grammy_types'; import { Character } from './charhub/api.ts'; import { @@ -78,6 +78,10 @@ export interface ChatMessage { id: number; text: string; replyTo?: ReplyTo; + threadId?: string; + threadRootMessageId?: number; + threadParentMessageId?: number; + threadSource?: string; isMyself: boolean; info: Message; reactions?: MessageReactions; @@ -147,10 +151,79 @@ function buildMessageFromRow( : ({} as ReplyMessage), } : undefined, + threadId: row.threadId ?? undefined, + threadRootMessageId: row.threadRootMessageId ?? undefined, + threadParentMessageId: row.threadParentMessageId ?? undefined, + threadSource: row.threadSource ?? undefined, reactions, }; } +async function loadMessageReactions( + db: DbClient, + chatId: number, + messageIds?: number[], +): Promise> { + if (messageIds && messageIds.length === 0) { + return new Map(); + } + + const reactionWhere = messageIds + ? and( + eq(messageReactions.chatId, chatId), + inArray(messageReactions.messageId, messageIds), + ) + : eq(messageReactions.chatId, chatId); + + const reactionRows = await db + .select() + .from(messageReactions) + .where(reactionWhere); + + const keys = reactionRows.map(( + r: typeof messageReactions.$inferSelect, + ) => r.reactionKey); + const usersRows = keys.length === 0 + ? [] + : await db + .select() + .from(messageReactionUsers) + .where(and( + eq(messageReactionUsers.chatId, chatId), + inArray(messageReactionUsers.reactionKey, keys), + messageIds + ? inArray(messageReactionUsers.messageId, messageIds) + : undefined, + )); + + const usersByReaction = new Map(); + for (const row of usersRows) { + const key = `${row.messageId}:${row.reactionKey}`; + const users = usersByReaction.get(key) ?? []; + users.push({ + id: row.userId, + username: row.username ?? undefined, + name: row.name, + }); + usersByReaction.set(key, users); + } + + const reactionsByMessage = new Map(); + for (const row of reactionRows) { + const bucket = reactionsByMessage.get(row.messageId) ?? {}; + bucket[row.reactionKey] = { + type: row.type, + emoji: row.emoji ?? undefined, + customEmojiId: row.customEmojiId ?? undefined, + by: usersByReaction.get(`${row.messageId}:${row.reactionKey}`) ?? [], + count: row.count, + }; + reactionsByMessage.set(row.messageId, bucket); + } + + return reactionsByMessage; +} + export class Memory { db: DbClient; @@ -171,53 +244,6 @@ export class Memory { .onConflictDoNothing(); } - private async getMessageReactions(chatId: number) { - const reactionRows = await this.db - .select() - .from(messageReactions) - .where(eq(messageReactions.chatId, chatId)); - - const keys = reactionRows.map(( - r: typeof messageReactions.$inferSelect, - ) => r.reactionKey); - const usersRows = keys.length === 0 ? [] : await this.db - .select() - .from(messageReactionUsers) - .where(and( - eq(messageReactionUsers.chatId, chatId), - inArray(messageReactionUsers.reactionKey, keys), - )); - - const usersByReaction = new Map(); - for (const row of usersRows) { - const key = `${row.messageId}:${row.reactionKey}`; - const users = usersByReaction.get(key) ?? []; - users.push({ - id: row.userId, - username: row.username ?? undefined, - name: row.name, - }); - usersByReaction.set(key, users); - } - - const reactionsByMessage = new Map(); - for (const row of reactionRows) { - const bucket = reactionsByMessage.get(row.messageId) ?? {}; - bucket[row.reactionKey] = { - type: row.type, - emoji: row.emoji ?? undefined, - customEmojiId: row.customEmojiId ?? undefined, - by: usersByReaction.get( - `${row.messageId}:${row.reactionKey}`, - ) ?? [], - count: row.count, - }; - reactionsByMessage.set(row.messageId, bucket); - } - - return reactionsByMessage; - } - async getChatById(chatId: number): Promise { const chatRow = await this.db.query.chats.findFirst({ where: eq(chats.id, chatId), @@ -231,9 +257,7 @@ export class Memory { notesRows, membersRows, optOutRows, - messagesRows, characterRow, - reactionsByMessage, configOverrideRow, ] = await Promise.all([ this.db @@ -250,15 +274,9 @@ export class Memory { .select() .from(chatOptOutUsers) .where(eq(chatOptOutUsers.chatId, chatId)), - this.db - .select() - .from(chatMessages) - .where(eq(chatMessages.chatId, chatId)) - .orderBy(asc(chatMessages.messageId)), this.db.query.chatCharacters.findFirst({ where: eq(chatCharacters.chatId, chatId), }), - this.getMessageReactions(chatId), this.db.query.chatConfigOverrides.findFirst({ where: eq(chatConfigOverrides.chatId, chatId), }), @@ -272,9 +290,7 @@ export class Memory { notes: notesRows.map((n: typeof chatNotes.$inferSelect) => n.text), lastNotes: chatRow.lastNotes, lastMemory: chatRow.lastMemory, - history: messagesRows.map((m: typeof chatMessages.$inferSelect) => - buildMessageFromRow(m, reactionsByMessage.get(m.messageId)) - ), + history: [], memory: chatRow.memory ?? undefined, lastUse: chatRow.lastUse, info: parseJson(chatRow.info), @@ -488,7 +504,55 @@ export class ChatMemory { } async getHistory() { - return (await this.getChat()).history; + const rows = await this.memory.db + .select() + .from(chatMessages) + .where(eq(chatMessages.chatId, this.chatInfo.id)) + .orderBy(asc(chatMessages.messageId)); + + if (rows.length === 0) { + return []; + } + + const messageIds = rows.map((row) => row.messageId); + const reactionsByMessage = await loadMessageReactions( + this.memory.db, + this.chatInfo.id, + messageIds, + ); + + return rows.map((row) => + buildMessageFromRow(row, reactionsByMessage.get(row.messageId)) + ); + } + + async getRecentHistory(limit: number) { + if (limit <= 0) { + return []; + } + + const rows = await this.memory.db + .select() + .from(chatMessages) + .where(eq(chatMessages.chatId, this.chatInfo.id)) + .orderBy(desc(chatMessages.messageId)) + .limit(limit); + + if (rows.length === 0) { + return []; + } + + rows.reverse(); + const messageIds = rows.map((row) => row.messageId); + const reactionsByMessage = await loadMessageReactions( + this.memory.db, + this.chatInfo.id, + messageIds, + ); + + return rows.map((row) => + buildMessageFromRow(row, reactionsByMessage.get(row.messageId)) + ); } async clear() { @@ -527,6 +591,10 @@ export class ChatMemory { replyToInfo: message.replyTo?.info ? JSON.stringify(message.replyTo.info) : null, + threadId: message.threadId, + threadRootMessageId: message.threadRootMessageId, + threadParentMessageId: message.threadParentMessageId, + threadSource: message.threadSource, info: JSON.stringify(message.info), }) .onConflictDoUpdate({ @@ -540,31 +608,55 @@ export class ChatMemory { replyToInfo: message.replyTo?.info ? JSON.stringify(message.replyTo.info) : null, + threadId: message.threadId, + threadRootMessageId: message.threadRootMessageId, + threadParentMessageId: message.threadParentMessageId, + threadSource: message.threadSource, info: JSON.stringify(message.info), }, }); } async removeOldMessages(maxLength: number) { - const history = await this.getHistory(); - if (history.length <= maxLength) return; + const countRows = await this.memory.db + .select({ total: sql`count(*)` }) + .from(chatMessages) + .where(eq(chatMessages.chatId, this.chatInfo.id)); + const total = countRows[0]?.total ?? 0; + const overflow = total - maxLength; + + if (overflow <= 0) return; + + const oldestRows = await this.memory.db + .select({ messageId: chatMessages.messageId }) + .from(chatMessages) + .where(eq(chatMessages.chatId, this.chatInfo.id)) + .orderBy(asc(chatMessages.messageId)) + .limit(overflow); + const toDelete = oldestRows.map((row) => row.messageId); + + if (toDelete.length === 0) return; + + const chunks: number[][] = []; + for (let i = 0; i < toDelete.length; i += 500) { + chunks.push(toDelete.slice(i, i + 500)); + } - const toDelete = history.slice(0, history.length - maxLength).map((m) => - m.id - ); await this.memory.db.transaction(async (tx: Tx) => { - await tx.delete(messageReactionUsers).where(and( - eq(messageReactionUsers.chatId, this.chatInfo.id), - inArray(messageReactionUsers.messageId, toDelete), - )); - await tx.delete(messageReactions).where(and( - eq(messageReactions.chatId, this.chatInfo.id), - inArray(messageReactions.messageId, toDelete), - )); - await tx.delete(chatMessages).where(and( - eq(chatMessages.chatId, this.chatInfo.id), - inArray(chatMessages.messageId, toDelete), - )); + for (const messageIds of chunks) { + await tx.delete(messageReactionUsers).where(and( + eq(messageReactionUsers.chatId, this.chatInfo.id), + inArray(messageReactionUsers.messageId, messageIds), + )); + await tx.delete(messageReactions).where(and( + eq(messageReactions.chatId, this.chatInfo.id), + inArray(messageReactions.messageId, messageIds), + )); + await tx.delete(chatMessages).where(and( + eq(chatMessages.chatId, this.chatInfo.id), + inArray(chatMessages.messageId, messageIds), + )); + } }); } @@ -826,7 +918,44 @@ export class ChatMemory { } async getMessageById(messageId: number) { - return (await this.getHistory()).find((m) => m.id === messageId); + const row = await this.memory.db.query.chatMessages.findFirst({ + where: and( + eq(chatMessages.chatId, this.chatInfo.id), + eq(chatMessages.messageId, messageId), + ), + }); + if (!row) { + return undefined; + } + + const reactionsByMessage = await loadMessageReactions( + this.memory.db, + this.chatInfo.id, + [messageId], + ); + return buildMessageFromRow(row, reactionsByMessage.get(messageId)); + } + + async getLastMessageByAuthorInTopic( + authorId: number, + topicId: number | undefined, + lookbackLimit: number, + ) { + const history = await this.getRecentHistory(lookbackLimit); + + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]; + if (message.info.from?.id !== authorId) { + continue; + } + if (message.info.message_thread_id !== topicId) { + continue; + } + + return message; + } + + return undefined; } async addEmojiReaction( diff --git a/lib/telegram/bot/character.ts b/lib/telegram/bot/character.ts index f95a914..a6f2c4f 100644 --- a/lib/telegram/bot/character.ts +++ b/lib/telegram/bot/character.ts @@ -10,7 +10,7 @@ import { sliceMessage } from '../../helpers.ts'; import { ChatMemory } from '../../memory.ts'; import logger from '../../logger.ts'; import { InlineQueryResultArticle } from 'grammy_types'; -import { generateObject, generateText } from 'ai'; +import { generateObject } from 'ai'; import z from 'zod'; import { limit } from 'grammy_ratelimiter'; import DOMPurify from 'isomorphic-dompurify'; @@ -414,108 +414,44 @@ bot.callbackQuery(/set.*/, async (ctx) => { // TODO: Allow to set different model for generating character names const model = chat.chatModel ?? config.model; - const useJsonResponses = config.useJsonResponses; let names: string[] = []; try { - if (useJsonResponses) { - const generationPolicy = resolveGenerationPolicy({ - modelRef: model, - config, - task: 'character', - expectsStructuredOutput: true, - }); - const providerOptions = generationPolicy - .providerOptions as Parameters< - typeof generateObject - >[0]['providerOptions']; - const result = await generateObject({ - model: generationPolicy.model, - providerOptions, - schema: z.array(z.string()), - temperature: config.temperature, - topK: config.topK, - topP: config.topP, - maxOutputTokens: generationPolicy.maxOutputTokens, - prompt: - `Напиши варианты имени "${character.name}", которые пользователи могут использовать в качестве обращения к этому персонажу. ` + - 'Варианты должны быть на русском, английском, уменьшительно ласкательные и очевидные похожие формы.', - experimental_telemetry: { - isEnabled: true, - functionId: 'character-names', - metadata: buildGenerationTelemetryMetadata({ - sessionId: chatId.toString(), - userId: '', - tags: ['character'], - temperature: config.temperature, - topK: config.topK, - topP: config.topP, - policy: generationPolicy, - }), - }, - }); - names = result.object; - } else { - const generationPolicy = resolveGenerationPolicy({ - modelRef: model, - config, - task: 'character', - expectsStructuredOutput: false, - }); - const providerOptions = generationPolicy - .providerOptions as Parameters< - typeof generateText - >[0]['providerOptions']; - const response = await generateText({ - model: generationPolicy.model, - providerOptions, - temperature: config.temperature, - topK: config.topK, - topP: config.topP, - maxOutputTokens: generationPolicy.maxOutputTokens, - prompt: - `Напиши варианты имени "${character.name}" (русские и английские, уменьшительные и очевидные похожие формы). ` + - 'Верни только список вариантов через запятую или с новой строки, без пояснений.', - experimental_telemetry: { - isEnabled: true, - functionId: 'character-names-dumb', - metadata: buildGenerationTelemetryMetadata({ - sessionId: chatId.toString(), - userId: '', - tags: ['character'], - temperature: config.temperature, - topK: config.topK, - topP: config.topP, - policy: generationPolicy, - }), - }, - }); - - const raw = response.text.trim(); - const split = raw - .split(/\n|,|;|•|·|\u2022/g) - .map((s) => s.trim()) - .map((s) => s.replace(/^[-*•·]\s*/, '')) - .map((s) => s.replace(/^"|"$/g, '')) - .filter((s) => s.length > 0 && s.length < 64); - - const dedup = new Set(); - for (const s of split) { - const k = s.toLowerCase(); - if (!dedup.has(k)) dedup.add(k); - } - names = Array.from(dedup).map((k) => { - // Recover original casing by finding first occurrence in split - const orig = split.find((s) => s.toLowerCase() === k); - return orig ?? k; - }); - - if (names.length === 0) { - names = [character.name]; - } - if (names.length > 20) { - names = names.slice(0, 20); - } - } + const generationPolicy = resolveGenerationPolicy({ + modelRef: model, + config, + task: 'character', + expectsStructuredOutput: true, + }); + const providerOptions = generationPolicy + .providerOptions as Parameters< + typeof generateObject + >[0]['providerOptions']; + const result = await generateObject({ + model: generationPolicy.model, + providerOptions, + schema: z.array(z.string()), + temperature: config.temperature, + topK: config.topK, + topP: config.topP, + maxOutputTokens: generationPolicy.maxOutputTokens, + prompt: + `Напиши варианты имени "${character.name}", которые пользователи могут использовать в качестве обращения к этому персонажу. ` + + 'Варианты должны быть на русском, английском, уменьшительно ласкательные и очевидные похожие формы.', + experimental_telemetry: { + isEnabled: true, + functionId: 'character-names', + metadata: buildGenerationTelemetryMetadata({ + sessionId: chatId.toString(), + userId: '', + tags: ['character'], + temperature: config.temperature, + topK: config.topK, + topP: config.topP, + policy: generationPolicy, + }), + }, + }); + names = result.object; } catch (error) { logger.error(error, 'Error getting names for character'); return await ctx.reply(ctx.t('character-names-error')); diff --git a/lib/telegram/bot/notes.ts b/lib/telegram/bot/notes.ts index 91f0baa..b28c111 100644 --- a/lib/telegram/bot/notes.ts +++ b/lib/telegram/bot/notes.ts @@ -4,6 +4,11 @@ import { Config } from '../../config.ts'; import logger from '../../logger.ts'; import { SlushaContext } from '../setup-bot.ts'; import { makeNotesHistory } from '../../history.ts'; +import { + buildChatInfoBlock, + buildChatPromptAddition, + isTelegramCommentsHistory, +} from '../../ai/chat-context.ts'; import { resolveGenerationPolicy } from '../../ai/generation-policy.ts'; import { buildGenerationTelemetryMetadata } from '../../ai/telemetry-metadata.ts'; @@ -16,7 +21,7 @@ export default function notes(config: Config, botId: number) { const effectiveConfig = await ctx.m.getEffectiveConfig(); const frequency = effectiveConfig.ai.notesFrequency; const chat = await ctx.m.getChat(); - const history = await ctx.m.getHistory(); + const history = await ctx.m.getRecentHistory(frequency); if ( chat.lastUse < @@ -170,7 +175,7 @@ export default function notes(config: Config, botId: number) { const effectiveConfig = await ctx.m.getEffectiveConfig(); const frequency = effectiveConfig.ai.memoryFrequency; const chat = await ctx.m.getChat(); - const history = await ctx.m.getHistory(); + const history = await ctx.m.getRecentHistory(frequency); if ( chat.lastUse < @@ -218,68 +223,47 @@ export default function notes(config: Config, botId: number) { let prompt = ''; // Intentionaly no pre-prompt here - // TODO: Improve this check - const isComments = savedHistory.some( - (m) => - m.info.forward_origin?.type === 'channel' && - m.info.from?.first_name === 'Telegram', - ); - - if (ctx.chat.type === 'private') { - if (effectiveConfig.ai.privateChatPromptAddition) { - prompt += effectiveConfig.ai.privateChatPromptAddition; - } - } else if ( - isComments && effectiveConfig.ai.commentsPromptAddition - ) { - prompt += effectiveConfig.ai.commentsPromptAddition; - } else if (effectiveConfig.ai.groupChatPromptAddition) { - prompt += effectiveConfig.ai.groupChatPromptAddition; - } + const isComments = isTelegramCommentsHistory(savedHistory); + prompt += buildChatPromptAddition({ + chatType: ctx.chat.type, + isComments, + privateChatPromptAddition: + effectiveConfig.ai.privateChatPromptAddition, + commentsPromptAddition: + effectiveConfig.ai.commentsPromptAddition, + groupChatPromptAddition: + effectiveConfig.ai.groupChatPromptAddition, + }); prompt += '\n\n'; - const currentChat = await ctx.m.getChat(); - const character = currentChat.character; + const character = chat.character; if (character) { prompt += '### Character ###\n' + character.description; } else { prompt += effectiveConfig.ai.prompt; } - let chatInfoMsg = `Date and time right now: ${ - new Date().toLocaleString() - }`; - - if (ctx.chat.type === 'private') { - chatInfoMsg += - `\nЛичный чат с ${ctx.from.first_name} (@${ctx.from.username})`; - } else { - const activeMembers = await ctx.m.getActiveMembers(); - if (activeMembers.length > 0) { - const prettyMembersList = activeMembers - .map((m) => { - let text = `- ${m.first_name}`; - if (m.username) { - text += ` (@${m.username})`; - } - return text; - }) - .join('\n'); - - chatInfoMsg += - `\nChat: ${ctx.chat.title}, Active members:\n${prettyMembersList}`; - } - } + const activeMembers = ctx.chat.type === 'private' + ? [] + : await ctx.m.getActiveMembers(); + const chatInfoMsg = buildChatInfoBlock({ + nowText: new Date().toLocaleString(), + chatType: ctx.chat.type, + chatTitle: ctx.chat.title, + userFirstName: ctx.from.first_name, + userUsername: ctx.from.username, + activeMembers, + }); prompt += '\n\n' + chatInfoMsg; let memPrompt = effectiveConfig.ai.memoryPrompt; - if (currentChat.memory) { + if (chat.memory) { memPrompt += '\n\n' + effectiveConfig.ai.memoryPromptRepeat + '\n' + - currentChat.memory; + chat.memory; } prompt += '\n\n' + effectiveConfig.ai.memoryPrompt; diff --git a/lib/telegram/handlers/ai.ts b/lib/telegram/handlers/ai.ts index aeb10e1..ab13b6e 100644 --- a/lib/telegram/handlers/ai.ts +++ b/lib/telegram/handlers/ai.ts @@ -8,13 +8,14 @@ import { ModelMessage, tool, } from 'ai'; -import { makeHistoryV2 } from '../../history.ts'; +import { makeHistoryV2, makeHistoryV3 } from '../../history.ts'; import { getRandomNepon, prettyDate } from '../../helpers.ts'; import { replyGeneric, replyWithMarkdownId } from '../helpers.ts'; import { ReplyTo } from '../../memory.ts'; import { chatActionsToolInputSchema, ChatEntry, + chatReactionsToolInputSchema, isReactEntry, isTextEntry, } from '../../ai/schema.ts'; @@ -23,6 +24,18 @@ import { buildTargetRefs, buildTargetRefsPrompt, } from '../../ai/target-refs.ts'; +import { + type GenerationAttemptPlan, + getGenerationFallbackPlans, + resolveCustomPrompt, + resolveReplyMethod, + splitTextByTwoLines, +} from '../../ai/chat-generation.ts'; +import { + buildChatInfoBlock, + buildChatPromptAddition, + isTelegramCommentsHistory, +} from '../../ai/chat-context.ts'; import { canonicalizeReaction, resolveEnabledReactions } from '../reactions.ts'; import { isMissingSendTextRightsError } from '../reply-rights.ts'; import { resolveGenerationPolicy } from '../../ai/generation-policy.ts'; @@ -30,10 +43,33 @@ import { parseModelRef } from '../../ai/model-ref.ts'; import { buildGenerationTelemetryMetadata } from '../../ai/telemetry-metadata.ts'; import { buildLanguageProtocol } from '../../ai/language-protocol.ts'; -export default function registerAI(bot: Bot) { - const sendChatActionsTool = tool({ - description: - 'Submit Telegram actions once per turn. Return entries where each item is either {"type":"reply","text":"...","target_ref":"tN"} or {"type":"react","react":"❤","target_ref":"tN"}. Use target_ref values from Reply Target Map. If target_ref is omitted, action applies to the triggering message.', +const DEFAULT_CHAT_ACTIONS_TOOL_DESCRIPTION = + 'Submit Telegram actions once per turn. Return entries where each item is either {"type":"reply","text":"...","target_ref":"tN"} or {"type":"react","react":"❤","target_ref":"tN"}. Use target_ref values from Reply Target Map. If target_ref is omitted, action applies to the triggering message.'; + +const DEFAULT_CHAT_REACTIONS_TOOL_DESCRIPTION = + 'Submit Telegram reactions once per turn. Return entries containing only {"type":"react","react":"❤","target_ref":"tN"}. Do not include reply text.'; + +const PLAIN_TEXT_META_OPEN = ''; +const PLAIN_TEXT_META_CLOSE = ''; + +const plainTextTargetRefLineRegex = /^@@target_ref=(t\d+)\s*\r?\n([\s\S]*)$/; +const plainTextMetadataBlockRegex = + /^\s*\r?\n([\s\S]*?)\r?\n<\/slusha_meta>\s*/; + +function parseJsonRecord(text: string): Record | undefined { + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' + ? parsed as Record + : undefined; + } catch { + return undefined; + } +} + +function createSendChatActionsTool(description: string) { + return tool({ + description, inputSchema: chatActionsToolInputSchema, inputExamples: [ { @@ -103,14 +139,426 @@ export default function registerAI(bot: Bot) { }, ], }); +} + +function createSendChatReactionsTool(description: string) { + return tool({ + description, + inputSchema: chatReactionsToolInputSchema, + inputExamples: [ + { + input: { + entries: [ + { + type: 'react', + react: '❤', + target_ref: 't1', + }, + ], + }, + }, + { + input: { + entries: [], + }, + }, + ], + }); +} + +function parseSendChatActionsEntries( + toolCalls: Array<{ toolName: string; input: unknown }>, +): ChatEntry[] | undefined { + const chatActionsToolCall = toolCalls.find((call) => + call.toolName === 'send_chat_actions' + ); + const parsedToolCallInput = chatActionsToolCall + ? chatActionsToolInputSchema.safeParse( + chatActionsToolCall.input, + ) + : undefined; + + if (parsedToolCallInput?.success) { + return parsedToolCallInput.data.entries; + } + + return undefined; +} + +function parseSendChatReactionsEntries( + toolCalls: Array<{ toolName: string; input: unknown }>, +): ChatEntry[] | undefined { + const chatReactionsToolCall = toolCalls.find((call) => + call.toolName === 'send_chat_reactions' + ); + const parsedToolCallInput = chatReactionsToolCall + ? chatReactionsToolInputSchema.safeParse( + chatReactionsToolCall.input, + ) + : undefined; + + if (parsedToolCallInput?.success) { + return parsedToolCallInput.data.entries; + } + + return undefined; +} + +function parsePlainTextRepliesWithTargets(rawText: string): ChatEntry[] { + const chunks = splitTextByTwoLines(rawText.trim()); + const entries: ChatEntry[] = []; + let pendingTargetRef: string | undefined; + + for (const chunk of chunks) { + const trimmed = chunk.trim(); + if (!trimmed) { + continue; + } + + let candidateText = trimmed; + let targetRef: string | undefined; + + while (true) { + const metadataMatch = candidateText.match( + plainTextMetadataBlockRegex, + ); + if (!metadataMatch) { + break; + } + + const metadataPayload = metadataMatch[1].trim(); + const metadata = parseJsonRecord(metadataPayload); + if ( + !targetRef && + metadata && + typeof metadata.target_ref === 'string' + ) { + const parsedTargetRef = metadata.target_ref; + if (/^t\d+$/.test(parsedTargetRef)) { + targetRef = parsedTargetRef; + } + } + + candidateText = candidateText.slice(metadataMatch[0].length) + .trimStart(); + } + + const targetMatch = candidateText.match(plainTextTargetRefLineRegex); + if (targetMatch) { + targetRef = targetMatch[1] ?? targetRef ?? pendingTargetRef; + pendingTargetRef = undefined; + const text = targetMatch[2].trim(); + if (!text) { + continue; + } + + entries.push({ + type: 'reply', + text, + target_ref: targetRef, + }); + continue; + } + + const visibleText = candidateText + .replace(/[\s\S]*?<\/slusha_meta>/g, '') + .trim(); + if (!visibleText) { + if (targetRef) { + pendingTargetRef = targetRef; + } + continue; + } + + const resolvedTargetRef = targetRef ?? pendingTargetRef; + pendingTargetRef = undefined; + + entries.push({ + type: 'reply', + text: visibleText, + target_ref: resolvedTargetRef, + }); + } + + return entries; +} + +function buildTelemetryMetadata( + ctx: SlushaContext, + chatName: string, + tags: string[], + effectiveConfig: Awaited< + ReturnType + >, + policy: ReturnType, +) { + return buildGenerationTelemetryMetadata({ + sessionId: ctx.chat?.id.toString() ?? '', + userId: ctx.from?.id.toString() ?? '', + chatName, + tags, + temperature: effectiveConfig.ai.temperature, + topK: effectiveConfig.ai.topK, + topP: effectiveConfig.ai.topP, + policy, + }); +} + +type EffectiveConfig = Awaited< + ReturnType +>; + +async function sendGeneratedOutput(params: { + bot: Bot; + ctx: SlushaContext; + output: ChatEntry[]; + targetRefMap: Map; + enabledReactions: string[]; + effectiveConfig: EffectiveConfig; + historyById: Map>[number]>; +}): Promise { + const { + bot, + ctx, + output, + targetRefMap, + enabledReactions, + effectiveConfig, + historyById, + } = params; + const chatTopicId = typeof ctx.msg?.message_thread_id === 'number' + ? ctx.msg.message_thread_id + : undefined; + + function resolveTargetMessageId(targetRef?: string): number | undefined { + if (targetRef) { + const fromMap = targetRefMap.get(targetRef); + if (fromMap) { + return fromMap; + } + + logger.debug( + 'Unknown target_ref, fallback to trigger message', + { + targetRef, + }, + ); + } + + return ctx.msg?.message_id; + } + + let lastMsgId: number | undefined = undefined; + for (let i = 0; i < output.length; i++) { + const res = output[i]; + if ( + isReactEntry(res) && typeof res.react === 'string' && + res.react.trim().length > 0 + ) { + const canon = canonicalizeReaction(res.react.trim()); + if (canon && enabledReactions.includes(canon)) { + const targetId = resolveTargetMessageId(res.target_ref); + if (targetId) { + try { + const chatId = ctx.chat?.id; + if (!chatId) { + logger.debug( + 'Reaction chat context missing, skipping', + ); + continue; + } + await ctx.api.setMessageReaction( + chatId, + targetId, + [{ type: 'emoji', emoji: canon }], + ); + await ctx.m.addEmojiReaction(targetId, canon, { + id: bot.botInfo.id, + username: bot.botInfo.username, + first_name: bot.botInfo.first_name ?? 'Slusha', + }); + } catch (error) { + logger.warn('Could not set reaction: ', error); + } + } else { + logger.debug('Reaction target not found, skipping'); + } + } else { + logger.debug( + 'Reaction blocked or not allowed, dropping: ' + + res.react, + ); + } + } + + if (!isTextEntry(res)) { + continue; + } + + let replyText = (res.text ?? '').trim(); + if (replyText.length === 0) { + logger.info('Empty response from AI'); + continue; + } + + if (replyText.startsWith('* ')) { + replyText = replyText.slice(1); + replyText = '-' + replyText; + } + + const explicitTargetRef = typeof res.target_ref === 'string' && + res.target_ref.trim().length > 0 + ? res.target_ref.trim() + : undefined; + let msgToReply: number | undefined; + if (explicitTargetRef) { + msgToReply = resolveTargetMessageId(explicitTargetRef); + } else if (lastMsgId) { + msgToReply = lastMsgId; + } else { + msgToReply = ctx.msg?.message_id; + } + + if (typeof chatTopicId === 'number' && typeof msgToReply === 'number') { + const targetMsg = historyById.get(msgToReply); + const targetTopicId = targetMsg?.info.message_thread_id; + const targetInSameTopic = targetMsg + ? targetTopicId === chatTopicId + : msgToReply === ctx.msg?.message_id; + if (!targetInSameTopic) { + msgToReply = undefined; + } + } + + const replyOther = typeof chatTopicId === 'number' + ? { message_thread_id: chatTopicId } + : undefined; + + let replyInfo; + try { + if (ctx.chat?.type === 'private') { + replyInfo = await replyGeneric( + ctx, + replyText, + false, + 'Markdown', + ); + } else { + replyInfo = await replyWithMarkdownId( + ctx, + replyText, + msgToReply, + replyOther, + ); + } + } catch (error) { + if ( + ctx.chat?.type !== 'private' && + isMissingSendTextRightsError(error) + ) { + await ctx.m.setDisableRepliesDueToRights(true); + await ctx.m.setDisabledReplyRightsLastProbeAt(Date.now()); + logger.warn( + 'Disabled replies in chat due to missing send rights', + ); + return false; + } + + logger.error('Could not reply to user: ', error); + if (!ctx.info.isRandom) { + await ctx.reply(getRandomNepon(effectiveConfig)); + } + return false; + } + + lastMsgId = replyInfo.message_id; + + let replyTo: ReplyTo | undefined; + let threadId: string | undefined; + let threadRootMessageId: number | undefined; + let threadParentMessageId: number | undefined; + let threadSource = 'bot_new'; + if (replyInfo.reply_to_message) { + threadParentMessageId = replyInfo.reply_to_message.message_id; + const parent = historyById.get(threadParentMessageId); + if (parent) { + threadId = parent.threadId ?? + (typeof parent.threadRootMessageId === 'number' + ? `thread:${parent.threadRootMessageId}` + : `thread:${threadParentMessageId}`); + threadRootMessageId = parent.threadRootMessageId ?? parent.id; + threadSource = 'bot_parent'; + } else { + threadId = `thread:${threadParentMessageId}`; + threadRootMessageId = threadParentMessageId; + threadSource = 'bot_parent_external'; + } + + replyTo = { + id: threadParentMessageId, + text: replyInfo.reply_to_message.text ?? + replyInfo.reply_to_message.caption ?? '', + isMyself: false, + info: replyInfo.reply_to_message, + }; + } else { + threadId = `thread:${replyInfo.message_id}`; + threadRootMessageId = replyInfo.message_id; + } + + const messageRecord = { + id: replyInfo.message_id, + text: replyText, + isMyself: true, + info: replyInfo, + replyTo, + threadId, + threadRootMessageId, + threadParentMessageId, + threadSource, + }; + await ctx.m.addMessage(messageRecord); + historyById.set(messageRecord.id, messageRecord); + + if (i === output.length - 1) { + break; + } + + const typingSpeed = 1200; // symbol per minute + const next = output[i + 1]; + const nextLen = + next && isTextEntry(next) && typeof next.text === 'string' + ? next.text.length + : 0; + let msToWait = nextLen / typingSpeed * 60 * 1000; + if (msToWait > 5000) { + msToWait = 5000; + } + await new Promise((resolve) => setTimeout(resolve, msToWait)); + } + + return true; +} +export default function registerAI(bot: Bot) { bot.on('message', async (ctx) => { - const messages: ModelMessage[] = []; const chatState = await ctx.m.getChat(); const effectiveConfig = await ctx.m.getEffectiveConfig(); - const savedHistory = await ctx.m.getHistory(); - - const useJsonResponses = effectiveConfig.ai.useJsonResponses; + const sendChatActionsTool = createSendChatActionsTool( + resolveCustomPrompt( + effectiveConfig.ai.chatActionsToolDescription, + DEFAULT_CHAT_ACTIONS_TOOL_DESCRIPTION, + ), + ); + const sendChatReactionsTool = createSendChatReactionsTool( + resolveCustomPrompt( + effectiveConfig.ai.chatReactionsToolDescription, + DEFAULT_CHAT_REACTIONS_TOOL_DESCRIPTION, + ), + ); + const replyMethod = resolveReplyMethod( + effectiveConfig.ai.replyMethod, + ); const enabledReactions = resolveEnabledReactions( effectiveConfig.blacklistedReactions, ); @@ -121,91 +569,102 @@ export default function registerAI(bot: Bot) { Math.max(messagesToPass * 2, 12), 40, ); + const attempts = getGenerationFallbackPlans(messagesToPass); + const maxAttemptHistoryLimit = attempts.reduce( + (maxLimit, attempt) => Math.max(maxLimit, attempt.historyLimit), + 0, + ); + const savedHistory = await ctx.m.getRecentHistory( + Math.max(maxTargetCount, maxAttemptHistoryLimit), + ); + const targetRefs = buildTargetRefs(savedHistory, maxTargetCount); const targetRefMap = new Map( targetRefs.map((target) => [target.ref, target.messageId]), ); - let prompt = ''; - if (useJsonResponses) { - prompt = (effectiveConfig.ai.prePrompt ?? '') + '\n\n'; - } else { - const fallbackDumbPre = - 'Отвечай одним сообщением простым текстом без какого-либо JSON.' + - '\nНе используй реакции. Пиши кратко и по делу.' + - '\nИспользуй Telegram markdown, но без заголовков.'; - prompt = (effectiveConfig.ai.dumbPrePrompt ?? fallbackDumbPre) + - '\n\n'; - } - // TODO: Improve this check - const isComments = savedHistory.some((m) => - m.info.forward_origin?.type === 'channel' && - m.info.from?.first_name === 'Telegram' - ); + const isComments = isTelegramCommentsHistory(savedHistory); + const currentLocale = chatState.locale ?? + await ctx.i18n.getLocale(); + const character = chatState.character; + + const modelRef = chatState.chatModel ?? effectiveConfig.ai.model; + const parsedModel = parseModelRef(modelRef); + + const time = new Date().getTime(); + const maxGenerationRetries = 2; + const tags = ['user-message']; if (ctx.chat.type === 'private') { - if (effectiveConfig.ai.privateChatPromptAddition) { - prompt += effectiveConfig.ai.privateChatPromptAddition; - } - } else if (isComments && effectiveConfig.ai.commentsPromptAddition) { - prompt += effectiveConfig.ai.commentsPromptAddition; - } else if (effectiveConfig.ai.groupChatPromptAddition) { - prompt += effectiveConfig.ai.groupChatPromptAddition; + tags.push('private'); } - - if (chatState.hateMode && effectiveConfig.ai.hateModePrompt) { - prompt += '\n' + effectiveConfig.ai.hateModePrompt; + if (ctx.info.isRandom) { + tags.push('random'); } + const chatName = ctx.chat.first_name ?? ctx.chat.title; - prompt += '\n\n'; + const activeMembers = ctx.chat.type === 'private' + ? [] + : await ctx.m.getActiveMembers(); - const currentLocale = chatState.locale ?? - await ctx.i18n.getLocale(); - prompt += buildLanguageProtocol(currentLocale) + '\n\n'; + const buildMessagesForAttempt = async ( + plan: GenerationAttemptPlan, + ): Promise => { + const messages: ModelMessage[] = []; - const character = chatState.character; - if (character) { - prompt += '### Character ###\n' + character.description; - } else { - prompt += useJsonResponses - ? effectiveConfig.ai.prompt - : (effectiveConfig.ai.dumbPrompt ?? effectiveConfig.ai.prompt); - } - - let chatInfoMsg = `Date and time right now: ${prettyDate()}`; + let prompt = ''; + if (replyMethod === 'json_actions') { + prompt = (effectiveConfig.ai.prePrompt ?? '') + '\n\n'; + } else { + const fallbackDumbPre = + 'Отвечай простым текстом без какого-либо JSON.' + + '\nНе описывай действия и не пиши служебные команды.' + + '\nИспользуй Telegram markdown, но без заголовков.'; + prompt = (effectiveConfig.ai.dumbPrePrompt ?? fallbackDumbPre) + + '\n\n'; + } - if (ctx.chat.type === 'private') { - chatInfoMsg += - `\nЛичный чат с ${ctx.from.first_name} (@${ctx.from.username})`; - } else { - const activeMembers = await ctx.m.getActiveMembers(); - if (activeMembers.length > 0) { - const prettyMembersList = activeMembers.map((m) => { - let text = `- ${m.first_name}`; - if (m.username) { - text += ` (@${m.username})`; - } - return text; - }).join('\n'); + prompt += buildChatPromptAddition({ + chatType: ctx.chat.type, + isComments, + privateChatPromptAddition: + effectiveConfig.ai.privateChatPromptAddition, + commentsPromptAddition: + effectiveConfig.ai.commentsPromptAddition, + groupChatPromptAddition: + effectiveConfig.ai.groupChatPromptAddition, + }); - chatInfoMsg += - `\nChat: ${ctx.chat.title}, Active members:\n${prettyMembersList}`; + if (chatState.hateMode && effectiveConfig.ai.hateModePrompt) { + prompt += '\n' + effectiveConfig.ai.hateModePrompt; } - } - // If we have notes, add them to messages - if (chatState.notes.length > 0) { - chatInfoMsg += `\n\nChat notes:\n${chatState.notes.join('\n')}`; - } + prompt += '\n\n'; + prompt += buildLanguageProtocol(currentLocale) + '\n\n'; - if (chatState.memory) { - chatInfoMsg += - `\n\nMY OWN PERSONAL NOTES AND MEMORY:\n${chatState.memory}`; - } + if (character) { + prompt += '### Character ###\n' + character.description; + } else { + prompt += replyMethod === 'json_actions' + ? effectiveConfig.ai.prompt + : (effectiveConfig.ai.dumbPrompt ?? + effectiveConfig.ai.prompt); + } - prompt += `\n\n### Chat Info ###\n${chatInfoMsg}`; + const chatInfoMsg = buildChatInfoBlock({ + nowText: prettyDate(), + chatType: ctx.chat.type, + chatTitle: ctx.chat.title, + userFirstName: ctx.from.first_name, + userUsername: ctx.from.username, + activeMembers, + notes: chatState.notes, + memory: chatState.memory, + includeNotes: plan.includeBotNotes, + includeMemory: plan.includeBotNotes, + }); - if (useJsonResponses) { + prompt += `\n\n### Chat Info ###\n${chatInfoMsg}`; if (enabledReactions.length === 0) { prompt += '\n\n### Reactions ###\n- Reactions are disabled for this chat. Do not output react actions.'; @@ -215,79 +674,74 @@ export default function registerAI(bot: Bot) { enabledReactions.join(', ') }`; } - prompt += `\n\n${buildTargetRefsPrompt(targetRefs)}`; - } - messages.push({ - role: 'system', - content: prompt, - }); + messages.push({ + role: 'system', + content: prompt, + }); - const modelRef = chatState.chatModel ?? effectiveConfig.ai.model; - const parsedModel = parseModelRef(modelRef); + const historyBuilder = effectiveConfig.ai.historyVersion === 'v3' + ? makeHistoryV3 + : makeHistoryV2; - let history: ModelMessage[] = []; - try { - history = await makeHistoryV2( + const history = await historyBuilder( { token: bot.token, id: bot.botInfo.id }, bot.api, logger, savedHistory, { - messagesLimit: messagesToPass, + messagesLimit: plan.historyLimit, bytesLimit: effectiveConfig.ai.bytesLimit, symbolLimit: effectiveConfig.ai.messageMaxLength, includeReactions: true, + activeMessageId: ctx.msg.message_id, attachments: effectiveConfig.ai.includeAttachmentsInHistory && parsedModel.provider === 'google', }, ); - } catch (error) { - logger.error('Could not get history: ', error); - if (!ctx.info.isRandom) { - await ctx.reply(getRandomNepon(effectiveConfig)); + const annotatedHistory = annotateHistoryWithTargetRefs( + history, + targetRefs, + ); + messages.push(...annotatedHistory); + + let finalPrompt = replyMethod === 'json_actions' + ? effectiveConfig.ai.finalPrompt + : (effectiveConfig.ai.dumbFinalPrompt ?? + 'Ответь простым текстом.'); + if (replyMethod === 'json_actions') { + finalPrompt += + ' Return only actions array using typed entries and target_ref values from Reply Target Map.'; + } + if (replyMethod !== 'json_actions' && ctx.info.userToReply) { + finalPrompt += + ` Ответь на сообщение от ${ctx.info.userToReply}.`; + } + if (replyMethod !== 'json_actions') { + finalPrompt += + ` If you need to reply to a specific message from Reply Target Map, start that reply block with ${PLAIN_TEXT_META_OPEN} on a separate line, then a one-line JSON object like {"target_ref":"tN"}, then ${PLAIN_TEXT_META_CLOSE}, then put reply text on the next line.`; + finalPrompt += + ` Never include ${PLAIN_TEXT_META_OPEN}...${PLAIN_TEXT_META_CLOSE} in user-facing text body. It is machine-only metadata.`; + if (enabledReactions.length > 0) { + finalPrompt += + ' Reply as plain text in assistant content. Optionally call send_chat_reactions only for react entries. Do not put reply text inside tool calls.'; + } } - return; - } - - const annotatedHistory = annotateHistoryWithTargetRefs( - history, - targetRefs, - ); - messages.push(...annotatedHistory); - - let finalPrompt = useJsonResponses - ? effectiveConfig.ai.finalPrompt - : (effectiveConfig.ai.dumbFinalPrompt ?? 'Ответь простым текстом.'); - if (useJsonResponses) { - finalPrompt += - ' Return only actions array using typed entries and target_ref values from Reply Target Map.'; - } - if (!useJsonResponses && ctx.info.userToReply) { - finalPrompt += ` Ответь на сообщение от ${ctx.info.userToReply}.`; - } - - messages.push({ - role: 'user', - content: finalPrompt, - }); - const time = new Date().getTime(); - const maxGenerationRetries = 2; + messages.push({ + role: 'user', + content: finalPrompt, + }); - const tags = ['user-message']; - if (ctx.chat.type === 'private') { - tags.push('private'); - } - if (ctx.info.isRandom) { - tags.push('random'); - } - const chatName = ctx.chat.first_name ?? ctx.chat.title; + return messages; + }; - const generatePlainTextOutput = async (): Promise => { + const generatePlainTextAndReactionsOutput = async ( + messages: ModelMessage[], + ): Promise => { const generationPolicy = resolveGenerationPolicy({ modelRef, config: effectiveConfig.ai, @@ -302,6 +756,11 @@ export default function registerAI(bot: Bot) { model: generationPolicy.model, providerOptions, maxRetries: maxGenerationRetries, + tools: enabledReactions.length > 0 + ? { + send_chat_reactions: sendChatReactionsTool, + } + : undefined, messages, temperature: effectiveConfig.ai.temperature, topK: effectiveConfig.ai.topK, @@ -310,103 +769,151 @@ export default function registerAI(bot: Bot) { experimental_telemetry: { isEnabled: true, functionId: 'user-message-dumb', - metadata: buildGenerationTelemetryMetadata({ - sessionId: ctx.chat.id.toString(), - userId: ctx.from?.id.toString() ?? '', + metadata: buildTelemetryMetadata( + ctx, chatName, tags, - temperature: effectiveConfig.ai.temperature, - topK: effectiveConfig.ai.topK, - topP: effectiveConfig.ai.topP, - policy: generationPolicy, - }), + effectiveConfig, + generationPolicy, + ), }, }); - return [{ type: 'reply', text: response.text }]; + + const plainTextResponse = response.text.trim(); + const replyEntries = parsePlainTextRepliesWithTargets( + plainTextResponse, + ); + + if (enabledReactions.length === 0) { + return replyEntries; + } + + const reactionEntries = parseSendChatReactionsEntries( + response.toolCalls, + ); + if (!reactionEntries) { + return replyEntries; + } + + const filteredReactionEntries = reactionEntries.filter((entry) => + isReactEntry(entry) + ); + + return [...replyEntries, ...filteredReactionEntries]; }; - let output: ChatEntry[]; - try { - if (useJsonResponses) { - const generationPolicy = resolveGenerationPolicy({ - modelRef, - config: effectiveConfig.ai, - task: 'chat', - expectsStructuredOutput: true, - }); - const providerOptions = generationPolicy - .providerOptions as Parameters< - typeof generateText - >[0]['providerOptions']; - const result = await generateText({ - model: generationPolicy.model, - providerOptions, - maxRetries: maxGenerationRetries, - tools: { - send_chat_actions: sendChatActionsTool, - }, - toolChoice: { - type: 'tool', - toolName: 'send_chat_actions', - }, - stopWhen: hasToolCall('send_chat_actions'), - temperature: effectiveConfig.ai.temperature, - topK: effectiveConfig.ai.topK, - topP: effectiveConfig.ai.topP, - maxOutputTokens: generationPolicy.maxOutputTokens, - messages, - experimental_telemetry: { - isEnabled: true, - functionId: 'user-message', - metadata: buildGenerationTelemetryMetadata({ - sessionId: ctx.chat.id.toString(), - userId: ctx.from?.id.toString() ?? '', - chatName, - tags, - temperature: effectiveConfig.ai.temperature, - topK: effectiveConfig.ai.topK, - topP: effectiveConfig.ai.topP, - policy: generationPolicy, - }), + const generateStructuredActionsOutput = async ( + messages: ModelMessage[], + ): Promise => { + const generationPolicy = resolveGenerationPolicy({ + modelRef, + config: effectiveConfig.ai, + task: 'chat', + expectsStructuredOutput: true, + }); + const providerOptions = generationPolicy + .providerOptions as Parameters< + typeof generateText + >[0]['providerOptions']; + const result = await generateText({ + model: generationPolicy.model, + providerOptions, + maxRetries: maxGenerationRetries, + tools: { + send_chat_actions: sendChatActionsTool, + }, + toolChoice: { + type: 'tool', + toolName: 'send_chat_actions', + }, + stopWhen: hasToolCall('send_chat_actions'), + temperature: effectiveConfig.ai.temperature, + topK: effectiveConfig.ai.topK, + topP: effectiveConfig.ai.topP, + maxOutputTokens: generationPolicy.maxOutputTokens, + messages, + experimental_telemetry: { + isEnabled: true, + functionId: 'user-message', + metadata: buildTelemetryMetadata( + ctx, + chatName, + tags, + effectiveConfig, + generationPolicy, + ), + }, + }); + + const entries = parseSendChatActionsEntries(result.toolCalls); + if (entries) { + return entries; + } else { + logger.warn( + 'Structured tool call missing or invalid after retries', + { + modelRef, + chatId: ctx.chat.id, + finishReason: result.finishReason, + toolCalls: result.toolCalls.map((call) => + call.toolName + ), + usage: result.totalUsage, }, + ); + throw new Error( + 'Structured tool call missing or invalid after retries', + ); + } + }; + + let output: ChatEntry[] | undefined; + let generationError: unknown; + + for (const attempt of attempts) { + let attemptMessages: ModelMessage[]; + try { + attemptMessages = await buildMessagesForAttempt(attempt); + } catch (error) { + generationError = error; + logger.warn('Could not get history for generation attempt', { + level: attempt.level, + historyLimit: attempt.historyLimit, + includeBotNotes: attempt.includeBotNotes, + error, }); + continue; + } - const chatActionsToolCall = result.toolCalls.find((call) => - call.toolName === 'send_chat_actions' - ); - const parsedToolCallInput = chatActionsToolCall - ? chatActionsToolInputSchema.safeParse( - chatActionsToolCall.input, - ) - : undefined; - - if (parsedToolCallInput?.success) { - output = parsedToolCallInput.data.entries; - } else { - logger.warn( - 'Structured tool call missing or invalid after retries', - { - modelRef, - chatId: ctx.chat.id, - finishReason: result.finishReason, - toolCalls: result.toolCalls.map((call) => - call.toolName - ), - usage: result.totalUsage, - }, - ); - throw new Error( - 'Structured tool call missing or invalid after retries', + try { + output = replyMethod === 'json_actions' + ? await generateStructuredActionsOutput(attemptMessages) + : await generatePlainTextAndReactionsOutput( + attemptMessages, ); - } - } else { - output = await generatePlainTextOutput(); + break; + } catch (error) { + generationError = error; + logger.warn('Generation attempt failed', { + level: attempt.level, + historyLimit: attempt.historyLimit, + includeBotNotes: attempt.includeBotNotes, + replyMethod, + error, + }); } - } catch (error) { + } + + if (!output) { let blockReason: string | undefined; - if (error instanceof APICallError && error.responseBody) { + if ( + generationError instanceof APICallError && + generationError.responseBody + ) { try { - const parsedError = JSON.parse(error.responseBody); + const parsedError = JSON.parse( + generationError.responseBody, + ); if ( typeof parsedError?.promptFeedback?.blockReason === 'string' @@ -432,7 +939,7 @@ export default function registerAI(bot: Bot) { ); } - logger.error('Could not get response: ', error); + logger.error('Could not get response: ', generationError); if (blockReason) { return ctx.reply( blockedByProviderMessage + @@ -454,155 +961,16 @@ export default function registerAI(bot: Bot) { `for "${name}" ${username}. `, ); - function resolveTargetMessageId( - targetRef?: string, - ): number | undefined { - if (targetRef) { - const fromMap = targetRefMap.get(targetRef); - if (fromMap) { - return fromMap; - } - - logger.debug( - 'Unknown target_ref, fallback to trigger message', - { - targetRef, - }, - ); - } - - return ctx.msg.message_id; - } - - let lastMsgId: number | undefined = undefined; - for (let i = 0; i < output.length; i++) { - const res = output[i]; - if ( - isReactEntry(res) && typeof res.react === 'string' && - res.react.trim().length > 0 - ) { - const canon = canonicalizeReaction(res.react.trim()); - if (canon && enabledReactions.includes(canon)) { - const targetId = resolveTargetMessageId(res.target_ref); - if (targetId) { - try { - await ctx.api.setMessageReaction( - ctx.chat.id, - targetId, - [{ type: 'emoji', emoji: canon }], - ); - await ctx.m.addEmojiReaction(targetId, canon, { - id: bot.botInfo.id, - username: bot.botInfo.username, - first_name: bot.botInfo.first_name ?? 'Slusha', - }); - } catch (error) { - logger.warn('Could not set reaction: ', error); - } - } else { - logger.debug('Reaction target not found, skipping'); - } - } else { - logger.debug( - 'Reaction blocked or not allowed, dropping: ' + - res.react, - ); - } - } - - if (!isTextEntry(res)) { - continue; - } - - let replyText = (res.text ?? '').trim(); - if (replyText.length === 0) { - logger.info('Empty response from AI'); - continue; - } - - if (replyText.startsWith('* ')) { - replyText = replyText.slice(1); - replyText = '-' + replyText; - } - - let msgToReply: number | undefined; - msgToReply = resolveTargetMessageId(res.target_ref); - if (!msgToReply && lastMsgId) { - msgToReply = lastMsgId; - } - - let replyInfo; - try { - if (ctx.chat.type === 'private') { - replyInfo = await replyGeneric( - ctx, - replyText, - false, - 'Markdown', - ); - } else { - replyInfo = await replyWithMarkdownId( - ctx, - replyText, - msgToReply, - ); - } - } catch (error) { - if ( - ctx.chat.type !== 'private' && - isMissingSendTextRightsError(error) - ) { - await ctx.m.setDisableRepliesDueToRights(true); - await ctx.m.setDisabledReplyRightsLastProbeAt(Date.now()); - logger.warn( - 'Disabled replies in chat due to missing send rights', - ); - return; - } - - logger.error('Could not reply to user: ', error); - if (!ctx.info.isRandom) { - await ctx.reply(getRandomNepon(effectiveConfig)); - } - return; - } + const historyById = new Map(savedHistory.map((msg) => [msg.id, msg])); - lastMsgId = replyInfo.message_id; - - let replyTo: ReplyTo | undefined; - if (replyInfo.reply_to_message) { - replyTo = { - id: replyInfo.reply_to_message.message_id, - text: replyInfo.reply_to_message.text ?? - replyInfo.reply_to_message.caption ?? '', - isMyself: false, - info: replyInfo.reply_to_message, - }; - } - - await ctx.m.addMessage({ - id: replyInfo.message_id, - text: replyText, - isMyself: true, - info: replyInfo, - replyTo, - }); - - if (i === output.length - 1) { - break; - } - - const typingSpeed = 1200; // symbol per minute - const next = output[i + 1]; - const nextLen = - next && isTextEntry(next) && typeof next.text === 'string' - ? next.text.length - : 0; - let msToWait = nextLen / typingSpeed * 60 * 1000; - if (msToWait > 5000) { - msToWait = 5000; - } - await new Promise((resolve) => setTimeout(resolve, msToWait)); - } + await sendGeneratedOutput({ + bot, + ctx, + output, + targetRefMap, + enabledReactions, + effectiveConfig, + historyById, + }); }); } diff --git a/lib/telegram/helpers.ts b/lib/telegram/helpers.ts index cf58db8..0bae0f5 100644 --- a/lib/telegram/helpers.ts +++ b/lib/telegram/helpers.ts @@ -82,12 +82,11 @@ async function replyGenericId( try { res = await ctx.reply(part, { parse_mode, - reply_to_message_id: ctx.msg?.message_id, ...other, }); } catch (_) { // Retry without markdown res = await ctx.reply(text, { - reply_to_message_id: ctx.msg?.message_id, + ...other, }); } } diff --git a/lib/telegram/setup-bot.ts b/lib/telegram/setup-bot.ts index 6276950..e63bea6 100644 --- a/lib/telegram/setup-bot.ts +++ b/lib/telegram/setup-bot.ts @@ -11,6 +11,7 @@ import { } from '../memory.ts'; import { sequentialize } from '@grammyjs/runner'; import { canMemberSendTextMessages } from './reply-rights.ts'; +import { Message } from 'grammy_types'; interface RequestInfo { isRandom: boolean; @@ -18,6 +19,13 @@ interface RequestInfo { config: Config['ai']; } +interface ThreadResolution { + threadId: string; + threadRootMessageId: number; + threadParentMessageId?: number; + threadSource: string; +} + export type SlushaContext = Context & I18nFlavor & { info: RequestInfo; memory: Memory; @@ -117,6 +125,67 @@ function parseReactionCounts( }).filter((entry): entry is ReactionCountEntry => entry !== undefined); } +function isSameTopic(left: Message, right: Message): boolean { + const leftTopic = left.message_thread_id; + const rightTopic = right.message_thread_id; + return leftTopic === rightTopic; +} + +async function resolveThreadForIncomingMessage( + memory: ChatMemory, + incoming: Message, + replyToId?: number, +): Promise { + if (typeof replyToId === 'number') { + const parent = await memory.getMessageById(replyToId); + const inheritedRoot = parent?.threadRootMessageId ?? replyToId; + const inheritedThread = parent?.threadId ?? `thread:${inheritedRoot}`; + + return { + threadId: inheritedThread, + threadRootMessageId: inheritedRoot, + threadParentMessageId: replyToId, + threadSource: parent ? 'explicit_reply' : 'explicit_reply_external', + }; + } + + const incomingAuthorId = incoming.from?.id; + const incomingDate = incoming.date; + const maxGapSeconds = 180; + const maxInterveningMessages = 6; + + if (typeof incomingAuthorId === 'number') { + const candidate = await memory.getLastMessageByAuthorInTopic( + incomingAuthorId, + incoming.message_thread_id, + maxInterveningMessages + 1, + ); + + if (candidate && isSameTopic(candidate.info, incoming)) { + const candidateDate = candidate.info.date; + const secondsSince = incomingDate - candidateDate; + if (secondsSince <= maxGapSeconds) { + const inheritedRoot = candidate.threadRootMessageId ?? + candidate.id; + const inheritedThread = candidate.threadId ?? + `thread:${inheritedRoot}`; + return { + threadId: inheritedThread, + threadRootMessageId: inheritedRoot, + threadParentMessageId: candidate.id, + threadSource: 'implicit_same_author', + }; + } + } + } + + return { + threadId: `thread:${incoming.message_id}`, + threadRootMessageId: incoming.message_id, + threadSource: 'new_thread', + }; +} + // TODO: Maybe derive from bot info somehow? const commands = [ '/optout', @@ -238,9 +307,11 @@ export default async function setupBot(config: Config, memory: Memory) { // Save all messages to memory let replyTo: ReplyTo | undefined; + let replyToId: number | undefined; if (ctx.msg.reply_to_message) { + replyToId = ctx.msg.reply_to_message.message_id; replyTo = { - id: ctx.msg.reply_to_message.message_id, + id: replyToId, text: ctx.msg.reply_to_message.text ?? ctx.msg.reply_to_message.caption ?? '', isMyself: false, @@ -248,11 +319,21 @@ export default async function setupBot(config: Config, memory: Memory) { }; } + const threadResolution = await resolveThreadForIncomingMessage( + ctx.m, + ctx.msg, + replyToId, + ); + // Save every message to memory await ctx.m.addMessage({ id: ctx.msg.message_id, text: ctx.msg.text ?? ctx.msg.caption ?? '', replyTo, + threadId: threadResolution.threadId, + threadRootMessageId: threadResolution.threadRootMessageId, + threadParentMessageId: threadResolution.threadParentMessageId, + threadSource: threadResolution.threadSource, isMyself: false, info: ctx.message, }); diff --git a/lib/web/config-policy.ts b/lib/web/config-policy.ts index ffb8f34..907842e 100644 --- a/lib/web/config-policy.ts +++ b/lib/web/config-policy.ts @@ -26,13 +26,14 @@ function pickTrustedAi(config: UserConfig['ai']): ChatEditableAi { temperature: config.temperature, topK: config.topK, topP: config.topP, + historyVersion: config.historyVersion, prompt: config.prompt, dumbPrompt: config.dumbPrompt, privateChatPromptAddition: config.privateChatPromptAddition, groupChatPromptAddition: config.groupChatPromptAddition, commentsPromptAddition: config.commentsPromptAddition, hateModePrompt: config.hateModePrompt, - useJsonResponses: config.useJsonResponses, + replyMethod: config.replyMethod, messagesToPass: config.messagesToPass, messageMaxLength: config.messageMaxLength, includeAttachmentsInHistory: config.includeAttachmentsInHistory, @@ -46,13 +47,14 @@ function pickTrustedAiOverride(config: ChatEditableAi): ChatEditableAi { temperature: config.temperature, topK: config.topK, topP: config.topP, + historyVersion: config.historyVersion, prompt: config.prompt, dumbPrompt: config.dumbPrompt, privateChatPromptAddition: config.privateChatPromptAddition, groupChatPromptAddition: config.groupChatPromptAddition, commentsPromptAddition: config.commentsPromptAddition, hateModePrompt: config.hateModePrompt, - useJsonResponses: config.useJsonResponses, + replyMethod: config.replyMethod, messagesToPass: config.messagesToPass, messageMaxLength: config.messageMaxLength, includeAttachmentsInHistory: config.includeAttachmentsInHistory, @@ -120,6 +122,9 @@ export function projectEffectiveConfigForRole( blacklistedReactions: config.blacklistedReactions, nepons: config.nepons, responseDelay: config.responseDelay, + ai: { + historyVersion: config.ai.historyVersion, + }, }; if (role === 'trusted' || role === 'admin') { @@ -157,6 +162,14 @@ export function sanitizeChatOverrideForRole( ); } + if (override.ai && role === 'regular') { + if (override.ai.historyVersion !== undefined) { + next.ai = { + historyVersion: override.ai.historyVersion, + }; + } + } + if ((role === 'trusted' || role === 'admin') && override.ai) { const ai = pickTrustedAiOverride(override.ai); const availableModels = resolveAvailableModels(globalConfig); diff --git a/scripts/import-memory-json.ts b/scripts/import-memory-json.ts index 110f7bb..38590a8 100644 --- a/scripts/import-memory-json.ts +++ b/scripts/import-memory-json.ts @@ -23,7 +23,6 @@ function toBoolInt(value: boolean | undefined): boolean | null { } async function upsertChat(db: DbClient, chatId: number, chat: Chat) { - await db.insert(chats).values({ id: chatId, info: JSON.stringify(chat.info), @@ -76,16 +75,22 @@ async function upsertChat(db: DbClient, chatId: number, chat: Chat) { await db.delete(chatOptOutUsers).where(eq(chatOptOutUsers.chatId, chatId)); if (chat.optOutUsers.length > 0) { - await db.insert(chatOptOutUsers).values(chat.optOutUsers.map((user) => ({ - chatId, - userId: user.id, - username: user.username ?? null, - firstName: user.first_name, - }))); + await db.insert(chatOptOutUsers).values( + chat.optOutUsers.map((user) => ({ + chatId, + userId: user.id, + username: user.username ?? null, + firstName: user.first_name, + })), + ); } - await db.delete(messageReactionUsers).where(eq(messageReactionUsers.chatId, chatId)); - await db.delete(messageReactions).where(eq(messageReactions.chatId, chatId)); + await db.delete(messageReactionUsers).where( + eq(messageReactionUsers.chatId, chatId), + ); + await db.delete(messageReactions).where( + eq(messageReactions.chatId, chatId), + ); await db.delete(chatMessages).where(eq(chatMessages.chatId, chatId)); if (chat.history.length > 0) { @@ -190,7 +195,9 @@ async function main() { const [stats] = await db.select({ total: count() }).from(chats); console.log( - `Imported ${entries.length} chats from memory.json to sqlite. Total chats in DB: ${stats?.total ?? 0}`, + `Imported ${entries.length} chats from memory.json to sqlite. Total chats in DB: ${ + stats?.total ?? 0 + }`, ); } diff --git a/widget/src/App.svelte b/widget/src/App.svelte index c0d7893..e0603bd 100644 --- a/widget/src/App.svelte +++ b/widget/src/App.svelte @@ -270,6 +270,8 @@ bind:config={controller.globalConfig} bind:text={controller.globalText} availableModels={controller.availableModels} + availableReactions={controller.availableReactions} + isAdmin={controller.role === 'admin'} searchQuery={settingsSearch} /> {:else} diff --git a/widget/src/lib/components/config/AiSettingsSection.svelte b/widget/src/lib/components/config/AiSettingsSection.svelte index 9bdfbe1..946eedb 100644 --- a/widget/src/lib/components/config/AiSettingsSection.svelte +++ b/widget/src/lib/components/config/AiSettingsSection.svelte @@ -29,15 +29,14 @@
-