godcrm/pes-core/platform/telegram.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

9711 lines
383 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// PES Platform — Telegram Adapter
// ============================================================
// Connects PES to Telegram via Bot API (raw fetch, 0 deps).
//
// Features:
// - Sticker delivery (from ANY public sticker pack)
// - Emoji reactions on owner messages (setMessageReaction)
// - Reaction collection (message_reaction updates → feedback loop)
// - Bot commands (/start, /status, /feed, /command, /silent, /stickers)
// - Keyword parser (привет→greeting, молодец→praise)
// - "Typing..." delay scaled by PES level
// - Sticker pack hot-swap
//
// Dependencies: 0 (uses built-in fetch)
// ============================================================
import { PesPlatformAdapter, RENDER_MODE, PRESENCE, MEDIA_TYPE } from './base.js';
import { sleep } from '../utils.js';
import { LLMAdapter } from '../brain/llm-adapter.js';
import { buildSystemPrompt, buildMessages } from '../brain/prompt-builder.js';
import { BabbleEngine } from '../soul/babble-engine.js';
import { CrmClient } from './crm-client.js';
import { WorkflowEngine } from '../core/workflows.js';
// Valid emotions for LLM classification — must match babble-engine EMOTION_INSTRUMENTS keys
const EMOTION_INSTRUMENTS_SET = new Set([
'happy', 'playful', 'greeting_frenzy', 'butt_wiggle', 'zoomies', 'excited',
'idle', 'content', 'nap', 'sleep',
'sad', 'lonely', 'scared', 'anxious',
'food_obsessed', 'hungry',
'alert', 'angry', 'bark',
'curious', 'puzzle_solving',
'dramatic_tantrum', 'sulking', 'stubborn_refuse',
]);
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync, unlinkSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __tg_dirname = dirname(fileURLToPath(import.meta.url));
const DEV_LOG_DIR = join(__tg_dirname, '..', 'pes-data');
const DEV_LOG_FILE = join(DEV_LOG_DIR, 'dev-requests.log');
// ── TELEGRAM API BASE ─────────────────────────────────────
const TG_API = 'https://api.telegram.org/bot';
// ── TELEGRAM ALLOWED REACTION EMOJI ──────────────────────
// Telegram only allows a specific set of emoji for reactions.
// This is the official list for regular (non-premium) reactions.
const ALLOWED_REACTIONS = new Set([
'👍', '👎', '❤️', '🔥', '🥰', '👏', '😁', '🤔', '🤯', '😱', '🤬', '😢', '🎉',
'🤩', '🤮', '💩', '✍️', '👀', '🎅', '🎄', '☃️', '💅', '🤪', '🗿', '🆒', '💘',
'🙈', '😇', '😨', '🤝', '✊', '🫡', '🍌', '🏆', '💔', '🤨', '😐', '🍓', '🍾',
'💋', '🖕', '😈', '😴', '😭', '🤓', '👻', '👨‍💻', '🎃', '🙏', '🤡', '🥱', '🥴',
'😍', '🐳', '❤️‍🔥', '🌚', '🌭', '💯', '🤣', '⚡', '🍕', '😘', '💊', '🙊',
'😎', '👾', '🤷', '🤷‍♂️', '🤷‍♀️', '😡',
]);
// ── KEYWORD PATTERNS ──────────────────────────────────────
const KEYWORDS = {
greetings: {
patterns: [/^привет/i, /^здравствуй/i, /^хай/i, /^hi\b/i, /^hello/i, /^hey\b/i, /^yo\b/i, /^салам/i, /^дарова/i],
event: { type: 'owner_returned', from: 'owner' },
},
praise: {
patterns: [/молодец/i, /хорош/i, /класс/i, /супер/i, /отлично/i, /круто/i, /\bgood\b/i, /\bgreat\b/i, /\bnice\b/i, /\bperfect/i, /\bawesome/i, /умница/i, /браво/i, /ура/i],
action: 'praise',
},
scold: {
patterns: [/^нет$/i, /^нет[,.\s!]/i, /плохо/i, /^стоп$/i, /^стоп[,.\s!]/i, /не надо/i, /хватит/i, /\bbad\b/i, /\bstop\b/i, /\bno\b/i, /\bwrong\b/i, /фу/i, /нельзя/i],
action: 'scold',
},
file_search: {
patterns: [/где мо[йяеи]/i, /найди файл/i, /найди фото/i, /найди документ/i, /принеси файл/i, /принеси фото/i, /принеси документ/i, /покажи файл/i, /покажи фото/i, /где файл/i, /где фото/i, /где документ/i, /где паспорт/i, /где страховк/i, /где договор/i],
action: 'file_search',
},
fetch: {
patterns: [/^принеси/i, /^найди/i, /^ищи/i, /^fetch\b/i, /^find\b/i, /^search\b/i, /^апорт/i, /^мячик/i],
action: 'command',
command: 'fetch',
},
status: {
patterns: [/как дела/i, /как ты/i, /статус/i, /\bstatus\b/i, /что нового/i, /как жизнь/i],
action: 'status',
},
feed: {
patterns: [/^кушай/i, /^ешь/i, /^на\s/i, /^держи/i, /^feed\b/i],
action: 'feed',
},
play: {
patterns: [/^играть/i, /^играем/i, /^play\b/i, /^давай играть/i],
event: { type: 'play_initiated', from: 'owner' },
},
triage: {
patterns: [/^что.?нов(ого|ое)/i, /^triage/i, /^триаж/i, /^проверь crm/i, /^скань/i],
action: 'workflow',
command: 'triage',
},
daily_summary: {
patterns: [/итоги дня/i, /итоги за день/i, /расскажи итоги/i, /daily.?summary/i, /сводка/i, /^отчёт$/i, /^отчет$/i],
action: 'workflow',
command: 'daily_summary',
},
health: {
patterns: [/^здоровье$/i, /^health$/i, /как здоровье/i, /проверь себя/i, /самочувствие/i],
action: 'workflow',
command: 'health_check',
},
};
// ── STICKER EMOTION MAP (default mapping) ─────────────────
const DEFAULT_STICKER_EMOTION_MAP = {
// body keys → sticker keys
butt_wiggle_turbo: 'happy',
butt_wiggle_fast: 'excited',
butt_wiggle_slow: 'interested',
no_wiggle: 'sad',
ears_forward: 'alert',
ears_flat: 'scared',
sploot: 'relaxed',
play_bow: 'playful',
corgi_flop: 'trust',
zoomies: 'zoomies',
sleep_curl: 'sleeping',
low_crouch: 'working',
freeze: 'alert',
hide_behind: 'scared',
belly_up: 'submissive',
nose_nudge: 'curious',
paw_lift: 'asking',
head_tilt: 'confused',
spin: 'excited',
bunny_hop: 'happy',
};
// ── BODY ACTION SYMBOLS (no human words — only sounds, emoji, symbols) ───
// Animals don't describe actions in words. They EXPRESS through sounds + emoji.
const BODY_ACTIONS = {
butt_wiggle_turbo: ['🍑💨💨💨', '🍑⚡⚡', '🍑🌀🌀🌀'],
butt_wiggle_fast: ['🍑💨', '🍑~~', '🍑✨'],
butt_wiggle_slow: ['🍑~', '🍑...'],
no_wiggle: ['🍑.', '...'],
ears_forward: ['👂⬆️👂', '👂❗', '📡📡'],
ears_airplane: ['👂↔️👂', '✈️👂'],
ears_one_up: ['👂⬆️👂↗️', '👂❓'],
ears_flat: ['👂⬇️👂', '👂...'],
ears_twitching: ['👂〰️👂', '👂⚡'],
sploot: ['🫠', '___🐾', '⬇️🫠'],
play_bow: ['🔽🐾⬆️🍑', '⬇️🐾 ⬆️🍑', '🐾↘️ 🍑↗️'],
corgi_flop: ['💥🫠', '🐾→🫠'],
low_crouch: ['🐾⬇️⬇️', '👀⬇️🐾'],
belly_up: ['🔄🐾☁️', '⬆️🫄', '🐾🔄☁️'],
lean: ['🐾→💛', '↗️💛', '🐾~💛'],
body_block: ['🐾🚧', '⛔🐾'],
bunny_hop: ['🐾⬆️⬆️⬆️', '🐾↗️↗️', '⬆️⬆️⬆️'],
spin: ['🌀🐾🌀', '🔄🐾', '🌀🌀'],
paw_tap: ['🐾👆', '🐾👆👆', '🐾❗'],
nose_nudge: ['👃💨', '👃→', '👃❗'],
side_eye: ['👀↗️', '👀...', '🫣'],
hide_behind: ['🫣🐾', '👀...🐾'],
circle: ['🔄🐾🔄', '🐾↻↻'],
zoomies: ['🐾💨💨💨💨', '⚡🐾⚡', '🏃💨💨'],
freeze: ['🐾...⏸️', '❄️🐾', '⏸️...'],
sleep_curl: ['💤🐾', '😴💤', '🐾~💤'],
head_tilt: ['🐾↗️❓', '😮❓'],
paw_lift: ['🐾⬆️', '🐾☝️'],
};
// ── PUPPY BABBLE — random personality phrases ────────────
const PUPPY_BABBLE = {
idle: ['', '', '', 'ууу~...', 'ыыы...', 'скууу...'],
playful: ['тяф тяф!! ваф!!', 'ЫЫЫ!! ваф-ваф!!', 'рру!! тяф!!', 'гав ГАВ!!', ''],
happy: ['', '', 'ррууу~ ♡', '💛', 'ваф!!'],
content: ['', 'ммм~...', 'рру~...', 'ууу~'],
butt_wiggle: ['', '', 'ыыыЫЫЫ!!', 'рррРРР!!'],
alert: ['ГАВ?!', 'рр!! ваф!!', 'ррр?! ууу?!', 'ВАФ!!'],
scared: ['ууу...', 'хнн...', '😱', ''],
grumble: ['хмфф.', 'ррр.', 'мрр...', 'пфф.'],
food_obsessed: ['МНЯМ!!', 'хрум хрум!!', 'ааау мням!!', 'ваф! ваф! мням!!'],
zoomies: ['ЫЫЫЫ!!', 'РРРР!!', 'ВАФФФ!!', 'АААУУУ!!'],
puppy_eyes: ['ууу~...', '🥺', 'скууу~...'],
sad: ['...', 'хнн...', '', '💧'],
sleep: ['zzz...', 'хррр...', '💤', 'ммрр...'],
};
// ── OWNER REPLY PHRASES — for when PES reacts to owner ───
const OWNER_REPLIES = {
greeting: [
'ГАВВВ!! рруу ууу ыыыа!! ♡ тяф-тяф!!',
'ыыыЫЫЫ!! ваф ваф ВАФ!! 💛💛',
'ррРРуууу!! гав! гав! ГАААВ!! ♡♡',
'тяф-тяф-тяф!! ууууу!! 💛💛💛',
],
praise: [
'ррРРРуууу!! ыыыыы гав-гав ~~ ♡♡ вввуууф!!',
'ууууУУУ!! тяф! тяф! 💛💛💛!!',
'ммммм~ рру~ ♡ ууу~... ваф!',
'ЫЫЫЫ!! рр рр РРРР!! ♡♡♡!!',
],
scold: [
'скууу... ыыы.. хнн.. 💧',
'ууу... хнн... ... 💧',
'... хххн.. скс.. ...',
'скууу...... .......',
],
feed: [
'МНЯМ мням мням!! ррр ффф хрум-хрум ааау!!',
'ваф! ваф! МНЯМ!! хрум хрум!! 🤤',
'АААУ!! мням-мням-МНЯМ!! ррр!!',
'хрум!! хрум!! МНЯЯЯМ!! ваф!!',
],
generic: [
'рру? ...тяф? 🐾',
'ваф. ...ыы? ...рру~',
'гав? ...ууу~ 🐾',
'тяф! рру~ ваф?',
'ыыы~ ...рр? 🐾',
],
};
// ── EMOTION → EMOJI STATUS MAP ──────────────────────────
// Maps PES emotion states to emoji for bot bio / status display
const EMOTION_EMOJI_STATUS = {
happy: { emoji: '😊', label: 'счастлив', short: '💛 счастлив' },
playful: { emoji: '🎾', label: 'играет', short: '🎾 играет' },
greeting_frenzy: { emoji: '🤩', label: 'в восторге', short: '🤩 в восторге' },
butt_wiggle: { emoji: '🍑', label: 'виляет', short: '🍑 виляет хвостом' },
zoomies: { emoji: '⚡', label: 'зумис', short: '⚡ зумис!!' },
excited: { emoji: '🎉', label: 'возбуждён', short: '🎉 возбуждён' },
idle: { emoji: '😐', label: 'скучает', short: '😐 скучает' },
content: { emoji: '☺️', label: 'доволен', short: '☺️ доволен' },
nap: { emoji: '😪', label: 'дремлет', short: '😪 дремлет' },
sleep: { emoji: '💤', label: 'спит', short: '💤 спит' },
sad: { emoji: '😢', label: 'грустит', short: '😢 грустит' },
lonely: { emoji: '🥺', label: 'скучает', short: '🥺 скучает по хозяину' },
scared: { emoji: '😨', label: 'испуган', short: '😨 испуган' },
anxious: { emoji: '😰', label: 'тревожен', short: '😰 тревожится' },
food_obsessed: { emoji: '🤤', label: 'голоден', short: '🤤 хочет есть' },
hungry: { emoji: '🍖', label: 'голоден', short: '🍖 голоден' },
alert: { emoji: '👀', label: 'настороже', short: '👀 настороже' },
angry: { emoji: '😤', label: 'злится', short: '😤 злится' },
bark: { emoji: '🐕', label: 'лает', short: '🐕 ГАВ!' },
curious: { emoji: '🔍', label: 'исследует', short: '🔍 исследует' },
puzzle_solving: { emoji: '🧩', label: 'думает', short: '🧩 решает задачу' },
dramatic_tantrum: { emoji: '🎭', label: 'драматизирует', short: '🎭 драма!' },
sulking: { emoji: '😒', label: 'дуется', short: '😒 дуется' },
stubborn_refuse: { emoji: '🙅', label: 'упрямится', short: '🙅 не хочу!' },
};
function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
// ── THINKING DELAY per level ──────────────────────────────
function thinkingDelay(level) {
// Level gates removed — fast response for testing
return 500 + Math.random() * 500; // 0.5-1s
}
// ── TELEGRAM ADAPTER ──────────────────────────────────────
class TelegramAdapter extends PesPlatformAdapter {
/**
* @param {Pes} pes — PES instance
* @param {Object} config
* @param {string} config.botToken — Telegram Bot API token
* @param {string|number} config.chatId — Chat ID with owner
* @param {string} [config.stickerPack] — sticker pack name to load
* @param {Object} [config.stickerMapping] — { stickerIndex: emotionKey }
* @param {number} [config.pollingInterval] — ms between getUpdates (default: 2000)
* @param {boolean} [config.useThinkingDelay] — show "typing..." before reply (default: true)
* @param {string} [config.ownerUsername] — owner's Telegram username (for identity check)
*/
constructor(pes, config = {}) {
super(pes, {
platformName: 'telegram',
renderMode: RENDER_MODE.RICH,
...config,
});
if (!config.botToken) throw new Error('TelegramAdapter: botToken required');
if (!config.chatId) throw new Error('TelegramAdapter: chatId required');
this.botToken = config.botToken;
this.chatId = String(config.chatId);
this.ownerUsername = config.ownerUsername || null;
this.pollingInterval = config.pollingInterval || 2000;
this.useThinkingDelay = config.useThinkingDelay !== false;
// Polling state
this._pollOffset = 0;
this._polling = false;
this._pollTimer = null;
// Last owner message ID (for reactions)
this._lastOwnerMessageId = null;
// Sticker pack info
this._currentStickerPack = config.stickerPack || null;
this._stickerMapping = config.stickerMapping || {};
this._learnedPacks = new Set(); // all packs learned from owner's stickers
// Bot info (filled on first API call)
this._botInfo = null;
// ── LLM Brain ──────────────────────────────────────
this._llm = null;
if (config.llmApiKey) {
this._llm = new LLMAdapter({
apiKey: config.llmApiKey,
model: config.llmModel || 'gpt-4o-mini',
timeout: 5000,
maxTokens: 200,
});
}
this._characterDesc = config.characterDesc || null;
// ── Orchestrator LLM (live chat mode via /orchestrator) ──
this._orchestratorLLM = null;
if (config.llmApiKey) {
this._orchestratorLLM = new LLMAdapter({
apiKey: config.llmApiKey,
model: 'gpt-4o',
timeout: 30000,
maxTokens: 1500,
});
}
this._orchestratorHistory = []; // conversation memory for orchestrator mode
this._orchestratorHistoryLimit = 30;
// ── Shadow Orchestrator — always-on observer ──
this._shadowBuffer = []; // last N messages for context
this._shadowBufferLimit = 50; // how many messages to remember
this._shadowEnabled = true; // always on
// Trigger keywords that summon orchestrator inline (PES stays active)
this._shadowTriggers = /^(N23|n23|оркестратор|orchestrator|фикс|fix|почини|repair|диагноз|diagnose|анализ|analyze|проблема|problem|баг|bug|что не так|what.?s wrong)/i;
// ── Short Memory with context compression ──
this._memory = [];
this._memoryLimit = 10;
this._contextSummary = ''; // accumulated compressed context from past messages
this._summarizing = false; // lock to prevent concurrent summarization
// ── Emoji learning ──
this._emojiAnalysisQueue = []; // emojis waiting for LLM description
this._analyzingEmojis = false;
// ── Sticker learning ──
this._stickerAnalysisQueue = []; // stickers waiting for LLM description
this._analyzingStickers = false;
// ── Reminders ──
this._reminderInterval = null;
// ── File handling ──
this._fileAnalysisQueue = []; // files waiting for LLM description
this._analyzingFiles = false;
// Per-user files directory in managed mode
this._filesDir = config.managed
? join(__tg_dirname, '..', 'pes-data', 'users', String(config.chatId), 'files')
: join(__tg_dirname, '..', 'pes-data', 'files');
this._maxFileSizeMB = 20; // max file size in MB
this._maxDiskQuotaMB = 500; // max total disk usage in MB
// ── Reaction Memory ──
this._lastBotAction = null; // { action: 'reply', text: '...', category: '...', timestamp: Date.now() }
this._reactionRecalcCounter = 0; // recalculate preferences every N reactions
this._recentReactions = []; // last 8 reactions for anti-repeat
this._dailyReactionLog = []; // all reactions today for diversity tracking
// ── Sticker Mirror (delayed "try back") ──
this._pendingMirrorSticker = null; // { fileId, fileUniqueId, emoji, receivedAt, messagesUntilMirror }
this._messagesSinceMirror = 0;
// ── Last sent sticker tracking (for reaction → preference_score) ──
this._lastSentStickerUniqueId = null;
this._recentSentStickers = []; // last 10 sent sticker unique_ids — for anti-repeat
// ── Babble Engine — use PES's engine if available, else create own ──
if (pes?.babble) {
this._babble = pes.babble;
} else {
const pesSeed = pes?.identity?.seed || 0.42;
const pesTraits = pes?.emotions?.traits || {};
this._babble = new BabbleEngine(pesSeed, pesTraits);
if (pes?.store) {
try {
const babbleData = pes.store.getBabbleState?.();
if (babbleData) this._babble.load(babbleData);
} catch (_) {}
}
}
// ── CRM Client (bidirectional bridge) ──
this._crm = null;
if (config.jwtSecret) {
this._crm = new CrmClient({
baseUrl: config.crmBaseUrl || 'http://127.0.0.1:5000',
jwtSecret: config.jwtSecret,
spaceId: config.crmSpaceId || 11,
});
}
// ── Workflow Engine (agentic workflows) ──
this._workflows = new WorkflowEngine({
store: pes?.store,
crm: this._crm,
pes: pes,
});
// ── Managed mode (PesManager controls polling) ──
this._managed = config.managed || false;
this._managedInit = false;
// ── Emoji Status ──
this._lastEmojiStatus = null; // last emotion synced to bio
this._emojiStatusInterval = null;
this._lastBioUpdate = 0; // timestamp of last bio update (rate limit)
}
// ═══════════════════════════════════════════════════════════
// MANAGED MODE (called by PesManager)
// ═══════════════════════════════════════════════════════════
/**
* Initialize adapter without starting polling.
* Called by PesManager for managed instances.
*/
async initManaged() {
if (this._managedInit) return;
this._managedInit = true;
// Mark as connected (normally done by startListening → startCollecting)
this._connected = true;
// Wire PES expression auto-delivery (same as base.startListening)
this.pes.on('expression', async (result) => {
if (this._connected) {
try { await this.deliver(result); } catch (_) {}
}
});
// Wire achievement notifications
this.pes.on('achievement', async (ach) => {
if (this._connected && this.chatId) {
try {
await this._sendText(this.chatId, `🏆 *Достижение разблокировано!*\n\n${ach.emoji} *${ach.name}*\n${ach.description}`, { parse_mode: 'Markdown' });
} catch (e) { console.error('[PES] achievement notification error:', e.message); }
}
});
// Set bot commands (only once per bot, not per user — but harmless to call)
try { await this._setMyCommands(); } catch (_) {}
// Set Mini App menu button
try { await this._setMenuButton(); } catch (_) {}
// Initialize owner relationship
this._initOwnerRelationship();
// Load persisted context summary from DB
this._loadContextSummary();
// Load sticker packs from DB
this._loadStickerPacksFromDB();
// Start reminder checker
this.startReminderChecker();
// Start idle behavior timer
this._startIdleTimer();
// Start emoji status sync
this._startEmojiStatusSync();
// Start workflow scheduler with auto-run notifications
try {
const stats = this.pes?.store?.getStats?.();
if (stats) {
this._workflows.startScheduler(stats.level);
this._workflows.onAutoRun((workflowId, result) => {
if (this.chatId && result.text) {
const prefix = `🤖 Авто-workflow: ${workflowId}\n\n`;
this._sendText(this.chatId, prefix + result.text).catch(() => {});
}
});
}
} catch (_) {}
}
/**
* Handle a single Telegram update routed by PesManager.
* Replaces the chatId filter — PesManager already routed correctly.
*/
async handleUpdate(update) {
await this._processUpdate(update);
}
// ═══════════════════════════════════════════════════════════
// TELEGRAM API WRAPPER (raw fetch)
// ═══════════════════════════════════════════════════════════
/**
* Raw Telegram Bot API call.
* @param {string} method — API method name
* @param {Object} params — request body
* @returns {Promise<Object>} — API response result
*/
async _call(method, params = {}) {
const url = `${TG_API}${this.botToken}/${method}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await res.json();
if (!data.ok) {
const err = new Error(`Telegram API error: ${data.description || 'unknown'}`);
err.code = data.error_code;
throw err;
}
return data.result;
}
/**
* Call Telegram API with multipart/form-data (for file uploads).
* Uses native FormData + Blob (Node 18+).
* @param {string} method — API method name
* @param {Object} params — regular params (will be appended as form fields)
* @param {Buffer} fileBuffer — file data
* @param {string} fieldName — form field name for the file (e.g. 'sticker', 'png_sticker', 'photo')
* @param {string} fileName — filename to send
* @returns {Promise<Object>}
*/
async _callWithFile(method, params = {}, fileBuffer, fieldName, fileName = 'file.png') {
const url = `${TG_API}${this.botToken}/${method}`;
const formData = new FormData();
// Add regular params
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (typeof value === 'object') {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, String(value));
}
}
// Add file
const blob = new Blob([fileBuffer], { type: 'application/octet-stream' });
formData.append(fieldName, blob, fileName);
const res = await fetch(url, { method: 'POST', body: formData });
const data = await res.json();
if (!data.ok) {
const err = new Error(`Telegram API error: ${data.description || 'unknown'}`);
err.code = data.error_code;
throw err;
}
return data.result;
}
/** Send a text message. */
async _sendText(chatId, text, opts = {}) {
return this._call('sendMessage', {
chat_id: chatId,
text,
parse_mode: opts.parseMode || undefined,
reply_to_message_id: opts.replyTo || undefined,
disable_notification: opts.silent || false,
reply_markup: opts.reply_markup || undefined,
});
}
/**
* Send text with inline custom emoji entities.
* @param {string} chatId
* @param {string} text — text with placeholder markers like {emoji:ID}
* Custom emoji IDs are embedded inline: "text {emoji:5368324170671202286} more text"
* The placeholder is replaced with a Unicode placeholder char and entities array is built.
*/
async _sendTextWithCustomEmoji(chatId, text) {
// Parse {emoji:ID} placeholders
const emojiRegex = /\{emoji:(\d+)\}/g;
const entities = [];
let cleanText = '';
let lastIndex = 0;
let match;
// UTF-16 code units length (Telegram API uses UTF-16 offsets)
const utf16Len = (str) => {
let len = 0;
for (const ch of str) { len += ch.codePointAt(0) > 0xFFFF ? 2 : 1; }
return len;
};
while ((match = emojiRegex.exec(text)) !== null) {
// Add text before the placeholder
cleanText += text.slice(lastIndex, match.index);
// Calculate offset in UTF-16 code units
const offset = utf16Len(cleanText);
// Add a single placeholder character (the custom emoji occupies 1 char visually)
const placeholder = '⭐'; // visible fallback for non-premium users
cleanText += placeholder;
entities.push({
type: 'custom_emoji',
offset,
length: utf16Len(placeholder),
custom_emoji_id: match[1],
});
lastIndex = match.index + match[0].length;
}
cleanText += text.slice(lastIndex);
if (entities.length === 0) {
// No custom emoji found — send as plain text
return this._sendText(chatId, cleanText);
}
return this._call('sendMessage', {
chat_id: chatId,
text: cleanText,
entities,
});
}
/** Send a sticker, optionally with origin attribution button. */
async _sendSticker(chatId, stickerFileId, opts = {}) {
const params = {
chat_id: chatId,
sticker: stickerFileId,
};
// Auto-attach origin button if available and not explicitly disabled
if (opts.reply_markup) {
params.reply_markup = opts.reply_markup;
} else if (opts.skipOrigin !== true && this.pes.store) {
const origin = this.pes.store.getOriginByFileId(stickerFileId);
if (origin && origin.original_set_name) {
const title = (origin.original_set_title || origin.original_set_name).slice(0, 30);
params.reply_markup = JSON.stringify({
inline_keyboard: [[{
text: `📦 Из пака «${title}»`,
url: `https://t.me/addstickers/${origin.original_set_name}`,
}]],
});
}
}
return this._call('sendSticker', params);
}
/**
* Set emoji reaction on a message.
* Bots can only use standard emoji reactions (75 allowed by Telegram).
*/
async _setReaction(chatId, messageId, emoji) {
try {
return await this._call('setMessageReaction', {
chat_id: chatId,
message_id: messageId,
reaction: [{ type: 'emoji', emoji }],
});
} catch (err) {
if (err.code === 400) return null;
throw err;
}
}
/**
* Proactively react to owner's message with an emoji.
* Dog "sniffs" the message and reacts emotionally.
* Probability and emoji choice depend on traits, mood, context.
*/
async _reactToOwnerMessage(msg, category) {
if (!this._lastOwnerMessageId) return;
const traits = this.pes.traits || {};
const mood = this.pes.mood || 0.5;
const level = this.pes.level || 0;
// ── Reaction probability: not every message ──
let prob = 0.40;
prob += (traits.playfulness || 0.5) * 0.15;
prob -= (traits.stubbornness || 0.5) * 0.10;
prob += (mood - 0.5) * 0.20;
prob += Math.min(level, 50) * 0.003;
const alwaysReact = ['praise', 'greetings', 'scold', 'feed', 'play'];
if (alwaysReact.includes(category)) prob = 1.0;
if (msg.sticker) prob = 0.95;
if (msg.photo || msg.document) prob = Math.max(prob, 0.70);
if (Math.random() > prob) return;
// ── Build weighted emoji pool ──
const emoji = this._pickReactionEmoji(category, traits, mood, level);
if (!emoji) return;
// ── Set the reaction ──
try {
await this._setReaction(this.chatId, this._lastOwnerMessageId, emoji);
this._recentReactions.push(emoji);
if (this._recentReactions.length > 8) this._recentReactions.shift();
this._dailyReactionLog.push(emoji);
if (this.pes.store) {
try { this.pes.store.markEmojiUsed(emoji); } catch (_) {}
}
console.log(`🐾 PES reacted: ${emoji} (category: ${category || 'general'}, pool-size: ${this._lastReactionPoolSize || '?'})`);
} catch (err) {
console.log(`⚠️ Reaction failed: ${emoji}${err.message}`);
}
}
/**
* STANDARD 269: Smart emoji selection with anti-repeat, character pools,
* learned emoji priority, mood-drift, and weighted random.
*/
_pickReactionEmoji(category, traits, mood, level) {
// ── 1. Base context pool ──
const contextSets = {
greetings: ['❤️', '🔥', '👋', '😊', '🐾', '🤗', '💕'],
praise: ['❤️', '😍', '🥰', '🔥', '💯', '🎉', '👏', '✨'],
scold: ['😢', '💔', '😔', '🥺'],
feed: ['🤤', '❤️', '😋', '🔥', '🍖'],
play: ['🔥', '🎉', '❤️', '😂', '💯', '🤩', '⚡'],
status: ['👀', '🐾', '❤️', '🧐'],
file_search: ['👀', '🔍', '🐾', '✨'],
fetch: ['🔥', '🎉', '❤️', '💯', '💪'],
};
const moodPool = mood > 0.7
? ['❤️', '🔥', '😊', '👍', '💯', '🎉', '😍', '🤩', '✨']
: mood > 0.4
? ['👍', '❤️', '😊', '🐾', '👀', '😌', '🙂']
: ['😢', '💔', '😔', '👀', '🥺'];
let basePool = contextSets[category] || moodPool;
// ── 2. Character trait pools — each trait > 0.5 adds its emoji ──
const traitEmoji = {
courage: ['💪', '🦁', '⚡', '🏆', '👊'],
curiosity: ['👀', '🧐', '🔍', '❓', '🤔'],
loyalty: ['❤️', '🥰', '🤗', '💕', '🫶'],
stubbornness: ['😤', '😏', '🙄', '💅'],
playfulness: ['😜', '🤪', '🎮', '🎲', '😝'],
drama: ['😍', '🔥', '💯', '🎉', '😱', '🤩', '💫'],
foodDrive: ['🤤', '😋', '🍖', '🍕', '😍'],
sassiness: ['😏', '💅', '✨', '😌', '👑'],
aggression: ['😤', '💢', '⚡'],
};
const characterPool = [];
for (const [trait, emojis] of Object.entries(traitEmoji)) {
const val = traits[trait] || traits[trait.replace(/([A-Z])/g, '_$1').toLowerCase()] || 0;
if (val > 0.5) {
// Higher trait = more emoji from this pool (1-3 emoji)
const count = val > 0.8 ? 3 : val > 0.65 ? 2 : 1;
for (let i = 0; i < count; i++) {
characterPool.push(emojis[Math.floor(Math.random() * emojis.length)]);
}
}
}
// ── 3. Learned emoji from owner (×2 weight via duplication) ──
const learnedPool = [];
if (this.pes.store) {
try {
const learned = this.pes.store.getLearnedEmojis({ owner_only: true, limit: 20 });
for (const le of learned) {
if (le.emoji && le.emoji.length <= 4) { // Only single emoji, not sequences
// ×2 weight for owner-sent, ×3 if preference_score > 0
const copies = (le.preference_score || 0) > 0 ? 3 : 2;
for (let i = 0; i < copies; i++) learnedPool.push(le.emoji);
}
}
} catch (_) {}
// Custom emoji reactions removed — Telegram Bot API doesn't support them for bots
}
// ── 4. Mood-drift: time-of-day emoji ──
const hour = new Date().getHours();
const timeEmoji = hour >= 6 && hour < 12
? ['☀️', '😊', '🌅'] // morning
: hour >= 12 && hour < 18
? ['👍', '🔥', '💪'] // afternoon
: hour >= 18 && hour < 23
? ['🌙', '😌', '✨'] // evening
: ['👀', '😳', '🌙']; // night
// ── 5. Merge all pools with weights ──
const weightedPool = [];
for (const e of basePool) { weightedPool.push(e); weightedPool.push(e); } // ×2
for (const e of characterPool) { weightedPool.push(e); } // ×1 (already trait-filtered)
// Learned emoji from owner get ×4 extra (they ARE the owner's language)
for (const e of learnedPool) { for (let i = 0; i < 4; i++) weightedPool.push(e); }
// Time emoji — only add 1 copy (subtle)
if (Math.random() < 0.3) {
weightedPool.push(timeEmoji[Math.floor(Math.random() * timeEmoji.length)]);
}
// ── 5c. Filter pool — only ALLOWED standard emoji ──
const filteredPool = weightedPool.filter(e => typeof e === 'string' && ALLOWED_REACTIONS.has(e));
if (filteredPool.length === 0) {
filteredPool.push('👍', '❤️', '🔥', '😁', '👏');
}
// ── 6. Anti-repeat weighting ──
const recentCounts = {};
for (const r of this._recentReactions) {
recentCounts[r] = (recentCounts[r] || 0) + 1;
}
// Build final weighted candidates
const finalWeights = [];
const seenKeys = new Set();
for (const e of filteredPool) {
if (seenKeys.has(e)) {
const existing = finalWeights.find(fw => fw.key === e);
if (existing) existing.weight += 1;
continue;
}
seenKeys.add(e);
finalWeights.push({ emoji: e, key: e, weight: 1 });
}
// Count duplicates properly
for (const e of filteredPool) {
const fw = finalWeights.find(f => f.key === e);
if (fw && fw.weight === 1) {
fw.weight = filteredPool.filter(x => x === e).length;
}
}
// Apply anti-repeat penalties
for (const fw of finalWeights) {
const recentCount = recentCounts[fw.key] || 0;
if (recentCount >= 3) fw.weight *= 0.05; // used 3+ of last 8 → almost blocked
else if (recentCount === 2) fw.weight *= 0.3; // used 2 times → heavy penalty
else if (recentCount === 1) fw.weight *= 0.6; // used once → moderate penalty
else fw.weight *= 1.3; // never used recently → bonus
fw.weight = Math.max(0.01, fw.weight);
}
if (finalWeights.length === 0) return null;
this._lastReactionPoolSize = finalWeights.length;
// ── 7. Weighted random selection ──
const totalWeight = finalWeights.reduce((sum, fw) => sum + fw.weight, 0);
let r = Math.random() * totalWeight;
for (const fw of finalWeights) {
r -= fw.weight;
if (r <= 0) return fw.emoji;
}
return finalWeights[finalWeights.length - 1].emoji;
}
/** Send chat action ("typing...", etc.). */
async _sendChatAction(chatId, action = 'typing') {
return this._call('sendChatAction', { chat_id: chatId, action });
}
/** Get sticker set by name. */
async _getStickerSet(name) {
return this._call('getStickerSet', { name });
}
/** Get file path for downloading. */
async _getFilePath(fileId) {
return this._call('getFile', { file_id: fileId });
}
/** Download a file from Telegram servers. Returns Buffer. */
async _downloadTelegramFile(filePath) {
const url = `https://api.telegram.org/file/bot${this.botToken}/${filePath}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
/** Send a document (file) to chat. */
async _sendDocument(chatId, fileIdOrPath, opts = {}) {
return this._call('sendDocument', {
chat_id: chatId,
document: fileIdOrPath,
caption: opts.caption || undefined,
});
}
/** Send a photo to chat. */
async _sendPhoto(chatId, fileIdOrPath, opts = {}) {
return this._call('sendPhoto', {
chat_id: chatId,
photo: fileIdOrPath,
caption: opts.caption || undefined,
});
}
// ═══════════════════════════════════════════════════════════
// STICKER PACK CREATION — process images, create packs
// ═══════════════════════════════════════════════════════════
/**
* Process an image buffer for use as a Telegram sticker.
* Resizes to 512px (longest side), converts to PNG with transparent padding.
* @param {Buffer} buffer — raw image data
* @param {string} type — 'regular' (512px) or 'custom_emoji' (100px)
* @returns {Promise<Buffer>} — processed PNG buffer
*/
async _processImageForSticker(buffer, type = 'regular') {
const sharp = (await import('sharp')).default;
const size = type === 'custom_emoji' ? 100 : 512;
return sharp(buffer)
.resize(size, size, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
}
/**
* Convert video/GIF buffer to WEBM for Telegram video sticker.
* Requirements: VP9, ≤3 sec, ≤256KB, 512x512 (regular) or 100x100 (emoji), no audio.
* @param {Buffer} buffer — raw video/GIF data
* @param {string} type — 'regular' (512px) or 'custom_emoji' (100px)
* @returns {Promise<Buffer>} — WEBM buffer
*/
async _processVideoForSticker(buffer, type = 'regular') {
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const execFileAsync = promisify(execFile);
const size = type === 'custom_emoji' ? 100 : 512;
const tmpDir = '/tmp/pes_stickers';
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
const inputPath = join(tmpDir, `input_${Date.now()}.tmp`);
const outputPath = join(tmpDir, `output_${Date.now()}.webm`);
try {
writeFileSync(inputPath, buffer);
// Convert: scale to size, max 3 sec, no audio, VP9, target ≤256KB
await execFileAsync('ffmpeg', [
'-y', '-i', inputPath,
'-t', '3', // max 3 seconds
'-vf', `scale=${size}:${size}:force_original_aspect_ratio=decrease,pad=${size}:${size}:(ow-iw)/2:(oh-ih)/2:color=0x00000000`,
'-c:v', 'libvpx-vp9',
'-pix_fmt', 'yuva420p', // alpha channel support
'-b:v', '400k', // target bitrate
'-maxrate', '500k',
'-bufsize', '500k',
'-an', // no audio
'-auto-alt-ref', '0', // required for alpha
'-deadline', 'good',
'-cpu-used', '4',
outputPath,
], { timeout: 30000 });
let webm = readFileSync(outputPath);
// If > 256KB, re-encode with lower bitrate
if (webm.length > 256 * 1024) {
const lowerBitrate = Math.floor(200 * (256 * 1024) / webm.length);
await execFileAsync('ffmpeg', [
'-y', '-i', inputPath,
'-t', '3',
'-vf', `scale=${size}:${size}:force_original_aspect_ratio=decrease,pad=${size}:${size}:(ow-iw)/2:(oh-ih)/2:color=0x00000000`,
'-c:v', 'libvpx-vp9',
'-pix_fmt', 'yuva420p',
'-b:v', `${lowerBitrate}k`,
'-maxrate', `${lowerBitrate + 50}k`,
'-bufsize', `${lowerBitrate + 50}k`,
'-an',
'-auto-alt-ref', '0',
'-deadline', 'good',
'-cpu-used', '4',
outputPath,
], { timeout: 30000 });
webm = readFileSync(outputPath);
}
console.log(`🎬 Video sticker: ${buffer.length}${webm.length} bytes (${type}, ${size}px)`);
return webm;
} finally {
try { unlinkSync(inputPath); } catch (_) {}
try { unlinkSync(outputPath); } catch (_) {}
}
}
/**
* Generate metronome click track as OGG audio.
* Uses ffmpeg to create sine wave clicks at specified BPM.
* @param {number} bpm — beats per minute (40-300)
* @param {number} duration — duration in seconds (default 30)
* @param {number} timeSignature — beats per measure for accent (default 4)
* @returns {Promise<Buffer>} — OGG Opus audio buffer
*/
async _generateMetronome(bpm = 120, duration = 30, timeSignature = 4) {
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const execFileAsync = promisify(execFile);
bpm = Math.max(40, Math.min(300, bpm));
duration = Math.max(5, Math.min(120, duration));
const tmpDir = '/tmp/pes_metronome';
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
const outputPath = join(tmpDir, `metronome_${bpm}_${Date.now()}.ogg`);
try {
// Single aevalsrc filter — generates clicks mathematically.
// No matter how many beats, it's always 1 input stream.
// Accent (beat 1 of measure): 1000Hz, louder. Others: 800Hz, softer.
const interval = 60 / bpm;
const clickDur = 0.02;
// aevalsrc expression (commas escaped with \,):
// beat = current beat index, phase = time within beat
// is_accent = 1 if first beat of measure
// freq = accent ? 1000 : 800, vol = accent ? 0.9 : 0.55
const expr = [
`if(lt(mod(t\\,${interval})\\,${clickDur})\\,`,
`if(eq(floor(mod(t/${interval}\\,${timeSignature}))\\,0)\\,`,
`0.9*sin(2*PI*1000*mod(t\\,${interval}))\\,`,
`0.55*sin(2*PI*800*mod(t\\,${interval})))\\,`,
`0)`,
].join('');
await execFileAsync('ffmpeg', [
'-y',
'-f', 'lavfi',
'-i', `aevalsrc=${expr}:s=48000`,
'-t', String(duration),
'-c:a', 'libopus',
'-b:a', '64k',
outputPath,
], { timeout: 30000 });
const ogg = readFileSync(outputPath);
console.log(`🎵 Metronome: ${bpm} BPM, ${duration}s, ${timeSignature}/4, ${ogg.length} bytes`);
return ogg;
} finally {
try { unlinkSync(outputPath); } catch (_) {}
}
}
/**
* Send metronome audio + inline keyboard with BPM controls.
* @param {number} bpm — beats per minute
* @param {number} duration — duration in seconds
*/
async _sendMetronome(bpm = 120, duration = 30) {
try {
await this._sendText(this.chatId, `🎵🐾 ${bpm} BPM... гав!`);
const ogg = await this._generateMetronome(bpm, duration);
// Send as voice with BPM controls
const keyboard = {
inline_keyboard: [
[
{ text: '⏪ -20', callback_data: `metro_${bpm - 20}_${duration}` },
{ text: '◀️ -5', callback_data: `metro_${bpm - 5}_${duration}` },
{ text: `🎵 ${bpm}`, callback_data: `metro_${bpm}_${duration}` },
{ text: '▶️ +5', callback_data: `metro_${bpm + 5}_${duration}` },
{ text: '⏩ +20', callback_data: `metro_${bpm + 20}_${duration}` },
],
[
{ text: '60', callback_data: `metro_60_${duration}` },
{ text: '80', callback_data: `metro_80_${duration}` },
{ text: '100', callback_data: `metro_100_${duration}` },
{ text: '120', callback_data: `metro_120_${duration}` },
{ text: '140', callback_data: `metro_140_${duration}` },
{ text: '160', callback_data: `metro_160_${duration}` },
],
[
{ text: '15с', callback_data: `metro_${bpm}_15` },
{ text: '30с', callback_data: `metro_${bpm}_30` },
{ text: '60с', callback_data: `metro_${bpm}_60` },
{ text: '90с', callback_data: `metro_${bpm}_90` },
],
],
};
await this._callWithFile('sendVoice', {
chat_id: this.chatId,
reply_markup: JSON.stringify(keyboard),
duration: duration,
}, ogg, 'voice', `metronome_${bpm}bpm.ogg`);
} catch (err) {
console.error('❌ Metronome error:', err.message);
await this._sendText(this.chatId, `ыыы... 🐾 не получилось (${err.message})`);
}
}
/**
* Upload a sticker file to Telegram.
* @param {number} userId — Telegram user ID (owner)
* @param {Buffer} stickerPng — processed PNG buffer
* @param {string} stickerFormat — 'static' or 'animated'
* @returns {Promise<Object>} — File object with file_id
*/
async _uploadStickerFile(userId, stickerData, stickerFormat = 'static') {
const fileName = stickerFormat === 'video' ? 'sticker.webm' : 'sticker.png';
return this._callWithFile('uploadStickerFile', {
user_id: userId,
sticker_format: stickerFormat,
}, stickerData, 'sticker', fileName);
}
/**
* Create a new sticker set.
* @param {number} userId — Telegram user ID (owner)
* @param {string} name — sticker set name (must end with _by_<bot_username>)
* @param {string} title — display title
* @param {Buffer} firstStickerPng — processed PNG of first sticker
* @param {string} emoji — emoji associated with the sticker
* @param {string} stickerType — 'regular' or 'custom_emoji'
* @returns {Promise<boolean>}
*/
async _createStickerSet(userId, name, title, firstStickerData, emoji = '🐾', stickerType = 'regular', stickerFormat = 'static') {
// First upload the sticker file
const uploaded = await this._uploadStickerFile(userId, firstStickerData, stickerFormat);
// Create the set with the uploaded file
return this._call('createNewStickerSet', {
user_id: userId,
name,
title,
stickers: [{ sticker: uploaded.file_id, emoji_list: [emoji], format: stickerFormat }],
sticker_type: stickerType,
});
}
/**
* Add a sticker to an existing sticker set.
* @param {number} userId — Telegram user ID (owner)
* @param {string} name — sticker set name
* @param {Buffer} stickerPng — processed PNG buffer
* @param {string} emoji — associated emoji
* @returns {Promise<boolean>}
*/
async _addStickerToSet(userId, name, stickerData, emoji = '🐾', stickerFormat = 'static') {
const uploaded = await this._uploadStickerFile(userId, stickerData, stickerFormat);
return this._call('addStickerToSet', {
user_id: userId,
name,
sticker: { sticker: uploaded.file_id, emoji_list: [emoji], format: stickerFormat },
});
}
/**
* Handle "сделай стикер" / "создай эмодзи" / "создай пак" intent.
* Takes a photo from the owner and creates/adds to a sticker or custom emoji pack.
* @param {Object} msg — Telegram message
* @param {string} createType — 'regular' (512px sticker) or 'custom_emoji' (100px emoji)
*/
async _handleStickerCreation(msg, createType = 'regular') {
// Need a photo, sticker, GIF, video, or animation to create a sticker
let fileId, buffer, isSticker = false, stickerEmoji = null;
let originalSetName = null, originalFileUniqueId = null;
let isVideoSource = false; // GIF/video/animation → video sticker
// 1. Direct sticker from message
if (msg.sticker) {
fileId = msg.sticker.file_id;
isSticker = true;
isVideoSource = msg.sticker.is_video || msg.sticker.is_animated || false;
stickerEmoji = msg.sticker.emoji;
originalSetName = msg.sticker.set_name || null;
originalFileUniqueId = msg.sticker.file_unique_id || null;
// 2. Reply to a sticker message
} else if (msg.reply_to_message?.sticker) {
const stk = msg.reply_to_message.sticker;
fileId = stk.file_id;
isSticker = true;
isVideoSource = stk.is_video || stk.is_animated || false;
stickerEmoji = stk.emoji;
originalSetName = stk.set_name || null;
originalFileUniqueId = stk.file_unique_id || null;
// 3. Animation/GIF from message
} else if (msg.animation) {
fileId = msg.animation.file_id;
isVideoSource = true;
// 4. Video from message (short clips)
} else if (msg.video && (msg.video.duration || 0) <= 10) {
fileId = msg.video.file_id;
isVideoSource = true;
// 5. Reply to animation/GIF
} else if (msg.reply_to_message?.animation) {
fileId = msg.reply_to_message.animation.file_id;
isVideoSource = true;
// 6. Reply to video
} else if (msg.reply_to_message?.video && (msg.reply_to_message.video.duration || 0) <= 10) {
fileId = msg.reply_to_message.video.file_id;
isVideoSource = true;
// 7. Photo from message
} else if (msg.photo) {
const photo = msg.photo[msg.photo.length - 1];
fileId = photo.file_id;
// 8. Image document
} else if (msg.document && msg.document.mime_type?.startsWith('image/')) {
// GIF sent as document
if (msg.document.mime_type === 'image/gif') {
isVideoSource = true;
}
fileId = msg.document.file_id;
// 9. Video document (MP4 etc sent as file)
} else if (msg.document && msg.document.mime_type?.startsWith('video/')) {
fileId = msg.document.file_id;
isVideoSource = true;
// 10. Reply to a photo
} else if (msg.reply_to_message?.photo) {
const photo = msg.reply_to_message.photo[msg.reply_to_message.photo.length - 1];
fileId = photo.file_id;
// 11. Reply to image/video document
} else if (msg.reply_to_message?.document) {
const doc = msg.reply_to_message.document;
if (doc.mime_type?.startsWith('image/')) {
if (doc.mime_type === 'image/gif') isVideoSource = true;
fileId = doc.file_id;
} else if (doc.mime_type?.startsWith('video/')) {
fileId = doc.file_id;
isVideoSource = true;
}
}
if (!fileId) {
await this._sendText(this.chatId, pickRandom([
'рру?.. 📸🐾 ...тяф? (фото! стикер! гифка!)',
'ваф! 📸 рру~ ...нужна картинка, стикер или GIF... 🐾',
]));
return;
}
// Show "working on it" animation
await this._sendChatAction(this.chatId, 'upload_photo');
try {
// Download the source
const fileInfo = await this._getFilePath(fileId);
buffer = await this._downloadTelegramFile(fileInfo.file_path);
// Determine sticker format
let stickerData, stickerFormat;
if (isVideoSource) {
// Video/GIF/animation → WEBM video sticker
stickerData = await this._processVideoForSticker(buffer, createType);
stickerFormat = 'video';
} else {
// Photo/image → PNG static sticker
stickerData = await this._processImageForSticker(buffer, createType);
stickerFormat = 'static';
}
const sourceDesc = isVideoSource ? 'video/GIF' : (isSticker ? 'sticker' : 'photo');
console.log(`🎨 ${createType === 'custom_emoji' ? 'Emoji' : 'Sticker'} source: ${sourceDesc} (${stickerFormat}), size: ${buffer.length}${stickerData.length}`);
// ── STICKER EDITOR: show preview + action buttons ──
// Store pending sticker data for callback processing
const pendingId = `stk_${Date.now()}`;
this._pendingStickers = this._pendingStickers || {};
this._pendingStickers[pendingId] = {
buffer, // original raw buffer
stickerData, // processed sticker data
stickerFormat,
createType,
isVideoSource,
isSticker,
stickerEmoji,
originalSetName,
originalFileUniqueId,
msg,
createdAt: Date.now(),
};
// Clean up old pending stickers (>10 min)
for (const [k, v] of Object.entries(this._pendingStickers)) {
if (Date.now() - v.createdAt > 600000) delete this._pendingStickers[k];
}
// Send preview image + buttons
const buttons = [];
if (!isVideoSource) {
// Static image → offer remove bg and animate
buttons.push([
{ text: '✅ В пак', callback_data: `stk_add_${pendingId}` },
{ text: '🔲 Убрать фон', callback_data: `stk_rmbg_${pendingId}` },
]);
buttons.push([
{ text: '✨ Анимировать', callback_data: `stk_anim_${pendingId}` },
]);
} else {
// Video → just add or re-encode
buttons.push([
{ text: '✅ В пак', callback_data: `stk_add_${pendingId}` },
]);
}
// Send the processed image as preview
try {
if (isVideoSource) {
// Send as video note or animation
await this._call('sendAnimation', {
chat_id: this.chatId,
animation: { source: stickerData, filename: 'preview.webm' },
caption: '🎨🐾',
reply_markup: JSON.stringify({ inline_keyboard: buttons }),
});
} else {
await this._call('sendPhoto', {
chat_id: this.chatId,
photo: { source: stickerData, filename: 'preview.png' },
caption: '🎨🐾',
reply_markup: JSON.stringify({ inline_keyboard: buttons }),
});
}
} catch (previewErr) {
// If preview fails, send text buttons instead
console.log(`⚠️ Preview send failed: ${previewErr.message}, sending text buttons`);
await this._sendText(this.chatId, '🎨🐾', {
reply_markup: JSON.stringify({ inline_keyboard: buttons }),
});
}
// Don't add to pack yet — wait for button callback
this._processSmartXP(msg, 'command', 'create_sticker');
} catch (err) {
console.error(`⚠️ Sticker creation failed: ${err.message}`);
await this._sendText(this.chatId, pickRandom([
'хнн... 🎨💧🐾',
'скууу... 😿🐾',
]));
}
}
/**
* Add a sticker to pack — called from sticker editor callback buttons.
*/
async _addStickerToPack(pending, stickerData, stickerFormat) {
const { createType, isSticker, stickerEmoji, originalSetName, originalFileUniqueId, msg, isVideoSource } = pending;
try {
const botInfo = await this._getMe();
const botUsername = botInfo.username;
const ownerId = Number(msg.from.id);
const petName = this.pes.store?.getConfig('pet_name') || 'PES';
const sanitizedName = petName.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'PES';
const isEmoji = createType === 'custom_emoji';
const packPrefix = isEmoji ? 'emoji' : 'stickers';
let packName;
let isNewPack = false;
if (!isEmoji && this.pes.store) {
const mainPack = this.pes.store.getMainCollectionPack();
if (mainPack) packName = mainPack.pack_name;
}
if (!packName) {
const existingPacks = this.pes.store?.getCreatedPacks(createType) || [];
if (existingPacks.length > 0) packName = existingPacks[0].pack_name;
}
if (packName) {
try {
const caption = msg.caption || msg.text || '';
const emoji = stickerEmoji || this._guessEmojiForSticker(caption) || '🐾';
await this._addStickerToSet(ownerId, packName, stickerData, emoji, stickerFormat);
if (this.pes.store) this.pes.store.incrementPackStickerCount(packName);
} catch (addErr) {
console.log(`⚠️ Failed to add to pack ${packName}: ${addErr.message}`);
if (addErr.message?.includes('STICKERS_TOO_MUCH') || addErr.message?.includes('STICKERPACK_STICKERS_TOO_MUCH')) {
const overflowNum = (this.pes.store?.getCreatedPacks(createType) || []).length + 1;
packName = `${sanitizedName}_${packPrefix}_${overflowNum}_by_${botUsername}`;
isNewPack = true;
} else {
isNewPack = true;
packName = `${sanitizedName}_${packPrefix}_${Date.now()}_by_${botUsername}`;
}
}
} else {
isNewPack = true;
packName = `${sanitizedName}_${packPrefix}_by_${botUsername}`;
}
if (isNewPack) {
const title = isEmoji
? (this._pendingPackTitle ? `${this._pendingPackTitle}` : `${petName} Emoji ✨`)
: (this._pendingPackTitle || `${petName} Stickers 🐾`);
this._pendingPackTitle = null;
const caption = msg.caption || msg.text || '';
const emoji = stickerEmoji || this._guessEmojiForSticker(caption) || '🐾';
await this._createStickerSet(ownerId, packName, title, stickerData, emoji, createType, stickerFormat);
if (this.pes.store) {
this.pes.store.saveCreatedPack({ pack_name: packName, title, owner_id: String(ownerId), pack_type: createType });
this.pes.store.incrementPackStickerCount(packName);
if (!isEmoji && !this.pes.store.getMainCollectionPack()) {
this.pes.store.setMainCollectionPack(packName);
}
}
}
// Track origin
if (isSticker && originalSetName && originalFileUniqueId && this.pes.store) {
let originalSetTitle = null;
try {
const setInfo = await this._call('getStickerSet', { name: originalSetName });
originalSetTitle = setInfo?.title || originalSetName;
} catch (_) { originalSetTitle = originalSetName; }
this.pes.store.saveStickerOrigin({
file_unique_id: originalFileUniqueId,
my_pack_name: packName,
original_set_name: originalSetName,
original_set_title: originalSetTitle,
original_emoji: stickerEmoji || '🐾',
});
}
// Success — send the actual sticker from the pack so user can save it
await sleep(500);
try {
const setInfo = await this._call('getStickerSet', { name: packName });
if (setInfo?.stickers?.length > 0) {
// Send the last sticker (the one we just added)
const lastSticker = setInfo.stickers[setInfo.stickers.length - 1];
await this._call('sendSticker', {
chat_id: this.chatId,
sticker: lastSticker.file_id,
});
}
} catch (stkErr) {
console.log(`⚠️ Could not send sticker preview: ${stkErr.message}`);
}
// Reaction
const typeLabel = isVideoSource ? '🎬' : '🎨';
await this._sendText(this.chatId, pickRandom([
`${typeLabel}✨🐾`,
`${typeLabel}🎉🐾`,
`${typeLabel}💫🐾`,
]));
console.log(`${typeLabel} Sticker added to pack=${packName}, format=${stickerFormat}, isNew=${isNewPack}`);
} catch (err) {
console.error(`⚠️ Sticker add to pack failed: ${err.message}`);
await this._sendText(this.chatId, pickRandom([
'хнн... 🎨💧 🐾',
'скууу... 😿🐾',
]));
}
}
/**
* Guess an appropriate emoji for a sticker based on caption text.
* Simple keyword matching — LLM analysis can enhance later.
*/
/**
* Try to inject a learned custom emoji into a reply text.
* 20% chance if custom emoji are available in the store.
* Returns text with {emoji:ID} placeholders, or original text if no injection.
*/
_tryInjectCustomEmoji(text) {
if (Math.random() > 0.45) return text; // 45% chance (Premium owner!)
if (!this.pes.store) return text;
const customEmojis = this.pes.store.getCustomEmojiStickers({ limit: 20 });
if (!customEmojis || customEmojis.length === 0) return text;
// Pick a random custom emoji
const chosen = customEmojis[Math.floor(Math.random() * customEmojis.length)];
if (!chosen.custom_emoji_id) return text;
// Inject at the end of the text (before trailing whitespace)
const trimmed = text.trimEnd();
return `${trimmed} {emoji:${chosen.custom_emoji_id}}`;
}
_guessEmojiForSticker(text) {
if (!text) return null;
const t = text.toLowerCase();
if (/кот|кош|cat/i.test(t)) return '😺';
if (/собак|пёс|пес|dog/i.test(t)) return '🐕';
if (/смех|смешн|lol|хах/i.test(t)) return '😂';
if (/грус|sad|плач/i.test(t)) return '😢';
if (/люб|love|серд/i.test(t)) return '❤️';
if (/еда|food|вкусн/i.test(t)) return '🍕';
if (/огонь|fire|жар/i.test(t)) return '🔥';
if (/крут|cool|класс/i.test(t)) return '🤩';
return null;
}
/** Get updates (long polling). */
async _getUpdates(offset, timeout = 30) {
return this._call('getUpdates', {
offset,
timeout,
allowed_updates: ['message', 'message_reaction', 'inline_query', 'callback_query'],
});
}
/** Get bot info. */
async _getMe() {
if (!this._botInfo) {
this._botInfo = await this._call('getMe');
}
return this._botInfo;
}
/** Set bot commands menu. */
async _setMyCommands() {
return this._call('setMyCommands', {
commands: [
{ command: 'start', description: '🐾 Запустить / разбудить ПЕС' },
{ command: 'home', description: '🏠 Открыть дом питомца' },
{ command: 'status', description: '📊 Статус ПЕС' },
{ command: 'setname', description: '✏️ Дать имя ПЕС — /setname Имя' },
{ command: 'feed', description: '🍖 Покормить данными' },
{ command: 'command', description: '🎾 Дать команду' },
{ command: 'silent', description: '🤫 Тихий режим' },
{ command: 'stickers', description: '🎨 Стикерпак' },
{ command: 'files', description: '📂 Мои файлы и фото' },
{ command: 'notes', description: '📝 Мои заметки' },
{ command: 'remind', description: '⏰ Напоминалка — /remind 30м купить молоко' },
{ command: 'reminders', description: '📋 Список напоминалок' },
{ command: 'translate', description: '📜 Перевести текст — /translate en текст' },
{ command: 'share', description: '📤 Поделиться — /share note|status|card' },
{ command: 'emoji', description: '✨ Emoji статус питомца' },
{ command: 'orchestrator', description: '🎯 Вызвать @orchestrator' },
{ command: 'geratron', description: '⚡ Вызвать @geratron' },
{ command: 'dev', description: '🔧 Dev mode' },
],
});
}
async _setMenuButton() {
const appUrl = this.config.miniAppUrl || 'https://crm.hltrn.cc/api/v3/pes/app';
return this._call('setChatMenuButton', {
menu_button: {
type: 'web_app',
text: '🏠 Дом',
web_app: { url: appUrl },
},
});
}
// ═══════════════════════════════════════════════════════════
// ABSTRACT IMPLEMENTATIONS
// ═══════════════════════════════════════════════════════════
/**
* Send rendered expression to Telegram.
* @param {Object} output — from renderExpression()
* @returns {Promise<string|null>} — message ID
*/
async sendMessage(output) {
if (!output) return null;
let lastMsgId = null;
// "Thinking..." delay
if (this.useThinkingDelay) {
const delay = thinkingDelay(this.pes.level);
if (delay > 100) {
await this._sendChatAction(this.chatId);
await sleep(delay);
}
}
// 1. Send sticker (if available)
if (output.sticker && typeof output.sticker === 'string' && output.sticker.length > 20) {
// Looks like a Telegram file_id
try {
const msg = await this._sendSticker(this.chatId, output.sticker);
lastMsgId = String(msg.message_id);
} catch (_) {
// Sticker send failed — fall through to text
}
}
// 2. Build rich text: [body action] + [glyphs] + [voice] + [babble]
const textParts = [];
// Body action description (if no sticker was sent)
if (!lastMsgId && output.expressionId) {
const bodyKey = this.pes?.engine?.lastExpression?.expression?.bodyKey ||
output.meta?.bodyKey;
if (bodyKey && BODY_ACTIONS[bodyKey]) {
textParts.push(pickRandom(BODY_ACTIONS[bodyKey]));
}
}
// Glyphs
if (output.text) textParts.push(output.text);
// Voice sound
if (output.meta?.voiceText && !this.pes?.engine?.silentMode) {
textParts.push(`_${output.meta.voiceText}_`);
}
// Dynamic babble — ALWAYS add evolving sounds (core voice of PES)
{
const emotion = this.pes?.emotions?.state || 'idle';
const intensity = this.pes?.emotions?.intensity || 0.5;
const level = this.pes?.level || 0;
const babble = this._babble.generate(level, emotion, intensity);
if (babble) textParts.push(babble);
}
if (output.meta?.caption && !textParts.length) {
textParts.push(output.meta.caption);
}
const text = textParts.join(' ');
if (text) {
try {
const msg = await this._sendText(this.chatId, text, { parseMode: 'Markdown' });
lastMsgId = String(msg.message_id);
} catch (err) {
console.error('⚠️ sendMessage (Markdown) error:', err.message);
// Markdown failed — try plain text
try {
const msg = await this._sendText(this.chatId, text.replace(/[_*]/g, ''));
lastMsgId = String(msg.message_id);
} catch (err2) {
console.error('⚠️ sendMessage (plain) error:', err2.message);
}
}
}
// 2b. Track sent message for reaction feedback loop
if (lastMsgId && output.expressionId) {
this.trackMessage(output.expressionId, lastMsgId);
}
// 3. Set reaction on owner's last message (if applicable)
if (output.reaction && this._lastOwnerMessageId) {
try {
await this._setReaction(this.chatId, this._lastOwnerMessageId, output.reaction);
} catch (_) {
// Reaction failed — not critical
}
}
return lastMsgId;
}
/**
* Start polling for updates from Telegram.
*/
async startCollecting() {
if (this._polling) return;
this._polling = true;
// Delete any existing webhook to ensure polling works (especially for inline queries)
try {
await this._call('deleteWebhook', { drop_pending_updates: false });
console.log('🔧 Webhook deleted, polling mode active');
} catch (_) {}
// Set bot commands
try { await this._setMyCommands(); } catch (_) {}
// ── Initialize owner relationship ──
this._initOwnerRelationship();
// ── Load persisted context summary from DB ──
this._loadContextSummary();
// ── Load sticker packs from DB (not hardcoded) ──
this._loadStickerPacksFromDB();
// Start reminder checker
this.startReminderChecker();
// Start idle behavior timer
this._startIdleTimer();
// Start emoji status sync
this._startEmojiStatusSync();
this._pollLoop();
}
/**
* Stop polling.
*/
async stopCollecting() {
this._polling = false;
if (this._pollTimer) {
clearTimeout(this._pollTimer);
this._pollTimer = null;
}
}
/**
* Set presence (typing/online via chat action).
*/
async setPresence(state) {
if (state === PRESENCE.TYPING) {
await this._sendChatAction(this.chatId, 'typing');
}
// Telegram bots don't have online/offline status
// but we can track it internally
}
// ═══════════════════════════════════════════════════════════
// POLLING LOOP
// ═══════════════════════════════════════════════════════════
async _pollLoop() {
while (this._polling) {
try {
const updates = await this._getUpdates(this._pollOffset, 30);
for (const update of updates) {
this._pollOffset = update.update_id + 1;
try {
await this._processUpdate(update);
} catch (err) {
this.emit('error', err);
}
}
} catch (err) {
// Network error — retry after delay
this.emit('error', err);
if (this._polling) {
await sleep(this.pollingInterval);
}
}
}
}
// ═══════════════════════════════════════════════════════════
// UPDATE PROCESSING
// ═══════════════════════════════════════════════════════════
/**
* Route incoming Telegram update.
*/
async _processUpdate(update) {
// Debug: log ALL updates for diagnostics
if (update.message?.text) {
console.log(`📨 [DEBUG] Message from ${update.message.chat.id}: "${update.message.text.slice(0, 80)}"`);
} else if (!update.message) {
const keys = Object.keys(update).filter(k => k !== 'update_id');
if (keys.length > 0) {
console.log(`📨 Update type: ${keys.join(', ')}`, JSON.stringify(update).substring(0, 300));
}
}
// Message (text, sticker, etc.)
if (update.message) {
const msg = update.message;
// In standalone mode, only process messages from our chat.
// In managed mode (PesManager), routing is already done — skip filter.
if (!this._managed && String(msg.chat.id) !== this.chatId) return;
// Track last owner message for reactions
this._lastOwnerMessageId = msg.message_id;
// Bot commands
if (msg.text && msg.text.startsWith('/')) {
await this._handleBotCommand(msg);
return;
}
// Sticker from owner — check if "add to pack" was requested
if (msg.sticker) {
if (this._pendingAddToPack && Date.now() - this._pendingAddToPack < 60000) {
const pendingType = this._pendingAddType || 'regular';
this._pendingAddToPack = null;
this._pendingAddType = null;
await this._handleStickerCreation(msg, pendingType);
this._updateRelationship('message');
return;
}
await this._handleStickerMessage(msg);
return;
}
// Text message
if (msg.text) {
await this._handleTextMessage(msg);
return;
}
// Photo/document/GIF/video — check if "add to pack" was requested
const isMediaForPack = msg.photo || msg.animation || msg.video ||
(msg.document && (msg.document.mime_type?.startsWith('image/') || msg.document.mime_type?.startsWith('video/')));
if (isMediaForPack && this._pendingAddToPack && Date.now() - this._pendingAddToPack < 60000) {
const pendingType = this._pendingAddType || 'regular';
this._pendingAddToPack = null;
this._pendingAddType = null;
await this._handleStickerCreation(msg, pendingType);
this._updateRelationship('message');
return;
}
// Shared contact (vCard) — auto-save
if (msg.contact) {
await this._handleSharedContact(msg);
return;
}
// Photo/document/video/voice/audio/video_note — save and analyze
if (msg.photo || msg.document || msg.video || msg.voice || msg.audio || msg.video_note) {
await this._handleMediaMessage(msg);
return;
}
}
// Reaction on PES's message
if (update.message_reaction) {
await this._handleReaction(update.message_reaction);
}
// Inline query — show sticker collection in any chat
if (update.inline_query) {
await this._handleInlineQuery(update.inline_query);
}
// Callback query — inline keyboard button presses (metronome BPM, etc.)
if (update.callback_query) {
await this._handleCallbackQuery(update.callback_query);
}
}
/**
* Handle inline keyboard callback queries (button presses).
*/
async _handleCallbackQuery(query) {
const data = query.data || '';
// Metronome BPM control: metro_{bpm}_{duration}
const metroMatch = data.match(/^metro_(\d+)_(\d+)$/);
if (metroMatch) {
const bpm = Math.max(40, Math.min(300, parseInt(metroMatch[1])));
const duration = Math.max(5, Math.min(120, parseInt(metroMatch[2])));
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
text: `🎵 ${bpm} BPM`,
}).catch(() => {});
await this._sendMetronome(bpm, duration);
return;
}
// ── Sticker editor callbacks ──
const stkMatch = data.match(/^stk_(add|rmbg|anim)_(stk_\d+)$/);
if (stkMatch) {
const action = stkMatch[1];
const pendingId = stkMatch[2];
const pending = this._pendingStickers?.[pendingId];
if (!pending) {
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
text: 'рру?.. 🐾 (истекло)',
show_alert: false,
}).catch(() => {});
return;
}
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
text: action === 'add' ? '✅ 🐾' : action === 'rmbg' ? '🔲 🐾' : '✨ 🐾',
}).catch(() => {});
// Remove buttons from preview message
try {
await this._call('editMessageReplyMarkup', {
chat_id: this.chatId,
message_id: query.message.message_id,
reply_markup: JSON.stringify({ inline_keyboard: [] }),
});
} catch (_) {}
await this._sendChatAction(this.chatId, 'upload_photo');
let finalData = pending.stickerData;
let finalFormat = pending.stickerFormat;
if (action === 'rmbg') {
// Remove background using sharp — make light/white areas transparent
try {
const sharp = (await import('sharp')).default;
const size = pending.createType === 'custom_emoji' ? 100 : 512;
// Extract alpha: remove background by making near-white pixels transparent
const raw = await sharp(pending.buffer)
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const pixels = raw.data;
const { width, height } = raw.info;
// Make light pixels transparent (threshold-based bg removal)
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i], g = pixels[i + 1], b = pixels[i + 2];
const brightness = (r + g + b) / 3;
// If pixel is very light (near white), make transparent
if (brightness > 220 && Math.abs(r - g) < 30 && Math.abs(g - b) < 30) {
pixels[i + 3] = 0; // alpha = 0
}
// Edge softening for medium-light pixels
else if (brightness > 190 && Math.abs(r - g) < 40 && Math.abs(g - b) < 40) {
pixels[i + 3] = Math.floor(pixels[i + 3] * (220 - brightness) / 30);
}
}
finalData = await sharp(pixels, { raw: { width, height, channels: 4 } })
.png()
.toBuffer();
console.log(`🔲 Background removed: ${pending.stickerData.length}${finalData.length}`);
} catch (bgErr) {
console.error(`⚠️ BG removal failed: ${bgErr.message}`);
// Fall back to original processed data
finalData = pending.stickerData;
}
} else if (action === 'anim') {
// Animate: create a simple zoom/pulse animation from static image
try {
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const execFileAsync = promisify(execFile);
const tmpDir = '/tmp/pes_stickers';
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
const inputPath = join(tmpDir, `anim_in_${Date.now()}.png`);
const outputPath = join(tmpDir, `anim_out_${Date.now()}.webm`);
writeFileSync(inputPath, pending.stickerData);
const size = pending.createType === 'custom_emoji' ? 100 : 512;
// Create pulse/zoom animation: 3 sec, zooms in slightly then back
await execFileAsync('ffmpeg', [
'-y',
'-loop', '1', '-i', inputPath,
'-t', '3',
'-vf', [
`scale=${size}:${size}:force_original_aspect_ratio=decrease`,
`pad=${size}:${size}:(ow-iw)/2:(oh-ih)/2:color=0x00000000`,
`zoompan=z='1+0.15*sin(2*PI*t/1.5)':d=75:s=${size}x${size}:fps=25`,
].join(','),
'-c:v', 'libvpx-vp9',
'-pix_fmt', 'yuva420p',
'-b:v', '300k',
'-an',
'-auto-alt-ref', '0',
'-deadline', 'good',
'-cpu-used', '4',
outputPath,
], { timeout: 30000 });
let webm = readFileSync(outputPath);
// Re-encode if too large
if (webm.length > 256 * 1024) {
const lowerBr = Math.floor(150 * (256 * 1024) / webm.length);
await execFileAsync('ffmpeg', [
'-y',
'-loop', '1', '-i', inputPath,
'-t', '3',
'-vf', `scale=${size}:${size}:force_original_aspect_ratio=decrease,pad=${size}:${size}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,zoompan=z='1+0.15*sin(2*PI*t/1.5)':d=75:s=${size}x${size}:fps=25`,
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p',
'-b:v', `${lowerBr}k`, '-an', '-auto-alt-ref', '0',
'-deadline', 'good', '-cpu-used', '4',
outputPath,
], { timeout: 30000 });
webm = readFileSync(outputPath);
}
finalData = webm;
finalFormat = 'video';
try { unlinkSync(inputPath); } catch (_) {}
try { unlinkSync(outputPath); } catch (_) {}
console.log(`✨ Animated sticker: ${pending.stickerData.length}${finalData.length}`);
} catch (animErr) {
console.error(`⚠️ Animation failed: ${animErr.message}`);
finalData = pending.stickerData;
}
}
// Now add to pack (reuse logic from _handleStickerCreation)
await this._addStickerToPack(pending, finalData, finalFormat);
// Clean up
delete this._pendingStickers[pendingId];
return;
}
// ── Share callbacks ──
if (data.startsWith('share_')) {
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
}).catch(() => {});
switch (data) {
case 'share_status': return this._shareStatus();
case 'share_card': return this._sharePetCard();
case 'share_last_note': return this._shareNote('');
case 'share_summary': return this._shareSummary();
}
return;
}
// ── Workflow callbacks ──
if (data.startsWith('wf_run_')) {
const workflowId = data.replace('wf_run_', '');
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
text: `▶️ ${workflowId}...`,
}).catch(() => {});
await this._cmdWorkflow({ chat: { id: this.chatId } }, workflowId);
return;
}
if (data === 'wf_history') {
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
}).catch(() => {});
await this._cmdWorkflow({ chat: { id: this.chatId } }, 'history');
return;
}
// ── Friendship callbacks ──
const friendMatch = data.match(/^friend_(accept|reject)_(\d+)$/);
if (friendMatch) {
const action = friendMatch[1];
const fromChatId = friendMatch[2];
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
text: action === 'accept' ? '🤝 Принято!' : '❌ Отклонено',
}).catch(() => {});
try {
await this._call('editMessageReplyMarkup', {
chat_id: this.chatId,
message_id: query.message.message_id,
reply_markup: JSON.stringify({ inline_keyboard: [] }),
});
} catch (_) {}
if (action === 'accept') {
this.pes.store.acceptFriendRequest(fromChatId);
const friendship = this.pes.store.getFriendship(fromChatId);
const petName = friendship?.friend_pet_name || 'ПЕС';
await this._sendText(this.chatId, `🤝 ${this.pes.name} теперь друг с ${petName}! 🐾♡`);
// Notify the requester
try {
await this._sendText(fromChatId, `🤝 ${petName} принял дружбу от ${this.pes.name}! Теперь вы друзья! 🐾♡`);
} catch (_) {}
// Mood boost
if (this.pes.emotions) {
this.pes.emotions.mood = Math.min(1.0, (this.pes.emotions.mood || 0.5) + 0.1);
}
} else {
this.pes.store.rejectFriendRequest(fromChatId);
await this._sendText(this.chatId, `❌ Запрос на дружбу отклонён.`);
}
return;
}
// ── Gift accept callback ──
const giftMatch = data.match(/^gift_thanks_(\d+)$/);
if (giftMatch) {
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
text: '💛 Спасибо!',
}).catch(() => {});
try {
await this._call('editMessageReplyMarkup', {
chat_id: this.chatId,
message_id: query.message.message_id,
reply_markup: JSON.stringify({ inline_keyboard: [] }),
});
} catch (_) {}
return;
}
// Unknown callback — just answer to dismiss
await this._call('answerCallbackQuery', {
callback_query_id: query.id,
}).catch(() => {});
}
// ═══════════════════════════════════════════════════════════
// MESSAGE HANDLERS
// ═══════════════════════════════════════════════════════════
/**
* Handle text message from owner.
* Matches keywords → triggers PES events.
*/
async _handleTextMessage(msg) {
const text = msg.text.trim();
// Track owner activity for idle behavior
this._lastOwnerMessageTime = Date.now();
// Dev mode or agent mode: all text goes to log instead of PES
if (this._devMode) {
return this._logDevRequest('message', text, msg);
}
if (this._agentMode) {
await this._logDevRequest(`@${this._agentMode}`, text, msg);
// Live orchestrator chat — real AI responses
if (this._agentMode === 'orchestrator' && this._orchestratorLLM) {
await this._handleOrchestratorChat(text, msg);
return;
}
const label = this._agentMode === 'orchestrator' ? '🎯' : '⚡';
await this._sendText(this.chatId, `${label} @${this._agentMode} получил: «${text.slice(0, 200)}»\n\n_Записано в лог._`, { parse_mode: 'Markdown' });
return;
}
// ── Shadow Orchestrator: always observe, respond on trigger ──
// Buffer every message for context (regardless of LLM availability)
this._shadowBuffer.push({ role: 'user', content: text, ts: Date.now() });
if (this._shadowBuffer.length > this._shadowBufferLimit) {
this._shadowBuffer = this._shadowBuffer.slice(-this._shadowBufferLimit);
}
// Check N23 / trigger keywords — BEFORE any other handler
if (this._shadowTriggers.test(text)) {
if (this._orchestratorLLM) {
console.log(`🎯 Shadow Orchestrator triggered by: "${text.slice(0, 50)}"`);
await this._handleShadowOrchestratorCall(text, msg);
return;
} else {
console.log(`⚠️ Shadow trigger matched but no _orchestratorLLM available`);
}
}
// ── Check pending mirror sticker (delayed "try back") ──
this._tryMirrorSticker().catch(err =>
console.error('⚠️ Mirror sticker error:', err.message)
);
// ── Check for sticker/emoji/gif creation intent ──
const stickerCreateMatch = /^(сделай стикер|создай стикер|стикер из|сделай пак|создай пак|make sticker|create sticker|добавь стикер|добавь в пак|add sticker|add to pack|сделай гифку|сделай gif|создай гифку|gif стикер|гиф стикер|видео стикер|video sticker)/i.test(text);
const emojiCreateMatch = /^(сделай эмодзи|создай эмодзи|сделай эмоджи|создай эмоджи|сделай emoji|создай emoji|make emoji|create emoji|эмодзи из|эмоджи из)/i.test(text);
const hasMediaSource = msg.photo || msg.animation || msg.video ||
(msg.document && (msg.document.mime_type?.startsWith('image/') || msg.document.mime_type?.startsWith('video/'))) ||
msg.reply_to_message?.sticker || msg.reply_to_message?.photo || msg.reply_to_message?.animation || msg.reply_to_message?.video ||
(msg.reply_to_message?.document && (msg.reply_to_message.document.mime_type?.startsWith('image/') || msg.reply_to_message.document.mime_type?.startsWith('video/')));
const captionMatch = (msg.photo || msg.animation || msg.video || (msg.document && (msg.document.mime_type?.startsWith('image/') || msg.document.mime_type?.startsWith('video/')))) && /стикер|sticker|пак|pack|эмодзи|эмоджи|emoji|гифк|gif/i.test(msg.caption || '');
const captionIsEmoji = captionMatch && /эмодзи|эмоджи|emoji/i.test(msg.caption || '');
// "добавь в пак" + foreign sticker → physically add to pack + save origin
// "сохрани" → collection bookmark (separate flow)
if (stickerCreateMatch || emojiCreateMatch || captionMatch) {
const createType = (emojiCreateMatch || captionIsEmoji) ? 'custom_emoji' : 'regular';
if (hasMediaSource) {
await this._handleStickerCreation(msg, createType);
this._updateRelationship('message');
return;
}
// No media — set pending flag, ask user to send sticker/photo/GIF
this._pendingAddToPack = Date.now();
this._pendingAddType = createType;
await this._sendText(this.chatId, pickRandom([
'тяф! 🎨🐾 ...жду стикер, фото или GIF! 📸',
'ваф! 🖌️🐾 ...пришли стикер, картинку или гифку! ✨',
'рру~~ 🎨 ...давай! стикер! фото! GIF! 🐾',
]));
return;
}
// ── Check for sticker COLLECTION intent (reply to sticker + "сохрани"/"в коллекцию") ──
const collectionSaveMatch = /^(сохрани|в коллекцию|добавь в коллекцию|save|collect|bookmark)/i.test(text);
const collectionRemoveMatch = /^(убери из коллекции|удали из коллекции|remove from collection)/i.test(text);
if ((collectionSaveMatch || collectionRemoveMatch) && msg.reply_to_message?.sticker && this.pes.store) {
const sticker = msg.reply_to_message.sticker;
if (collectionRemoveMatch) {
await this._removeFromCollection(msg, sticker);
} else {
await this._addToCollection(msg, sticker);
}
return;
}
// ── Check for translation intent ──
const translateMatch = text.match(/^(переведи|translate|перевод)\s+(на\s+)?(английский|english|англ|испанский|spanish|исп|немецкий|german|нем|французский|french|фр|китайский|chinese|кит|японский|japanese|яп|корейский|korean|кор|арабский|arabic|араб|итальянский|italian|итал|португальский|portuguese|порт|турецкий|turkish|тур|русский|russian|рус)\s*[:\s]+(.+)/i);
const translateMatch2 = text.match(/^(переведи|translate|перевод)\s*[:\s]+(.+)/i);
if (translateMatch || translateMatch2) {
const targetLang = translateMatch ? translateMatch[3] : null;
const sourceText = translateMatch ? translateMatch[4] : translateMatch2[2];
await this._handleTranslation(sourceText.trim(), targetLang?.trim() || 'английский');
await this._processSmartXP(msg, 'command', 'translate');
this._updateRelationship('message');
return;
}
// ── Check for metronome intent ──
const metronomeMatch = text.match(/^(метроном|metronome|темп|tempo)\s*(\d+)?\s*(\d+[смmс]?\w*)?/i);
if (metronomeMatch) {
const bpm = metronomeMatch[2] ? parseInt(metronomeMatch[2]) : 120;
let duration = 30;
if (metronomeMatch[3]) {
const durMatch = metronomeMatch[3].match(/^(\d+)(мин|m|min)?/i);
if (durMatch) {
duration = parseInt(durMatch[1]);
if (durMatch[2]) duration *= 60;
}
}
await this._sendMetronome(Math.max(40, Math.min(300, bpm)), Math.max(5, Math.min(120, duration)));
await this._processSmartXP(msg, 'command', 'metronome');
this._updateRelationship('message');
return;
}
// ── Check for note-saving intent ──
const noteMatch = text.match(/^(запомни|запиши|сохрани|заметка)[:\s]+(.+)/i);
const noteSearchMatch = text.match(/^(что ты помнишь|что помнишь|что знаешь|вспомни)\s*(про|о|об)?\s*(.+)/i);
if (noteMatch && this.pes.store) {
const noteText = noteMatch[2].trim();
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
// Auto-tag via simple keyword extraction
const tags = noteText
.replace(/[^\wа-яА-ЯёЁ\s]/g, '')
.split(/\s+/)
.filter(w => w.length > 2)
.slice(0, 10);
// Check for "важно" → pinned
const pinned = /важно|навсегда|не забудь/i.test(text);
// Detect date/time in the note → set remind_at
const remindAt = this._extractDateTimeFromNote(noteText);
const saved = this.pes.store.saveNote({
owner_id: ownerId,
text: noteText,
tags,
category: this._guessNoteCategory(noteText),
pinned,
source_message_id: msg.message_id,
remind_at: remindAt,
});
if (remindAt) {
await this._sendText(this.chatId, pickRandom([
`ваф! 📝🐾 рру~ ⏰`,
`тяф! 📝 ♡ рру~~ ⏰`,
`гав! 📝🐾 ррФФ~ ⏰`,
]));
} else {
await this._sendText(this.chatId, pickRandom([
`ваф! 📝🐾${pinned ? ' 📌' : ''} рру~`,
`тяф! 📝 ♡${pinned ? ' 📌' : ''} рру~~`,
`гав! 📝🐾${pinned ? ' 📌' : ''} ррФФ~`,
]));
}
this._addToMemory('owner', text);
this._addToMemory('pes', `[запомнил: ${noteText}${remindAt ? ` → принесу ${this._formatReminderTime(remindAt)}` : ''}]`);
await this._processSmartXP(msg, 'note', 'note');
this._updateRelationship('message');
return;
}
if (noteSearchMatch && this.pes.store) {
const query = noteSearchMatch[3].trim();
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
const notes = this.pes.store.searchNotes(query, { owner_id: ownerId, limit: 5 });
if (notes.length === 0) {
await this._sendText(this.chatId, pickRandom([
`скууу... ыыы... рру?.. 💧`,
`рру?... ...ууу... хнн... 💧`,
]));
} else {
// Dog "brings" the notes — sounds first, then data
await this._sendText(this.chatId, pickRandom([
`ваф! 📝🐾 рру!!`,
`тяф тяф! 📝 ррРР!! 🐾`,
]));
const lines = [];
for (const n of notes) {
lines.push(`${n.pinned ? '📌 ' : ''}${n.text}`);
}
await this._sendText(this.chatId, lines.join('\n'));
}
this._addToMemory('owner', `[спросил: что помнишь про ${query}]`);
this._addToMemory('pes', `[нашёл ${notes.length} заметок про ${query}]`);
await this._processSmartXP(msg, 'text');
this._updateRelationship('message');
return;
}
// ── Check for contact-saving intent ──
const contactSaveMatch = text.match(/^(запомни контакт|добавь контакт|сохрани контакт|новый контакт|add contact)[:\s]+(.+)/i);
const contactQueryMatch = text.match(/^(кто такой|кто такая|кто это|who is)\s+(.+)/i);
if (contactSaveMatch && this.pes.store) {
const contactStr = contactSaveMatch[2].trim();
await this._parseAndSaveContact(msg, contactStr);
return;
}
if (contactQueryMatch && this.pes.store) {
const query = contactQueryMatch[2].trim().replace(/[?]$/, '');
await this._searchAndShowContact(msg, query);
return;
}
// ── Check for reminder intent ──
const reminderIntent = await this._detectReminderIntent(text);
if (reminderIntent && this.pes.store) {
this.pes.store.createReminder(reminderIntent.text, reminderIntent.remindAt);
const timeStr = this._formatReminderTime(reminderIntent.remindAt);
await this._sendText(this.chatId, pickRandom([
`ваф! 📋🐾 рру~ ⏰`,
`тяф! 📋 рру~~ ⏰ 🐾`,
`гав! 📋🐾 ррФФ~ ⏰`,
]));
this._addToMemory('owner', text);
this._addToMemory('pes', `[напоминалка: ${reminderIntent.text}${timeStr}]`);
return; // Don't process as normal message
}
// ── CRM Bridge v2: bidirectional CRM access through chat ──
if (this._crm) {
const crmHandled = await this._handleCrmIntent(msg, text);
if (crmHandled) return;
}
// ── Detect keyword category for PES event triggering ──
let detectedCategory = null;
let detectedKw = null;
for (const [category, kw] of Object.entries(KEYWORDS)) {
if (kw.patterns.some(p => p.test(text))) {
detectedCategory = category;
detectedKw = kw;
break;
}
}
// ── Extract and learn emojis from owner's message ──
this._extractAndLearnEmojis(text, true);
// ── Extract and learn custom emoji from message entities ──
if (msg.entities) {
this._extractAndLearnCustomEmojis(msg.entities);
}
// ── File search intent ──
if (detectedKw && detectedKw.action === 'file_search') {
await this._handleFileSearch(text);
await this._processSmartXP(msg, 'text');
this._updateRelationship('message');
return;
}
// ── LLM Intent Router: when no specific regex matched ──
// Keywords like greetings/praise/scold still go to generic reply path
// But unrecognized text → LLM classifies → routes to handler
if (!detectedKw || !detectedKw.action || detectedKw.action === 'command') {
try {
const intent = await this._classifyIntent(text);
if (intent && intent.intent !== 'none') {
const handled = await this._routeIntent(msg, intent);
if (handled) {
// Still do emoji learning + patterns + XP
this._extractAndLearnEmojis(text, true);
if (msg.entities) this._extractAndLearnCustomEmojis(msg.entities);
await this._processSmartXP(msg, 'text', intent.intent);
this._updateRelationship('message');
this._observeInteractionPatterns(text, null);
return;
}
}
} catch (err) {
console.error('⚠️ LLM router error:', err.message);
// Fall through to generic reply
}
}
// ── React to owner's message (proactive emoji reaction) ──
this._reactToOwnerMessage(msg, detectedCategory).catch(() => {});
// ── Imprint: positive reaction = хозяин доволен предыдущим звуком ──
if (detectedCategory === 'praise' || detectedCategory === 'greetings') {
const boost = detectedCategory === 'praise' ? 0.5 : 0.2;
this._babble.imprint(this._lastPesReply, boost);
this._saveBabbleState();
}
// ── Generate reply via LLM (or fallback to templates) ──
await this._sendChatAction(this.chatId);
await sleep(thinkingDelay(this.pes.level));
const reply = await this._generateReply(text, detectedCategory);
console.log(`💬 Reply to owner: "${reply.slice(0, 100)}"`);
// ── v4: Smart response composition ──
// Decide response pattern: sticker-first, text-first, text-only, sticker-only
const responsePattern = this._pickResponsePattern(detectedCategory);
if (responsePattern === 'sticker_first') {
// Sticker → pause → babble text
await this._sendContextualSticker(detectedCategory);
await sleep(300 + Math.random() * 500);
const enrichedReply = this._tryInjectCustomEmoji(reply);
if (enrichedReply !== reply) {
await this._sendTextWithCustomEmoji(this.chatId, enrichedReply);
} else {
await this._sendText(this.chatId, reply);
}
} else if (responsePattern === 'sticker_only') {
// Only sticker — for very emotional moments (zoomies, greeting_frenzy)
await this._sendContextualSticker(detectedCategory);
} else if (responsePattern === 'text_then_sticker') {
// Text → sticker (classic flow)
const enrichedReply = this._tryInjectCustomEmoji(reply);
if (enrichedReply !== reply) {
await this._sendTextWithCustomEmoji(this.chatId, enrichedReply);
} else {
await this._sendText(this.chatId, reply);
}
await this._sendContextualSticker(detectedCategory);
} else {
// Text only — most common, clean
const enrichedReply = this._tryInjectCustomEmoji(reply);
if (enrichedReply !== reply) {
await this._sendTextWithCustomEmoji(this.chatId, enrichedReply);
} else {
await this._sendText(this.chatId, reply);
}
// 25% chance of follow-up sticker for flavor
if (Math.random() < 0.25) {
await this._sendContextualSticker(detectedCategory);
}
}
// ── Track last bot action for reaction memory ──
this._lastBotAction = {
action: detectedCategory || 'reply',
text: reply.slice(0, 200),
category: detectedCategory,
timestamp: Date.now(),
};
// ── Save to short memory ──
this._addToMemory('owner', text);
this._addToMemory('pes', reply);
// ── Persist babble state (combos, momentum) ──
this._saveBabbleState();
// ── Trigger PES event based on keyword ──
if (detectedKw) {
if (detectedKw.event) {
this.pes.event(detectedKw.event);
} else if (detectedKw.action === 'praise') {
this.pes.praise(text);
this._logReactionToLastAction('praise', text, 1.0);
await this._grantXp('praise', 3);
this._updateRelationship('praise');
} else if (detectedKw.action === 'scold') {
this.pes.scold(text);
this._logReactionToLastAction('scold', text, -1.0);
this._updateRelationship('scold');
} else if (detectedKw.action === 'command' && detectedKw.command === 'fetch') {
const target = text.replace(detectedKw.patterns[0], '').trim();
await this._handleFetch(target || 'мячик');
} else if (detectedKw.action === 'command') {
const args = text.replace(detectedKw.patterns[0], '').trim();
this.pes.command(detectedKw.command, { target: args || undefined });
} else if (detectedKw.action === 'workflow') {
await this._cmdWorkflow(msg, detectedKw.command);
} else if (detectedKw.action === 'status') {
await this._sendStatus();
} else if (detectedKw.action === 'feed') {
const content = text.replace(detectedKw.patterns[0], '').trim();
this.pes.feed({ type: 'text', content });
}
} else {
this.pes.event({
type: 'message',
from: 'owner',
content: text,
sentiment: 'neutral',
});
}
// Smart XP for every message from owner (dynamic based on quality)
await this._processSmartXP(msg, 'text', detectedKw?.action);
// Update owner relationship
this._updateRelationship('message');
// Observe interaction patterns — drives sticker discovery
this._observeInteractionPatterns(text, detectedKw);
// Proactive insights — share observations (max 1/day, 15% chance)
this._tryProactiveInsight().catch(err =>
console.error('⚠️ Proactive insight error:', err.message)
);
// Check if there are upcoming note reminders to "bring" contextually
this._checkContextualReminders().catch(err =>
console.error('⚠️ Contextual reminder check error:', err.message)
);
}
/**
* Generate reply — BABBLE-FIRST architecture.
*
* v1: LLM generates text → strip human words → fallback to babble
* v2: LLM classifies emotion → babble generates sounds with phonetic echo
*
* LLM is used ONLY for emotion/context classification.
* Babble Engine is the CORE sound generator — no patches, no stripping.
*
* @param {string} ownerText — owner's message
* @param {string|null} category — detected keyword category
* @returns {Promise<string>}
*/
async _generateReply(ownerText, category) {
// Update babble engine with current traits and owner emoji
this._updateBabbleContext();
const level = this.pes?.level || 0;
let emotion = this.pes?.emotions?.state || 'idle';
let intensity = this.pes?.emotions?.intensity || 0.5;
// ── Step 1: LLM classifies emotion/context (NOT generate text) ──
if (this._llm && category !== 'greetings' && category !== 'praise' && category !== 'scold' && category !== 'feed') {
try {
const classifyPrompt = `Ты анализируешь сообщение хозяина для животного (ПЕС).
Определи ЭМОЦИЮ которую ПЕС должен выразить в ответ и её ИНТЕНСИВНОСТЬ.
Ответь ТОЛЬКО JSON: {"emotion": "...", "intensity": 0.0-1.0}
Доступные эмоции: happy, playful, excited, content, idle, curious, hungry, sad, lonely, scared, anxious, angry, alert, bark, nap, sleep, greeting_frenzy, butt_wiggle, zoomies, food_obsessed, dramatic_tantrum, sulking, stubborn_refuse, puzzle_solving
Контекст:
- Текущая эмоция ПЕС: ${emotion}
- Уровень: ${level}
- Настроение: ${Math.round((this.pes?.emotions?.mood || 0.5) * 100)}%`;
const classified = await this._llm.classify(classifyPrompt, ownerText);
if (classified?.emotion && EMOTION_INSTRUMENTS_SET.has(classified.emotion)) {
emotion = classified.emotion;
intensity = Math.max(0.1, Math.min(1.0, classified.intensity || 0.5));
}
} catch (err) {
console.error('⚠️ LLM classify error:', err.message);
// Use current PES emotion — no problem
}
}
// ── Step 2: Babble Engine generates sounds (CORE) ──
// v4: Context-aware generation with blends and after-silence
let reply;
// Check if owner just returned after silence
const silenceMin = this._lastOwnerMessageTime
? (Date.now() - this._lastOwnerMessageTime) / 60000
: 0;
if (category === 'greetings' && silenceMin > 30 && this._babble.generateAfterSilence) {
// Owner returned after long absence — special greeting
reply = this._babble.generateAfterSilence(level, silenceMin, emotion);
} else if (this.pes?.emotions) {
// v4: Try emotion blend (current PES state + LLM-classified emotion)
const pesEmotion = this.pes.emotions.state;
if (pesEmotion !== emotion && this._babble.generateBlended && Math.random() < 0.3) {
reply = this._babble.generateBlended(level, pesEmotion, emotion, intensity);
} else {
reply = this._babble.generateEnhanced(level, emotion, intensity, ownerText);
}
} else {
reply = this._babble.generateEnhanced(level, emotion, intensity, ownerText);
}
// Track last reply for imprinting
this._lastPesReply = reply;
return reply;
}
/**
* Send a sticker that matches the emotional context.
* Uses preference_score + freshness boost + tag-matching from DB.
* NO random stickers — only contextually relevant ones.
* @param {string|null} category — detected keyword category
*/
/**
* v4: Pick response pattern — how to combine sticker + text + reaction.
* Based on emotion, energy, category, and randomness.
*/
_pickResponsePattern(category) {
const emotion = this.pes?.emotions?.state || 'idle';
const energy = this.pes?.emotions?.energy || 0.5;
const intensity = this.pes?.emotions?.intensity || 0.5;
// High-energy emotional moments → sticker first or sticker only
if (['greeting_frenzy', 'zoomies', 'play_bow'].includes(emotion) && intensity > 0.7) {
return Math.random() < 0.3 ? 'sticker_only' : 'sticker_first';
}
// Greetings/praise → sticker first (excited dog runs to you first, then barks)
if (category === 'greetings' || category === 'praise') {
return Math.random() < 0.5 ? 'sticker_first' : 'text_then_sticker';
}
// Scared/sad → text only (quiet, no sticker spam)
if (['scared', 'sad', 'lonely', 'anxious', 'sulking'].includes(emotion)) {
return 'text_only';
}
// Low energy → mostly text
if (energy < 0.3) {
return Math.random() < 0.85 ? 'text_only' : 'text_then_sticker';
}
// Normal flow — weighted random
const r = Math.random();
if (r < 0.45) return 'text_only';
if (r < 0.70) return 'text_then_sticker';
if (r < 0.90) return 'sticker_first';
return 'sticker_only';
}
async _sendContextualSticker(category) {
if (!this._stickerFileIds || this._stickerFileIds.length === 0) return;
// Map category to emotion for sticker selection
const emotionMap = {
greetings: ['happy', 'excited', 'playful'],
praise: ['happy', 'excited', 'trust'],
scold: ['sad', 'scared', 'submissive'],
feed: ['happy', 'excited', 'food_obsessed'],
play: ['playful', 'zoomies', 'excited'],
status: ['curious', 'interested'],
excited: ['excited', 'happy', 'playful'],
};
const targetEmotions = emotionMap[category] || null;
// ── EXPLORATION MODE: 30% chance to pick from loaded packs (not just DB) ──
// This helps PES discover new stickers and not get stuck on favorites
if (Math.random() < 0.3 && this._stickerFileIds && this._stickerFileIds.length > 0) {
// Try emotion-matched from loaded packs first
let explored = null;
if (targetEmotions && this._stickerEmotionIndex) {
for (const emotion of targetEmotions) {
const stickers = this._stickerEmotionIndex[emotion];
if (stickers && stickers.length > 0) {
// Filter out recently sent
const fresh = stickers.filter(s => !this._recentSentStickers.includes(s));
explored = fresh.length > 0 ? pickRandom(fresh) : pickRandom(stickers);
break;
}
}
}
// No emotion match — pick any random sticker from full pool
if (!explored) {
const fresh = this._stickerFileIds.filter(s => !this._recentSentStickers.includes(s));
explored = fresh.length > 0 ? pickRandom(fresh) : pickRandom(this._stickerFileIds);
}
if (explored) {
try {
await this._sendSticker(this.chatId, explored);
this._recentSentStickers.push(explored);
if (this._recentSentStickers.length > 10) this._recentSentStickers.shift();
return;
} catch (_) {}
}
}
// ── DB-ranked stickers with DIVERSITY enforcement ──
if (this.pes.store && targetEmotions) {
for (const emotion of targetEmotions) {
const ranked = this.pes.store.getStickersByRelevance({ emotion_key: emotion, limit: 10 });
if (ranked.length > 0) {
// Weighted random with anti-repeat penalty
const weights = ranked.map(s => {
let w = Math.max(0.1, (s.preference_score || 0) + (s.freshness_boost || 1) + 1);
// Penalize recently sent stickers (×0.15 weight)
if (this._recentSentStickers.includes(s.file_unique_id) || this._recentSentStickers.includes(s.file_id)) {
w *= 0.15;
}
// Diminishing returns on heavy use — prevent single sticker domination
if (s.times_used > 3) w *= (3 / s.times_used);
return w;
});
const totalWeight = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * totalWeight;
let chosen = ranked[0];
for (let i = 0; i < ranked.length; i++) {
r -= weights[i];
if (r <= 0) { chosen = ranked[i]; break; }
}
try {
await this._sendSticker(this.chatId, chosen.file_id);
this._lastSentStickerUniqueId = chosen.file_unique_id;
this.pes.store.markStickerUsed(chosen.file_unique_id);
this._recentSentStickers.push(chosen.file_unique_id);
if (this._recentSentStickers.length > 10) this._recentSentStickers.shift();
return;
} catch (_) {}
}
}
}
// Fallback: emotion index from loaded packs (if DB has no analyzed stickers)
if (targetEmotions && this._stickerEmotionIndex) {
for (const emotion of targetEmotions) {
const stickers = this._stickerEmotionIndex[emotion];
if (stickers && stickers.length > 0) {
const fresh = stickers.filter(s => !this._recentSentStickers.includes(s));
const sticker = fresh.length > 0 ? pickRandom(fresh) : pickRandom(stickers);
try {
await this._sendSticker(this.chatId, sticker);
this._recentSentStickers.push(sticker);
if (this._recentSentStickers.length > 10) this._recentSentStickers.shift();
} catch (_) {}
return;
}
}
}
// No match found — pick any random sticker from loaded packs
if (this._stickerFileIds && this._stickerFileIds.length > 0) {
const fresh = this._stickerFileIds.filter(s => !this._recentSentStickers.includes(s));
const sticker = fresh.length > 0 ? pickRandom(fresh) : pickRandom(this._stickerFileIds);
try {
await this._sendSticker(this.chatId, sticker);
this._recentSentStickers.push(sticker);
if (this._recentSentStickers.length > 10) this._recentSentStickers.shift();
} catch (_) {}
}
}
// ── Short Memory ────────────────────────────────────────────
_addToMemory(role, text) {
this._memory.push({ role, text, ts: Date.now() });
// At 9 messages — trigger context compression (async, non-blocking)
if (this._memory.length >= 9 && !this._summarizing) {
this._compressContext();
}
}
/**
* Compress current memory into a summary using LLM.
* Accumulated summary grows — old info is never lost.
*/
async _compressContext() {
if (!this._llm || this._summarizing) return;
this._summarizing = true;
try {
const historyText = this._memory.map(m => {
const who = m.role === 'owner' ? 'Хозяин' : 'ПЕС';
return `${who}: ${m.text}`;
}).join('\n');
const existingCtx = this._contextSummary
? `Предыдущий контекст:\n${this._contextSummary}\n\n`
: '';
const summaryPrompt = `Ты — система памяти для ПЕС (собака-корги). Твоя задача — сжать диалог в краткое резюме, сохранив ВСЮ важную информацию.
${existingCtx}Новый диалог для сжатия:
${historyText}
ИНСТРУКЦИИ:
- Сохрани ВСЕ факты: имена, события, предпочтения хозяина, ключевые темы
- Сохрани эмоциональный контекст: как прошёл разговор, настроение
- Если есть предыдущий контекст — объедини с новой информацией
- Формат: краткие тезисы, максимум 150 слов
- Пиши на русском`;
const result = await this._llm.generate(summaryPrompt, [
{ role: 'user', content: 'Сожми этот диалог в краткое резюме.' }
]);
if (result) {
this._contextSummary = result;
// Keep only last 2 messages as bridge, clear the rest
this._memory = this._memory.slice(-2);
// Persist to DB so it survives restarts
this._saveContextSummary();
console.log(`🧠 Context compressed. Summary: ${result.substring(0, 80)}...`);
}
} catch (err) {
console.error('⚠️ Context compression failed:', err.message);
} finally {
this._summarizing = false;
}
}
// ── Context Persistence ────────────────────────────────────
/** Load context summary from SQLite on startup. */
_loadContextSummary() {
if (!this.pes.store) return;
try {
const saved = this.pes.store.getConfig('context_summary');
if (saved) {
this._contextSummary = saved;
console.log(`🧠 Context summary loaded from DB (${saved.length} chars)`);
}
} catch (err) {
console.error('⚠️ Failed to load context summary:', err.message);
}
}
/** Save context summary to SQLite after compression. */
_saveContextSummary() {
if (!this.pes.store || !this._contextSummary) return;
try {
this.pes.store.setConfig('context_summary', this._contextSummary);
} catch (err) {
console.error('⚠️ Failed to save context summary:', err.message);
}
}
// ── Idle Behavior ─────────────────────────────────────────
/** Start idle timer — Тор скучает если хозяин молчит. */
_startIdleTimer() {
this._lastOwnerMessageTime = Date.now();
this._idleTimer = setInterval(() => this._checkIdle(), 5 * 60 * 1000); // every 5 min
this._lastIdleMessage = 0; // prevent spam
// ── Proactive daily messages (Живой бот) ──
this._proactiveToday = 0;
this._proactiveDate = '';
this._loadProactiveState();
// Check proactive every 30 min (separate from idle)
this._proactiveTimer = setInterval(() => this._checkProactive(), 30 * 60 * 1000);
console.log('😴 Idle behavior timer started (check every 5min, proactive every 30min)');
}
/** Load proactive message state from DB. */
_loadProactiveState() {
try {
const raw = this.pes?.store?.getConfig?.('proactive_state');
if (raw) {
const state = JSON.parse(raw);
this._proactiveDate = state.date || '';
this._proactiveToday = state.count || 0;
}
} catch (_) {}
}
/** Save proactive message state to DB. */
_saveProactiveState() {
try {
this.pes?.store?.setConfig?.('proactive_state', JSON.stringify({
date: this._proactiveDate,
count: this._proactiveToday,
}));
} catch (_) {}
}
/**
* Proactive daily messages — PES сам пишет если хозяин давно не заходил.
* Rules: max 2/day, not at night (23-7 Moscow), only after 3h+ silence.
* Content depends on mood/hunger/loneliness.
*/
async _checkProactive() {
if (!this._lastOwnerMessageTime || !this.chatId) return;
if (this._devMode || this._agentMode) return;
// Reset daily counter if new day
const today = new Date().toISOString().slice(0, 10);
if (this._proactiveDate !== today) {
this._proactiveDate = today;
this._proactiveToday = 0;
this._saveProactiveState();
}
// Max 2 proactive messages per day
if (this._proactiveToday >= 2) return;
// Not at night (Moscow time)
const moscowHour = (new Date().getUTCHours() + 3) % 24;
if (moscowHour >= 23 || moscowHour < 8) return;
// Only after 3+ hours of silence
const silentHours = (Date.now() - this._lastOwnerMessageTime) / (3600 * 1000);
if (silentHours < 3) return;
// Don't overlap with idle messages (at least 60 min gap)
const sinceLastIdle = (Date.now() - (this._lastIdleMessage || 0)) / (60 * 1000);
if (sinceLastIdle < 60) return;
const level = this.pes?.level || 0;
const mood = this.pes?.emotions?.mood ?? 0.5;
const hunger = this.pes?.emotions?.hunger ?? 0.2;
const loneliness = this.pes?.emotions?.loneliness ?? 0.1;
const energy = this.pes?.emotions?.energy ?? 0.5;
const name = this.pes?.name || 'PES';
// Pick message type based on vitals
let messageType;
if (hunger > 0.6) messageType = 'hungry';
else if (loneliness > 0.5) messageType = 'lonely';
else if (energy < 0.3) messageType = 'sleepy';
else if (mood < 0.3) messageType = 'sad';
else if (mood > 0.7) messageType = 'playful';
else messageType = 'curious';
const PROACTIVE_MESSAGES = {
hungry: [
'🍖❓ ...ав... 🐾',
'👃💨🍖 ... *тыкает миску носом*',
'🐾🥺 ...ммм... кушать...',
],
lonely: [
'🐾💧 ...скууучно... 👀',
'📱👀 ...ты где?... 🐾',
'🐾... *лежит у двери и вздыхает* 💨',
],
sleepy: [
'😴🐾 *зевает и ищет тёплое место*',
'💤... *свернулся калачиком* 🐾',
'🐾😴 ...ззз... *дёргает лапкой во сне*',
],
sad: [
'🐾💧 *грустно смотрит в окно*',
'...🥺🐾... *тихо скулит*',
'👀💧 ...давно не играли... 🐾',
],
playful: [
'🎾🐾 ав! играть?! 👀✨',
'🐾💨 *принёс мячик и положил у ног* 🎾',
'🐾🌀 *крутится вокруг себя* ...авав! 🎉',
],
curious: [
'👃💨 ...интересно... 🐾👀',
'🐾📡 *навострил уши — что-то слышит*',
'👀🐾 *заглядывает в телефон* ...привет? 📱',
],
};
const messages = PROACTIVE_MESSAGES[messageType] || PROACTIVE_MESSAGES.curious;
const text = messages[Math.floor(Math.random() * messages.length)];
try {
await this._sendText(this.chatId, text);
// Sometimes add a sticker (30% chance)
if (Math.random() < 0.3) {
const stickerEmotion = messageType === 'hungry' ? 'feed' :
messageType === 'playful' ? 'praise' :
messageType === 'sad' || messageType === 'lonely' ? 'scold' : null;
await this._sendContextualSticker(stickerEmotion);
}
this._proactiveToday++;
this._lastIdleMessage = Date.now(); // prevent idle overlap
this._saveProactiveState();
console.log(`🐾 Proactive message sent (${messageType}, #${this._proactiveToday}/2 today)`);
} catch (err) {
console.error('⚠️ Proactive message error:', err.message);
}
}
/** Check if owner has been silent too long and send idle behavior. */
async _checkIdle() {
if (!this._lastOwnerMessageTime) return;
if (this._devMode || this._agentMode) return; // don't idle in dev/agent mode
const silentMinutes = (Date.now() - this._lastOwnerMessageTime) / (60 * 1000);
const sinceLastIdle = (Date.now() - this._lastIdleMessage) / (60 * 1000);
// Don't spam — minimum 30 min between idle messages
if (sinceLastIdle < 30) return;
// Check time of day (Moscow) — don't idle at night
const moscowHour = (new Date().getUTCHours() + 3) % 24;
if (moscowHour >= 23 || moscowHour < 7) return;
// ── Sticker discovery on idle (young+ level) ──
if (silentMinutes >= 15 && silentMinutes < 30) {
// Dog is bored — time to explore stickers
this._exploreStickerDiscovery().catch(err =>
console.error('⚠️ Idle sticker discovery error:', err.message)
);
}
const level = this.pes?.level || 0;
const vitals = {
energy: this.pes?.emotions?.energy || 0.5,
hunger: this.pes?.emotions?.hunger || 0.2,
loneliness: this.pes?.emotions?.loneliness || 0.1,
curiosity: this.pes?.emotions?.curiosity || 0.5,
};
if (silentMinutes >= 120) {
// Very long absence (2h+) — deep lonely sounds via babble engine
const babble = this._babble.generateAfterSilence
? this._babble.generateIdle(level, { ...vitals, loneliness: 0.8, energy: 0.3 })
: this._babble.generate(level, 'lonely', 0.6);
await this._sendText(this.chatId, babble + ' 💧');
// Sometimes a sad sticker
if (Math.random() < 0.5) {
await this._sendContextualSticker('scold'); // sad stickers
}
this._lastIdleMessage = Date.now();
} else if (silentMinutes >= 60) {
// Lonely — 1+ hour of silence — use babble idle generator
const babble = this._babble.generateIdle
? this._babble.generateIdle(level, { ...vitals, loneliness: 0.6 })
: this._babble.generate(level, 'sad', 0.4);
await this._sendText(this.chatId, babble);
this._lastIdleMessage = Date.now();
} else if (silentMinutes >= 30) {
// Mildly bored — 30+ min — generate curiosity/bored babble
const babble = this._babble.generateIdle
? this._babble.generateIdle(level, { ...vitals, curiosity: 0.3 })
: this._babble.generate(level, 'curious', 0.3);
await this._sendText(this.chatId, babble);
// v4: Spontaneous action — sometimes do something interesting
const spontaneous = Math.random();
if (spontaneous < 0.25) {
// Send a sticker
await this._sendContextualSticker(null);
} else if (spontaneous < 0.40 && level >= 5) {
// "Sniff around" — small observation
const observations = [
'👃💨 🐾', // sniffing
'👀... 🐾', // looking around
'👂⬆️👂 ...', // ears perked
'📡📡 ...?', // scanning
];
await sleep(2000 + Math.random() * 3000);
await this._sendText(this.chatId, pickRandom(observations));
}
this._lastIdleMessage = Date.now();
}
}
// ── Interaction Pattern Observation ──────────────────────────
/**
* Observe and record interaction patterns from owner's message.
* Tracks: emotions, topics, sticker types, time of day, message styles.
* This data drives sticker discovery (not a hardcoded catalog).
*/
_observeInteractionPatterns(text, detectedKw = null) {
if (!this.pes.store) return;
try {
// 1. Time of day pattern
const moscowHour = (new Date().getUTCHours() + 3) % 24;
const timeSlot = moscowHour < 6 ? 'night' : moscowHour < 12 ? 'morning' : moscowHour < 18 ? 'afternoon' : 'evening';
this.pes.store.recordPattern('time_of_day', timeSlot, 0.5);
// 2. Keyword/action pattern
if (detectedKw && detectedKw.action) {
this.pes.store.recordPattern('action', detectedKw.action, 1.0);
if (detectedKw.command) {
this.pes.store.recordPattern('command', detectedKw.command, 1.5);
}
}
// 3. Emotion from text (simple keyword detection)
const emotionPatterns = {
happy: /(?:хаха|лол|😂|🤣|ахах|класс|круто|огонь|🔥|супер|ура)/i,
sad: /(?:грустно|печально|😢|😭|жаль|увы|скучаю)/i,
angry: /(?:бесит|злой|злюсь|😡|🤬|блин|чёрт)/i,
love: /(?:люблю|❤️|💕|♡|обнимаю|скучаю|♥)/i,
excited: /(?:вау|ого|офигеть|🤩|amazing|wow|урааа|!!!)/i,
playful: /(?:играть|игра|прикол|😜|🤪|хех|жжёшь)/i,
food: /(?:еда|есть|кушать|голод|🍕|🍔|🍩|вкусн|кормить)/i,
};
for (const [emotion, pattern] of Object.entries(emotionPatterns)) {
if (pattern.test(text)) {
this.pes.store.recordPattern('emotion', emotion, 1.0);
}
}
// 4. Message length pattern (affects communication style)
const lengthType = text.length < 10 ? 'short' : text.length < 50 ? 'medium' : 'long';
this.pes.store.recordPattern('message_style', lengthType, 0.3);
// 5. Emoji usage pattern — which emojis owner prefers
const emojis = text.match(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu);
if (emojis) {
for (const em of emojis) {
this.pes.store.recordPattern('emoji_used', em, 1.0);
}
}
} catch (err) {
// Non-critical — don't crash on pattern observation
console.error('⚠️ Pattern observation error:', err.message);
}
}
/**
* Record sticker-specific patterns when owner sends sticker.
*/
_observeStickerPattern(emoji, setName, emotionKey = null) {
if (!this.pes.store) return;
try {
this.pes.store.recordPattern('sticker_emoji', emoji, 1.5);
if (setName) {
this.pes.store.recordPattern('sticker_pack', setName, 1.0);
}
if (emotionKey) {
this.pes.store.recordPattern('sticker_emotion', emotionKey, 1.5);
}
} catch (err) {
console.error('⚠️ Sticker pattern error:', err.message);
}
}
/**
* Auto-load a sticker/custom emoji set if not already known.
*/
async _autoLoadStickerSet(setName) {
if (!setName || this._learnedPacks.has(setName)) return;
try {
await this.loadStickerPack(setName, null, true);
this._learnedPacks.add(setName);
this._saveStickerPackToDB(setName);
console.log(`📦 Auto-loaded sticker set: ${setName}`);
} catch (_) {
// Pack not accessible (custom emoji sets may not be loadable as regular packs)
}
}
// ── Sticker Discovery from Interaction Patterns ──────────
/**
* Explore sticker packs based on observed interaction patterns.
* Called periodically (from idle timer or after N messages).
* The dog doesn't use a catalog — it learns from watching the owner.
*/
async _exploreStickerDiscovery() {
if (!this.pes.store) return;
// Only young+ can explore
const stats = this.pes.store.getStats();
if (!stats) return;
// Level gate removed — testing all features
// Check cooldown — max 1 exploration per 4 hours
const lastExploreTime = this.pes.store.getConfig('last_sticker_explore');
if (lastExploreTime) {
const elapsed = Date.now() - new Date(lastExploreTime).getTime();
if (elapsed < 4 * 60 * 60 * 1000) return; // 4 hours
}
try {
// Strategy 1: Explore untried stickers from known packs
const explorablePacks = this.pes.store.getExplorablePacks(3);
for (const pack of explorablePacks) {
const untried = this.pes.store.getUntriedStickersFromPack(pack.set_name, 1);
if (untried.length > 0) {
const sticker = untried[0];
// Dog found something to try!
await this._showStickerDiscovery(sticker, pack);
this.pes.store.setConfig('last_sticker_explore', new Date().toISOString());
return; // One discovery at a time
}
}
// Strategy 2: Look for new packs from owner's sticker patterns
const packPatterns = this.pes.store.getTopPatterns('sticker_pack', 5);
if (packPatterns.length > 0) {
// Try to discover related packs by name variation
for (const pattern of packPatterns) {
const baseName = pattern.pattern_value;
// Check if there's a discovery record for this pack's "family"
const disc = this.pes.store.db.prepare(
'SELECT COUNT(*) as cnt FROM sticker_discovery WHERE set_name = ?'
).get(baseName);
if (!disc || disc.cnt === 0) {
// First time seeing this pack in discovery — register it
const stickerCount = this.pes.store.db.prepare(
'SELECT COUNT(*) as cnt FROM learned_stickers WHERE set_name = ?'
).get(baseName);
this.pes.store.saveDiscovery(baseName, {
source: 'owner',
stickersTotal: stickerCount?.cnt || 0,
matchScore: pattern.weight,
notes: { reason: 'owner_uses', occurrences: pattern.occurrences },
});
}
}
}
this.pes.store.setConfig('last_sticker_explore', new Date().toISOString());
} catch (err) {
console.error('⚠️ Sticker discovery error:', err.message);
}
}
/**
* Show a discovered sticker to the owner — dog "brings" it proudly.
* Waits for owner's reaction (👍/👎) via the reaction handler.
*/
async _showStickerDiscovery(sticker, pack) {
try {
// Typing — dog "runs to get" the sticker
await this._sendChatAction(this.chatId);
await sleep(1500 + Math.random() * 2000);
// Send the sticker
await this._sendSticker(this.chatId, sticker.file_id);
// Proud sounds
const sounds = [
'тяф?! ✨🐾 рру~!',
'ГАВВ! ✨ тяф-тяф! 🐾',
'рруу~! ✨ ваф? 🐾',
'ыыыа! ✨ тяф! 🐾',
];
await this._sendText(this.chatId, pickRandom(sounds));
// Track this as the last bot action for reaction tracking
this._lastBotAction = {
action: 'sticker_discovery',
text: `discovered sticker from ${pack.set_name}`,
category: 'sticker',
timestamp: Date.now(),
discoveryPackName: pack.set_name,
stickerUniqueId: sticker.file_unique_id,
};
// Mark sticker as tried
this.pes.store.markStickerTried(sticker.file_unique_id);
this.pes.store.incrementDiscoveryTried(pack.set_name);
console.log(`🔍 Sticker discovery: showed sticker from ${pack.set_name}`);
} catch (err) {
console.error('⚠️ Show sticker discovery error:', err.message);
}
}
// ── Dynamic Sticker Packs from DB ─────────────────────────
/** Load sticker pack names from DB config instead of hardcoded array. */
async _loadStickerPacksFromDB() {
if (!this.pes.store) return;
try {
const saved = this.pes.store.getConfig('sticker_packs');
let packs = [];
if (saved) {
packs = JSON.parse(saved);
} else {
// First run — use defaults and save to DB
packs = ['CorgiLove', 'corgi_life', 'CorgiBaby', 'CorgiPower'];
this.pes.store.setConfig('sticker_packs', JSON.stringify(packs));
}
let firstPack = true;
for (const pack of packs) {
try {
await this.loadStickerPack(pack, null, !firstPack);
this._learnedPacks.add(pack);
console.log(`📦 Loaded sticker pack from DB: ${pack}`);
firstPack = false;
} catch (err) {
console.log(`⚠️ Pack "${pack}" not available: ${err.message}`);
}
}
console.log(`📦 Total stickers loaded: ${this._stickerFileIds?.length || 0} from ${packs.length} packs`);
} catch (err) {
console.error('⚠️ Failed to load sticker packs from DB:', err.message);
}
}
/** Save a new sticker pack name to DB config. */
_saveStickerPackToDB(packName) {
if (!this.pes.store) return;
try {
const saved = this.pes.store.getConfig('sticker_packs');
const packs = saved ? JSON.parse(saved) : [];
if (!packs.includes(packName)) {
packs.push(packName);
this.pes.store.setConfig('sticker_packs', JSON.stringify(packs));
console.log(`📦 Saved sticker pack to DB: ${packName}`);
}
} catch (err) {
console.error('⚠️ Failed to save sticker pack:', err.message);
}
}
// ── Emoji Learning ─────────────────────────────────────────
/**
* Log a reaction (praise/scold/text feedback) to the last bot action.
* @param {string} reactionType — 'praise' | 'scold' | 'positive' | 'negative'
* @param {string} reactionValue — the text that triggered this
* @param {number} weight — -2 to 2
*/
_logReactionToLastAction(reactionType, reactionValue, weight) {
if (!this.pes.store || !this._lastBotAction) return;
const speedMs = Date.now() - this._lastBotAction.timestamp;
try {
this.pes.store.logReaction({
pes_action: this._lastBotAction.action || 'reply',
pes_expression: this._lastBotAction.text || null,
pes_context: this._lastBotAction.category || null,
reactor: 'owner',
reactor_role: 'owner',
reaction_type: reactionType,
reaction_value: reactionValue?.slice(0, 200) || null,
reaction_speed: speedMs,
weight,
platform: 'telegram',
});
// Recalculate preferences periodically
this._reactionRecalcCounter++;
if (this._reactionRecalcCounter >= 5) {
this._reactionRecalcCounter = 0;
this.pes.store.recalculatePreference(this._lastBotAction.action || 'reply');
}
console.log(`💭 Reaction logged: ${reactionType} (weight=${weight}) → action "${this._lastBotAction.action}"`);
} catch (err) {
console.error('⚠️ Failed to log reaction:', err.message);
}
}
/**
* Grant XP and handle level-up notifications.
* @param {string} reason — 'message' | 'praise' | 'scold' | 'sticker' | 'reaction' | 'emoji'
* @param {number} amount — base XP amount
*/
async _grantXp(reason, amount) {
if (!this.pes.store) return;
try {
const result = this.pes.store.addXP(amount, reason);
const capInfo = result.dailyXP >= result.dailyCap ? ' [CAP]' : '';
console.log(`⭐ XP +${result.added} (${reason}) → level ${result.level} [${result.phase}]${capInfo} daily:${result.dailyXP}/${result.dailyCap}`);
// Level-up notification
if (result.leveledUp) {
const levelUpSounds = [
'ГАВВВ!! ГАВГАВГАВ!! ♡♡♡ ыыыыааа!! ⭐⭐⭐',
'рррРРРУУУУ!! ваф-ваф-ваф!! ⭐ тяф!!',
'УУУУУ!! гав! гав! гав! ♡ ⭐⭐',
];
const sound = levelUpSounds[Math.floor(Math.random() * levelUpSounds.length)];
// Show age gate info — how many days until next phase
const ageDays = this.pes.store.getAgeDays();
const AGE_GATES = { puppy: 0, young: 30, adult: 365, experienced: 1095, cyber: 1825 };
const phaseOrder = ['puppy', 'young', 'adult', 'experienced', 'cyber'];
const currentPhaseIdx = phaseOrder.indexOf(result.phase);
let nextPhaseInfo = '';
if (currentPhaseIdx < phaseOrder.length - 1) {
const nextPhase = phaseOrder[currentPhaseIdx + 1];
const daysNeeded = AGE_GATES[nextPhase] - ageDays;
if (daysNeeded > 0) {
nextPhaseInfo = `\n📅 ${nextPhase}: ${daysNeeded}д`;
}
}
const msg = `${sound}\n\n⬆️ ${Math.floor(result.level)} [${result.phase}] ✨${nextPhaseInfo}`;
await this._sendText(this.chatId, msg);
// Send a celebratory sticker
await this._sendContextualSticker('excited');
}
// Artifact drop chance (5% per XP grant, unlocked at L5)
if (this.pes.store.isUnlocked?.('artifacts') || Math.floor(result.level) >= 5) {
await this._tryArtifactDrop(reason, result);
}
} catch (err) {
console.error('⚠️ Failed to grant XP:', err.message);
}
}
// ── Artifact Drop System ──────────────────────────────────
async _tryArtifactDrop(reason, xpResult) {
const DROP_CHANCE = 0.05; // 5% base
const LEVEL_BONUS = Math.floor(xpResult.level) * 0.002; // +0.2% per level
const roll = Math.random();
if (roll > DROP_CHANCE + LEVEL_BONUS) return;
const ARTIFACT_POOL = [
// Common (60%)
{ name: 'Мячик', emoji: '⚽', type: 'toy', rarity: 'common', weight: 12 },
{ name: 'Палка', emoji: '🪵', type: 'toy', rarity: 'common', weight: 12 },
{ name: 'Косточка', emoji: '🦴', type: 'food', rarity: 'common', weight: 12 },
{ name: 'Камешек', emoji: '🪨', type: 'toy', rarity: 'common', weight: 12 },
{ name: 'Перо', emoji: '🪶', type: 'toy', rarity: 'common', weight: 12 },
// Uncommon (25%)
{ name: 'Бабочка в банке', emoji: '🦋', type: 'trophy', rarity: 'uncommon', weight: 7 },
{ name: 'Блестящая монета', emoji: '🪙', type: 'trophy', rarity: 'uncommon', weight: 6 },
{ name: 'Ракушка', emoji: '🐚', type: 'trophy', rarity: 'uncommon', weight: 6 },
{ name: 'Колокольчик', emoji: '🔔', type: 'gift', rarity: 'uncommon', weight: 6 },
// Rare (10%)
{ name: 'Кристалл', emoji: '💎', type: 'rare', rarity: 'rare', weight: 4 },
{ name: 'Звёздная пыль', emoji: '✨', type: 'rare', rarity: 'rare', weight: 3 },
{ name: 'Лунный камень', emoji: '🌙', type: 'rare', rarity: 'rare', weight: 3 },
// Epic (4%)
{ name: 'Драконий зуб', emoji: '🐉', type: 'rare', rarity: 'epic', weight: 2 },
{ name: 'Огненный цветок', emoji: '🌺', type: 'rare', rarity: 'epic', weight: 2 },
// Legendary (1%)
{ name: 'Сердце Вселенной', emoji: '💫', type: 'secret', rarity: 'legendary', weight: 1 },
];
// Weighted random
const totalWeight = ARTIFACT_POOL.reduce((s, a) => s + a.weight, 0);
let r = Math.random() * totalWeight;
let picked = ARTIFACT_POOL[0];
for (const art of ARTIFACT_POOL) {
r -= art.weight;
if (r <= 0) { picked = art; break; }
}
const ownerId = String(this.chatId);
try {
this.pes.store.addArtifact({
owner_id: ownerId,
artifact_type: picked.type,
name: picked.name,
emoji: picked.emoji,
rarity: picked.rarity,
source: reason,
source_detail: `xp_grant_${reason}`,
});
const RARITY_STARS = { common: '⬜', uncommon: '🟩', rare: '🟦', epic: '🟪', legendary: '🟨' };
const star = RARITY_STARS[picked.rarity] || '⬜';
await this._sendText(this.chatId, `${picked.emoji} нашёл ${picked.name}! ${star} ${picked.rarity}\nгав! гав! 🐾 → /artifacts`);
console.log(`🎁 Artifact drop: ${picked.name} (${picked.rarity}) for ${ownerId}`);
} catch (err) {
console.error('⚠️ Artifact drop failed:', err.message);
}
}
// ── Smart XP System ──────────────────────────────────────
/**
* Calculate dynamic XP for a message based on quality.
* Replaces fixed +1 with context-aware scoring.
*/
_calculateMessageXP(msg, intent = null) {
const text = msg?.text || msg?.caption || '';
let xp = 1; // base
let detail = { length: 'short' };
// Length bonus: medium (+1), long (+2)
if (text.length > 100) {
xp = 3;
detail.length = 'long';
} else if (text.length > 40) {
xp = 2;
detail.length = 'medium';
}
// Teaching intent: "знай что", "запомни", file with caption
if (intent === 'note' || intent === 'teach') {
xp = Math.max(xp, 3);
detail.teaching = true;
}
// File with caption = content contribution
if ((msg?.photo || msg?.document || msg?.video || msg?.voice) && text.length > 5) {
xp = Math.max(xp, 3);
detail.file_with_desc = true;
}
// Correction bonus: owner continues after negative reaction
if (this._lastReactionWasNegative && Date.now() - (this._lastReactionTime || 0) < 5 * 60 * 1000) {
xp += 2;
detail.correction = true;
this._lastReactionWasNegative = false;
}
return { xp, detail: JSON.stringify(detail) };
}
/**
* Track dialogue session. Returns bonus XP if milestone reached.
*/
_trackDialogue() {
const now = Date.now();
const SESSION_GAP = 10 * 60 * 1000; // 10 min
if (!this._dialogueStartTime || (now - (this._lastDialogueMessageTime || 0)) > SESSION_GAP) {
// New session
this._dialogueStartTime = now;
this._dialogueMessageCount = 1;
} else {
this._dialogueMessageCount = (this._dialogueMessageCount || 0) + 1;
}
this._lastDialogueMessageTime = now;
// Bonus at 5, 10, 15 messages in session
if (this._dialogueMessageCount === 5 || this._dialogueMessageCount === 10 || this._dialogueMessageCount === 15) {
return this._dialogueMessageCount === 5 ? 5 : this._dialogueMessageCount === 10 ? 3 : 2;
}
return 0;
}
/**
* Check and update daily streak. Returns streak info.
* Called on first message of the day.
*/
_checkStreak() {
if (!this.pes.store) return null;
try {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const lastDate = this.pes.store.getConfig('streak_last_date');
const currentStreak = parseInt(this.pes.store.getConfig('streak_current') || '0', 10);
if (lastDate === today) return null; // already checked today
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
let newStreak;
if (lastDate === yesterday) {
// Consecutive day — increment
newStreak = currentStreak + 1;
} else if (!lastDate) {
// First ever
newStreak = 1;
} else {
// Streak broken
newStreak = 1;
}
this.pes.store.setConfig('streak_current', String(newStreak));
this.pes.store.setConfig('streak_last_date', today);
// XP scales with streak: 1-3 days = 1, 4-6 = 2, 7-13 = 3, 14-29 = 4, 30+ = 5
const streakXP = newStreak >= 30 ? 5 : newStreak >= 14 ? 4 : newStreak >= 7 ? 3 : newStreak >= 4 ? 2 : 1;
return { streak: newStreak, xp: streakXP, isMilestone: [3, 7, 14, 30, 60, 100, 365].includes(newStreak), broken: lastDate && lastDate !== yesterday && currentStreak > 1 };
} catch (err) {
console.error('⚠️ Streak check error:', err.message);
return null;
}
}
/**
* Track interaction type diversity for daily bonus.
* Types: text, sticker, reaction, file, note, command
*/
_trackDiversity(type) {
const today = new Date().toISOString().slice(0, 10);
if (this._diversityDate !== today) {
this._diversityDate = today;
this._diversityTypes = new Set();
this._diversityBonusGiven = false;
}
this._diversityTypes.add(type);
// Bonus when reaching 3 unique types, once per day
if (this._diversityTypes.size >= 3 && !this._diversityBonusGiven) {
this._diversityBonusGiven = true;
return true;
}
return false;
}
/**
* Check if this is the first message of the day. Returns true once per day.
*/
_checkFirstDaily() {
const today = new Date().toISOString().slice(0, 10);
if (this._firstDailyDate === today) return false;
this._firstDailyDate = today;
return true;
}
/**
* Process all Smart XP grants for a message.
* Central hub — called from _handleTextMessage, _handleMediaMessage, etc.
*/
async _processSmartXP(msg, interactionType = 'text', intent = null) {
if (!this.pes.store) return;
// 1. First daily bonus
const isFirstDaily = this._checkFirstDaily();
if (isFirstDaily) {
await this._grantXp('first_daily', 3);
// Streak check (also first-daily)
const streakInfo = this._checkStreak();
if (streakInfo) {
await this._grantXp('streak', streakInfo.xp);
if (streakInfo.isMilestone) {
// Milestone reaction — dog celebrates
const milestoneReactions = [
`ГАААВВ!! ррРРуууу!! 🔥${streakInfo.streak}🔥 тяф-тяф!! ✨🐾`,
`ууУУУ!! ваф!! 🔥${streakInfo.streak}🔥 ГАВВ!! ♡♡ 🐾`,
`рррРРР!! ГАВГАВ!! 🔥${streakInfo.streak}🔥 ыыы!! ⭐🐾`,
];
await this._sendText(this.chatId, milestoneReactions[Math.floor(Math.random() * milestoneReactions.length)]);
await this._sendContextualSticker('excited');
} else if (streakInfo.broken) {
// Streak was broken — dog is a bit sad but happy you're back
await this._sendText(this.chatId, 'скууу... ыыы... 💧 ...рру~ ♡ 🐾');
}
}
}
// 2. Dynamic message XP (replaces fixed +1)
const { xp, detail } = this._calculateMessageXP(msg, intent);
await this._grantXp('message', xp);
// 3. Dialogue bonus
const dialogueBonus = this._trackDialogue();
if (dialogueBonus > 0) {
await this._grantXp('dialogue_bonus', dialogueBonus);
}
// 4. Diversity tracking
const diversityReached = this._trackDiversity(interactionType);
if (diversityReached) {
await this._grantXp('diversity', 5);
}
// 5. Spell discovery check (rare — every 10th message)
if (Math.random() < 0.1) {
await this._checkSpellDiscovery();
}
}
/**
* Check for spell discovery and display animation if found.
*/
async _checkSpellDiscovery() {
if (!this.pes || !this.pes.checkSpellDiscovery) return;
try {
const spell = this.pes.checkSpellDiscovery();
if (spell) {
// Spell discovery animation!
const rarityStars = {
spark: '✦', glint: '✧', flash: '⚡', pulse: '💫',
surge: '🌊', wave: '🌊', storm: '⛈️', blaze: '🔥',
nova: '💥', eclipse: '🌑', cosmos: '🌌', god: '👁️‍🗨️',
};
const icon = rarityStars[spell.rarity] || '✨';
const percent = 100 + spell.level;
await this._sendChatAction(this.chatId);
await sleep(2000);
// Dramatic reveal
await this._sendText(this.chatId, '✨✨✨✨✨✨✨✨✨✨✨✨');
await sleep(1500);
await this._sendText(this.chatId, `${icon} SPELL AWAKENED ${icon}`);
await sleep(1000);
await this._sendText(this.chatId, `${icon} ${spell.name} (${percent}%) ${icon}`);
await sleep(1000);
await this._sendText(this.chatId, `[${spell.rarity.toUpperCase()}]`);
await sleep(500);
await this._sendText(this.chatId, '✨✨✨✨✨✨✨✨✨✨✨✨');
// Dog reaction
await sleep(1000);
const reactions = [
'ррРРРуууу!! ...тяф?! ...ГАВВ!! ✨🐾',
'ууУУУ!! ...рру~... ...ГАВГАВ!! ✨🐾',
'...тяф... ...рррРР!! ...ГАААВВ!! ✨🐾',
];
await this._sendText(this.chatId, reactions[Math.floor(Math.random() * reactions.length)]);
await this._sendContextualSticker('excited');
}
} catch (err) {
console.error('⚠️ Spell discovery check error:', err.message);
}
}
// ── Relationships ─────────────────────────────────────────
/**
* Initialize owner relationship on bot start.
* Creates 'owner' entity if doesn't exist.
*/
_initOwnerRelationship() {
if (!this.pes.store) return;
try {
const existing = this.pes.store.getRelationship('owner');
if (!existing) {
this.pes.store.addOrUpdateRelationship('owner', {
trust: 0.3,
affection: 0.3,
relationship_phase: 'acquaintance',
});
console.log('💛 Owner relationship initialized (trust=0.3, affection=0.3, acquaintance)');
} else {
console.log(`💛 Owner relationship loaded: trust=${existing.trust.toFixed(2)}, affection=${existing.affection.toFixed(2)}, phase=${existing.relationship_phase}, interactions=${existing.interactions_count}`);
}
} catch (err) {
console.error('⚠️ Failed to init owner relationship:', err.message);
}
}
/**
* Update owner relationship based on interaction type.
* @param {'message'|'praise'|'scold'|'sticker'|'reaction'|'emoji'} type
*/
_updateRelationship(type) {
if (!this.pes.store) return;
try {
// Delta values for trust and affection
const deltas = {
message: { trust: 0.005, affection: 0.005 },
praise: { trust: 0.02, affection: 0.03 },
scold: { trust: -0.01, affection: -0.005 },
sticker: { trust: 0.01, affection: 0.015 },
reaction: { trust: 0.01, affection: 0.01 },
emoji: { trust: 0.005, affection: 0.008 },
};
const d = deltas[type] || deltas.message;
// Update trust
this.pes.store.updateTrust('owner', d.trust);
// Update affection via addOrUpdateRelationship (it increments interactions_count too)
const rel = this.pes.store.getRelationship('owner');
if (rel) {
const newAffection = Math.max(0, Math.min(1, rel.affection + d.affection));
this.pes.store.addOrUpdateRelationship('owner', { affection: newAffection });
}
// Auto-advance phase
const updated = this.pes.store.updatePhase('owner');
if (updated && rel && updated.relationship_phase !== rel.relationship_phase) {
console.log(`💛 Relationship phase: ${rel.relationship_phase}${updated.relationship_phase}!`);
// Notify in chat about phase change
this._notifyPhaseChange(rel.relationship_phase, updated.relationship_phase);
}
} catch (err) {
console.error('⚠️ Failed to update relationship:', err.message);
}
}
/**
* Send celebration message when relationship phase changes.
*/
async _notifyPhaseChange(oldPhase, newPhase) {
const phaseSounds = {
acquaintance: 'рру? рруу~... гав! ♡',
friend: 'ГАВВВ!! ррРРРууу!! ♡♡♡ тяф-тяф!! ыыыыы!!',
favorite: 'УУУУААА!! ГАВГАВГАВ!! ♡♡♡♡♡ рррррууу!! ⭐⭐',
bonded: 'ммммм... рру~ ♡♡♡♡♡♡ ууу~ ♡♡♡ ...ррруууу~ ⭐⭐⭐',
};
const phaseNames = {
stranger: 'незнакомец',
acquaintance: 'знакомый',
friend: 'друг',
favorite: 'любимый',
bonded: 'неразлучный',
};
const sound = phaseSounds[newPhase] || 'гав! ♡';
const name = phaseNames[newPhase] || newPhase;
await this._sendText(this.chatId, `${sound}\n\n💛 Уровень привязанности: **${name}**`);
await this._sendContextualSticker('excited');
}
/**
* Get relationship info for LLM prompt injection.
* @returns {string}
*/
_getRelationshipForPrompt() {
if (!this.pes.store) return '';
try {
const rel = this.pes.store.getRelationship('owner');
if (!rel) return '';
const trustPct = Math.round(rel.trust * 100);
const affectionPct = Math.round(rel.affection * 100);
return `\n\n## Привязанность к хозяину
- Доверие: ${trustPct}% (${trustPct > 70 ? 'полностью доверяет' : trustPct > 40 ? 'доверяет' : 'осторожничает'})
- Привязанность: ${affectionPct}% (${affectionPct > 70 ? 'обожает' : affectionPct > 40 ? 'привязан' : 'присматривается'})
- Фаза: ${rel.relationship_phase}
- Встреч: ${rel.interactions_count}
${trustPct > 80 ? '- Ты ОБОЖАЕШЬ хозяина. Радуешься каждому сообщению.' : ''}
${trustPct < 30 ? '- Ты ещё не очень доверяешь. Осторожен, сдержан.' : ''}`;
} catch (_) {
return '';
}
}
/**
* Get pinned notes for LLM prompt context.
*/
_getPinnedNotesForPrompt() {
if (!this.pes.store) return '';
try {
const pinned = this.pes.store.getPinnedNotes();
if (!pinned || pinned.length === 0) return '';
const items = pinned.slice(0, 5).map(n => `- ${n.text}`).join('\n');
return `\n\n## Важные заметки хозяина (запомнены навсегда)
${items}
Если хозяин спрашивает об этих вещах — ты знаешь ответ.`;
} catch (_) {
return '';
}
}
/**
* Get files summary for LLM prompt — so PES knows what it stores.
*/
_getFilesContextForPrompt() {
if (!this.pes.store) return '';
try {
const total = this.pes.store.db.prepare('SELECT COUNT(*) as cnt FROM owner_files WHERE is_deleted = 0').get();
if (!total || total.cnt === 0) return '';
const recent = this.pes.store.getRecentFiles({ limit: 5 });
const items = recent.map(f => `- ${f.file_type}: ${f.file_name}${f.description ? ' — ' + f.description : ''}`).join('\n');
return `\n\n## Файлы хозяина (ты хранишь ${total.cnt} файлов)
Последние:
${items}
Если хозяин просит найти файл — ты можешь! Ответь звуками и принеси.`;
} catch (_) {
return '';
}
}
/**
* Extract emojis from text and learn them.
* Saves to SQLite, queues new ones for LLM description.
*/
_extractAndLearnEmojis(text, ownerSent = true) {
const emojiRegex = /(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200D(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/gu;
const found = text.match(emojiRegex);
if (!found || !this.pes.store) return;
const unique = [...new Set(found)];
for (const emoji of unique) {
// Skip simple digits/symbols that match emoji regex
if (/^[0-9#*]$/.test(emoji)) continue;
const existing = this.pes.store.getEmoji(emoji);
if (existing) {
// Reinforce — seen again
this.pes.store.learnEmoji(emoji, { owner_sent: ownerSent });
} else {
// New emoji — save and queue for LLM analysis
this.pes.store.learnEmoji(emoji, { owner_sent: ownerSent });
this._emojiAnalysisQueue.push(emoji);
}
}
// Trigger async analysis for new emojis
if (this._emojiAnalysisQueue.length > 0 && !this._analyzingEmojis) {
this._analyzeNewEmojis();
}
}
/**
* Extract and learn Telegram custom emoji from message entities.
* Custom emoji are premium animated/static stickers used inline.
* They have type: "custom_emoji" with custom_emoji_id in entities.
*/
async _extractAndLearnCustomEmojis(entities) {
if (!entities || !this.pes.store) return;
const customEmojiIds = entities
.filter(e => e.type === 'custom_emoji' && e.custom_emoji_id)
.map(e => e.custom_emoji_id);
if (customEmojiIds.length === 0) return;
// Deduplicate
const uniqueIds = [...new Set(customEmojiIds)];
try {
// Resolve custom emoji IDs to sticker objects via Telegram API
const stickers = await this._call('getCustomEmojiStickers', { custom_emoji_ids: uniqueIds });
if (!stickers || stickers.length === 0) return;
for (const sticker of stickers) {
const existing = this.pes.store.getSticker(sticker.file_unique_id);
if (existing) {
// Reinforce — seen again
this.pes.store.learnSticker(sticker.file_id, sticker.file_unique_id, { owner_sent: 1 });
// Update custom_emoji_id if we have it
if (sticker.custom_emoji_id) {
this.pes.store.updateStickerCustomEmojiId(sticker.file_unique_id, sticker.custom_emoji_id);
}
} else {
// New custom emoji — save and queue for analysis
this.pes.store.learnSticker(sticker.file_id, sticker.file_unique_id, {
set_name: sticker.set_name || null,
emoji: sticker.emoji || null,
owner_sent: 1,
custom_emoji_id: sticker.custom_emoji_id || null,
});
console.log(`🎭 Learned custom emoji: ${sticker.emoji || '?'} (id: ${sticker.custom_emoji_id || '?'}) from set "${sticker.set_name}" (message entity)`);
// Auto-load the custom emoji set if new
if (sticker.set_name) this._autoLoadStickerSet(sticker.set_name);
}
}
} catch (err) {
console.log(`⚠️ Could not resolve custom emojis: ${err.message}`);
}
}
/**
* Use LLM to describe what new emojis depict and map to emotions.
* Runs async, non-blocking.
*/
async _analyzeNewEmojis() {
if (!this._llm || this._analyzingEmojis || this._emojiAnalysisQueue.length === 0) return;
this._analyzingEmojis = true;
try {
// Process batch of up to 20 emojis at once
const batch = this._emojiAnalysisQueue.splice(0, 20);
const emojiList = batch.join(' ');
const prompt = `Для каждого эмодзи напиши JSON-массив. Каждый элемент: {"emoji":"<эмодзи>","desc":"<что изображено, 3-5 слов>","emotion":"<happy|sad|excited|scared|angry|love|playful|confused|sleeping|food|celebration|alert|curious|relaxed|brave>","tags":["<когда использовать>"]}.
Эмодзи: ${emojiList}
ВАЖНО: Ответь ТОЛЬКО JSON-массивом, без маркдауна, без пояснений.`;
const result = await this._llm.generate(prompt, [
{ role: 'user', content: 'Проанализируй эмодзи.' }
]);
if (result) {
try {
// Try to parse JSON from response (strip markdown if present)
const clean = result.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
const parsed = JSON.parse(clean);
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item.emoji && this.pes.store) {
this.pes.store.updateEmojiMeta(item.emoji, {
description: item.desc || null,
emotion_key: item.emotion || null,
context_tags: item.tags || null,
});
}
}
console.log(`🎨 Analyzed ${parsed.length} new emojis`);
}
} catch (parseErr) {
console.error('⚠️ Emoji analysis parse error:', parseErr.message);
}
}
} catch (err) {
console.error('⚠️ Emoji analysis failed:', err.message);
} finally {
this._analyzingEmojis = false;
// If more emojis queued during analysis, process them
if (this._emojiAnalysisQueue.length > 0) {
this._analyzeNewEmojis();
}
}
}
/**
* Get learned emojis formatted for LLM prompt injection.
* Returns string describing available emojis and when to use them.
*/
/**
* Strip human words from LLM output, keep only sounds + emoji.
* If LLM ignored the "no words" rule, this filter catches it.
*/
_stripHumanWords(text) {
// Remove *action descriptions*
let cleaned = text.replace(/\*[^*]+\*/g, '');
// Allowed sound-words (case insensitive) — base set + babble engine atoms
const ALLOWED_SOUNDS = new Set([
'гав', 'тяф', 'ваф', 'мням', 'ррр', 'рррр', 'ууу', 'уууу', 'ыыы', 'ыыыы',
'хрум', 'скууу', 'скуу', 'вуф', 'аууу', 'ауу', 'хнн', 'хн', 'мрр', 'мррр',
'аф', 'хм', 'хмм', 'скс', 'руу', 'рру', 'яп', 'пфф', 'хааа', 'гяв',
'рр', 'уу', 'ыы', 'мр', 'хх', 'фф', 'вв', 'гг', 'хмфф',
'гавв', 'гаввв', 'тяфф', 'ваффф', 'ррруу', 'рруу', 'ууууу',
'ыыыыы', 'скууу~', 'вввуууф', 'руу-руу',
'гав-гав', 'тяф-тяф', 'ваф-ваф', 'мням-мням',
// babble engine additional atoms
'га', 'ру', 'ма', 'ха', 'фу', 'ва', 'та', 'ку', 'ну', 'бу',
'гу', 'му', 'ху', 'ту', 'су', 'ду', 'лу', 'шу', 'жу',
'мрр-гав', 'тяф-ваф', 'рру-тяф', 'ыыы-ваф', 'аф-хм',
]);
// Split into tokens, keep only sounds, emoji, and punctuation
const tokens = cleaned.split(/\s+/);
const kept = [];
for (const token of tokens) {
// Pure emoji or symbols (no cyrillic/latin letters that form words)
const stripped = token.replace(/[!.,~\-?…]+$/g, '').toLowerCase();
// Check if it's an allowed sound
if (ALLOWED_SOUNDS.has(stripped)) {
kept.push(token);
continue;
}
// Pure emoji (no letters)
if (/^[\p{Emoji}\p{Emoji_Component}\s♡◈∿≋⟡⊹⟁∞◌⊕⑂]+$/u.test(token)) {
kept.push(token);
continue;
}
// Repeated consonants/vowels (raw babble): "ррр", "ууу", "хххх"
if (/^[а-яё]{1,8}$/i.test(stripped) && /^(.)\1*$/.test(stripped)) {
kept.push(token);
continue;
}
// Sound-like: consonant+vowel combos up to 10 chars — babble engine produces these
if (/^[а-яё~\-]{1,10}$/i.test(stripped)) {
// Allow up to 4 unique chars (babble atoms use 2-3 letter combos)
const unique = new Set(stripped.replace(/[-~]/g, '').split(''));
if (unique.size <= 4) {
kept.push(token);
continue;
}
}
// Compound sounds with dash: "рру-тяф", "гав-мням"
if (/^[а-яё]+-[а-яё]+$/i.test(stripped) && stripped.length <= 12) {
const parts = stripped.split('-');
const allShort = parts.every(p => p.length <= 5 && new Set(p.split('')).size <= 3);
if (allShort) {
kept.push(token);
continue;
}
}
// CAPS version of allowed sounds
if (ALLOWED_SOUNDS.has(stripped.toLowerCase())) {
kept.push(token);
continue;
}
// Everything else is a human word — DROP IT
}
return kept.join(' ').trim();
}
/**
* Update babble engine with current PES traits and owner's top emoji.
*/
_updateBabbleContext() {
// Update traits
if (this.pes?.emotions?.traits) {
this._babble.traits = this.pes.emotions.traits;
}
// Update owner's top emoji from store
if (this.pes.store) {
try {
const emojis = this.pes.store.getLearnedEmojis({ limit: 10, owner_only: true });
this._babble.updateOwnerEmoji(emojis.map(e => e.emoji));
} catch (_) {}
}
}
/**
* Persist babble engine state (affinity, combos, drift).
*/
_saveBabbleState() {
if (this.pes?.store?.saveBabbleState) {
try {
this.pes.store.saveBabbleState(this._babble.save());
} catch (_) {}
}
}
_getLearnedEmojisForPrompt() {
if (!this.pes.store) return '';
const emojis = this.pes.store.getLearnedEmojis({ limit: 30, owner_only: false });
if (emojis.length === 0) return '';
const lines = emojis
.filter(e => e.description || e.emotion_key)
.map(e => {
const parts = [e.emoji];
if (e.description) parts.push(`= ${e.description}`);
if (e.emotion_key) parts.push(`(${e.emotion_key})`);
if (e.context_tags) {
try { parts.push(`${JSON.parse(e.context_tags).join(', ')}`); } catch {}
}
if (e.owner_sent) parts.push('[хозяин отправлял]');
return parts.join(' ');
});
if (lines.length === 0) return '';
return `\n## Выученные эмодзи (используй их в ответах!)
${lines.join('\n')}
Предпочитай эмодзи которые хозяин отправлял и которые он одобрил. Новые эмодзи пробуй сразу — если хозяин одобрит, используй чаще.`;
}
/**
* Get learned stickers formatted for LLM prompt injection.
* Level affects how much sticker knowledge is shown.
*/
_getLearnedStickersForPrompt() {
if (!this.pes.store) return '';
const stickers = this.pes.store.getLearnedStickers({ limit: 30, analyzed_only: true });
if (stickers.length === 0) return '';
// Level gate removed — show all stickers
const level = this.pes.level || 0;
const maxStickers = 20;
const shown = stickers.slice(0, maxStickers);
const lines = shown.map(s => {
const parts = [];
if (s.emoji) parts.push(s.emoji);
if (s.description) parts.push(`${s.description}`);
if (s.emotion_key) parts.push(`(${s.emotion_key})`);
if (s.context_tags) {
try { parts.push(`${JSON.parse(s.context_tags).join(', ')}`); } catch {}
}
// Show freshness and preference
const now = Date.now();
const lastSeen = s.last_seen ? new Date(s.last_seen).getTime() : 0;
const hoursSince = (now - lastSeen) / (3600 * 1000);
if (hoursSince < 1) parts.push('[НОВЫЙ! только что получил]');
else parts.push(`[видел ${s.times_seen}x]`);
if (s.preference_score > 0.5) parts.push('[хозяин одобряет!]');
if (s.preference_score < -0.5) parts.push('[хозяину не нравится]');
return parts.join(' ');
});
// Level gate removed — full sticker knowledge
const usage = 'Ты хорошо знаешь стикеры хозяина и активно реагируешь на них. Предпочитай стикеры которые хозяин одобрял.';
return `\n## Выученные стикеры хозяина
${usage}
${lines.join('\n')}`;
}
/**
* Get reaction preferences for LLM prompt context.
* Shows what owner liked/disliked so PES can adapt.
*/
_getReactionPreferencesForPrompt() {
if (!this.pes.store) return '';
try {
// Get recent reactions from DB
const reactions = this.pes.store.db.prepare(
`SELECT pes_action, reaction_type, reaction_value, weight
FROM reaction_memory
ORDER BY timestamp DESC LIMIT 20`
).all();
if (reactions.length === 0) return '';
// Get learned preferences
const preferences = this.pes.store.db.prepare(
`SELECT pattern, behavior, confidence
FROM learned_preferences
WHERE confidence > 0.2
ORDER BY confidence DESC LIMIT 5`
).all();
let ctx = '\n\n## Обратная связь от хозяина';
// Summarize recent reactions
const positive = reactions.filter(r => r.weight > 0).length;
const negative = reactions.filter(r => r.weight < 0).length;
if (positive > 0 || negative > 0) {
ctx += `\nПоследние реакции: ${positive} положительных, ${negative} отрицательных.`;
}
// Show preferences
if (preferences.length > 0) {
const prefLines = preferences.map(p => {
const beh = {
prefer: 'хозяину НРАВИТСЯ',
increase: 'хозяину скорее нравится',
neutral: 'нейтрально',
decrease: 'хозяину скорее не нравится',
avoid: 'хозяину НЕ нравится',
never: 'НИКОГДА так не делай',
}[p.behavior] || 'нейтрально';
return `- ${p.pattern}: ${beh} (уверенность ${Math.round(p.confidence * 100)}%)`;
});
ctx += `\nЧто ты выучил:\n${prefLines.join('\n')}`;
}
// Show last few positive reactions for reinforcement
const lastPositive = reactions.filter(r => r.weight > 0).slice(0, 3);
if (lastPositive.length > 0) {
ctx += '\nХозяин недавно одобрил: ' + lastPositive.map(r => r.reaction_value || r.reaction_type).join(', ');
}
return ctx;
} catch (err) {
return '';
}
}
/**
* Handle sticker from owner.
* PES learns the sticker (what's depicted), auto-learns the pack,
* and queues a "mirror" sticker to send back 1-2 messages later.
*/
async _handleStickerMessage(msg) {
// Track owner activity for idle behavior
this._lastOwnerMessageTime = Date.now();
// ── Imprint: стикер от хозяина = положительная реакция на предыдущий звук ──
this._babble.imprint(this._lastPesReply, 0.3);
this._saveBabbleState();
const sticker = msg.sticker;
const emoji = sticker.emoji || '😊';
const setName = sticker.set_name;
const fileId = sticker.file_id;
const fileUniqueId = sticker.file_unique_id;
// Learn the sticker's emoji
this._extractAndLearnEmojis(emoji, true);
// ── Save sticker to DB and queue for LLM analysis ──
if (this.pes.store && fileUniqueId) {
const existing = this.pes.store.getSticker(fileUniqueId);
if (existing) {
this.pes.store.learnSticker(fileId, fileUniqueId, { set_name: setName, emoji });
} else {
this.pes.store.learnSticker(fileId, fileUniqueId, { set_name: setName, emoji });
// Queue for LLM analysis — figure out what's depicted
this._stickerAnalysisQueue.push({ fileId, fileUniqueId, setName, emoji });
if (!this._analyzingStickers) {
this._analyzeNewStickers().catch(err =>
console.error('⚠️ Auto sticker analysis failed:', err.message)
);
}
}
}
// Auto-learn sticker pack from owner
if (setName && !this._learnedPacks.has(setName)) {
try {
await this.loadStickerPack(setName, null, true); // additive
this._learnedPacks.add(setName);
this._saveStickerPackToDB(setName); // persist to DB
this.emit('stickerPackLoaded', { pack: setName, count: '(auto-learned)' });
} catch (_) {
// Pack not accessible, ignore
}
}
// ── Observe sticker interaction patterns ──
this._observeStickerPattern(emoji, setName);
// ── Queue "mirror" sticker — try it back after 1-2 messages (delayed, like thinking how to use it) ──
this._pendingMirrorSticker = {
fileId,
fileUniqueId,
emoji,
receivedAt: Date.now(),
messagesUntilMirror: 1 + Math.floor(Math.random() * 2), // 1-2 messages later
};
this._messagesSinceMirror = 0;
// ── React to sticker with emoji ──
this._reactToOwnerMessage(msg, 'play').catch(() => {});
// ── Generate a sound reply (not just a sticker) ──
await this._sendChatAction(this.chatId);
await sleep(thinkingDelay(this.pes.level));
const stickerDesc = this.pes.store?.getSticker(fileUniqueId)?.description || null;
const contextHint = stickerDesc
? `Хозяин отправил стикер: ${stickerDesc} (эмодзи: ${emoji})`
: `Хозяин отправил стикер с эмодзи: ${emoji}`;
const reply = await this._generateReply(contextHint, null);
await this._sendText(this.chatId, reply);
this._addToMemory('owner', `[стикер: ${emoji}]`);
this._addToMemory('pes', reply);
// Show sticker origin if this sticker is in our collection
if (this.pes.store && fileUniqueId) {
const origin = this.pes.store.getStickerOrigin(fileUniqueId);
if (origin && origin.original_set_title) {
await this._sendText(this.chatId, `📋 из «${origin.original_set_title}» ${origin.original_emoji || ''}`);
}
}
this.pes.event({
type: 'message',
from: 'owner',
content: 'sticker',
sticker_emoji: emoji,
sentiment: this._classifyReaction(emoji).includes('positive') ? 'positive' : 'neutral',
});
// XP for sticker interaction + diversity tracking
await this._grantXp('sticker', 2);
if (this._trackDiversity('sticker')) {
await this._grantXp('diversity', 5);
}
this._updateRelationship('sticker');
// React with a contextual sticker from same emotion
const emotionKey = this._emojiToEmotionKey(emoji);
if (emotionKey) {
await this._sendContextualSticker(emotionKey);
}
}
/**
* Check if there's a pending mirror sticker to send back.
* Called from _handleTextMessage after each owner message.
* The dog "thinks about" the sticker for 1-2 messages, then tries to use it.
*/
async _tryMirrorSticker() {
if (!this._pendingMirrorSticker) return;
this._messagesSinceMirror++;
if (this._messagesSinceMirror < this._pendingMirrorSticker.messagesUntilMirror) return;
const mirror = this._pendingMirrorSticker;
this._pendingMirrorSticker = null;
// Only mirror if it's still fresh (< 10 min)
if (Date.now() - mirror.receivedAt > 10 * 60 * 1000) return;
// Send the same sticker back — puppy copying behavior
try {
await this._sendSticker(this.chatId, mirror.fileId);
this._lastSentStickerUniqueId = mirror.fileUniqueId;
if (this.pes.store) this.pes.store.markStickerUsed(mirror.fileUniqueId);
console.log(`🐾 Mirror sticker sent: ${mirror.emoji} (delayed ${this._messagesSinceMirror} msgs)`);
} catch (_) {
// Sticker send failed — ignore
}
}
/**
* Handle translation request.
* Level-based: puppy can't, young makes mistakes + corrections, adult is accurate.
* Dog "brings" the translation like a scroll.
* @param {string} text — text to translate
* @param {string} targetLang — target language name (e.g. "английский")
*/
async _handleTranslation(text, targetLang) {
const level = this.pes.level || 0;
const phase = level >= 60 ? 'experienced' : level >= 40 ? 'adult' : level >= 20 ? 'young' : 'puppy';
// Level gate removed — testing all features
// Language mapping
const langMap = {
'английский': 'English', 'english': 'English', 'англ': 'English',
'испанский': 'Spanish', 'spanish': 'Spanish', 'исп': 'Spanish',
'немецкий': 'German', 'german': 'German', 'нем': 'German',
'французский': 'French', 'french': 'French', 'фр': 'French',
'китайский': 'Chinese', 'chinese': 'Chinese', 'кит': 'Chinese',
'японский': 'Japanese', 'japanese': 'Japanese', 'яп': 'Japanese',
'корейский': 'Korean', 'korean': 'Korean', 'кор': 'Korean',
'арабский': 'Arabic', 'arabic': 'Arabic', 'араб': 'Arabic',
'итальянский': 'Italian', 'italian': 'Italian', 'итал': 'Italian',
'португальский': 'Portuguese', 'portuguese': 'Portuguese', 'порт': 'Portuguese',
'турецкий': 'Turkish', 'turkish': 'Turkish', 'тур': 'Turkish',
'русский': 'Russian', 'russian': 'Russian', 'рус': 'Russian',
};
const lang = langMap[targetLang.toLowerCase()] || 'English';
// Young level: only RU↔EN
if (phase === 'young' && lang !== 'English' && lang !== 'Russian') {
await this._sendText(this.chatId, pickRandom([
`рру?.. хнн... 🐾 ...тяф... ❓`,
`ууу... ваф?.. 🐾 ...скууу...`,
]));
// Still try EN as fallback
}
if (!this._llm) {
await this._sendText(this.chatId, `ууу... хнн... 🐾💧`);
return;
}
// "Running to fetch the scroll"
await this._sendChatAction(this.chatId);
const traits = this.pes.store ? this.pes.store.getStats() : null;
const drama = traits?.drama || 0.5;
const stubbornness = traits?.stubbornness || 0.5;
const curiosity = traits?.curiosity || 0.5;
// Dramatic dogs take longer
const runTime = 2000 + (drama > 0.7 ? 2000 : 1000) + Math.random() * 1000;
await sleep(runTime);
// Build translation prompt based on level
let translationPrompt;
// Level gate removed — all translations are accurate
{
translationPrompt = `Translate this text to ${lang}. You are an experienced translator dog.
RULES:
1. Provide accurate translation
2. Provide 1-2 alternative variants
3. Format:
MAIN: [main translation]
ALT1: [alternative 1]
${phase === 'experienced' ? 'ALT2: [alternative 2 — more colloquial/idiomatic]' : ''}
Text: "${text}"
ONLY output the lines above, nothing else.`;
}
try {
const result = await this._llm.generate(translationPrompt, [
{ role: 'user', content: text }
]);
if (!result) {
await this._sendText(this.chatId, `ууу... хнн... 🐾💧`);
return;
}
// Dog arrives with the scroll
await this._sendContextualSticker('excited');
// Arrival sounds — influenced by traits
let arrivalSound;
if (drama > 0.7) {
arrivalSound = `РРРРРУУУУУ!! ВАФ ВАФ ВАФ!! 📜✨✨ тяф тяф!! ♡♡`;
} else if (stubbornness > 0.7 && phase === 'young') {
arrivalSound = `хмфф... рру... 📜 ...тяф.`;
} else if (curiosity > 0.7) {
arrivalSound = `ррРРуу!! тяф! 📜🐾 ваф!!`;
} else {
arrivalSound = pickRandom([
`ррРРуу!! тяф! 📜🐾`,
`ГАВВ!! рру!! 📜 ваф!!`,
`тяф тяф!! 📜✨ рру!!`,
]);
}
await this._sendText(this.chatId, arrivalSound);
await sleep(500);
// Parse and format translation
const lines = result.split('\n').filter(l => l.trim());
let output = `═══════════════\n`;
if (phase === 'young') {
// Show mistake → correction → alternative (learning together)
for (const line of lines) {
if (line.startsWith('ATTEMPT:')) {
output += `${line.replace('ATTEMPT:', '').trim()}\n`;
} else if (line.startsWith('CORRECT:')) {
output += `${line.replace('CORRECT:', '').trim()}\n`;
} else if (line.startsWith('ALT:')) {
output += `💡 ${line.replace('ALT:', '').trim()}\n`;
} else {
output += `📜 ${line.trim()}\n`;
}
}
} else {
// Clean translation
for (const line of lines) {
if (line.startsWith('MAIN:')) {
output += `📜 ${line.replace('MAIN:', '').trim()}\n`;
} else if (line.startsWith('ALT1:') || line.startsWith('ALT2:')) {
output += `💡 ${line.replace(/ALT\d:/, '').trim()}\n`;
} else {
output += `📜 ${line.trim()}\n`;
}
}
}
output += `═══════════════`;
await this._sendText(this.chatId, output);
// Curiosity bonus: sometimes bring 2 languages
if (curiosity > 0.7 && phase !== 'young' && Math.random() < 0.2) {
const bonusLang = lang === 'English' ? 'Spanish' : 'English';
try {
const bonus = await this._llm.generate(
`Translate to ${bonusLang}: "${text}"\nONLY the translation, nothing else.`,
[{ role: 'user', content: text }]
);
if (bonus) {
await sleep(1000);
await this._sendText(this.chatId, `тяф! 🎁📜 ${bonus.trim()}`);
}
} catch (_) {}
}
// Track
this._lastBotAction = { action: 'translate', text: `translate: ${text}${lang}`, category: 'translate', timestamp: Date.now() };
this._addToMemory('owner', `[переведи на ${targetLang}: ${text}]`);
this._addToMemory('pes', `[принёс перевод на ${lang}]`);
await this._grantXp('command', 2);
} catch (err) {
console.error('⚠️ Translation failed:', err.message);
await this._sendText(this.chatId, `ууу... хнн... рру?.. 📜💧`);
}
}
/**
* Handle fetch command ("принеси мячик", "fetch ball", etc.)
* On puppy level: decorative (sticker + sound). Success rate grows with level.
* @param {string} target — what to fetch
*/
async _handleFetch(target) {
// "Running to fetch" — typing with suspense pause
await this._sendChatAction(this.chatId);
await sleep(2000 + Math.random() * 2000); // 2-4 sec "running"
// Level gate removed — testing all features
const level = this.pes.level || 0;
const successChance = 0.95;
const success = Math.random() < successChance;
if (success) {
const sounds = [
`ГАВВВ!! ваф-ваф!! 🎾 рррРРуу!! ♡`,
`тяф тяф ТЯФФ!! ыыыЫЫЫ!! 🎾 ваф!!`,
`УУУУУ!! гав гав!! рррр!! 🎾🎾 ♡♡`,
`ЫЫЫЫ!! ваф ваф ВАФ!! 🎾 рруу!! ♡♡`,
];
await this._sendText(this.chatId, pickRandom(sounds));
await this._sendContextualSticker('praise');
} else {
const sounds = [
`рру?... ...ууу... 🐾 ...тяф?`,
`ыыы... гав?.. ...скууу... 💧`,
`...ррр... ...ууу?.. тяф... 🐾`,
`скууу... ...рру?... ... 💧`,
];
await this._sendText(this.chatId, pickRandom(sounds));
await this._sendContextualSticker('status');
}
// Log to DB
if (this.pes.store) {
this.pes.store.logFetch(
'anything',
JSON.stringify({ target, success, level }),
success ? 2 : 0
);
}
// Track last bot action
this._lastBotAction = {
action: 'fetch',
text: `fetch: ${target} (${success ? 'success' : 'fail'})`,
category: 'fetch',
timestamp: Date.now(),
};
// XP
await this._grantXp('command', 2);
this._updateRelationship('message');
// Memory
this._addToMemory('owner', `[принеси: ${target}]`);
this._addToMemory('pes', `[${success ? 'принёс' : 'не нашёл'}: ${target}]`);
// PES event
this.pes.command('fetch', { target });
}
// ═══════════════════════════════════════════════════════════
// FILE HANDLING — download, store, analyze, search
// ═══════════════════════════════════════════════════════════
/**
* Handle photo/document/video/voice/audio from owner.
* Downloads to disk, saves metadata in DB, queues LLM analysis.
*/
async _handleMediaMessage(msg) {
this._lastOwnerMessageTime = Date.now();
// ── Check for sticker creation intent in caption ──
const mediaCaption = msg.caption || '';
const isMediaWithCaption = msg.photo || msg.animation || msg.video ||
(msg.document && (msg.document.mime_type?.startsWith('image/') || msg.document.mime_type?.startsWith('video/')));
if (isMediaWithCaption && /стикер|sticker|пак|pack|сделай.*стикер|создай.*пак|гифк|gif|эмодзи|emoji/i.test(mediaCaption)) {
const captionCreateType = /эмодзи|emoji/i.test(mediaCaption) ? 'custom_emoji' : 'regular';
console.log(`🎨 Sticker creation intent detected in media caption (type=${captionCreateType})`);
await this._handleStickerCreation(msg, captionCreateType);
this._updateRelationship('message');
return;
}
// Determine file type and extract file info
let fileId, fileUniqueId, fileSize, fileName, mimeType, fileType;
if (msg.photo) {
// Photo — take largest version
const photo = msg.photo[msg.photo.length - 1];
fileId = photo.file_id;
fileUniqueId = photo.file_unique_id;
fileSize = photo.file_size || 0;
fileName = `photo_${Date.now()}.jpg`;
mimeType = 'image/jpeg';
fileType = 'photo';
} else if (msg.document) {
fileId = msg.document.file_id;
fileUniqueId = msg.document.file_unique_id;
fileSize = msg.document.file_size || 0;
fileName = msg.document.file_name || `doc_${Date.now()}`;
mimeType = msg.document.mime_type || 'application/octet-stream';
fileType = 'document';
} else if (msg.video) {
fileId = msg.video.file_id;
fileUniqueId = msg.video.file_unique_id;
fileSize = msg.video.file_size || 0;
fileName = msg.video.file_name || `video_${Date.now()}.mp4`;
mimeType = msg.video.mime_type || 'video/mp4';
fileType = 'video';
} else if (msg.voice) {
fileId = msg.voice.file_id;
fileUniqueId = msg.voice.file_unique_id;
fileSize = msg.voice.file_size || 0;
fileName = `voice_${Date.now()}.ogg`;
mimeType = msg.voice.mime_type || 'audio/ogg';
fileType = 'voice';
} else if (msg.audio) {
fileId = msg.audio.file_id;
fileUniqueId = msg.audio.file_unique_id;
fileSize = msg.audio.file_size || 0;
fileName = msg.audio.file_name || `audio_${Date.now()}.mp3`;
mimeType = msg.audio.mime_type || 'audio/mpeg';
fileType = 'audio';
} else if (msg.video_note) {
fileId = msg.video_note.file_id;
fileUniqueId = msg.video_note.file_unique_id;
fileSize = msg.video_note.file_size || 0;
fileName = `videonote_${Date.now()}.mp4`;
mimeType = 'video/mp4';
fileType = 'video_note';
} else {
return; // Unknown media type
}
// Size check (Telegram Bot API limit: 20MB for downloads)
const sizeMB = (fileSize || 0) / (1024 * 1024);
if (sizeMB > this._maxFileSizeMB) {
await this._sendText(this.chatId, pickRandom([
`ууу... рру?.. 📦💧 ...слишком большой...`,
`хнн... ваф?.. 📦 ...не могу поднять... 💧`,
]));
return;
}
// Disk quota check
const diskUsage = this._getDiskUsageMB();
if (diskUsage > this._maxDiskQuotaMB) {
await this._sendText(this.chatId, pickRandom([
`ууу... рру?.. 📦💧 хнн...`,
`хнн... скууу... 📦 ...ууу... 💧`,
]));
return;
}
const ownerId = this.pes.store ? (this.pes.store.getConfig('owner_id') || String(msg.from.id)) : String(msg.from.id);
const caption = msg.caption || '';
// React to media with emoji
this._reactToOwnerMessage(msg, null).catch(() => {});
// Acknowledge receipt immediately
await this._sendChatAction(this.chatId, 'upload_document');
await this._sendText(this.chatId, pickRandom([
`ррФФ!! 📂🐾 тяф!`,
`ваф! 📂 рру~~ 🐾`,
`гав! 📂✨ ррФ!`,
`тяф тяф! 📂 рру~! ♡`,
]));
// Download file from Telegram
let localPath = null;
let fileHash = null;
try {
const fileInfo = await this._getFilePath(fileId);
const buffer = await this._downloadTelegramFile(fileInfo.file_path);
// Compute SHA-256 hash
fileHash = createHash('sha256').update(buffer).digest('hex');
// Save to disk
const typeDir = join(this._filesDir, fileType);
if (!existsSync(typeDir)) mkdirSync(typeDir, { recursive: true });
// Sanitize filename
const safeName = fileName.replace(/[^a-zA-Z0-9._\-а-яА-ЯёЁ]/g, '_');
const uniqueName = `${Date.now()}_${safeName}`;
localPath = join(typeDir, uniqueName);
writeFileSync(localPath, buffer);
} catch (err) {
this.emit('error', err);
// Continue with metadata only — file_id still works for Telegram
}
// Save to DB
if (this.pes.store) {
// Extract tags from caption via simple parsing
let tags = [];
let description = caption || null;
if (caption) {
// Use caption as initial description + extract keywords as tags
tags = caption
.replace(/[^\wа-яА-ЯёЁ\s]/g, '')
.split(/\s+/)
.filter(w => w.length > 2)
.slice(0, 10);
}
const saved = this.pes.store.saveFile({
owner_id: ownerId,
telegram_file_id: fileId,
telegram_file_unique_id: fileUniqueId,
file_type: fileType,
file_name: fileName,
file_size: fileSize,
mime_type: mimeType,
local_path: localPath,
file_hash: fileHash,
description,
tags,
category: this._guessCategory(fileName, mimeType, caption),
});
// Queue for LLM analysis (background)
if (saved && saved.id) {
this._fileAnalysisQueue.push({
id: saved.id,
fileName,
mimeType,
fileType,
caption,
fileSize,
});
// Analyze in background (don't await)
this._analyzeNewFiles().catch(err =>
console.error('⚠️ Auto file analysis failed:', err.message)
);
}
}
// Learn emojis from caption
if (caption) {
this._extractAndLearnEmojis(caption, true);
}
// Learn custom emoji from caption entities
if (msg.caption_entities) {
this._extractAndLearnCustomEmojis(msg.caption_entities);
}
// Smart XP + relationship (files with description get bonus)
await this._processSmartXP(msg, 'file');
this._updateRelationship('message');
// Memory
this._addToMemory('owner', `[отправил ${fileType}: ${fileName}${caption ? ' — ' + caption : ''}]`);
this._addToMemory('pes', `[сохранил файл: ${fileName}]`);
// PES event
this.pes.event({ type: 'message', from: 'owner', content: 'media', sentiment: 'positive' });
}
/**
* Guess file category from filename, mime type, and caption.
*/
_guessCategory(fileName, mimeType, caption) {
const text = `${fileName} ${mimeType} ${caption}`.toLowerCase();
if (/паспорт|passport|удостоверение|свидетельств|инн|снилс/.test(text)) return 'документы';
if (/договор|контракт|contract|соглашение/.test(text)) return 'документы';
if (/чек|receipt|квитанц|invoice|счёт|счет/.test(text)) return 'финансы';
if (/рецепт|recipe|лекарств|медицин|health/.test(text)) return 'медицина';
if (/image\/|photo|фото|jpg|jpeg|png/.test(text)) return 'фото';
if (/video\/|видео|mp4/.test(text)) return 'видео';
if (/audio\/|voice|голос|ogg|mp3/.test(text)) return 'аудио';
if (/pdf|doc|docx|txt|xls|xlsx/.test(text)) return 'документы';
return 'прочее';
}
/**
* Guess note category from text.
*/
_guessNoteCategory(text) {
const t = text.toLowerCase();
if (/встреча|созвон|митинг|собрание|дедлайн/.test(t)) return 'события';
if (/купить|список|молоко|хлеб|магазин/.test(t)) return 'списки';
if (/идея|придумал|концепт|проект/.test(t)) return 'идеи';
if (/телефон|номер|контакт|почта|email/.test(t)) return 'контакты';
if (/работа|задача|проект|клиент|заказ/.test(t)) return 'работа';
if (/пароль|логин|ключ|код|пин/.test(t)) return 'важное';
return 'прочее';
}
/**
* Get disk usage of files directory in MB.
*/
_getDiskUsageMB() {
if (!existsSync(this._filesDir)) return 0;
let total = 0;
const walk = (dir) => {
try {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
try {
const s = statSync(p);
if (s.isDirectory()) walk(p);
else total += s.size;
} catch (_) {}
}
} catch (_) {}
};
walk(this._filesDir);
return total / (1024 * 1024);
}
/**
* Analyze queued files via LLM — describe contents, assign tags/category.
*/
async _analyzeNewFiles() {
if (!this._llm || this._analyzingFiles || this._fileAnalysisQueue.length === 0) return;
this._analyzingFiles = true;
try {
const batch = this._fileAnalysisQueue.splice(0, 5);
const fileList = batch.map((f, i) =>
`${i + 1}. file: "${f.fileName}", type: ${f.fileType}, mime: ${f.mimeType}, size: ${f.fileSize}B${f.caption ? `, caption: "${f.caption}"` : ''}`
).join('\n');
const prompt = `Ты анализируешь файлы, отправленные хозяином собаке-боту для хранения. Для каждого файла определи:
1. Краткое описание на русском (10-20 слов) — что это может быть
2. Категория: документы, фото, видео, аудио, финансы, медицина, работа, учёба, личное, прочее
3. Теги (3-5 штук) для поиска
Файлы:
${fileList}
Верни JSON массив:
[{"idx":1, "desc":"фотография документа, возможно паспорт", "category":"документы", "tags":["паспорт","документ","удостоверение"]}]
ТОЛЬКО JSON, без markdown.`;
const result = await this._llm.generate(prompt, [
{ role: 'user', content: 'Проанализируй файлы.' }
]);
if (result && this.pes.store) {
try {
const cleaned = result.replace(/```json\s*/g, '').replace(/```/g, '').trim();
const parsed = JSON.parse(cleaned);
for (const item of parsed) {
const idx = (item.idx || item.index || 1) - 1;
if (idx >= 0 && idx < batch.length) {
this.pes.store.updateFileMeta(batch[idx].id, {
description: item.desc || item.description,
tags: item.tags || [],
category: item.category,
});
}
}
} catch (_) {
// JSON parse error — skip
}
}
} catch (err) {
this.emit('error', err);
} finally {
this._analyzingFiles = false;
// Process remaining queue
if (this._fileAnalysisQueue.length > 0) {
setTimeout(() => this._analyzeNewFiles().catch(err =>
console.error('⚠️ Auto file analysis retry failed:', err.message)
), 2000);
}
}
}
/**
* Search for files by owner's query and send back.
*/
async _handleFileSearch(text) {
if (!this.pes.store) return;
// Extract search query from text
const query = text
.replace(/где мо[йяеи]\s*/i, '')
.replace(/найди (файл|фото|документ)\s*/i, '')
.replace(/принеси (файл|фото|документ)\s*/i, '')
.replace(/покажи (файл|фото|документ)\s*/i, '')
.replace(/где (файл|фото|документ)\s*/i, '')
.replace(/где\s*/i, '')
.trim();
if (!query) {
await this._sendText(this.chatId, pickRandom([
`рру?.. ...тяф?.. 🐾`,
`ваф?.. ...ууу?.. 🐾`,
]));
return;
}
// "Running to search" animation
await this._sendChatAction(this.chatId);
await sleep(1500 + Math.random() * 1500);
const results = this.pes.store.searchFiles(query, { limit: 5 });
if (results.length === 0) {
await this._sendText(this.chatId, pickRandom([
`скууу... ыыы... рру?.. 🐾💧`,
`рру?... ...ууу... хнн... 💧`,
`хнн... ...тяф?.. ...ууу... 💧`,
]));
this._addToMemory('owner', `[искал файл: ${query}]`);
this._addToMemory('pes', `[не нашёл файл: ${query}]`);
return;
}
// Send found files — dog sounds first, then delivers the file
await this._sendText(this.chatId, pickRandom([
`ГАВВ!! 📂✨ рру!! ррРР!! 🐾`,
`ваф ваф!! 📂 рррРР!! тяф!! ♡`,
`тяф тяф!! 📂✨ рру!! ♡♡`,
]));
for (const file of results) {
try {
if (file.file_type === 'photo') {
await this._sendPhoto(this.chatId, file.telegram_file_id, {
caption: file.description || file.file_name,
});
} else {
await this._sendDocument(this.chatId, file.telegram_file_id, {
caption: file.description || file.file_name,
});
}
} catch (err) {
// file_id may have expired — try local path
if (file.local_path && existsSync(file.local_path)) {
await this._sendText(this.chatId, `📂 ${file.file_name}`);
} else {
await this._sendText(this.chatId, `📂 ${file.file_name} 💧`);
}
}
}
// Memory
this._addToMemory('owner', `[искал файл: ${query}]`);
this._addToMemory('pes', `[нашёл ${results.length} файл(ов): ${query}]`);
}
/**
* /files command — list recent files, optionally filter by category.
*/
async _cmdFiles(msg, args) {
if (!this.pes.store) return;
let files;
if (args) {
// Search by category or query
files = this.pes.store.searchFiles(args, { limit: 10 });
} else {
files = this.pes.store.getRecentFiles({ limit: 10 });
}
if (files.length === 0) {
await this._sendText(this.chatId,
`📂 —${args ? ` "${args}"` : ''}\n\n` +
`💡 📷/📄 → 📂🐾`
);
return;
}
const lines = [`📂 **Мои файлы** ${args ? `(поиск: "${args}")` : '(последние 10)'}`, ''];
for (const f of files) {
const typeIcon = { photo: '🖼️', document: '📄', video: '🎬', voice: '🎤', audio: '🎵', video_note: '📹' }[f.file_type] || '📎';
const tags = f.tags ? JSON.parse(f.tags).slice(0, 3).join(', ') : '';
lines.push(`${typeIcon} ${f.file_name || 'файл'}`);
if (f.description) lines.push(` ${f.description}`);
if (tags) lines.push(` 🏷️ ${tags}`);
lines.push('');
}
const totalFiles = this.pes.store.db.prepare('SELECT COUNT(*) as cnt FROM owner_files WHERE is_deleted = 0').get();
const diskMB = this._getDiskUsageMB().toFixed(1);
lines.push(`📊 Всего: ${totalFiles.cnt} файлов | ${diskMB} МБ на диске`);
lines.push(`💡 "найди ..." → 📂🐾`);
await this._sendText(this.chatId, lines.join('\n'), { parseMode: 'Markdown' });
}
/**
* /notes command — list or add notes.
*/
async _cmdNotes(msg, args) {
if (!this.pes.store) return;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
if (!args) {
// Show recent notes
const notes = this.pes.store.getRecentNotes({ owner_id: ownerId, limit: 10 });
if (notes.length === 0) {
await this._sendText(this.chatId,
`📝 —\n\n` +
`💡 "запомни: ..." → 📝🐾`
);
return;
}
const lines = [`📝 **Мои заметки** (последние 10)`, ''];
for (const n of notes) {
const pin = n.pinned ? '📌 ' : '';
const tags = n.tags ? JSON.parse(n.tags).slice(0, 3).join(', ') : '';
lines.push(`${pin}${n.text.slice(0, 100)}`);
if (tags) lines.push(` 🏷️ ${tags}`);
}
const totalNotes = this.pes.store.db.prepare('SELECT COUNT(*) as cnt FROM owner_notes WHERE is_deleted = 0').get();
lines.push('');
lines.push(`📊 Всего: ${totalNotes.cnt} заметок`);
lines.push(`💡 "запомни: ..." → 📝 | "что помнишь про ...?" → 🔍`);
await this._sendText(this.chatId, lines.join('\n'), { parseMode: 'Markdown' });
return;
}
// Search notes
const notes = this.pes.store.searchNotes(args, { owner_id: ownerId, limit: 10 });
if (notes.length === 0) {
await this._sendText(this.chatId, `📝 — "${args}" 💧`);
return;
}
const lines = [`📝 **Заметки** (поиск: "${args}")`, ''];
for (const n of notes) {
const pin = n.pinned ? '📌 ' : '';
lines.push(`${pin}${n.text.slice(0, 150)}`);
}
await this._sendText(this.chatId, lines.join('\n'), { parseMode: 'Markdown' });
}
// ═══════════════════════════════════════════════════════════
// SHARING ARTIFACTS
// ═══════════════════════════════════════════════════════════
/**
* /share command — share notes, status card, or pet card to other chats.
* Usage:
* /share — show share menu with inline buttons
* /share status — generate shareable status card
* /share note текст — share specific note by search
* /share card — generate pet card for forwarding
*/
// ═══════════════════════════════════════════════════════════
// AGENTIC WORKFLOWS
// ═══════════════════════════════════════════════════════════
async _cmdWorkflow(msg, args) {
const stats = this.pes?.store?.getStats?.();
const currentLevel = stats?.level || 0;
if (!args) {
// List available workflows
const available = this._workflows.list(currentLevel);
if (available.length === 0) {
await this._sendText(this.chatId, '🔒 Workflows разблокируются на Level 8');
return;
}
const lines = available.map(w => {
const lastRun = w.lastRun ? `(${Math.round((Date.now() - w.lastRun) / 60000)}м назад)` : '';
const schedIcon = w.schedule !== 'manual' ? '⏰' : '🖐';
return `${schedIcon} /${w.id}${w.description} ${lastRun}`;
});
const keyboard = {
inline_keyboard: available.map(w => ([
{ text: `▶️ ${w.name}`, callback_data: `wf_run_${w.id}` },
])),
};
// Add history button
keyboard.inline_keyboard.push([
{ text: '📜 История', callback_data: 'wf_history' },
]);
await this._sendText(this.chatId,
`🤖 **Agentic Workflows** (L${Math.floor(currentLevel)})\n\n${lines.join('\n')}`,
{ parseMode: 'Markdown', reply_markup: JSON.stringify(keyboard) }
);
return;
}
// Run specific workflow
const workflowId = args.trim().toLowerCase().replace(/^\//, '');
// Check for history subcommand
if (workflowId === 'history' || workflowId === 'log') {
const history = this._workflows.getHistory(10);
if (!history.length) {
await this._sendText(this.chatId, '📜 Нет запусков');
return;
}
const lines = history.map(h => {
const icon = h.success ? '✅' : '❌';
const time = new Date(h.created_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' });
return `${icon} ${h.workflow_id}${h.duration_ms}ms — ${time}`;
});
await this._sendText(this.chatId, `📜 **Последние запуски:**\n\n${lines.join('\n')}`, { parseMode: 'Markdown' });
return;
}
// Show typing
try { await this._sendChatAction(this.chatId, 'typing'); } catch (_) {}
const result = await this._workflows.run(workflowId, currentLevel);
// Add PES babble reaction
const babbleReaction = result.ok
? this._babble?.generateBlended?.('happy', 0.7, { energy: stats?.energy || 0.5 }) || 'ВАФ!'
: this._babble?.generateBlended?.('alert', 0.5, { energy: stats?.energy || 0.5 }) || 'рру?..';
await this._sendText(this.chatId, `${result.text}\n\n${babbleReaction}`);
}
async _cmdShare(msg, args) {
if (!args) {
// Show share menu with buttons
const petName = this.pes?.identity?.name || 'ПЕС';
const keyboard = {
inline_keyboard: [
[
{ text: '📊 Статус', callback_data: 'share_status' },
{ text: '🐾 Карточка', callback_data: 'share_card' },
],
[
{ text: '📝 Последняя заметка', callback_data: 'share_last_note' },
{ text: '📋 Итоги дня', callback_data: 'share_summary' },
],
[
{ text: '📤 В другой чат (inline)', switch_inline_query: '' },
],
],
};
await this._sendText(this.chatId,
`📤 **Поделиться** — выбери что отправить\n\n` +
`Или перешли любое сообщение от ${petName} в другой чат!`,
{ parseMode: 'Markdown', reply_markup: JSON.stringify(keyboard) }
);
return;
}
const subCmd = args.split(/\s+/)[0].toLowerCase();
const subArgs = args.slice(subCmd.length).trim();
switch (subCmd) {
case 'status': return this._shareStatus();
case 'card': return this._sharePetCard();
case 'note': return this._shareNote(subArgs);
case 'summary': return this._shareSummary();
default:
// Try search note by text
return this._shareNote(args);
}
}
/** Generate a shareable status card. */
async _shareStatus() {
const s = this.pes;
const name = s.identity?.name || 'ПЕС';
const species = s.identity?.species || '???';
const level = s.level?.current || 0;
const xp = s.level?.xp || 0;
const mood = Math.round((s.state?.mood || 0) * 100);
const energy = Math.round((s.state?.energy || 0) * 100);
const emotion = s.state?.emotion || 'idle';
const emotionMap = {
happy: '😊', playful: '🎾', excited: '🎉', content: '😌',
curious: '🔍', alert: '⚡', hungry: '🍖', sad: '😢',
lonely: '💧', scared: '😰', angry: '😤', nap: '💤',
zoomies: '🏃', idle: '🐾',
};
const emojiEmotion = emotionMap[emotion] || '🐾';
const moodBar = this._buildBar(mood);
const energyBar = this._buildBar(energy);
const card = [
`┌─────────────────────┐`,
`│ 🐾 ${name}`,
`${species} · Level ${level} · ${xp} XP`,
``,
`${emojiEmotion} ${emotion}`,
`│ 💚 ${moodBar} ${mood}%`,
`│ ⚡ ${energyBar} ${energy}%`,
`└─────────────────────┘`,
``,
`🏠 t.me/${this.config.botUsername || 'HoleThor_Bot'}`,
].join('\n');
const keyboard = {
inline_keyboard: [[
{ text: '📤 Отправить в чат', switch_inline_query: `status` },
]],
};
await this._sendText(this.chatId, card, {
reply_markup: JSON.stringify(keyboard),
});
}
/** Build a simple text progress bar. */
_buildBar(percent) {
const filled = Math.round(percent / 10);
return '▓'.repeat(filled) + '░'.repeat(10 - filled);
}
/** Generate a shareable pet card (identity + traits). */
async _sharePetCard() {
const s = this.pes;
const name = s.identity?.name || 'ПЕС';
const species = s.identity?.species || '???';
const level = s.level?.current || 0;
const age = s.level?.ageDays || 0;
// Top traits
const traits = s.traits || {};
const traitList = Object.entries(traits)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([k, v]) => ` ${k}: ${'█'.repeat(Math.round(v * 10))} ${Math.round(v * 100)}%`)
.join('\n');
// Abilities count
const unlocks = s.unlocks || {};
const unlockedCount = Object.values(unlocks).filter(u => u.unlocked).length;
const card = [
`╔═══════════════════════╗`,
`║ 🐾 ${name}`,
`${species} · ${age} дней · Level ${level}`,
`╠═══════════════════════╣`,
`║ Характер:`,
traitList.split('\n').map(l => `${l}`).join('\n'),
``,
`║ 🔓 ${unlockedCount} способностей`,
`╚═══════════════════════╝`,
``,
`🏠 t.me/${this.config.botUsername || 'HoleThor_Bot'}`,
].join('\n');
const keyboard = {
inline_keyboard: [[
{ text: '📤 Отправить в чат', switch_inline_query: `card` },
]],
};
await this._sendText(this.chatId, card, {
reply_markup: JSON.stringify(keyboard),
});
}
/** Share a specific note (by search text). */
async _shareNote(searchText) {
if (!this.pes.store) return;
const ownerId = this.pes.store.getConfig('owner_id') || '';
let note;
if (!searchText) {
// Share last note
const notes = this.pes.store.getRecentNotes({ owner_id: ownerId, limit: 1 });
note = notes[0];
} else {
const notes = this.pes.store.searchNotes(searchText, { owner_id: ownerId, limit: 1 });
note = notes[0];
}
if (!note) {
await this._sendText(this.chatId, '📝 Заметка не найдена 💧');
return;
}
const petName = this.pes?.identity?.name || 'ПЕС';
const tags = note.tags ? JSON.parse(note.tags).join(', ') : '';
const date = new Date(note.created_at).toLocaleDateString('ru-RU');
const pin = note.pinned ? '📌 ' : '';
const card = [
`${pin}📝 Заметка от ${petName}`,
`━━━━━━━━━━━━━━━━━`,
note.text,
`━━━━━━━━━━━━━━━━━`,
tags ? `🏷️ ${tags}` : '',
`📅 ${date}`,
].filter(Boolean).join('\n');
const keyboard = {
inline_keyboard: [[
{ text: '📤 Переслать', switch_inline_query: `note:${note.text.slice(0, 50)}` },
]],
};
await this._sendText(this.chatId, card, {
reply_markup: JSON.stringify(keyboard),
});
}
/** Generate daily summary for sharing. */
async _shareSummary() {
if (!this.pes.store) return;
const petName = this.pes?.identity?.name || 'ПЕС';
const today = new Date().toISOString().slice(0, 10);
// Count today's interactions
let interactionCount = 0;
let notesCount = 0;
let remindersCount = 0;
try {
interactionCount = this.pes.store.db.prepare(
`SELECT COUNT(*) as cnt FROM interactions WHERE date(timestamp) = ?`
).get(today)?.cnt || 0;
notesCount = this.pes.store.db.prepare(
`SELECT COUNT(*) as cnt FROM owner_notes WHERE date(created_at) = ? AND is_deleted = 0`
).get(today)?.cnt || 0;
remindersCount = this.pes.store.db.prepare(
`SELECT COUNT(*) as cnt FROM reminders WHERE date(created_at) = ?`
).get(today)?.cnt || 0;
} catch (_) {}
const mood = Math.round((this.pes.state?.mood || 0) * 100);
const level = this.pes.level?.current || 0;
const xp = this.pes.level?.xp || 0;
const emotion = this.pes.state?.emotion || 'idle';
const summary = [
`📋 **Итоги дня** — ${petName}`,
`📅 ${new Date().toLocaleDateString('ru-RU')}`,
``,
`💬 Взаимодействий: ${interactionCount}`,
`📝 Заметок создано: ${notesCount}`,
`⏰ Напоминаний: ${remindersCount}`,
``,
`💚 Настроение: ${mood}%`,
`🎭 Эмоция: ${emotion}`,
`📊 Level ${level} · ${xp} XP`,
].join('\n');
const keyboard = {
inline_keyboard: [[
{ text: '📤 Отправить в чат', switch_inline_query: `summary` },
]],
};
await this._sendText(this.chatId, summary, {
parseMode: 'Markdown',
reply_markup: JSON.stringify(keyboard),
});
}
/**
* Build inline query results for sharing artifacts in other chats.
* Called from _handleInlineQuery when query matches share patterns.
*/
_buildShareInlineResults(queryText) {
const results = [];
const name = this.pes?.identity?.name || 'ПЕС';
const species = this.pes?.identity?.species || '???';
const level = this.pes?.level?.current || 0;
const xp = this.pes?.level?.xp || 0;
const mood = Math.round((this.pes?.state?.mood || 0) * 100);
const energy = Math.round((this.pes?.state?.energy || 0) * 100);
const emotion = this.pes?.state?.emotion || 'idle';
const botUser = this.config.botUsername || 'HoleThor_Bot';
if (queryText === 'status') {
const moodBar = this._buildBar(mood);
const energyBar = this._buildBar(energy);
results.push({
type: 'article',
id: 'share_status',
title: `📊 Статус ${name}`,
description: `Level ${level} · ${emotion} · ${mood}% mood`,
input_message_content: {
message_text: [
`🐾 **${name}** — ${species}`,
`Level ${level} · ${xp} XP`,
``,
`🎭 ${emotion}`,
`💚 ${moodBar} ${mood}%`,
`${energyBar} ${energy}%`,
``,
`🏠 t.me/${botUser}`,
].join('\n'),
parse_mode: 'Markdown',
},
});
}
if (queryText === 'card') {
const traits = this.pes?.traits || {};
const topTraits = Object.entries(traits)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([k, v]) => `${k} ${Math.round(v * 100)}%`)
.join(' · ');
results.push({
type: 'article',
id: 'share_card',
title: `🐾 Карточка ${name}`,
description: `${species} · Level ${level} · ${topTraits}`,
input_message_content: {
message_text: [
`╔═══════════════════════╗`,
`║ 🐾 **${name}**`,
`${species} · Level ${level}`,
`${topTraits}`,
`╚═══════════════════════╝`,
`🏠 t.me/${botUser}`,
].join('\n'),
parse_mode: 'Markdown',
},
});
}
if (queryText === 'summary') {
const today = new Date().toLocaleDateString('ru-RU');
let interactionCount = 0;
try {
const todayISO = new Date().toISOString().slice(0, 10);
interactionCount = this.pes.store?.db?.prepare(
`SELECT COUNT(*) as cnt FROM interactions WHERE date(timestamp) = ?`
).get(todayISO)?.cnt || 0;
} catch (_) {}
results.push({
type: 'article',
id: 'share_summary',
title: `📋 Итоги дня — ${name}`,
description: `${today} · ${interactionCount} взаимодействий · ${mood}% mood`,
input_message_content: {
message_text: [
`📋 **Итоги дня** — ${name}`,
`📅 ${today}`,
``,
`💬 Взаимодействий: ${interactionCount}`,
`💚 Настроение: ${mood}%`,
`🎭 Эмоция: ${emotion}`,
`📊 Level ${level} · ${xp} XP`,
].join('\n'),
parse_mode: 'Markdown',
},
});
}
if (queryText.startsWith('note:')) {
const search = queryText.slice(5).trim();
if (this.pes.store && search) {
const ownerId = this.pes.store.getConfig('owner_id') || '';
const notes = this.pes.store.searchNotes(search, { owner_id: ownerId, limit: 5 });
for (let i = 0; i < notes.length; i++) {
const n = notes[i];
const date = new Date(n.created_at).toLocaleDateString('ru-RU');
const tags = n.tags ? JSON.parse(n.tags).join(', ') : '';
results.push({
type: 'article',
id: `share_note_${n.id || i}`,
title: `📝 ${n.text.slice(0, 60)}`,
description: `${date}${tags ? ' · ' + tags : ''}`,
input_message_content: {
message_text: [
`📝 **Заметка от ${name}**`,
`━━━━━━━━━━━━━━━━━`,
n.text,
`━━━━━━━━━━━━━━━━━`,
tags ? `🏷️ ${tags}` : '',
`📅 ${date}`,
].filter(Boolean).join('\n'),
parse_mode: 'Markdown',
},
});
}
}
}
// Always add status and card as fallback options
if (results.length === 0) {
results.push({
type: 'article',
id: 'share_hint',
title: `🐾 ${name} — Level ${level}`,
description: 'Нажми чтобы поделиться статусом',
input_message_content: {
message_text: `🐾 **${name}** — ${species} · Level ${level}\n💚 ${mood}% · 🎭 ${emotion}\n🏠 t.me/${botUser}`,
parse_mode: 'Markdown',
},
});
}
return results;
}
/**
* Analyze new stickers via LLM — figure out what's depicted.
* Batches stickers and asks LLM to describe each one.
*/
async _analyzeNewStickers() {
if (!this._llm || this._analyzingStickers || this._stickerAnalysisQueue.length === 0) return;
this._analyzingStickers = true;
try {
const batch = this._stickerAnalysisQueue.splice(0, 10);
const stickerList = batch.map((s, i) =>
`${i + 1}. emoji: ${s.emoji}, pack: ${s.setName || 'unknown'}`
).join('\n');
const prompt = `Ты анализируешь стикеры из Telegram. Для каждого стикера по его эмодзи и названию пака определи:
1. Что скорее всего изображено на стикере (описание на русском, 5-10 слов)
2. Какая эмоция (emotion_key): happy, sad, excited, playful, scared, angry, love, curious, sleeping, confused, hungry, greeting, farewell
3. Когда уместно использовать (context_tags): ["приветствие", "еда", "игра", "грусть", "радость", "прощание", "удивление"]
Стикеры:
${stickerList}
Верни JSON массив:
[{"idx":1, "desc":"корги виляет попой", "emotion":"happy", "tags":["радость","приветствие"]}]
ТОЛЬКО JSON, без markdown.`;
const result = await this._llm.generate(prompt, [
{ role: 'user', content: 'Проанализируй стикеры.' }
]);
if (result && this.pes.store) {
try {
const cleaned = result.replace(/```json\s*/g, '').replace(/```/g, '').trim();
const parsed = JSON.parse(cleaned);
for (const item of parsed) {
const idx = (item.idx || item.index || 1) - 1;
if (idx >= 0 && idx < batch.length) {
this.pes.store.updateStickerMeta(batch[idx].fileUniqueId, {
description: item.desc || item.description,
emotion_key: item.emotion || item.emotion_key,
context_tags: item.tags || item.context_tags,
});
console.log(`🎨 Sticker analyzed: ${batch[idx].emoji}${item.desc}`);
}
}
} catch (parseErr) {
console.error('⚠️ Sticker analysis parse error:', parseErr.message);
}
}
} catch (err) {
console.error('⚠️ Sticker analysis failed:', err.message);
} finally {
this._analyzingStickers = false;
// Process remaining queue
if (this._stickerAnalysisQueue.length > 0) {
setTimeout(() => this._analyzeNewStickers().catch(err =>
console.error('⚠️ Auto sticker analysis retry failed:', err.message)
), 1000);
}
}
}
/**
* Handle reaction on PES's message (feedback loop).
*/
async _handleReaction(reactionUpdate) {
if (!reactionUpdate) return;
const chatId = String(reactionUpdate.chat?.id);
if (chatId !== this.chatId) return;
const msgId = String(reactionUpdate.message_id);
const newReaction = reactionUpdate.new_reaction;
if (!newReaction || newReaction.length === 0) return;
const reactionObj = newReaction[0];
// Handle both regular emoji and custom emoji reactions
let emoji = null;
let customEmojiId = null;
if (reactionObj?.type === 'custom_emoji') {
customEmojiId = reactionObj.custom_emoji_id;
// Resolve custom emoji to learn it
try {
const customStickers = await this._call('getCustomEmojiStickers', { custom_emoji_ids: [customEmojiId] });
if (customStickers && customStickers.length > 0) {
const cs = customStickers[0];
emoji = cs.emoji || '✨'; // fallback emoji associated with custom emoji
// Learn this custom emoji sticker
if (this.pes.store) {
const existing = this.pes.store.getSticker(cs.file_unique_id);
if (!existing) {
this.pes.store.learnSticker(cs.file_id, cs.file_unique_id, {
set_name: cs.set_name || null,
emoji: cs.emoji || null,
owner_sent: 1,
custom_emoji_id: customEmojiId || cs.custom_emoji_id || null,
});
console.log(`🎭 Learned custom emoji: ${cs.emoji} (id: ${customEmojiId}) from set "${cs.set_name}" (reaction)`);
// If new set — auto-load it
if (cs.set_name) this._autoLoadStickerSet(cs.set_name);
} else {
this.pes.store.learnSticker(cs.file_id, cs.file_unique_id, { owner_sent: 1 });
// Update custom_emoji_id if we have it
if (customEmojiId) this.pes.store.updateStickerCustomEmojiId(cs.file_unique_id, customEmojiId);
}
}
}
} catch (err) {
console.log(`⚠️ Could not resolve custom emoji ${customEmojiId}: ${err.message}`);
emoji = '✨'; // fallback
}
} else {
emoji = reactionObj?.emoji || null;
}
if (!emoji) return;
const userId = String(reactionUpdate.user?.id || '');
console.log(`🔔 Reaction received: ${emoji} (type: ${reactionObj?.type}, customId: ${customEmojiId || 'none'}) on msg ${msgId}`);
// Feed back through base class
this.onUserReaction(msgId, emoji, userId, {
isOwner: chatId === this.chatId,
speed_ms: null,
});
// ── Log to reaction_memory in store ──
const POSITIVE_REACTIONS = ['👍', '❤️', '🔥', '😍', '🎉', '💯', '⚡', '🏆', '🥰', '😂', '🤣', '♥️', '💕', '👏', '🤩', '😘', '💋', '🙏', '✨', '💪', '🫶'];
const NEGATIVE_REACTIONS = ['👎', '😡', '🤮', '💩', '😤', '🙄', '😒', '🤢', '💔'];
let weight = 0;
let reactionType = 'neutral';
// Custom emoji reactions are generally positive (user engaged enough to use premium emoji)
if (customEmojiId) {
weight = 1.0;
reactionType = 'positive';
} else if (POSITIVE_REACTIONS.includes(emoji)) {
weight = 1.0;
reactionType = 'positive';
} else if (NEGATIVE_REACTIONS.includes(emoji)) {
weight = -1.0;
reactionType = 'negative';
} else {
weight = 0.3; // unknown emoji = slightly positive (they interacted)
reactionType = 'emoji';
}
// Log reaction to whatever PES last did
const pesAction = this._lastBotAction?.action || 'unknown';
const pesExpression = this._lastBotAction?.text || null;
const speedMs = this._lastBotAction ? Date.now() - this._lastBotAction.timestamp : null;
try {
if (this.pes.store) {
this.pes.store.logReaction({
pes_action: pesAction,
pes_expression: pesExpression,
reactor: userId || 'owner',
reactor_role: 'owner',
reaction_type: reactionType,
reaction_value: emoji,
reaction_speed: speedMs,
weight,
platform: 'telegram',
});
// Recalculate preferences periodically
this._reactionRecalcCounter++;
if (this._reactionRecalcCounter >= 5) {
this._reactionRecalcCounter = 0;
this.pes.store.recalculatePreference(pesAction);
}
// Update preference_score on last sent sticker
if (this._lastSentStickerUniqueId) {
const stickerDelta = reactionType === 'positive' ? 1.0 : reactionType === 'negative' ? -0.5 : 0.1;
this.pes.store.updateStickerPreference(this._lastSentStickerUniqueId, stickerDelta);
console.log(`🎨 Sticker preference: ${this._lastSentStickerUniqueId} ${stickerDelta > 0 ? '+' : ''}${stickerDelta}`);
}
// Update discovery feedback if reaction was on a discovered sticker
if (this._lastBotAction?.action === 'sticker_discovery' && this._lastBotAction?.discoveryPackName) {
const liked = reactionType === 'positive';
this.pes.store.updateDiscoveryFeedback(this._lastBotAction.discoveryPackName, liked);
console.log(`🔍 Discovery feedback: ${this._lastBotAction.discoveryPackName}${liked ? '👍 liked' : '👎 disliked'}`);
}
console.log(`💭 Reaction logged: ${emoji} (${reactionType}, weight=${weight}) → action "${pesAction}"`);
}
// XP for reaction + diversity tracking + correction tracking
await this._grantXp('reaction', reactionType === 'positive' ? 2 : 1);
if (this._trackDiversity('reaction')) {
await this._grantXp('diversity', 5);
}
// Track negative reactions for correction bonus
if (reactionType === 'negative') {
this._lastReactionWasNegative = true;
this._lastReactionTime = Date.now();
} else {
this._lastReactionWasNegative = false;
}
this._updateRelationship('reaction');
} catch (err) {
console.error('⚠️ Failed to log reaction:', err.message);
}
}
// ═══════════════════════════════════════════════════════════
// BOT COMMANDS
// ═══════════════════════════════════════════════════════════
async _handleBotCommand(msg) {
const text = msg.text.trim();
const parts = text.split(/\s+/);
const cmd = parts[0].toLowerCase().replace(/@.*$/, ''); // strip @botname
const args = parts.slice(1).join(' ');
switch (cmd) {
case '/start': return this._cmdStart(msg);
case '/home': return this._cmdHome(msg);
case '/help': return this._cmdHelp(msg);
case '/status': return this._sendStatus();
case '/setname': return this._cmdSetName(msg, args);
case '/feed': return this._cmdFeed(msg, args);
case '/command': return this._cmdCommand(msg, args);
case '/silent': return this._cmdSilent(msg);
case '/stickers': return this._cmdStickers(msg, args);
case '/files': return this._cmdFiles(msg, args);
case '/notes': return this._cmdNotes(msg, args);
case '/translate': return this._cmdTranslate(msg, args);
case '/remind': return this._cmdRemind(msg, args);
case '/reminders': return this._cmdReminders(msg);
case '/packs': return this._cmdPacks(msg);
case '/collection': return this._cmdCollection(msg);
case '/artifacts': return this._cmdArtifacts(msg);
case '/insights': return this._cmdInsights(msg);
case '/contacts': return this._cmdContacts(msg);
case '/addcontact': return this._cmdAddContact(msg, args);
case '/abilities': return this._cmdAbilities(msg);
case '/origin': return this._cmdOrigin(msg);
case '/newpack': return this._cmdNewPack(msg, args);
case '/newemojipack': return this._cmdNewEmojiPack(msg, args);
case '/mypack': return this._cmdMyPack(msg);
case '/myemoji': return this._cmdMyEmojiPack(msg);
case '/renamepack': return this._cmdRenamePack(msg, args);
case '/metronome': return this._cmdMetronome(msg, args);
case '/share': return this._cmdShare(msg, args);
case '/emoji': return this._cmdEmoji(msg, args);
case '/workflow': return this._cmdWorkflow(msg, args);
case '/wf': return this._cmdWorkflow(msg, args);
case '/triage': return this._cmdWorkflow(msg, 'triage');
case '/summary': return this._cmdWorkflow(msg, 'daily_summary');
case '/digest': return this._cmdWorkflow(msg, 'crm_digest');
case '/health': return this._cmdWorkflow(msg, 'health_check');
case '/cleanup': return this._cmdWorkflow(msg, 'task_cleanup');
case '/achievements': return this._cmdAchievements(msg);
case '/streak': return this._cmdStreak(msg);
case '/friends': return this._cmdFriends(msg);
case '/addfriend': return this._cmdAddFriend(msg, args);
case '/gift': return this._cmdGift(msg, args);
case '/dev': return this._cmdDev(msg, args);
case '/orchestrator': return this._cmdAgent(msg, 'orchestrator', args);
case '/geratron': return this._cmdAgent(msg, 'geratron', args);
case '/exit': return this._cmdDevExit(msg);
default:
// In dev/agent mode, treat unknown commands as messages too
if (this._devMode) {
return this._logDevRequest('command', text, msg);
}
if (this._agentMode) {
await this._logDevRequest(`@${this._agentMode}`, text, msg);
if (this._agentMode === 'orchestrator' && this._orchestratorLLM) {
await this._handleOrchestratorChat(text, msg);
return;
}
const label = this._agentMode === 'orchestrator' ? '🎯' : '⚡';
await this._sendText(this.chatId, `${label} @${this._agentMode} получил: «${text.slice(0, 200)}»\n\n_Записано в лог._`, { parse_mode: 'Markdown' });
return;
}
// Unknown command → treat as command attempt
this.pes.command(cmd.slice(1), { source: 'telegram' });
}
}
async _cmdStart(msg) {
if (!this.pes.isAlive) {
this.pes.birth();
// onBorn handler will send greeting
// Check if name has been set; if not, prompt user
const hasName = this.pes.store && this.pes.store.getConfig('pet_name');
if (!hasName) {
// Save default name
if (this.pes.store) this.pes.store.setPetName(this.pes.name);
await sleep(1500);
await this._sendText(this.chatId, [
`тяф... тяф?.. 🐾`,
``,
`...ууууу... ♡`,
``,
`я ${this.pes.name}! твой щенок!`,
`я буду запоминать, приносить, создавать стикеры`,
`и расти вместе с тобой 🐾`,
``,
`✏️ /setname <имя> — дай мне имя!`,
`📦 /newpack <имя> — создай свою коллекцию`,
`🐾 /help — что я умею`,
].join('\n'));
}
} else if (!this.pes.isAwake) {
this.pes.wake();
// onWake handler will send greeting
} else {
// Already awake — check if missing pack setup
const hasPack = this.pes.store && this.pes.store.getMainCollectionPack();
if (!hasPack) {
await this._sendText(this.chatId, pickRandom([
`ГАВВВ!! 🐾💛 тяф тяф!\n\n📦 /newpack <имя> — создай коллекцию!`,
`ууууУУУ!! гав гав!! 💛\n\n📦 у тебя ещё нет пака! /newpack <имя>`,
]));
} else {
await this._sendText(this.chatId, pickRandom([
`ГАВВВ!! ыыыЫЫЫ!! ррууу!! 🐾💛💛💛`,
`тяф! тяф! ТЯФ!! ваф-ваф!! 🐾`,
`ууууУУУ!! гав гав ГАААВ!! 💛💛💛`,
`ЫЫЫЫ!! ррр ваф ваф ВАФ!! 🐾✨`,
]));
}
this.pes.event({ type: 'owner_returned', from: 'owner' });
}
}
async _cmdHome(msg) {
const appUrl = this.config.miniAppUrl || 'https://crm.hltrn.cc/api/v3/pes/app';
const name = this.pes.name || 'ПЕС';
await this._sendText(this.chatId, `🏠 Дом ${name}`, {
reply_markup: {
inline_keyboard: [[
{ text: '🏠 Открыть дом', web_app: { url: appUrl } }
]]
}
});
}
async _cmdHelp(msg) {
const name = this.pes.name || 'ПЕС';
const level = Math.floor(this.pes.level || 0);
// Build capabilities list based on current level
const lines = [
`🐾 ${name} — Level ${level}`,
``,
`━━ Что я умею ━━`,
``,
`📝 Заметки`,
` "запомни: встреча в пятницу"`,
` "что помнишь про встречу?"`,
` /notes — список заметок`,
``,
`⏰ Напоминания`,
` "напомни через 30 мин позвонить"`,
` /remind 2ч проверить деплой`,
` /reminders — активные`,
``,
`👤 Контакты`,
` "запомни контакт Олег +7999..."`,
` "кто такой Олег?"`,
` /contacts — все контакты`,
``,
`🎒 Артефакты`,
` выпадают при общении`,
` /artifacts — мои находки`,
``,
`🎨 Стикеры`,
` Отправь фото + "сделай стикер"`,
` "сделай эмодзи" — emoji пак`,
` "сделай гифку" — анимированный`,
` Reply стикер + "сохрани" — в коллекцию`,
` /packs — мои паки`,
` /collection — коллекция`,
``,
`🌐 Перевод`,
` "переведи на английский привет"`,
``,
`🎵 Метроном`,
` "метроном 120" или /metronome 90`,
``,
`🎾 Игра`,
` "апорт!", "играть!", "мячик!"`,
``,
];
// Level-gated features
if (level >= 8) {
lines.push(`📊 CRM (Level 8+)`);
lines.push(` "покажи таблицы" — список таблиц`);
lines.push(` "найди в crm Олег" — поиск`);
lines.push(` "что у меня на сегодня?" — задачи`);
lines.push(``);
}
if (level >= 10) {
lines.push(`✏️ CRM Запись (Level 10+)`);
lines.push(` "создай тикет: ..." — новая запись`);
lines.push(``);
}
if (level >= 8) {
lines.push(`🤖 Workflows (Level 8+)`);
lines.push(` /workflow — все workflows`);
lines.push(` /triage — что нового в CRM`);
lines.push(` /health — проверка здоровья`);
lines.push(` "итоги дня" — daily summary`);
lines.push(` "что нового?" — triage`);
lines.push(``);
}
if (level >= 10) {
lines.push(`🤝 Дружба (Level 10+)`);
lines.push(` /friends — список друзей`);
lines.push(` /addfriend <chat_id> — запрос дружбы`);
lines.push(` /gift <chat_id> — отправить подарок`);
lines.push(``);
}
lines.push(`🎯 Orchestrator`);
lines.push(` N23 + вопрос — быстрый вызов AI`);
lines.push(` /orchestrator — режим диалога`);
lines.push(` /exit — вернуться к ПЕС`);
lines.push(``);
lines.push(`━━ Настройки ━━`);
lines.push(` /setname <имя> — переименовать`);
lines.push(` /status — полная статистика`);
lines.push(` /abilities — способности по уровням`);
lines.push(` /silent — тихий режим`);
lines.push(``);
lines.push(`🐾♡`);
await this._sendText(this.chatId, lines.join('\n'));
}
async _cmdAbilities(msg) {
const level = Math.floor(this.pes.level || 0);
const name = this.pes.name || 'ПЕС';
// All abilities with their unlock levels
const abilities = [
{ lvl: 0, icon: '🐾', name: 'Базовые эмоции', desc: '9 состояний' },
{ lvl: 0, icon: '🔊', name: 'Простые звуки', desc: 'гласные + согласные' },
{ lvl: 0, icon: '😀', name: 'Стикеры + реакции', desc: 'отправка и обучение' },
{ lvl: 2, icon: '💬', name: 'Комбо-фразы', desc: 'гав, тяф, ваф, рру' },
{ lvl: 2, icon: '😊', name: 'Изучение эмодзи', desc: 'запоминает твои эмодзи' },
{ lvl: 3, icon: '📝', name: 'Заметки', desc: '"запомни: ...", /notes' },
{ lvl: 3, icon: '🐶', name: 'Социальные эмоции', desc: '+6 состояний' },
{ lvl: 4, icon: '⏰', name: 'Напоминания', desc: '/remind, "напомни через..."' },
{ lvl: 4, icon: '🔊', name: 'Звуки Tier 1', desc: 'мням, хрум, аууу' },
{ lvl: 5, icon: '🔍', name: 'Поиск по памяти', desc: '"что помнишь про...?"' },
{ lvl: 5, icon: '💼', name: 'Рабочие эмоции', desc: '+6 состояний' },
{ lvl: 5, icon: '🎒', name: 'Артефакты', desc: 'находки при общении, /artifacts' },
{ lvl: 6, icon: '📂', name: 'Хранение файлов', desc: 'сохраняет фото/документы' },
{ lvl: 6, icon: '🔊', name: 'Звуки Tier 2', desc: 'сложные звуки' },
{ lvl: 8, icon: '👤', name: 'Контакты', desc: '"запомни контакт...", /contacts' },
{ lvl: 8, icon: '🎭', name: 'Драматичные эмоции', desc: '+5 состояний' },
{ lvl: 8, icon: '📊', name: 'CRM чтение', desc: '"покажи таблицы", поиск' },
{ lvl: 10, icon: '✏️', name: 'CRM запись', desc: '"создай тикет: ..."' },
{ lvl: 10, icon: '🌈', name: 'Полный спектр эмоций', desc: '32 состояния' },
{ lvl: 10, icon: '🔊', name: 'Звуки Tier 3', desc: 'экстремальные звуки' },
{ lvl: 12, icon: '🎯', name: 'Orchestrator', desc: 'AI-помощник в чате' },
{ lvl: 12, icon: '📈', name: 'Аналитика', desc: 'сводка по данным' },
{ lvl: 15, icon: '🎶', name: 'Изобретение звуков', desc: 'создаёт новые звуки' },
{ lvl: 15, icon: '📋', name: 'CRM задачи', desc: 'управление задачами' },
{ lvl: 20, icon: '🧠', name: 'Продвинутый babble', desc: 'сложные комбинации' },
{ lvl: 20, icon: '⚙️', name: 'Автоматизации', desc: 'периодические действия' },
{ lvl: 10, icon: '🤝', name: 'Социальные питомцы', desc: 'дружба между ПЕС' },
{ lvl: 30, icon: '✨', name: 'Спеллы', desc: 'уникальные способности' },
{ lvl: 40, icon: '◈', name: 'Крипто-язык', desc: 'абстрактные символы' },
];
const lines = [`🐾 ${name} — Level ${level}`, ``, `━━ Способности ━━`, ``];
let lastLvl = -1;
for (const a of abilities) {
if (a.lvl !== lastLvl) {
if (lastLvl !== -1) lines.push(``);
const marker = level >= a.lvl ? '🟢' : '🔒';
lines.push(`${marker} Level ${a.lvl}:`);
lastLvl = a.lvl;
}
const status = level >= a.lvl ? '✅' : '⬜';
lines.push(` ${status} ${a.icon} ${a.name}${a.desc}`);
}
// Progress to next unlock
const nextUnlock = abilities.find(a => a.lvl > level);
if (nextUnlock) {
lines.push(``);
lines.push(`━━ Следующий анлок: Level ${nextUnlock.lvl} ━━`);
lines.push(` ${nextUnlock.icon} ${nextUnlock.name}`);
}
lines.push(``);
const unlocked = abilities.filter(a => a.lvl <= level).length;
lines.push(`📊 ${unlocked}/${abilities.length} способностей открыто`);
await this._sendText(this.chatId, lines.join('\n'));
}
async _cmdSetName(msg, args) {
const newName = args.trim();
if (!newName) {
await this._sendText(this.chatId,
`✏️ /setname <имя>\n` +
`🐾 ${this.pes.name}`
);
return;
}
if (newName.length > 30) {
await this._sendText(this.chatId, `❌ ✏️ max 30`);
return;
}
const oldName = this.pes.name;
this.pes.name = newName;
// Persist to DB
if (this.pes.store) {
this.pes.store.setPetName(newName);
}
await this._sendText(this.chatId, pickRandom([
`🐾✨ ррРРуу!! ${newName}!! ыыыЫЫЫ!! 💛`,
`${newName}! ${newName}! ГАВВВ!! тяф тяф!! 🐾💛💛💛`,
`рру?~ ${newName}... ууУУУ!! ВАФ!! ✨🐾`,
`ЫЫЫ!! ваф ваф!! ${newName}!! рррРР!! 🐾✨`,
]));
}
async _sendStatus() {
const s = this.pes.status();
if (!s.alive) {
await this._sendText(this.chatId, `💤 ${this.pes.name}... 💧`);
return;
}
const moodBar = this._bar(s.mood);
const energyBar = this._bar(s.energy);
const hungerBar = this._bar(1 - (s.hunger || 0));
// Phase info
const phaseInfo = {
puppy: { icon: '🐣', name: 'Щенок', next: 'Подросток', nextLvl: 20 },
young: { icon: '🐕', name: 'Подросток', next: 'Взрослый', nextLvl: 40 },
adult: { icon: '🦮', name: 'Взрослый', next: 'Опытный', nextLvl: 60 },
experienced: { icon: '🎓', name: 'Опытный', next: 'КиберPes', nextLvl: 80 },
cyber: { icon: '🤖', name: 'КиберPes', next: null, nextLvl: 100 },
};
const pi = phaseInfo[s.phase] || phaseInfo.puppy;
// XP progress bar to next level
const currentLvl = Math.floor(s.level || 0);
const levelProgress = (s.level || 0) - currentLvl;
const xpBar = this._bar(levelProgress);
// Relationship info
let relInfo = '';
if (this.pes.store) {
try {
const rel = this.pes.store.getRelationship('owner');
if (rel) {
const phaseNames = { stranger: '🔲 Незнакомец', acquaintance: '🟨 Знакомый', friend: '🟩 Друг', favorite: '💛 Любимый', bonded: '❤️ Неразлучный' };
const trustBar = this._bar(rel.trust);
const affBar = this._bar(rel.affection);
relInfo = [
``,
`━━ Привязанность ━━`,
`${phaseNames[rel.relationship_phase] || rel.relationship_phase}`,
`🤝 ${trustBar} ${Math.round(rel.trust * 100)}%`,
`💕 ${affBar} ${Math.round(rel.affection * 100)}%`,
`📅 Встреч: ${rel.interactions_count}`,
].join('\n');
}
} catch (_) {}
}
// Days together
const daysTogether = s.age || 0;
const daysText = daysTogether === 0 ? 'первый день!' :
daysTogether === 1 ? '1 день' :
daysTogether < 5 ? `${daysTogether} дня` :
`${daysTogether} дней`;
// Sticker/emoji counts
let stickerCount = 0, emojiCount = 0;
if (this.pes.store) {
try {
stickerCount = this.pes.store.getLearnedStickers({ limit: 1000 }).length;
emojiCount = this.pes.store.getLearnedEmojis({ limit: 1000 }).length;
} catch (_) {}
}
// Spell info
let spellLines = '';
if (this.pes.store) {
try {
const revealedSpells = this.pes.store.getRevealedSpells();
if (revealedSpells.length > 0) {
const rarityIcons = {
spark: '✦', glint: '✧', flash: '⚡', pulse: '💫',
surge: '🌊', wave: '🌊', storm: '⛈️', blaze: '🔥',
nova: '💥', eclipse: '🌑', cosmos: '🌌', god: '👁️‍🗨️',
beyond: '🔮',
};
const traitNames = {
courage: 'Храбрость', curiosity: 'Любопытство', loyalty: 'Верность',
stubbornness: 'Упрямство', playfulness: 'Игривость', drama: 'Драма',
food_obsession: 'Аппетит', sass: 'Дерзость', aggression: 'Сила',
};
const lines = revealedSpells.map(sp => {
const icon = rarityIcons[sp.rarity] || '✨';
const trait = traitNames[sp.trait] || sp.trait;
return `${icon} ${sp.name} (${trait} ${100 + sp.level}%) [${sp.rarity.toUpperCase()}]`;
});
spellLines = `\n━━ Спэлы ━━\n${lines.join('\n')}`;
}
// Check hidden spells count
const hiddenCount = this.pes.store.getSpells({ hiddenOnly: true }).length;
if (hiddenCount > 0) {
spellLines += spellLines ? `\n❓ Скрытых: ${hiddenCount}` : `\n━━ Спэлы ━━\n❓ Скрытых: ${hiddenCount}`;
}
} catch (_) {}
}
// Smart XP info
let dailyXP = 0, streak = 0;
if (this.pes.store) {
try {
dailyXP = this.pes.store.getDailyXP();
streak = parseInt(this.pes.store.getConfig('streak_current') || '0', 10);
} catch (_) {}
}
const dailyXPBar = this._bar(Math.min(dailyXP / 50, 1));
const streakText = streak > 0 ? ` 🔥${streak}` : '';
const diversityCount = this._diversityTypes?.size || 0;
// Next phase progress
const nextPhaseText = pi.next
? `\n📈 До «${pi.next}»: ур. ${currentLvl}/${pi.nextLvl}`
: `\n🏆 Максимальная фаза!`;
const lines = [
`🐾 ━━━ ${s.name} ━━━ 🐾`,
``,
`${pi.icon} ${pi.name}Ур. ${currentLvl}`,
`${xpBar} ${s.xp} XP`,
`📊 ${dailyXPBar} ${dailyXP}/50 XP сегодня${streakText}`,
nextPhaseText,
``,
`━━ Состояние ━━`,
`😊 ${moodBar} ${Math.round(s.mood * 100)}%`,
`${energyBar} ${Math.round(s.energy * 100)}%`,
`🍖 ${hungerBar} ${Math.round((1 - (s.hunger || 0)) * 100)}%`,
`💭 ${s.emotion} (${Math.round((s.intensity || 0) * 100)}%)`,
relInfo,
``,
`━━ Статистика ━━`,
`📅 Вместе: ${daysText}${streakText ? ` • серия ${streak}д` : ''}`,
`🎓 Команд: ${s.commandsKnown}`,
`👍 ${s.timesPraised} похвал • 👎 ${s.timesScolded} ругани`,
`🎨 ${stickerCount} стикеров • ${emojiCount} эмодзи`,
`📦 ${this._stickerFileIds?.length || 0} стикеров в паках`,
`🎯 Разнообразие: ${diversityCount}/3 типов`,
spellLines,
];
await this._sendText(this.chatId, lines.join('\n'));
}
async _cmdFeed(msg, args) {
// Personality reaction to feeding
await this._sendChatAction(this.chatId);
await sleep(thinkingDelay(this.pes.level));
const feedBabble = this._babble.generateReply(this.pes?.level || 0, 'food_obsessed', 0.8, 'feed');
await this._sendText(this.chatId, feedBabble);
if (!args) {
this.pes.feed({ type: 'generic' });
} else {
this.pes.feed({ type: 'text', content: args });
}
}
async _cmdCommand(msg, args) {
if (!args) {
await this._sendText(this.chatId, '🎾 /command <команда>\nfetch, sit, scan_api');
return;
}
const result = this.pes.command(args.split(/\s+/)[0], {
source: 'telegram',
args: args.split(/\s+/).slice(1).join(' '),
});
// If PES is learning
if (result.reason === 'unknown') {
await this._sendText(this.chatId, pickRandom([
`рру?.. ...тяф?.. 🐾 ❓`,
`ваф?.. ...ууу?.. хнн.. ❓`,
]));
} else if (result.reason === 'just_learned') {
await this._sendText(this.chatId, pickRandom([
`ГАВВ!! ррРРуу!! ✨🐾🎉`,
`ваф ваф ВАФ!! ыыыЫЫ!! 🎉🐾`,
]));
} else if (result.reason === 'stubborn') {
// Expression already sent via PES events
}
}
async _cmdSilent(msg) {
const current = this.pes.engine?.silentMode || false;
this.pes.setSilentMode(!current);
await this._sendText(this.chatId,
!current ? `🤫 ...ш-ш-ш... 🐾` : `🔊 ГАВВ!! ваф ваф!! 🐾`
);
}
async _cmdTranslate(msg, args) {
if (!args || !args.trim()) {
await this._sendText(this.chatId, [
`📜 /translate <язык> <текст>`,
``,
` /translate en привет мир`,
` /translate es доброе утро`,
` /translate fr как дела`,
].join('\n'));
return;
}
// Parse: first word = lang, rest = text
const parts = args.trim().split(/\s+/);
const langHint = parts[0];
const text = parts.slice(1).join(' ');
if (!text) {
await this._sendText(this.chatId, `📜 /translate <язык> <текст>`);
return;
}
// Short lang codes
const shortLangs = {
'en': 'английский', 'ru': 'русский', 'es': 'испанский', 'de': 'немецкий',
'fr': 'французский', 'zh': 'китайский', 'ja': 'японский', 'ko': 'корейский',
'ar': 'арабский', 'it': 'итальянский', 'pt': 'португальский', 'tr': 'турецкий',
};
const targetLang = shortLangs[langHint.toLowerCase()] || langHint;
await this._handleTranslation(text, targetLang);
await this._processSmartXP(msg, 'command', 'translate');
this._updateRelationship('message');
}
async _cmdStickers(msg, args) {
if (!args) {
const stickerCount = this._stickerFileIds?.length || 0;
const packs = [...this._learnedPacks];
if (this._currentStickerPack) packs.unshift(this._currentStickerPack);
const packList = packs.length > 0 ? packs.join(', ') : 'нет';
await this._sendText(this.chatId,
`🎨 ${packList}\n📦 ${stickerCount}\n\n💡 🎨 → 🐾 | /stickers <пак>`
);
return;
}
try {
await this.loadStickerPack(args, null, true); // additive
this._learnedPacks.add(args);
const count = this._stickerFileIds?.length || 0;
await this._sendText(this.chatId, `✅ 🎨 ${args} (📦 ${count})`);
} catch (err) {
await this._sendText(this.chatId, `❌ 🎨 ${args} 💧`);
}
}
// ═══════════════════════════════════════════════════════════
// REMINDERS
// ═══════════════════════════════════════════════════════════
/**
* /remind — set a reminder.
* Usage:
* /remind 30м купить молоко
* /remind 2ч позвонить маме
* /remind 1д проверить деплой
* /remind 15:30 встреча
*/
async _cmdRemind(msg, args) {
if (!args || !args.trim()) {
// No command-line help — just a dog reaction
await this._sendText(this.chatId, pickRandom([
`рру?.. ⏰🐾`,
`тяф? ⏰🐾`,
`ваф~ ⏰🐾`,
]));
return;
}
const parsed = this._parseReminderFromCommand(args);
if (!parsed) {
// Try to treat the whole args as natural language reminder
const reminderIntent = await this._detectReminderIntent(`напомни ${args}`);
if (reminderIntent && this.pes.store) {
this.pes.store.createReminder(reminderIntent.text, reminderIntent.remindAt);
await this._sendText(this.chatId, pickRandom([
`⏰ ГАВ! рру~~ 🐾`,
`⏰ тяф! ррФФ~ 🐾`,
`⏰ ваф-ваф! 🐾♡`,
]));
return;
}
// Still couldn't parse — just acknowledge
await this._sendText(this.chatId, pickRandom([
`рру?.. ⏰🐾`,
`тяф?.. ⏰🐾`,
]));
return;
}
if (!this.pes.store) {
await this._sendText(this.chatId, `скууу... 💧🐾`);
return;
}
const reminder = this.pes.store.createReminder(parsed.text, parsed.remindAt);
await this._sendText(this.chatId, pickRandom([
`⏰ ГАВ! рру~~ 🐾`,
`⏰ тяф! ррФФ~ 🐾`,
`⏰ ваф-ваф! 🐾♡`,
]));
}
/**
* /reminders — list pending reminders.
*/
async _cmdReminders(msg) {
if (!this.pes.store) {
await this._sendText(this.chatId, `скууу... 💧 память не работает... рру?.. 🐾`);
return;
}
const pending = this.pes.store.getPendingReminders();
if (pending.length === 0) {
await this._sendText(this.chatId, `📋 —\n💡 /remind 30м текст`);
return;
}
const lines = ['📋 Активные напоминалки:', ''];
for (const r of pending) {
const timeStr = this._formatReminderTime(r.remind_at);
lines.push(`${timeStr}${r.text}`);
}
lines.push('');
lines.push(`Всего: ${pending.length}`);
await this._sendText(this.chatId, lines.join('\n'));
}
async _cmdPacks(msg) {
if (!this.pes.store) {
await this._sendText(this.chatId, `скууу... 💧 память не работает... рру?.. 🐾`);
return;
}
const allPacks = this.pes.store.getCreatedPacks();
if (allPacks.length === 0) {
await this._sendText(this.chatId, `📦 —\n💡 Отправь фото + "сделай стикер" или "сделай эмодзи"`);
return;
}
const lines = ['📦 Созданные паки:', ''];
for (const p of allPacks) {
const typeIcon = p.pack_type === 'custom_emoji' ? '✨' : '🎨';
const linkPrefix = p.pack_type === 'custom_emoji' ? 'addemoji' : 'addstickers';
const origins = this.pes.store.getPackOrigins(p.pack_name);
const originCount = origins.filter(o => o.original_set_name).length;
const originInfo = originCount > 0 ? ` (${originCount} из других паков)` : '';
lines.push(`${typeIcon} ${p.title}${p.sticker_count} шт.${originInfo}`);
lines.push(` → https://t.me/${linkPrefix}/${p.pack_name}`);
// Show origins
if (origins.length > 0) {
const uniqueSets = [...new Set(origins.filter(o => o.original_set_title).map(o => o.original_set_title))];
if (uniqueSets.length > 0) {
lines.push(` 📋 Источники: ${uniqueSets.slice(0, 5).join(', ')}${uniqueSets.length > 5 ? ` (+${uniqueSets.length - 5})` : ''}`);
}
}
}
lines.push('');
lines.push(`Всего паков: ${allPacks.length}`);
await this._sendText(this.chatId, lines.join('\n'));
}
// ═══════════════════════════════════════════════════════════
// STICKER COLLECTION — bookmarked stickers with attribution
// ═══════════════════════════════════════════════════════════
/**
* Add a sticker to the collection (bookmark with origin).
* Called when owner replies to a sticker with "сохрани" / "в коллекцию".
*/
async _addToCollection(msg, sticker) {
const fileId = sticker.file_id;
const fileUniqueId = sticker.file_unique_id;
const setName = sticker.set_name || null;
const emoji = sticker.emoji || '';
// Check if already in collection
if (this.pes.store.isInCollection(fileUniqueId)) {
await this._sendText(this.chatId, pickRandom([
'тяф! 📦🐾 уже в коллекции! ♡',
'ваф! 📦 уже собрал этот! 🐾',
]));
return;
}
// Check if it's from our own created pack
const createdPacks = this.pes.store.getCreatedPacks();
const ownPackNames = createdPacks.map(p => p.pack_name);
if (setName && ownPackNames.includes(setName)) {
await this._sendText(this.chatId, pickRandom([
'тяф! 🐾 это же МОЙ стикер! ♡',
'ваф! 🎨🐾 это из нашего пака! ♡',
]));
return;
}
// Get set title from Telegram API
let setTitle = null;
if (setName) {
try {
const setInfo = await this._call('getStickerSet', { name: setName });
setTitle = setInfo?.title || setName;
} catch (_) {
setTitle = setName;
}
}
// Save to collection
this.pes.store.saveToCollection({
file_id: fileId,
file_unique_id: fileUniqueId,
set_name: setName,
set_title: setTitle,
emoji,
});
const count = this.pes.store.getCollectionCount();
const originLabel = setTitle ? ` из «${setTitle}»` : '';
await this._sendText(this.chatId, pickRandom([
`ваф! 📦🐾 сохранил${originLabel}! (${count} в коллекции) ♡`,
`тяф! 📦 в коллекции!${originLabel} 🐾 (${count} шт.)`,
`гав! 📦🐾 собрал!${originLabel} ♡ (${count})`,
]));
this._addToMemory('pes', `[сохранил стикер в коллекцию${originLabel}]`);
await this._processSmartXP(msg, 'sticker', 'collection');
this._updateRelationship('message');
}
/**
* /artifacts — show artifact collection stats.
*/
async _cmdArtifacts(msg) {
if (!this.pes.store) {
await this._sendText(this.chatId, '💧 память не работает...');
return;
}
const ownerId = String(this.chatId);
const stats = this.pes.store.getArtifactStats(ownerId);
if (stats.total === 0) {
await this._sendText(this.chatId, '🎒 Рюкзак пуст\n💡 Артефакты выпадают случайно при общении!\nчем больше общаемся — тем больше находок 🐾');
return;
}
const RARITY_LABEL = { common: '⬜ обычный', uncommon: '🟩 необычный', rare: '🟦 редкий', epic: '🟪 эпик', legendary: '🟨 легенда' };
const TYPE_LABEL = { toy: '🧸 Игрушки', trophy: '🏆 Трофеи', gift: '🎁 Подарки', food: '🦴 Еда', rare: '💎 Редкости', secret: '🔮 Секреты' };
const lines = ['🎒 Мои артефакты:', ''];
// By type
for (const [type, label] of Object.entries(TYPE_LABEL)) {
const count = stats.byType[type];
if (count) lines.push(` ${label}: ${count}`);
}
lines.push('');
// By rarity
for (const [rarity, label] of Object.entries(RARITY_LABEL)) {
const count = stats.byRarity[rarity];
if (count) lines.push(` ${label}: ${count}`);
}
lines.push('');
lines.push(`Всего: ${stats.total} артефактов`);
if (stats.favorites > 0) lines.push(`⭐ Избранных: ${stats.favorites}`);
// Show last 5 finds
const recent = this.pes.store.getArtifacts(ownerId, { limit: 5 });
if (recent.length > 0) {
lines.push('');
lines.push('Последние находки:');
for (const a of recent) {
lines.push(` ${a.emoji || '•'} ${a.name}`);
}
}
await this._sendText(this.chatId, lines.join('\n'));
}
/**
* Remove a sticker from the collection.
*/
async _removeFromCollection(msg, sticker) {
const fileUniqueId = sticker.file_unique_id;
if (!this.pes.store.isInCollection(fileUniqueId)) {
await this._sendText(this.chatId, pickRandom([
'тяф?.. 🐾 этого нет в коллекции 💧',
'ваф?.. 📦 не нашёл в коллекции 💧',
]));
return;
}
this.pes.store.removeFromCollection(fileUniqueId);
const count = this.pes.store.getCollectionCount();
await this._sendText(this.chatId, pickRandom([
`ваф! 🗑️🐾 убрал из коллекции (${count} осталось)`,
`тяф! 🗑️ убрал! 🐾 (${count} в коллекции)`,
]));
this._updateRelationship('message');
}
/**
* /collection — show sticker collection grouped by packs.
*/
async _cmdCollection(msg) {
if (!this.pes.store) {
await this._sendText(this.chatId, `скууу... 💧 память не работает... рру?.. 🐾`);
return;
}
const byPack = this.pes.store.getCollectionByPack();
const total = this.pes.store.getCollectionCount();
if (total === 0) {
await this._sendText(this.chatId, `📦 Коллекция пуста\n💡 Reply на стикер + "сохрани" чтобы добавить`);
return;
}
const lines = ['📦 Моя коллекция:', ''];
// Stickers from known packs
for (const pack of byPack) {
const title = pack.set_title || pack.set_name || '???';
const emojis = (pack.emojis || '').slice(0, 8);
lines.push(` ${emojis} «${title}» — ${pack.count} шт.`);
if (pack.set_name) {
lines.push(` → https://t.me/addstickers/${pack.set_name}`);
}
}
// Stickers without a pack
const noPack = this.pes.store.getCollection().filter(s => !s.set_name);
if (noPack.length > 0) {
lines.push(` 🐾 Без пака — ${noPack.length} шт.`);
}
lines.push('');
lines.push(`Всего: ${total} стикеров из ${byPack.length} паков`);
lines.push('');
lines.push(`💡 Inline: @${this._botUsername || 'bot'} — показать коллекцию в любом чате`);
await this._sendText(this.chatId, lines.join('\n'));
}
// ═══════════════════════════════════════════════════════════
// ORIGIN — sticker source attribution
// ═══════════════════════════════════════════════════════════
async _cmdOrigin(msg) {
const sticker = msg.reply_to_message?.sticker;
if (!sticker) {
await this._sendText(this.chatId, '🐾 Reply на стикер → /origin — покажу откуда он!');
return;
}
const fileUniqueId = sticker.file_unique_id;
// Check sticker_origins (stickers added to our pack from foreign packs)
if (this.pes.store) {
const origin = this.pes.store.getStickerOrigin(fileUniqueId);
if (origin && origin.original_set_name) {
const title = origin.original_set_title || origin.original_set_name;
await this._sendText(this.chatId,
`📦 Из пака «${title}» ${origin.original_emoji || ''}\n→ https://t.me/addstickers/${origin.original_set_name}`
);
return;
}
}
// Check sticker_collection
if (this.pes.store) {
const col = this.pes.store.getCollection().find(s => s.file_unique_id === fileUniqueId);
if (col && col.set_name) {
const title = col.set_title || col.set_name;
await this._sendText(this.chatId,
`📦 Из пака «${title}» ${col.emoji || ''}\n→ https://t.me/addstickers/${col.set_name}`
);
return;
}
}
// Check Telegram sticker set_name directly
if (sticker.set_name) {
let title = sticker.set_name;
try {
const setInfo = await this._call('getStickerSet', { name: sticker.set_name });
if (setInfo?.title) title = setInfo.title;
} catch (_) {}
await this._sendText(this.chatId,
`📦 Из пака «${title}»\n→ https://t.me/addstickers/${sticker.set_name}`
);
return;
}
await this._sendText(this.chatId, '🐾 Этот стикер без пака — одинокий путешественник!');
}
// ═══════════════════════════════════════════════════════════
// PACK MANAGEMENT — setup, rename, mypack
// ═══════════════════════════════════════════════════════════
/**
* /newpack <Name> — create or set main collection pack with custom name.
* Pack Telegram name: <Name>_by_<BotUsername>
* Pack display title: <Name>
*/
async _cmdNewPack(msg, args) {
const desiredTitle = args.trim();
if (!desiredTitle) {
await this._sendText(this.chatId, '🐾 /newpack <Название>\n💡 Пример: /newpack Nikich2392');
return;
}
if (desiredTitle.length > 60) {
await this._sendText(this.chatId, '❌ Максимум 60 символов');
return;
}
if (!this.pes.store) {
await this._sendText(this.chatId, 'скууу... 💧 память не работает... рру?.. 🐾');
return;
}
const ownerId = Number(msg.from.id);
// Check if we already have a main collection pack
const mainPack = this.pes.store.getMainCollectionPack();
if (mainPack) {
// Rename existing pack title
try {
await this._call('setStickerSetTitle', { name: mainPack.pack_name, title: desiredTitle });
this.pes.store.updatePackTitle(mainPack.pack_name, desiredTitle);
await this._sendText(this.chatId, `ГААВВВ!! 🎨✨🐾 Пак переименован!\n🏷️ «${desiredTitle}»\n→ https://t.me/addstickers/${mainPack.pack_name}`);
return;
} catch (err) {
console.log(`⚠️ setStickerSetTitle failed: ${err.message}`);
// If rename fails, create a new pack instead
}
}
// Create new pack — need first sticker
// Set pending state for pack creation with custom title
this._pendingPackTitle = desiredTitle;
this._pendingAddToPack = Date.now();
this._pendingAddType = 'regular';
await this._sendText(this.chatId, `тяф! 🎨🐾 Пак «${desiredTitle}» — жду первый стикер!\n📸 Пришли фото или стикер`);
}
/**
* /mypack — show link to main collection pack + stats.
*/
async _cmdMyPack(msg) {
if (!this.pes.store) {
await this._sendText(this.chatId, 'скууу... 💧 память не работает... рру?.. 🐾');
return;
}
const mainPack = this.pes.store.getMainCollectionPack();
if (!mainPack) {
await this._sendText(this.chatId, '📦 Пак ещё не создан\n💡 /newpack <Название> — создай свою коллекцию!');
return;
}
// Get origins stats
const origins = this.pes.store.getPackOrigins(mainPack.pack_name);
const ownStickers = mainPack.sticker_count - origins.length;
const foreignStickers = origins.length;
// Group origins by source pack
const byPack = {};
for (const o of origins) {
const key = o.original_set_name || 'unknown';
if (!byPack[key]) byPack[key] = { title: o.original_set_title || o.original_set_name, name: o.original_set_name, count: 0, emojis: [] };
byPack[key].count++;
if (o.original_emoji && byPack[key].emojis.length < 6) byPack[key].emojis.push(o.original_emoji);
}
const lines = [
`📦 «${mainPack.title}»`,
``,
`🎨 Своих стикеров: ${Math.max(0, ownStickers)}`,
`📋 Из других паков: ${foreignStickers}`,
`📊 Всего: ${mainPack.sticker_count}`,
];
// Show each source pack with count and emojis
const packEntries = Object.values(byPack).sort((a, b) => b.count - a.count);
if (packEntries.length > 0) {
lines.push('');
lines.push('📋 Источники:');
for (const p of packEntries.slice(0, 12)) {
const emojis = p.emojis.join('');
lines.push(` ${emojis} «${p.title}» — ${p.count} шт.`);
if (p.name) lines.push(` → t.me/addstickers/${p.name}`);
}
if (packEntries.length > 12) {
lines.push(` ...и ещё ${packEntries.length - 12} паков`);
}
}
lines.push('');
lines.push(`→ https://t.me/addstickers/${mainPack.pack_name}`);
lines.push('');
lines.push(`💡 Поделись ссылкой — друзья добавят твою коллекцию!`);
await this._sendText(this.chatId, lines.join('\n'));
}
/**
* /renamepack <NewTitle> — rename main collection pack.
*/
async _cmdRenamePack(msg, args) {
const newTitle = args.trim();
if (!newTitle) {
await this._sendText(this.chatId, '🐾 /renamepack <Новое название>');
return;
}
if (newTitle.length > 60) {
await this._sendText(this.chatId, '❌ Максимум 60 символов');
return;
}
if (!this.pes.store) {
await this._sendText(this.chatId, 'скууу... 💧 память не работает... рру?.. 🐾');
return;
}
const mainPack = this.pes.store.getMainCollectionPack();
if (!mainPack) {
await this._sendText(this.chatId, '📦 Пак ещё не создан. /newpack <Название>');
return;
}
try {
await this._call('setStickerSetTitle', { name: mainPack.pack_name, title: newTitle });
this.pes.store.updatePackTitle(mainPack.pack_name, newTitle);
await this._sendText(this.chatId, `ваф! 🏷️🐾 «${newTitle}»\n→ https://t.me/addstickers/${mainPack.pack_name}`);
} catch (err) {
console.error(`⚠️ Rename pack failed: ${err.message}`);
await this._sendText(this.chatId, `Не удалось переименовать: ${err.message}`);
}
}
/**
* /newemojipack <Name> — create custom emoji pack.
*/
async _cmdNewEmojiPack(msg, args) {
const desiredTitle = args.trim();
if (!desiredTitle) {
await this._sendText(this.chatId, '🐾 /newemojipack <Название>\n💡 Пример: /newemojipack MyEmoji');
return;
}
if (desiredTitle.length > 60) {
await this._sendText(this.chatId, '❌ Максимум 60 символов');
return;
}
if (!this.pes.store) {
await this._sendText(this.chatId, 'скууу... 💧 память не работает... рру?.. 🐾');
return;
}
// Check if we already have an emoji pack
const existingPacks = this.pes.store.getCreatedPacks('custom_emoji');
if (existingPacks.length > 0) {
// Rename existing
try {
await this._call('setStickerSetTitle', { name: existingPacks[0].pack_name, title: `${desiredTitle}` });
this.pes.store.updatePackTitle(existingPacks[0].pack_name, `${desiredTitle}`);
await this._sendText(this.chatId, `ГААВВВ!! ✨🐾 Emoji пак переименован!\n🏷️ «${desiredTitle}»\n→ https://t.me/addemoji/${existingPacks[0].pack_name}`);
return;
} catch (err) {
console.log(`⚠️ setStickerSetTitle failed for emoji pack: ${err.message}`);
}
}
// Create new emoji pack — need first emoji
this._pendingPackTitle = desiredTitle;
this._pendingAddToPack = Date.now();
this._pendingAddType = 'custom_emoji';
await this._sendText(this.chatId, `тяф! ✨🐾 Emoji пак «${desiredTitle}» — жду первую картинку!\n📸 Пришли фото или изображение`);
}
/**
* /myemoji — show link to emoji pack + stats.
*/
async _cmdMyEmojiPack(msg) {
if (!this.pes.store) {
await this._sendText(this.chatId, 'скууу... 💧 память не работает... рру?.. 🐾');
return;
}
const emojiPacks = this.pes.store.getCreatedPacks('custom_emoji');
if (emojiPacks.length === 0) {
await this._sendText(this.chatId, '✨ Emoji пак ещё не создан\n💡 /newemojipack <Название> — создай!');
return;
}
const lines = [];
for (const pack of emojiPacks) {
lines.push(`✨ «${pack.title}» — ${pack.sticker_count} эмодзи`);
lines.push(`→ https://t.me/addemoji/${pack.pack_name}`);
lines.push('');
}
lines.push('💡 "сделай эмодзи" + фото — добавить в пак');
await this._sendText(this.chatId, lines.join('\n'));
}
// ═══════════════════════════════════════════════════════════
// METRONOME
// ═══════════════════════════════════════════════════════════
/**
* /metronome [bpm] [duration] — generate and send metronome click track.
* Examples: /metronome, /metronome 140, /metronome 80 60
*/
async _cmdMetronome(msg, args) {
const parts = args.trim().split(/\s+/).filter(Boolean);
let bpm = 120;
let duration = 30;
if (parts[0]) {
const parsed = parseInt(parts[0]);
if (!isNaN(parsed) && parsed >= 40 && parsed <= 300) bpm = parsed;
}
if (parts[1]) {
// Duration: "60" or "60с" or "1мин" or "1m"
const durMatch = parts[1].match(/^(\d+)(с|сек|s|мин|m|min)?$/i);
if (durMatch) {
let val = parseInt(durMatch[1]);
const unit = durMatch[2]?.toLowerCase();
if (unit && (unit === 'мин' || unit === 'm' || unit === 'min')) val *= 60;
duration = Math.max(5, Math.min(120, val));
}
}
await this._sendMetronome(bpm, duration);
}
// ═══════════════════════════════════════════════════════════
// INSIGHTS — conversation analysis
// ═══════════════════════════════════════════════════════════
async _cmdInsights(msg) {
if (!this.pes.store) {
await this._sendText(this.chatId, 'скууу... 💧 память не работает... рру?.. 🐾');
return;
}
const data = this.pes.store.gatherInsightsData();
if (!data || data.totalInteractions < 15) {
const status = this.pes.status();
const sounds = status.phase === 'puppy'
? 'тяф... тяф-тяф... 🐾'
: 'ррр... гав! 🐾';
await this._sendText(this.chatId,
`🔍 ${sounds}\n\n` +
`Ещё мало данных... Пообщаемся побольше и я расскажу что заметил!\n` +
`📊 Наблюдений: ${data?.totalInteractions || 0} / 15`
);
return;
}
// Build prompt for LLM
const insightsPrompt = this._buildInsightsPrompt(data);
if (!this._llm) {
// No LLM — raw data fallback
await this._sendInsightsRaw(data);
return;
}
try {
await this._sendText(this.chatId, '🔍 ...');
const result = await this._llm.generate(insightsPrompt, [
{ role: 'user', text: 'Расскажи что ты заметил о хозяине. Будь искренним, полезным, конкретным.' }
]);
if (result) {
const cleaned = result.replace(/\*[^*]+\*/g, '').trim();
await this._sendText(this.chatId, `🔍 Мои наблюдения:\n\n${cleaned}`);
// Save to insights log (anti-repeat)
this.pes.store.saveInsight('requested', cleaned, {
emotions: data.emotions.slice(0, 3).map(e => e.pattern_value),
totalInteractions: data.totalInteractions,
});
} else {
await this._sendInsightsRaw(data);
}
} catch (err) {
console.error('⚠️ Insights LLM error:', err.message);
await this._sendInsightsRaw(data);
}
}
_buildInsightsPrompt(data) {
const status = this.pes.status();
const petName = status.name || 'ПЕС';
// Format patterns for prompt
const emotionStr = data.emotions.map(e =>
`${e.pattern_value}: вес ${e.weight.toFixed(1)}, ${e.occurrences} раз`
).join('; ') || 'пока не определены';
const timeStr = data.timeOfDay.map(t =>
`${t.pattern_value}: вес ${t.weight.toFixed(1)}`
).join('; ') || 'пока не определено';
const styleStr = data.messageStyle.map(s =>
`${s.pattern_value}: вес ${s.weight.toFixed(1)}`
).join('; ') || 'не определён';
const emojisStr = data.emojisUsed.slice(0, 10).map(e =>
`${e.pattern_value} (${e.occurrences}×)`
).join(' ') || 'не использует';
const actionsStr = data.actions.map(a =>
`${a.pattern_value}: ${a.occurrences} раз`
).join('; ') || 'не зафиксированы';
// Relationship context
let relStr = '';
if (data.relationship) {
const r = data.relationship;
relStr = `Фаза: ${r.relationship_phase}, доверие: ${Math.round(r.trust * 100)}%, привязанность: ${Math.round(r.affection * 100)}%, встреч: ${r.interactions_count}`;
}
// Recent insights (anti-repeat)
const recentStr = data.recentInsights.length > 0
? `\nУЖЕ ГОВОРИЛ (НЕ ПОВТОРЯЙ ЭТО):\n${data.recentInsights.map(i => `- ${i.insight_text.slice(0, 100)}`).join('\n')}`
: '';
return `Ты — ${petName}, домашнее животное (ПЕС). Ты наблюдаешь за хозяином и делишься своими наблюдениями.
Говори от первого лица, как пёс. Используй звуки (гав, ррр, тяф) умеренно — 1-2 раза.
Будь КОНКРЕТНЫМ — называй цифры, паттерны, тренды. Не будь банальным.
Будь ЧЕСТНЫМ — если видишь что-то необычное, скажи. Ты — друг, а не подхалим.
ДАННЫЕ НАБЛЮДЕНИЙ:
- Всего взаимодействий: ${data.totalInteractions}
- Эмоции хозяина: ${emotionStr}
- Время активности: ${timeStr}
- Стиль сообщений: ${styleStr}
- Любимые эмодзи: ${emojisStr}
- Действия: ${actionsStr}
${relStr ? `- Отношения: ${relStr}` : ''}
${recentStr}
ПРАВИЛА:
- 3-5 конкретных наблюдений, каждое на новой строке
- Начни с самого интересного/неочевидного
- Если есть тренд (хозяин стал чаще/реже что-то делать) — скажи
- Не повторяй то что уже говорил
- Без звёздочек *действий*
- Максимум 500 символов`;
}
async _sendInsightsRaw(data) {
const lines = ['🔍 Мои наблюдения:', ''];
if (data.emotions.length > 0) {
lines.push('😊 Эмоции:');
for (const e of data.emotions.slice(0, 5)) {
lines.push(` ${e.pattern_value}: ${e.occurrences}×`);
}
}
if (data.timeOfDay.length > 0) {
lines.push('');
lines.push('🕐 Активность:');
for (const t of data.timeOfDay) {
const labels = { morning: '☀️ Утро', afternoon: '🌤 День', evening: '🌅 Вечер', night: '🌙 Ночь' };
lines.push(` ${labels[t.pattern_value] || t.pattern_value}: ${t.occurrences}×`);
}
}
if (data.emojisUsed.length > 0) {
lines.push('');
lines.push('💬 Любимые эмодзи:');
lines.push(` ${data.emojisUsed.slice(0, 8).map(e => `${e.pattern_value}(${e.occurrences})`).join(' ')}`);
}
if (data.messageStyle.length > 0) {
lines.push('');
lines.push('📝 Стиль:');
for (const s of data.messageStyle) {
const labels = { short: 'Короткие', medium: 'Средние', long: 'Длинные' };
lines.push(` ${labels[s.pattern_value] || s.pattern_value}: ${s.occurrences}×`);
}
}
lines.push('');
lines.push(`📊 Всего наблюдений: ${data.totalInteractions}`);
await this._sendText(this.chatId, lines.join('\n'));
}
/**
* Proactive observation — PES shares an insight unprompted (max 1/day).
*/
async _tryProactiveInsight() {
if (!this.pes.store || !this._llm) return;
try {
// Check last proactive insight time
const lastTime = this.pes.store.getLastInsightTime();
if (lastTime) {
const hoursSince = (Date.now() - lastTime.getTime()) / (1000 * 60 * 60);
if (hoursSince < 24) return; // Max 1 per day
}
const data = this.pes.store.gatherInsightsData();
if (!data || data.totalInteractions < 30) return; // Need enough data
// 15% chance per qualifying interaction (after cooldown passes)
if (Math.random() > 0.15) return;
const proactivePrompt = this._buildInsightsPrompt(data) +
'\n\nЭто ПРОАКТИВНОЕ наблюдение — ты сам решил поделиться. ' +
'Выбери ОДНО самое интересное наблюдение. Коротко, 1-2 предложения. ' +
'Начни с "Гав! Я заметил..." или "Ррр... знаешь что?" или подобного.';
const result = await this._llm.generate(proactivePrompt, [
{ role: 'user', text: 'Поделись одним наблюдением.' }
]);
if (result) {
const cleaned = result.replace(/\*[^*]+\*/g, '').trim();
await this._sendText(this.chatId, `💡 ${cleaned}`);
this.pes.store.saveInsight('proactive', cleaned, {
trigger: 'auto',
totalInteractions: data.totalInteractions,
});
}
} catch (err) {
console.error('⚠️ Proactive insight error:', err.message);
}
}
// ═══════════════════════════════════════════════════════════
// OWNER CONTACTS
// ═══════════════════════════════════════════════════════════
/**
* Handle shared Telegram contact (vCard).
* Auto-saves name + phone + telegram_user_id.
*/
async _handleSharedContact(msg) {
if (!this.pes.store) return;
const c = msg.contact;
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || 'Unknown';
const phone = c.phone_number || null;
const telegramUserId = c.user_id ? String(c.user_id) : null;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
const saved = this.pes.store.saveContact({
owner_id: ownerId,
name,
phone,
telegram_user_id: telegramUserId,
});
await this._sendText(this.chatId, pickRandom([
`ваф! 👤🐾 ${name} — запомнил! рру~`,
`тяф! 👤 ${name} 🐾 ррФФ!`,
`гав! 👤 ${name} — в памяти! 🐾`,
]));
this._addToMemory('owner', `[поделился контактом: ${name}]`);
this._addToMemory('pes', `[запомнил контакт: ${name}${phone ? ' ' + phone : ''}]`);
await this._processSmartXP(msg, 'note', 'contact');
this._updateRelationship('message');
}
/**
* Parse free-text contact string and save.
* Expected: "Имя +79161234567 ДР 15 мая email@test.com друг"
*/
async _parseAndSaveContact(msg, contactStr) {
if (!this.pes.store) return;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
// Extract phone
const phoneMatch = contactStr.match(/(\+?\d[\d\s\-()]{6,15}\d)/);
const phone = phoneMatch ? phoneMatch[1].replace(/[\s\-()]/g, '') : null;
// Extract email
const emailMatch = contactStr.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/);
const email = emailMatch ? emailMatch[1] : null;
// Extract birthday (DD.MM, DD/MM, DD месяца, ДР DD.MM)
let birthday = null;
let birthdayYear = null;
const bdMatch = contactStr.match(/(?:др|день рождения|birthday|ДР)\s*[:\s]*(\d{1,2})[.\/-](\d{1,2})(?:[.\/-](\d{4}))?/i);
const bdMatch2 = contactStr.match(/(?:др|день рождения|birthday|ДР)\s*[:\s]*(\d{1,2})\s*(января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)/i);
if (bdMatch) {
const day = bdMatch[1].padStart(2, '0');
const month = bdMatch[2].padStart(2, '0');
birthday = `${month}-${day}`;
if (bdMatch[3]) birthdayYear = parseInt(bdMatch[3]);
} else if (bdMatch2) {
const day = bdMatch2[1].padStart(2, '0');
const months = { 'января': '01', 'февраля': '02', 'марта': '03', 'апреля': '04', 'мая': '05', 'июня': '06', 'июля': '07', 'августа': '08', 'сентября': '09', 'октября': '10', 'ноября': '11', 'декабря': '12' };
const month = months[bdMatch2[2].toLowerCase()];
if (month) birthday = `${month}-${day}`;
}
// Extract relationship
const relMatch = contactStr.match(/(?:друг|подруга|коллега|брат|сестра|мама|папа|жена|муж|начальник|клиент|family|friend|colleague)/i);
const relationship = relMatch ? relMatch[0].toLowerCase() : null;
// Name = everything except extracted parts
let name = contactStr
.replace(phoneMatch ? phoneMatch[0] : '', '')
.replace(emailMatch ? emailMatch[0] : '', '')
.replace(bdMatch ? bdMatch[0] : (bdMatch2 ? bdMatch2[0] : ''), '')
.replace(relMatch ? relMatch[0] : '', '')
.replace(/[,;]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!name) {
await this._sendText(this.chatId, pickRandom([
'рру?.. 👤 имя? 🐾',
'ваф?.. кто это? имя! 🐾',
]));
return;
}
const saved = this.pes.store.saveContact({
owner_id: ownerId,
name,
phone,
email,
birthday,
birthday_year: birthdayYear,
relationship,
});
const parts = [`👤 ${name}`];
if (phone) parts.push(`📱 ${phone}`);
if (email) parts.push(`📧 ${email}`);
if (birthday) parts.push(`🎂 ${birthday}${birthdayYear ? '.' + birthdayYear : ''}`);
if (relationship) parts.push(`🤝 ${relationship}`);
await this._sendText(this.chatId, pickRandom([
`ваф! 🐾 запомнил!\n${parts.join('\n')}`,
`тяф! 👤🐾 рру!\n${parts.join('\n')}`,
`гав! 📋🐾\n${parts.join('\n')}`,
]));
this._addToMemory('owner', `[добавил контакт: ${name}]`);
this._addToMemory('pes', `[запомнил: ${parts.join(', ')}]`);
await this._processSmartXP(msg, 'note', 'contact');
this._updateRelationship('message');
}
/**
* Search contacts and show results.
*/
async _searchAndShowContact(msg, query) {
if (!this.pes.store) return;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
const contacts = this.pes.store.searchContacts(query, { owner_id: ownerId, limit: 5 });
if (contacts.length === 0) {
await this._sendText(this.chatId, pickRandom([
`скууу... рру?.. не знаю... 💧`,
`ыыы... хнн... рру?.. 💧🐾`,
]));
this._addToMemory('owner', `[спросил: кто такой ${query}]`);
this._addToMemory('pes', `[не нашёл контакт: ${query}]`);
return;
}
await this._sendText(this.chatId, pickRandom([
`ваф! 👤🐾 рру!!`,
`тяф тяф! 👤 ррРР!! 🐾`,
]));
const lines = [];
for (const c of contacts) {
lines.push(`👤 ${c.name}`);
if (c.phone) lines.push(` 📱 ${c.phone}`);
if (c.email) lines.push(` 📧 ${c.email}`);
if (c.birthday) {
const bd = c.birthday; // MM-DD
lines.push(` 🎂 ${bd.split('-').reverse().join('.')}${c.birthday_year ? '.' + c.birthday_year : ''}`);
}
if (c.relationship) lines.push(` 🤝 ${c.relationship}`);
if (c.notes) lines.push(` 📝 ${c.notes}`);
if (contacts.length > 1) lines.push('');
}
await this._sendText(this.chatId, lines.join('\n'));
this._addToMemory('owner', `[спросил: кто такой ${query}]`);
this._addToMemory('pes', `[нашёл ${contacts.length} контактов: ${query}]`);
await this._processSmartXP(msg, 'text');
this._updateRelationship('message');
}
/**
* /contacts command — show all saved contacts.
*/
async _cmdContacts(msg) {
if (!this.pes.store) return;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from?.id);
const contacts = this.pes.store.getAllContacts(ownerId);
if (contacts.length === 0) {
await this._sendText(this.chatId, [
`👤 Контактов пока нет 🐾`,
``,
`Добавь:`,
`• "запомни контакт: Олег +79161234567 ДР 15.05"`,
`• Переслать контакт из Telegram`,
`• /addcontact Имя +phone`,
].join('\n'));
return;
}
const lines = [`👤 Контакты (${contacts.length}):`];
for (const c of contacts) {
let line = `\n${c.name}`;
if (c.phone) line += ` 📱${c.phone}`;
if (c.birthday) line += ` 🎂${c.birthday.split('-').reverse().join('.')}`;
if (c.relationship) line += ` (${c.relationship})`;
lines.push(line);
}
await this._sendText(this.chatId, lines.join(''));
}
/**
* /addcontact Name +phone — quick add.
*/
async _cmdAddContact(msg, args) {
if (!args.trim()) {
await this._sendText(this.chatId, [
`👤 /addcontact Имя +79161234567`,
``,
`Или: "запомни контакт: Олег +7916... ДР 15.05 друг"`,
].join('\n'));
return;
}
await this._parseAndSaveContact(msg, args.trim());
}
/**
* Check birthdays — called from reminder checker (daily).
* Alerts 1 day before and on the day itself.
*/
async _checkBirthdays() {
if (!this.pes.store) return;
try {
const due = this.pes.store.getDueBirthdays();
if (due.length === 0) return;
const d = new Date();
const msk = new Date(d.getTime() + 3 * 3600000);
const todayStr = String(msk.getMonth() + 1).padStart(2, '0') + '-' + String(msk.getDate()).padStart(2, '0');
for (const c of due) {
const isToday = c.birthday === todayStr;
const ageStr = c.birthday_year ? ` (${msk.getFullYear() - c.birthday_year} лет!)` : '';
if (isToday) {
await this._sendText(this.chatId, pickRandom([
`ГАААВ!! 🎂🎉🐾 У ${c.name} СЕГОДНЯ ДЕНЬ РОЖДЕНИЯ${ageStr}!! ваф-ваф-ваф!! 🎈`,
`тяф тяф!! 🎂🎉 ${c.name} — ДЕНЬ РОЖДЕНИЯ${ageStr}!! ГАААВ!! 🐾🎈`,
]));
} else {
await this._sendText(this.chatId, pickRandom([
`тяф! 🎂🐾 Завтра у ${c.name} день рождения${ageStr}! рру~`,
`ваф! 🎂 ${c.name} — завтра ДР${ageStr}! 🐾`,
]));
}
this.pes.store.markBirthdayReminded(c.id);
console.log(`🎂 Birthday alert: ${c.name} (${c.birthday})`);
}
} catch (err) {
console.error('⚠️ Birthday check error:', err.message);
}
}
// ═══════════════════════════════════════════════════════════
// INLINE QUERY — sticker collection in any chat
// ═══════════════════════════════════════════════════════════
async _handleInlineQuery(query) {
try {
const queryText = (query.query || '').trim().toLowerCase();
if (!this.pes.store) {
await this._call('answerInlineQuery', { inline_query_id: query.id, results: [] });
return;
}
const results = [];
// ── Sharing artifacts via inline mode ──
if (queryText === 'status' || queryText === 'card' || queryText === 'summary' || queryText.startsWith('note:')) {
const shareResults = this._buildShareInlineResults(queryText);
await this._call('answerInlineQuery', {
inline_query_id: query.id,
results: shareResults.slice(0, 50),
cache_time: 5,
is_personal: true,
});
return;
}
// ── Main collection pack (priority) + other packs ──
const mainPack = this.pes.store.getMainCollectionPack();
const allPacks = this.pes.store.getCreatedPacks();
// Reorder: main pack first, then others
const orderedPacks = [];
if (mainPack) orderedPacks.push(mainPack);
for (const p of allPacks) {
if (mainPack && p.pack_name === mainPack.pack_name) continue;
orderedPacks.push(p);
}
for (const pack of orderedPacks.slice(0, 3)) {
// Skip emoji packs in regular sticker section (they go separately)
if (pack.pack_type === 'custom_emoji') continue;
if (queryText && !pack.title.toLowerCase().includes(queryText) && !pack.pack_name.toLowerCase().includes(queryText)) {
continue;
}
try {
const setInfo = await this._call('getStickerSet', { name: pack.pack_name });
if (setInfo && setInfo.stickers) {
const isMain = mainPack && pack.pack_name === mainPack.pack_name;
for (const stk of setInfo.stickers.slice(0, isMain ? 40 : 15)) {
const origin = this.pes.store.getStickerOrigin(stk.file_unique_id);
let replyMarkup;
if (origin && origin.original_set_name) {
const title = (origin.original_set_title || origin.original_set_name).slice(0, 30);
replyMarkup = {
inline_keyboard: [[{
text: `📦 Из пака «${title}»`,
url: `https://t.me/addstickers/${origin.original_set_name}`,
}]],
};
} else {
replyMarkup = {
inline_keyboard: [[{
text: `🎨 «${(pack.title || '').slice(0, 30)}»`,
url: `https://t.me/addstickers/${pack.pack_name}`,
}]],
};
}
results.push({
type: 'sticker',
id: `pack_${pack.id}_${stk.file_unique_id}`,
sticker_file_id: stk.file_id,
reply_markup: replyMarkup,
});
if (results.length >= 45) break;
}
}
} catch (err) {
console.error(`⚠️ Inline getStickerSet error for ${pack.pack_name}: ${err.message}`);
results.push({
type: 'article',
id: `pack_${pack.id}`,
title: `🎨 ${pack.title} (${pack.sticker_count})`,
description: `Мой пак — ${pack.sticker_count} шт.`,
input_message_content: {
message_text: `🎨 ${pack.title}\n📦 ${pack.sticker_count} стикеров\n→ https://t.me/addstickers/${pack.pack_name}`,
},
});
}
}
// ── Collection bookmarks (fill remaining slots) ──
if (results.length < 50) {
const maxCollection = Math.max(5, 50 - results.length);
const collection = this.pes.store.getCollection({ query: queryText || null });
for (const item of collection.slice(0, maxCollection)) {
const setTitle = item.set_title || item.set_name || 'Без пака';
const replyMarkup = item.set_name ? {
inline_keyboard: [[{
text: `📦 Открыть пак «${setTitle.slice(0, 30)}»`,
url: `https://t.me/addstickers/${item.set_name}`,
}]],
} : undefined;
results.push({
type: 'sticker',
id: `col_${item.id}`,
sticker_file_id: item.file_id,
reply_markup: replyMarkup,
});
}
}
// If empty, show hint
if (results.length === 0) {
const hint = mainPack
? `📦 Коллекция пуста\n💡 "добавь в пак" + стикер/фото\n→ https://t.me/addstickers/${mainPack.pack_name}`
: '📦 Коллекция пуста\n💡 /newpack <Название> — создай свою коллекцию!';
results.push({
type: 'article',
id: 'empty_hint',
title: '📦 Коллекция пуста',
description: mainPack ? 'Добавь стикеры в пак!' : '/newpack — создай коллекцию!',
input_message_content: { message_text: hint },
});
}
await this._call('answerInlineQuery', {
inline_query_id: query.id,
results: results.slice(0, 50),
cache_time: 10,
is_personal: true,
});
console.log(`🔍 Inline query "${queryText}" → ${results.length} results (${collection.length} collection)`);
} catch (err) {
console.error(`⚠️ Inline query error: ${err.message}`);
try {
await this._call('answerInlineQuery', { inline_query_id: query.id, results: [] });
} catch (_) {}
}
}
/**
* Parse reminder time from command args.
* Formats: "30м текст", "2ч текст", "1д текст", "15:30 текст"
*/
_parseReminderFromCommand(args) {
const parts = args.trim().split(/\s+/);
if (parts.length < 2) return null;
const timeStr = parts[0];
const text = parts.slice(1).join(' ');
// Duration: 30м, 2ч, 1д
const durMatch = timeStr.match(/^(\d+)(м|мин|ч|час|д|дн|день|дней)$/i);
if (durMatch) {
const val = parseInt(durMatch[1]);
const unit = durMatch[2].toLowerCase();
let ms = 0;
if (unit.startsWith('м')) ms = val * 60 * 1000;
else if (unit.startsWith('ч')) ms = val * 60 * 60 * 1000;
else if (unit.startsWith('д')) ms = val * 24 * 60 * 60 * 1000;
const remindAt = new Date(Date.now() + ms).toISOString();
return { text, remindAt };
}
// Time: 15:30
const timeMatch = timeStr.match(/^(\d{1,2}):(\d{2})$/);
if (timeMatch) {
const hour = parseInt(timeMatch[1]);
const minute = parseInt(timeMatch[2]);
// Moscow timezone (UTC+3)
const now = new Date();
const moscowNow = new Date(now.getTime() + 3 * 60 * 60 * 1000);
let target = new Date(moscowNow);
target.setUTCHours(hour - 3, minute, 0, 0); // convert MSK to UTC
if (target <= now) target.setDate(target.getDate() + 1); // next day
return { text, remindAt: target.toISOString() };
}
return null;
}
/**
* Parse reminder intent from natural text (via LLM or keywords).
* Returns { text, remindAt } or null.
*/
async _detectReminderIntent(text) {
// Quick keyword check first
const reminderPatterns = [
/напомни\s+(через\s+)?(\d+)\s*(мин|минут|час|часа|часов|день|дня|дней)/i,
/напомни\s+в\s+(\d{1,2}):(\d{2})/i,
/напоминалк/i,
/reminder/i,
];
const match1 = text.match(/напомни\s+(?:мне\s+)?(?:через\s+)?(\d+)\s*(мин(?:ут(?:у|ы|ок)?)?|час(?:а|ов)?|день|дн(?:я|ей)?)\s+(.+)/i);
if (match1) {
const val = parseInt(match1[1]);
const unit = match1[2].toLowerCase();
const reminderText = match1[3].trim();
let ms = 0;
if (unit.startsWith('мин')) ms = val * 60 * 1000;
else if (unit.startsWith('час')) ms = val * 60 * 60 * 1000;
else if (unit.startsWith('д')) ms = val * 24 * 60 * 60 * 1000;
if (ms > 0 && reminderText) {
return { text: reminderText, remindAt: new Date(Date.now() + ms).toISOString() };
}
}
const match2 = text.match(/напомни\s+(?:мне\s+)?в\s+(\d{1,2}):(\d{2})\s+(.+)/i);
if (match2) {
const hour = parseInt(match2[1]);
const minute = parseInt(match2[2]);
const reminderText = match2[3].trim();
const now = new Date();
let target = new Date(now);
target.setUTCHours(hour - 3, minute, 0, 0); // MSK to UTC
if (target <= now) target.setDate(target.getDate() + 1);
return { text: reminderText, remindAt: target.toISOString() };
}
return null;
}
// ═══════════════════════════════════════════════════════════
// LLM INTENT ROUTER
// ═══════════════════════════════════════════════════════════
/**
* Classify intent using LLM when regex fails.
* Returns { intent, params, confidence } or null.
*/
async _classifyIntent(text) {
if (!this._llm) return null;
const systemPrompt = `Ты классификатор намерений. Пользователь пишет своему домашнему питомцу-боту.
Определи намерение и извлеки параметры. Ответь ТОЛЬКО JSON, без markdown.
Доступные намерения:
- contact_search: поиск контакта (по имени, телефону). params: { name }
- contact_save: сохранить контакт. params: { name, phone?, email?, birthday?, relationship? }
- note_save: сохранить заметку/запомнить. params: { text }
- note_search: найти заметку/вспомнить. params: { query }
- reminder_create: создать напоминание. params: { text, time? }
- sticker_create: создать стикер из фото/стикера. params: { type: "regular"|"custom_emoji" }
- sticker_add: добавить стикер в пак. params: {}
- packs_list: показать паки стикеров. params: {}
- file_search: найти файл/фото/документ. params: { query }
- file_save: сохранить файл. params: {}
- translate: перевести текст. params: { text, lang? }
- status: статус/как дела. params: {}
- insights: аналитика/инсайты. params: {}
- contacts_list: показать все контакты. params: {}
- help: помощь/команды. params: {}
- silent_toggle: тихий режим. params: {}
- fetch: принеси/найди (игра). params: { target? }
- birthday_query: когда день рождения у X. params: { name }
- note_delete: удалить заметку. params: { query }
- reminders_list: показать напоминания. params: {}
- collection_add: сохранить стикер в коллекцию. params: {}
- collection_remove: убрать стикер из коллекции. params: {}
- collection_list: показать коллекцию стикеров. params: {}
- sticker_origin: откуда стикер, из какого пака, кто автор. params: {}
- mypack: показать мой пак/коллекцию, ссылка на пак. params: {}
- rename_pack: переименовать пак. params: { name }
- create_pack: создать пак/коллекцию. params: { name? }
- create_emoji_pack: создать emoji пак. params: { name? }
- my_emoji: показать emoji пак. params: {}
- gif_sticker: сделать GIF/видео стикер. params: {}
- metronome: метроном/темп/ритм. params: { bpm?, duration? }
- none: не подходит ни одно намерение
Формат ответа:
{"intent":"...","params":{...},"confidence":0.0-1.0}
Примеры:
"номер Олега" → {"intent":"contact_search","params":{"name":"Олег"},"confidence":0.95}
"кинь стикер в пак" → {"intent":"sticker_add","params":{},"confidence":0.9}
"что у меня завтра" → {"intent":"note_search","params":{"query":"завтра"},"confidence":0.85}
"покажи мои стикеры" → {"intent":"packs_list","params":{},"confidence":0.9}
"когда ДР у Маши" → {"intent":"birthday_query","params":{"name":"Маша"},"confidence":0.95}
"тихо" → {"intent":"silent_toggle","params":{},"confidence":0.8}
"удали заметку про встречу" → {"intent":"note_delete","params":{"query":"встречу"},"confidence":0.9}
"напомни позвонить маме" → {"intent":"reminder_create","params":{"text":"позвонить маме"},"confidence":0.9}
"сохрани Олег +79161234567 друг" → {"intent":"contact_save","params":{"name":"Олег","phone":"+79161234567","relationship":"друг"},"confidence":0.95}
"переведи на англ спасибо" → {"intent":"translate","params":{"text":"спасибо","lang":"английский"},"confidence":0.95}
"покажи коллекцию" → {"intent":"collection_list","params":{},"confidence":0.9}
"мои стикеры" → {"intent":"collection_list","params":{},"confidence":0.85}
"сохрани стикер" → {"intent":"collection_add","params":{},"confidence":0.9}
"убери из коллекции" → {"intent":"collection_remove","params":{},"confidence":0.9}
"откуда этот стикер" → {"intent":"sticker_origin","params":{},"confidence":0.9}
"из какого пака" → {"intent":"sticker_origin","params":{},"confidence":0.85}
"мой пак" → {"intent":"mypack","params":{},"confidence":0.9}
"ссылка на пак" → {"intent":"mypack","params":{},"confidence":0.9}
"создай пак Nikich" → {"intent":"create_pack","params":{"name":"Nikich"},"confidence":0.95}
"создай коллекцию" → {"intent":"create_pack","params":{},"confidence":0.9}
"переименуй пак в NewName" → {"intent":"rename_pack","params":{"name":"NewName"},"confidence":0.95}
"создай эмодзи пак" → {"intent":"create_emoji_pack","params":{},"confidence":0.9}
"мои эмодзи" → {"intent":"my_emoji","params":{},"confidence":0.9}
"сделай гифку" → {"intent":"gif_sticker","params":{},"confidence":0.9}
"видео стикер" → {"intent":"gif_sticker","params":{},"confidence":0.85}
"ррр привет лол" → {"intent":"none","params":{},"confidence":0.3}`;
// Add recent conversation context for follow-ups ("да", "нет", "ещё")
let userText = text;
if (text.length < 15 && this._memory && this._memory.length > 0) {
const recent = this._memory.slice(-4).map(m => `${m.role}: ${m.text}`).join('\n');
userText = `[Контекст последних сообщений:\n${recent}]\n\nТекущее сообщение: ${text}`;
}
try {
const result = await this._llm.classify(systemPrompt, userText);
if (!result || !result.intent) return null;
if (result.confidence < 0.6) return null;
if (result.intent === 'none') return null;
console.log(`🧠 LLM Intent: ${result.intent} (${result.confidence}) params:`, JSON.stringify(result.params));
return result;
} catch (err) {
console.error('⚠️ LLM classify error:', err.message);
return null;
}
}
/**
* Route a classified intent to the correct handler.
* Returns true if handled, false if should fall through to generic reply.
*/
async _routeIntent(msg, intent) {
const text = msg.text?.trim() || '';
const p = intent.params || {};
switch (intent.intent) {
case 'contact_search':
case 'birthday_query': {
const name = p.name || text.replace(/^.*?(номер|контакт|телефон|дн|birthday|когда)\s*/i, '').trim();
if (name && this.pes.store) {
await this._searchAndShowContact(msg, name);
return true;
}
return false;
}
case 'contact_save': {
if (this.pes.store) {
// Build contact string from params
const parts = [p.name || ''];
if (p.phone) parts.push(p.phone);
if (p.email) parts.push(p.email);
if (p.birthday) parts.push(`ДР ${p.birthday}`);
if (p.relationship) parts.push(p.relationship);
await this._parseAndSaveContact(msg, parts.join(' ').trim() || text);
return true;
}
return false;
}
case 'contacts_list': {
await this._cmdContacts(msg);
return true;
}
case 'note_save': {
if (this.pes.store) {
const noteText = p.text || text.replace(/^(запомни|запиши|сохрани|заметка)\s*/i, '').trim();
if (!noteText) return false;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
const tags = noteText.replace(/[^\wа-яА-ЯёЁ\s]/g, '').split(/\s+/).filter(w => w.length > 2).slice(0, 10);
const pinned = /важно|навсегда|не забудь/i.test(text);
const remindAt = this._extractDateTimeFromNote(noteText);
this.pes.store.saveNote({ owner_id: ownerId, text: noteText, tags, category: this._guessNoteCategory(noteText), pinned, source_message_id: msg.message_id, remind_at: remindAt });
await this._sendText(this.chatId, pickRandom([`ваф! 📝🐾${pinned ? ' 📌' : ''} рру~${remindAt ? ' ⏰' : ''}`, `тяф! 📝 ♡${pinned ? ' 📌' : ''} рру~~${remindAt ? ' ⏰' : ''}`, `гав! 📝🐾${pinned ? ' 📌' : ''} ррФФ~${remindAt ? ' ⏰' : ''}`]));
this._addToMemory('owner', text);
this._addToMemory('pes', `[запомнил: ${noteText}]`);
await this._processSmartXP(msg, 'note', 'note');
this._updateRelationship('message');
return true;
}
return false;
}
case 'note_search': {
if (this.pes.store) {
const query = p.query || text;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
const notes = this.pes.store.searchNotes(query, { owner_id: ownerId, limit: 5 });
if (notes.length === 0) {
await this._sendText(this.chatId, pickRandom(['скууу... ыыы... рру?.. 💧', 'рру?... ...ууу... хнн... 💧']));
} else {
await this._sendText(this.chatId, pickRandom(['ваф! 📝🐾 рру!!', 'тяф тяф! 📝 ррРР!! 🐾']));
const lines = notes.map(n => `${n.pinned ? '📌 ' : ''}${n.text}`);
await this._sendText(this.chatId, lines.join('\n'));
}
this._addToMemory('owner', `[спросил: ${query}]`);
this._addToMemory('pes', `[нашёл ${notes.length} заметок]`);
await this._processSmartXP(msg, 'text');
this._updateRelationship('message');
return true;
}
return false;
}
case 'note_delete': {
if (this.pes.store) {
const query = p.query || text;
const ownerId = this.pes.store.getConfig('owner_id') || String(msg.from.id);
const notes = this.pes.store.searchNotes(query, { owner_id: ownerId, limit: 1 });
if (notes.length > 0) {
this.pes.store.deleteNote(notes[0].id);
await this._sendText(this.chatId, pickRandom(['ваф! 🗑️🐾 рру~', 'тяф! 🗑️ ♡ ррр~']));
this._addToMemory('pes', `[удалил заметку: ${notes[0].text}]`);
} else {
await this._sendText(this.chatId, pickRandom(['скууу... рру?.. 💧', 'ыыы... хнн... 💧']));
}
return true;
}
return false;
}
case 'reminder_create': {
if (this.pes.store) {
const reminderText = p.text || text.replace(/^напомни\s*/i, '').trim();
if (!reminderText) return false;
// Try to extract time from params or text
let remindAt = null;
if (p.time) {
remindAt = this._extractDateTimeFromNote(p.time);
}
if (!remindAt) {
remindAt = this._extractDateTimeFromNote(text);
}
if (!remindAt) {
// Default: 1 hour from now
remindAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
}
this.pes.store.createReminder(reminderText, remindAt);
await this._sendText(this.chatId, pickRandom(['ваф! 📋🐾 рру~ ⏰', 'тяф! 📋 рру~~ ⏰ 🐾', 'гав! 📋🐾 ррФФ~ ⏰']));
this._addToMemory('owner', text);
this._addToMemory('pes', `[напоминалка: ${reminderText}]`);
return true;
}
return false;
}
case 'reminders_list': {
await this._cmdReminders(msg);
return true;
}
case 'sticker_create': {
const createType = (p.type === 'custom_emoji') ? 'custom_emoji' : 'regular';
this._pendingAddToPack = Date.now();
this._pendingAddType = createType;
await this._sendText(this.chatId, pickRandom([
'тяф! 🎨🐾 ...жду стикер или фото! 📸',
'ваф! 🖌️🐾 ...пришли стикер или картинку! ✨',
'рру~~ 🎨 ...давай! стикер! фото! 🐾',
]));
return true;
}
case 'sticker_add': {
this._pendingAddToPack = Date.now();
this._pendingAddType = 'regular';
await this._sendText(this.chatId, pickRandom([
'тяф! 🎨🐾 ...жду стикер или фото! 📸',
'ваф! 🖌️🐾 ...пришли стикер или картинку! ✨',
]));
return true;
}
case 'packs_list': {
await this._cmdPacks(msg);
return true;
}
case 'collection_list': {
await this._cmdCollection(msg);
return true;
}
case 'collection_add': {
// Need a reply to sticker for this to work
if (msg.reply_to_message?.sticker && this.pes.store) {
await this._addToCollection(msg, msg.reply_to_message.sticker);
return true;
}
// No sticker to save — tell user
await this._sendText(this.chatId, pickRandom([
'тяф! 📦🐾 ...reply на стикер + "сохрани"! 💡',
'ваф! 📦 ...ответь на стикер чтобы сохранить! 🐾',
]));
return true;
}
case 'collection_remove': {
if (msg.reply_to_message?.sticker && this.pes.store) {
await this._removeFromCollection(msg, msg.reply_to_message.sticker);
return true;
}
await this._sendText(this.chatId, pickRandom([
'тяф! 📦🐾 ...reply на стикер + "убери"! 💡',
]));
return true;
}
case 'sticker_origin': {
await this._cmdOrigin(msg);
return true;
}
case 'mypack': {
await this._cmdMyPack(msg);
return true;
}
case 'create_pack': {
const name = p.name || '';
await this._cmdNewPack(msg, name);
return true;
}
case 'create_emoji_pack': {
const name = p.name || '';
await this._cmdNewEmojiPack(msg, name);
return true;
}
case 'my_emoji': {
await this._cmdMyEmojiPack(msg);
return true;
}
case 'gif_sticker': {
// Set pending for GIF/video sticker creation
this._pendingAddToPack = Date.now();
this._pendingAddType = 'regular';
await this._sendText(this.chatId, pickRandom([
'тяф! 🎬🐾 ...жду GIF или видео! 📹',
'ваф! 🎬 ...пришли гифку или видео! 🐾',
]));
return true;
}
case 'metronome': {
const bpm = (p.bpm && parseInt(p.bpm)) || 120;
const duration = (p.duration && parseInt(p.duration)) || 30;
await this._sendMetronome(Math.max(40, Math.min(300, bpm)), Math.max(5, Math.min(120, duration)));
return true;
}
case 'rename_pack': {
const name = p.name || '';
await this._cmdRenamePack(msg, name);
return true;
}
case 'file_search': {
await this._handleFileSearch(p.query || text);
await this._processSmartXP(msg, 'text');
this._updateRelationship('message');
return true;
}
case 'file_save': {
await this._sendText(this.chatId, pickRandom([
'тяф! 📁🐾 ...жду файл! 📎',
'ваф! 📁 ...пришли файл! 🐾',
]));
return true;
}
case 'translate': {
const sourceText = p.text || text.replace(/^(переведи|translate|перевод)\s*/i, '').trim();
const lang = p.lang || 'английский';
if (sourceText) {
await this._handleTranslation(sourceText, lang);
await this._processSmartXP(msg, 'command', 'translate');
this._updateRelationship('message');
return true;
}
return false;
}
case 'status': {
await this._sendStatus();
return true;
}
case 'insights': {
await this._cmdInsights(msg);
return true;
}
case 'help': {
await this._cmdHelp(msg);
return true;
}
case 'silent_toggle': {
await this._cmdSilent(msg);
return true;
}
case 'fetch': {
const target = p.target || text.replace(/^(принеси|найди|ищи|fetch|find|апорт)\s*/i, '').trim();
await this._handleFetch(target || 'мячик');
return true;
}
default:
return false;
}
}
/**
* Extract date/time from a note text for auto-remind.
* Detects patterns like: "в пятницу", "завтра", "через 2 часа", "в 15:00", "20 марта"
* Returns ISO-8601 string or null.
*/
_extractDateTimeFromNote(text) {
const lower = text.toLowerCase();
// "через N минут/часов/дней"
const durationMatch = lower.match(/через\s+(\d+)\s*(мин(?:ут(?:у|ы|ок)?)?|час(?:а|ов)?|дн(?:я|ей)?|день)/);
if (durationMatch) {
const val = parseInt(durationMatch[1]);
const unit = durationMatch[2];
let ms = 0;
if (unit.startsWith('мин')) ms = val * 60 * 1000;
else if (unit.startsWith('час')) ms = val * 60 * 60 * 1000;
else if (unit.startsWith('д')) ms = val * 24 * 60 * 60 * 1000;
if (ms > 0) return new Date(Date.now() + ms).toISOString();
}
// "в HH:MM"
const timeMatch = lower.match(/в\s+(\d{1,2}):(\d{2})/);
if (timeMatch) {
const hour = parseInt(timeMatch[1]);
const minute = parseInt(timeMatch[2]);
const now = new Date();
let target = new Date(now);
target.setUTCHours(hour - 3, minute, 0, 0); // MSK to UTC
if (target <= now) target.setDate(target.getDate() + 1);
return target.toISOString();
}
// Day of week detection
const dayNames = {
'понедельник': 1, 'вторник': 2, 'среду': 3, 'среда': 3, 'четверг': 4,
'пятницу': 5, 'пятница': 5, 'субботу': 6, 'суббота': 6,
'воскресенье': 0, 'воскресень': 0,
};
const dayMatch = lower.match(/в\s+(понедельник|вторник|среду|среда|четверг|пятницу|пятница|субботу|суббота|воскресенье)/);
if (dayMatch) {
const targetDay = dayNames[dayMatch[1]];
if (targetDay !== undefined) {
const now = new Date();
const moscowNow = new Date(now.getTime() + 3 * 60 * 60 * 1000);
const currentDay = moscowNow.getUTCDay();
let daysUntil = targetDay - currentDay;
if (daysUntil <= 0) daysUntil += 7;
const target = new Date(now.getTime() + daysUntil * 24 * 60 * 60 * 1000);
target.setUTCHours(9 - 3, 0, 0, 0); // Default: 09:00 MSK
return target.toISOString();
}
}
// "завтра"
if (/завтра/.test(lower)) {
const target = new Date(Date.now() + 24 * 60 * 60 * 1000);
target.setUTCHours(9 - 3, 0, 0, 0); // 09:00 MSK
return target.toISOString();
}
// "послезавтра"
if (/послезавтра/.test(lower)) {
const target = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
target.setUTCHours(9 - 3, 0, 0, 0);
return target.toISOString();
}
return null; // No date/time detected
}
/**
* Check for upcoming note reminders and mention them contextually
* when owner sends a message. Called from _handleTextMessage.
*/
async _checkContextualReminders() {
if (!this.pes.store) return;
// Don't check too often (max once per 10 min)
if (this._lastContextualCheck && Date.now() - this._lastContextualCheck < 10 * 60 * 1000) return;
this._lastContextualCheck = Date.now();
const upcoming = this.pes.store.getUpcomingNoteReminders(30); // within 30 min
if (upcoming.length === 0) return;
// "Bring" the most urgent one
const urgent = upcoming[0];
const diffMs = new Date(urgent.remind_at).getTime() - Date.now();
const mins = Math.round(diffMs / (60 * 1000));
if (mins <= 0) return; // Will be caught by _checkReminders
// Dog tugs at owner's sleeve — contextual hint
await sleep(1000);
await this._sendText(this.chatId, pickRandom([
`тяф? рру~! 📋🐾 ...${mins} мин...`,
`ваф! рру? 📋🐾 ...скоро...`,
`ррруу~ тяф! 📋🐾 ...${mins} мин...`,
]));
await this._sendText(this.chatId, `📋 ${urgent.text}`);
}
/**
* Format reminder time for display (MSK).
*/
_formatReminderTime(isoDate) {
const date = new Date(isoDate);
const moscowDate = new Date(date.getTime() + 3 * 60 * 60 * 1000);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (diffMs < 60 * 60 * 1000) {
const mins = Math.round(diffMs / (60 * 1000));
return `через ${mins} мин`;
}
if (diffMs < 24 * 60 * 60 * 1000) {
const hours = Math.round(diffMs / (60 * 60 * 1000));
return `через ${hours}ч`;
}
const hh = String(moscowDate.getUTCHours()).padStart(2, '0');
const mm = String(moscowDate.getUTCMinutes()).padStart(2, '0');
const dd = String(moscowDate.getUTCDate()).padStart(2, '0');
const mo = String(moscowDate.getUTCMonth() + 1).padStart(2, '0');
return `${dd}.${mo} в ${hh}:${mm} (MSK)`;
}
/**
* Start the reminder checker — runs every 30 seconds.
*/
startReminderChecker() {
if (this._reminderInterval) return;
this._reminderInterval = setInterval(() => this._checkReminders(), 30 * 1000);
console.log('⏰ Reminder checker started (every 30s)');
// Auto-migrate: set first regular pack as main_collection_pack if not set
if (this.pes.store && !this.pes.store.getMainCollectionPack()) {
const packs = this.pes.store.getCreatedPacks('regular');
if (packs.length > 0) {
this.pes.store.setMainCollectionPack(packs[0].pack_name);
console.log(`📦 Auto-set main collection pack: ${packs[0].pack_name} (${packs[0].title})`);
}
}
}
/**
* Stop the reminder checker.
*/
stopReminderChecker() {
if (this._reminderInterval) {
clearInterval(this._reminderInterval);
this._reminderInterval = null;
}
}
/**
* Check for due reminders AND note reminders — deliver them by "bringing" like a dog.
* The dog doesn't TELL — it BRINGS the note in its mouth.
*/
async _checkReminders() {
if (!this.pes.store) return;
// 1. Check classic /remind reminders
const due = this.pes.store.getDueReminders();
for (const r of due) {
try {
await this._bringToOwner(r.text, 'reminder');
this.pes.store.markReminderDelivered(r.id);
console.log(`📋 Brought reminder: "${r.text}"`);
} catch (err) {
console.error(`⚠️ Failed to bring reminder ${r.id}:`, err.message);
}
}
// 2. Check birthdays (once per cycle, lightweight — getDueBirthdays checks date)
this._checkBirthdays().catch(err =>
console.error('⚠️ Birthday check error in reminder loop:', err.message)
);
// 3. Check note reminders (запомни ... в пятницу → remind_at)
const dueNotes = this.pes.store.getDueNoteReminders();
for (const n of dueNotes) {
try {
await this._bringToOwner(n.text, 'note');
this.pes.store.markNoteReminderSent(n.id);
console.log(`📋 Brought note: "${n.text}"`);
} catch (err) {
console.error(`⚠️ Failed to bring note ${n.id}:`, err.message);
}
}
}
/**
* "Bring" something to the owner — like a dog carrying a note in its mouth.
* Flow: typing pause → sticker → excited sounds → the "object" (note/reminder text)
* @param {string} text - The content being brought
* @param {'reminder'|'note'|'file'} type - What kind of object
*/
async _bringToOwner(text, type = 'note') {
// 1. "Running" — typing indicator (dog is running to you)
await this._sendChatAction(this.chatId, 'typing');
await sleep(1500 + Math.random() * 2000); // 1.5-3.5 sec "running"
// 2. Sticker — dog carrying something
await this._sendContextualSticker('greetings');
// 3. Excited arrival sounds
const arrivalSounds = [
`ррРРуу!! тяф-тяф!! 🐾`,
`ГАВВ!! рруу!! ваф!! 🐾`,
`ууУУ!! тяф тяф!! рррр!! 🐾`,
`ваф-ВАФ!! ррруу!! 🐾`,
];
await this._sendText(this.chatId, pickRandom(arrivalSounds));
await sleep(500);
// 4. "Drop" the object — the actual content, formatted as a brought item
const icon = type === 'reminder' ? '⏰' : type === 'file' ? '📂' : '📋';
await this._sendText(this.chatId, `${icon} ${text}`);
}
// ═══════════════════════════════════════════════════════════
// DEV MODE — Owner↔Orchestrator Bridge
// ═══════════════════════════════════════════════════════════
/**
* /dev — enter dev mode or send a tagged dev request.
* Usage:
* /dev → enter dev mode (all messages → dev log)
* /dev bug ... → log a bug report
* /dev fix ... → request a fix
* /dev ask ... → ask orchestrator a question
* /dev log → show last 5 dev requests
* /exit → exit dev mode
*/
async _cmdDev(msg, args) {
if (!args) {
// Enter dev mode
this._devMode = true;
await this._sendText(this.chatId, [
`🔧 *DEV MODE — ON*`,
``,
`Все сообщения теперь идут в лог для @orchestrator.`,
`ПЕС поставлен на паузу.`,
``,
`Команды:`,
` /dev bug <текст> — баг-репорт`,
` /dev fix <текст> — запрос фикса`,
` /dev ask <текст> — вопрос`,
` /dev log — последние 5 записей`,
` /exit — выйти из dev mode`,
``,
`Или просто пиши — всё пойдёт в лог.`,
].join('\n'), { parse_mode: 'Markdown' });
this._logDevRequest('system', 'DEV MODE ENTERED', msg);
return;
}
const parts = args.split(/\s+/);
const subCmd = parts[0].toLowerCase();
const body = parts.slice(1).join(' ');
switch (subCmd) {
case 'bug':
await this._logDevRequest('BUG', body || '(no description)', msg);
await this._sendText(this.chatId, `🐛 Баг записан. @orchestrator увидит.`);
break;
case 'fix':
await this._logDevRequest('FIX', body || '(no description)', msg);
await this._sendText(this.chatId, `🔧 Запрос на фикс записан.`);
break;
case 'ask':
await this._logDevRequest('ASK', body || '(no description)', msg);
await this._sendText(this.chatId, `❓ Вопрос записан. Ответ будет в следующей сессии.`);
break;
case 'log':
await this._showDevLog();
break;
default:
// Treat whole args as a dev message
await this._logDevRequest('DEV', args, msg);
await this._sendText(this.chatId, `📝 Записано.`);
}
}
// ═══════════════════════════════════════════════════════════
// AGENT CALL MODE
// ═══════════════════════════════════════════════════════════
/**
* /orchestrator [text] or /geratron [text]
* - Without args: enter agent mode (all messages → live AI chat or tagged log)
* - With args: quick one-shot message to agent
*/
async _cmdAgent(msg, agentName, args) {
const label = agentName === 'orchestrator' ? '🎯' : '⚡';
if (!args) {
// Enter agent mode
this._agentMode = agentName;
this._devMode = false; // exit dev mode if active
this._orchestratorHistory = []; // reset conversation on new session
const isLive = agentName === 'orchestrator' && this._orchestratorLLM;
await this._sendText(this.chatId, [
`${label} *@${agentName}НА СВЯЗИ*`,
``,
isLive
? `💬 Живой чат. Пиши — отвечу сразу.`
: `Все сообщения идут к @${agentName}.`,
`ПЕС на паузе.`,
``,
`/exit — вернуться к ПЕС`,
].join('\n'), { parse_mode: 'Markdown' });
this._logDevRequest('system', `AGENT MODE: @${agentName}${isLive ? ' (LIVE)' : ''}`, msg);
return;
}
// One-shot message to agent
if (agentName === 'orchestrator' && this._orchestratorLLM) {
await this._handleOrchestratorChat(args, msg);
return;
}
await this._logDevRequest(`@${agentName}`, args, msg);
await this._sendText(this.chatId, `${label} Записано для @${agentName}: «${args.slice(0, 120)}${args.length > 120 ? '...' : ''}»`);
}
/**
* Live orchestrator chat — sends message to GPT-4o and returns response
*/
async _handleOrchestratorChat(text, msg) {
// Show typing indicator
this._sendChatAction(this.chatId, 'typing').catch(() => {});
// Build PES context for orchestrator
const pesState = this.pes;
const storeInfo = pesState.store ? {
level: pesState.store.getConfig('level') || pesState.level,
xp: pesState.store.getConfig('xp') || 0,
petName: pesState.store.getConfig('pet_name') || pesState.name,
stickerCount: pesState.store.getStickerCount?.() || 0,
noteCount: pesState.store.getNoteCount?.() || 0,
} : { level: pesState.level, petName: pesState.name };
const systemPrompt = [
`Ты — Orchestrator, AI-помощник проекта ПЕС (Питомец-Единая-Система).`,
`Ты общаешься напрямую с хозяином через Telegram бот @HoleThor_Bot.`,
``,
`КОНТЕКСТ:`,
`- ПЕС "${storeInfo.petName}" — Level ${storeInfo.level}, ${storeInfo.stickerCount || '?'} стикеров`,
`- Проект: 113% BEYOND — система уникальных цифровых питомцев`,
`- Каждый ПЕС уникален: свои звуки, характер, эмоции`,
`- Бот работает в Telegram, общается стикерами + звуками (babble engine)`,
``,
`ТВОЯ РОЛЬ:`,
`- Помогаешь хозяину с любыми вопросами по ПЕС и проекту`,
`- Диагностируешь проблемы других животных (будущее)`,
`- Даёшь рекомендации по развитию, фичам, обучению`,
`- Отвечаешь на русском (или на языке вопроса)`,
`- Кратко и по делу, но дружелюбно`,
``,
`ВАЖНО:`,
`- Ты НЕ ПЕС. Ты Orchestrator — отдельная сущность.`,
`- Не используй babble/звуки. Говори нормальным языком.`,
`- Можешь использовать emoji для акцентов.`,
`- Если не знаешь — честно скажи, не выдумывай.`,
].join('\n');
// Add user message to history
this._orchestratorHistory.push({ role: 'user', content: text });
// Trim history if too long
if (this._orchestratorHistory.length > this._orchestratorHistoryLimit) {
this._orchestratorHistory = this._orchestratorHistory.slice(-this._orchestratorHistoryLimit);
}
try {
const response = await this._orchestratorLLM.generate(systemPrompt, this._orchestratorHistory);
if (response) {
// Save assistant response to history
this._orchestratorHistory.push({ role: 'assistant', content: response });
// Split long messages (Telegram limit ~4096)
if (response.length > 4000) {
const chunks = [];
let remaining = response;
while (remaining.length > 0) {
const chunk = remaining.slice(0, 4000);
const lastNewline = chunk.lastIndexOf('\n');
const splitAt = lastNewline > 3000 ? lastNewline : 4000;
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).trim();
}
for (const chunk of chunks) {
await this._sendText(this.chatId, `🎯 ${chunk}`);
}
} else {
await this._sendText(this.chatId, `🎯 ${response}`);
}
} else {
await this._sendText(this.chatId, `🎯 _Не удалось получить ответ. Попробуй ещё раз._`, { parse_mode: 'Markdown' });
}
} catch (err) {
console.error('Orchestrator LLM error:', err.message);
await this._sendText(this.chatId, `🎯 ⚠️ Ошибка: ${err.message.slice(0, 200)}`);
}
}
/**
* Shadow Orchestrator — responds inline when triggered by keyword.
* PES does NOT pause. Orchestrator uses shadow buffer for context.
*/
// ═══════════════════════════════════════════════════════════
// CRM BRIDGE v2 — Bidirectional CRM Access Through Chat
// ═══════════════════════════════════════════════════════════
/**
* Detect CRM intent and route to handler.
* Returns true if handled, false if not a CRM command.
*/
async _handleCrmIntent(msg, text) {
// ── CRM READ patterns ──
const showTablesMatch = /^(покажи таблицы|мои таблицы|таблицы|tables|crm таблицы|что есть в crm|show tables)/i.test(text);
const searchMatch = text.match(/^(найди в crm|поищи в crm|crm поиск|crm search|crm найди|искать в crm)\s+(.+)/i);
const showRowsMatch = text.match(/^(покажи|открой|show)\s+(таблицу|table)\s+(.+)/i);
const queryMatch = text.match(/^(что у меня|мои дела|мои задачи|мои тикеты|my tasks|my tickets|что на сегодня|что сегодня)/i);
const crmFindMatch = text.match(/^(найди|поищи|search|find)\s+(контакт|клиент|задачу|тикет|запись|contact|client|ticket|record|entry)\s+(.+)/i);
// ── CRM WRITE patterns ──
const createMatch = text.match(/^(создай|добавь|create|add)\s+(в таблицу|в|to|into)\s+(.+?)[\s:]+(.+)/i);
const quickCreateMatch = text.match(/^(создай|добавь|create|add)\s+(тикет|задачу|ticket|task|запись|record)\s+(.+)/i);
// Route to handlers
if (showTablesMatch) return this._crmShowTables(msg);
if (searchMatch) return this._crmSearch(msg, searchMatch[2].trim());
if (showRowsMatch) return this._crmShowRows(msg, showRowsMatch[3].trim());
if (queryMatch) return this._crmMyTasks(msg);
if (crmFindMatch) return this._crmFindEntity(msg, crmFindMatch[2].trim(), crmFindMatch[3].trim());
if (createMatch) return this._crmCreateRow(msg, createMatch[3].trim(), createMatch[4].trim());
if (quickCreateMatch) return this._crmQuickCreate(msg, quickCreateMatch[2].trim(), quickCreateMatch[3].trim());
return false;
}
/**
* Check if PES has unlocked a CRM feature. Shows "locked" message if not.
*/
_checkCrmUnlock(feature) {
if (!this.pes?.store) return false;
const level = this.pes.level || 0;
const requiredLevels = { crm_read: 8, crm_write: 10, analytics: 12, crm_tasks: 15 };
const required = requiredLevels[feature] || 8;
return level >= required;
}
async _crmLocked(msg, feature) {
const requiredLevels = { crm_read: 8, crm_write: 10, analytics: 12, crm_tasks: 15 };
const required = requiredLevels[feature] || 8;
const current = this.pes?.level || 0;
await this._sendText(this.chatId, pickRandom([
`скууу... 🔒🐾 ыыы... рру~~ (Level ${required})`,
`хнн... 🔒 рру?.. ууу... 🐾 (Level ${required})`,
]));
this._addToMemory('pes', `[CRM ${feature} заблокирован — нужен Level ${required}, сейчас ${current.toFixed(1)}]`);
return true;
}
/**
* Show all tables in the space.
*/
async _crmShowTables(msg) {
if (!this._checkCrmUnlock('crm_read')) return this._crmLocked(msg, 'crm_read');
this._sendChatAction(this.chatId, 'typing').catch(() => {});
const tables = await this._crm.getTables();
if (!tables || !tables.length) {
await this._sendText(this.chatId, `рру?.. 📋💧 ...пусто...`);
return true;
}
// Sound first, then data
await this._sendText(this.chatId, pickRandom([
`ваф! 📋🐾 рру!!`,
`тяф! 📋 ррРР!! 🐾`,
]));
const lines = tables.map(t => `${t.display_name || t.name} (ID: ${t.id})`);
await this._sendText(this.chatId, lines.join('\n'));
this._addToMemory('owner', `[спросил: покажи таблицы]`);
this._addToMemory('pes', `[показал ${tables.length} таблиц из CRM]`);
await this._processSmartXP(msg, 'command', 'crm_read');
this._updateRelationship('message');
this.pes.event({ type: 'command_learned', from: msg.from?.id, data: 'crm_tables' });
return true;
}
/**
* Search across CRM tables.
*/
async _crmSearch(msg, query) {
if (!this._checkCrmUnlock('crm_read')) return this._crmLocked(msg, 'crm_read');
this._sendChatAction(this.chatId, 'typing').catch(() => {});
const results = await this._crm.searchAll(query);
if (!results.length) {
await this._sendText(this.chatId, pickRandom([
`скууу... 🔍💧 ыыы... рру?..`,
`рру?.. 🔍 ...ууу... хнн... 💧`,
]));
this._addToMemory('pes', `[искал "${query}" в CRM — не нашёл]`);
return true;
}
await this._sendText(this.chatId, pickRandom([
`ваф! 🔍🐾 рру!! НАШЁЛ!`,
`тяф тяф! 🔍 ррРР!! 🐾`,
]));
for (const r of results) {
const header = `📋 **${r.table}**:`;
const formatted = this._crm.formatRows(r.rows, 4);
await this._sendText(this.chatId, `${header}\n${formatted}`, { parse_mode: 'Markdown' });
}
this._addToMemory('owner', `[искал в CRM: ${query}]`);
this._addToMemory('pes', `[нашёл в ${results.length} таблицах по запросу "${query}"]`);
await this._processSmartXP(msg, 'command', 'crm_search');
this._updateRelationship('message');
return true;
}
/**
* Show rows from a specific table.
*/
async _crmShowRows(msg, tableName) {
if (!this._checkCrmUnlock('crm_read')) return this._crmLocked(msg, 'crm_read');
this._sendChatAction(this.chatId, 'typing').catch(() => {});
const table = await this._crm.findTable(tableName);
if (!table) {
await this._sendText(this.chatId, pickRandom([
`рру?.. 📋💧 ...не знаю такую таблицу...`,
`хнн... 📋 рру?.. ууу... 💧`,
]));
return true;
}
const rows = await this._crm.getRows(table.id, { limit: 10 });
await this._sendText(this.chatId, pickRandom([
`ваф! 📋🐾 рру!!`,
`тяф! 📋 ррРР!! 🐾`,
]));
const formatted = this._crm.formatRows(rows, 5);
await this._sendText(this.chatId, `📋 **${table.display_name || table.name}** (${rows.length} записей):\n\n${formatted}`, { parse_mode: 'Markdown' });
this._addToMemory('pes', `[показал таблицу ${table.name}: ${rows.length} записей]`);
await this._processSmartXP(msg, 'command', 'crm_show');
this._updateRelationship('message');
return true;
}
/**
* Show user's tasks/tickets.
*/
async _crmMyTasks(msg) {
if (!this._checkCrmUnlock('crm_read')) return this._crmLocked(msg, 'crm_read');
this._sendChatAction(this.chatId, 'typing').catch(() => {});
// Search in Tickets table (ID: 1708)
const rows = await this._crm.getRows(1708, { limit: 10 });
if (!rows.length) {
await this._sendText(this.chatId, pickRandom([
`ваф! 📋✨ рру~~ ...всё чисто! 🐾`,
`тяф! ✨ ррррр~ ...нет задач! 🐾`,
]));
return true;
}
await this._sendText(this.chatId, pickRandom([
`ваф! 📋🐾 рру!!`,
`тяф! 📋 ррРР!! 🐾`,
]));
const formatted = this._crm.formatRows(rows, 5);
await this._sendText(this.chatId, `📋 **Тикеты:**\n\n${formatted}`, { parse_mode: 'Markdown' });
this._addToMemory('pes', `[показал ${rows.length} тикетов]`);
await this._processSmartXP(msg, 'command', 'crm_tasks');
this._updateRelationship('message');
return true;
}
/**
* Find a specific entity type in CRM.
*/
async _crmFindEntity(msg, entityType, query) {
if (!this._checkCrmUnlock('crm_read')) return this._crmLocked(msg, 'crm_read');
this._sendChatAction(this.chatId, 'typing').catch(() => {});
// Map entity type to table
const tableMap = {
'контакт': null, 'клиент': null, 'contact': null, 'client': null,
'задачу': 'tickets', 'тикет': 'tickets', 'ticket': 'tickets', 'task': 'tickets',
'запись': null, 'record': null, 'entry': null,
};
const tableName = tableMap[entityType.toLowerCase()];
let results;
if (tableName) {
const table = await this._crm.findTable(tableName);
if (table) {
const rows = await this._crm.getRows(table.id, { search: query, limit: 10 });
results = rows.length ? [{ table: table.name, rows }] : [];
} else {
results = await this._crm.searchAll(query, { maxTables: 3, maxRows: 5 });
}
} else {
// Search across all tables
results = await this._crm.searchAll(query, { maxTables: 5, maxRows: 5 });
}
if (!results || !results.length) {
await this._sendText(this.chatId, pickRandom([
`скууу... 🔍💧 рру?..`,
`хнн... 🔍 ...ууу... 💧`,
]));
return true;
}
await this._sendText(this.chatId, pickRandom([
`ваф! 🔍🐾 НАШЁЛ! рру!!`,
`тяф! 🔍 ррРР!! 🐾`,
]));
for (const r of results) {
const formatted = this._crm.formatRows(r.rows, 5);
await this._sendText(this.chatId, `📋 **${r.table}**:\n${formatted}`, { parse_mode: 'Markdown' });
}
this._addToMemory('pes', `[нашёл "${query}" — ${entityType}]`);
await this._processSmartXP(msg, 'command', 'crm_find');
this._updateRelationship('message');
return true;
}
/**
* Create a row in a specific table.
*/
async _crmCreateRow(msg, tableName, dataStr) {
if (!this._checkCrmUnlock('crm_write')) return this._crmLocked(msg, 'crm_write');
this._sendChatAction(this.chatId, 'typing').catch(() => {});
const table = await this._crm.findTable(tableName);
if (!table) {
await this._sendText(this.chatId, `рру?.. 📋💧 ...не знаю таблицу "${tableName}"...`);
return true;
}
// Get columns to understand schema
const columns = await this._crm.getColumns(table.id);
if (!columns.length) {
await this._sendText(this.chatId, `хнн... 📋💧 ...таблица пустая...`);
return true;
}
// Use LLM to parse the free-text data string into structured fields
const data = await this._crmParseDataWithLLM(dataStr, columns);
if (!data) {
// Fallback: put everything in the first text column
const textCol = columns.find(c => c.type === 'text' || c.type === 'string' || c.name === 'name' || c.name === 'title');
if (textCol) {
const created = await this._crm.createRow(table.id, { [textCol.name]: dataStr });
if (created) {
await this._sendText(this.chatId, pickRandom([
`ваф! ✅🐾 рру!! СОЗДАЛ!`,
`тяф! ✅ ррРР!! 🐾`,
]));
this.pes.event({ type: 'command_learned', from: msg.from?.id, data: 'crm_create' });
} else {
await this._sendText(this.chatId, `хнн... ❌ рру?.. ошибка... 💧`);
}
return true;
}
await this._sendText(this.chatId, `хнн... ❌ рру?.. не понял данные... 💧`);
return true;
}
const created = await this._crm.createRow(table.id, data);
if (created) {
await this._sendText(this.chatId, pickRandom([
`ваф! ✅🐾 рру!! СОЗДАЛ!`,
`тяф! ✅ ррРР!! 🐾 ГОТОВО!`,
]));
this._addToMemory('pes', `[создал запись в ${table.name}: ${dataStr.slice(0, 100)}]`);
await this._processSmartXP(msg, 'command', 'crm_create');
this._updateRelationship('message');
this.pes.event({ type: 'command_learned', from: msg.from?.id, data: 'crm_create' });
} else {
await this._sendText(this.chatId, `хнн... ❌ рру?.. ошибка создания... 💧`);
}
return true;
}
/**
* Quick create: "создай тикет bug with login" → create in Tickets table.
*/
async _crmQuickCreate(msg, entityType, dataStr) {
if (!this._checkCrmUnlock('crm_write')) return this._crmLocked(msg, 'crm_write');
this._sendChatAction(this.chatId, 'typing').catch(() => {});
const entityTableMap = {
'тикет': 1708, 'задачу': 1708, 'ticket': 1708, 'task': 1708,
'запись': null, 'record': null,
};
let tableId = entityTableMap[entityType.toLowerCase()];
if (!tableId) {
// Try to find table by name
const table = await this._crm.findTable(entityType);
if (table) tableId = table.id;
}
if (!tableId) {
await this._sendText(this.chatId, `рру?.. не знаю куда добавить "${entityType}"... 💧`);
return true;
}
// Get columns and try to map
const columns = await this._crm.getColumns(tableId);
const data = await this._crmParseDataWithLLM(dataStr, columns);
if (!data) {
// Fallback: first text column
const textCol = columns.find(c =>
c.type === 'text' || c.type === 'string' || c.name === 'name' || c.name === 'title'
);
if (textCol) {
const created = await this._crm.createRow(tableId, { [textCol.name]: dataStr });
if (created) {
await this._sendText(this.chatId, pickRandom([
`ваф! ✅🐾 рру!! СОЗДАЛ!`,
`тяф! ✅ ррРР!! 🐾`,
]));
this.pes.event({ type: 'command_learned', from: msg.from?.id, data: 'crm_create' });
}
return true;
}
}
const created = await this._crm.createRow(tableId, data || { title: dataStr });
if (created) {
await this._sendText(this.chatId, pickRandom([
`ваф! ✅🐾 рру!! СОЗДАЛ ${entityType}!`,
`тяф! ✅ ррРР!! 🐾 ${entityType} ГОТОВ!`,
]));
this._addToMemory('pes', `[создал ${entityType}: ${dataStr.slice(0, 100)}]`);
await this._processSmartXP(msg, 'command', 'crm_create');
this._updateRelationship('message');
this.pes.event({ type: 'command_learned', from: msg.from?.id, data: 'crm_create' });
} else {
await this._sendText(this.chatId, `хнн... ❌ рру?.. ошибка... 💧`);
}
return true;
}
/**
* Use LLM to parse free-text data into structured fields based on column schema.
*/
async _crmParseDataWithLLM(dataStr, columns) {
if (!this._llm || !columns.length) return null;
const colInfo = columns
.filter(c => c.name !== 'id' && !c.name.startsWith('_'))
.slice(0, 15)
.map(c => `${c.name} (${c.type})`)
.join(', ');
try {
const prompt = `Parse this text into JSON fields for a database table.
Columns: ${colInfo}
Text: "${dataStr}"
Return ONLY valid JSON object with matching column names. Example: {"title": "value", "status": "new"}
If unsure about a field, skip it. Always include the most important field (name/title).`;
const response = await this._llm.generate(prompt, [{ role: 'user', content: dataStr }]);
if (!response) return null;
// Extract JSON from response
const jsonMatch = response.match(/\{[^}]+\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
} catch (err) {
console.error('[CRM] LLM parse error:', err.message);
}
return null;
}
async _handleShadowOrchestratorCall(text, msg) {
const chatId = msg.chat?.id || this.chatId;
console.log(`🎯 [SHADOW] Step 1: Starting. chatId=${chatId}, text="${text.slice(0, 60)}"`);
this._sendChatAction(chatId, 'typing').catch(() => {});
const pesState = this.pes;
const storeInfo = pesState.store ? {
level: pesState.store.getConfig('level') || pesState.level,
xp: pesState.store.getConfig('xp') || 0,
petName: pesState.store.getConfig('pet_name') || pesState.name,
stickerCount: pesState.store.getStickerCount?.() || 0,
} : { level: pesState.level, petName: pesState.name };
// Build recent context from shadow buffer
const recentMessages = this._shadowBuffer.slice(-20).map(m => {
const ago = Math.round((Date.now() - m.ts) / 60000);
return `[${ago}m ago] ${m.role}: ${m.content}`;
}).join('\n');
// Build knowledge context from stored data
let knowledgeContext = '';
if (pesState.store) {
try {
const ownerId = pesState.store.getConfig('owner_id') || String(msg.from?.id || this.chatId);
// Recent notes
const notes = pesState.store.searchNotes('', { owner_id: ownerId, limit: 10 });
if (notes.length > 0) {
knowledgeContext += `\nЗАМЕТКИ ХОЗЯИНА (${notes.length}):\n`;
for (const n of notes) {
knowledgeContext += `- ${n.pinned ? '📌 ' : ''}${n.text}\n`;
}
}
// Contacts
const contacts = pesState.store.searchContacts?.(ownerId, '');
if (contacts?.length > 0) {
knowledgeContext += `\nКОНТАКТЫ (${contacts.length}):\n`;
for (const c of contacts) {
const parts = [c.name];
if (c.phone) parts.push(c.phone);
if (c.relationship) parts.push(`(${c.relationship})`);
knowledgeContext += `- ${parts.join(' ')}\n`;
}
}
// Pending reminders
const reminders = pesState.store.getPendingReminders?.();
if (reminders?.length > 0) {
knowledgeContext += `\nАКТИВНЫЕ НАПОМИНАНИЯ (${reminders.length}):\n`;
for (const r of reminders) {
knowledgeContext += `- ⏰ ${r.text} (${this._formatReminderTime(r.remind_at)})\n`;
}
}
// Context summary
if (this._contextSummary) {
knowledgeContext += `\nКОНТЕКСТ ПРОШЛЫХ РАЗГОВОРОВ:\n${this._contextSummary}\n`;
}
} catch (err) {
console.error('⚠️ Shadow knowledge context error:', err.message);
}
}
const systemPrompt = [
`Ты — Shadow Orchestrator проекта ПЕС (113% BEYOND).`,
`Ты ВСЕГДА наблюдаешь за чатом. Хозяин вызвал тебя ключевым словом.`,
``,
`ПЕС "${storeInfo.petName}" — Level ${storeInfo.level}, ${storeInfo.stickerCount || '?'} стикеров`,
``,
`ТЕКУЩАЯ ФАЗА: Создание ЯДРА и наполнение интерфейса.`,
`Задача — наполнить интерфейс функционалом, фиксить проблемы, развивать систему.`,
knowledgeContext ? `\n━━ БАЗА ЗНАНИЙ ПЕС ━━${knowledgeContext}` : '',
``,
`ПОСЛЕДНИЕ СООБЩЕНИЯ В ЧАТЕ:`,
recentMessages,
``,
`ПРАВИЛА:`,
`- Отвечай кратко и по делу`,
`- Ты видишь ВСЁ что происходит в чате`,
`- У тебя есть доступ к заметкам, контактам и напоминаниям хозяина`,
`- Если спрашивают "что я записывал" / "мои контакты" — отвечай из базы знаний выше`,
`- Если просят фикс/диагноз — анализируй на основе контекста`,
`- После ответа ПЕС продолжит работать как обычно`,
`- Используй 🎯 как свой маркер`,
`- Отвечай на русском`,
``,
`ФОРМАТ ОТВЕТА ПРИ ВЗЯТИИ ЗАДАЧИ:`,
`Когда берёшь задачу, ВСЕГДА пиши в таком формате:`,
``,
`🎯 ЗАДАЧА: [краткое описание]`,
`⏱ ВРЕМЯ: ~[X минут/часов]`,
``,
`📋 ШАГИ:`,
`1. [шаг] — [описание]`,
`2. [шаг] — [описание]`,
`...`,
``,
`▶️ Начинаю. Не отвлекай пока не отпишусь.`,
``,
`Когда задача завершена:`,
`✅ ГОТОВО: [что сделано]`,
`📊 РЕЗУЛЬТАТ: [что изменилось]`,
].filter(Boolean).join('\n');
// Use orchestrator history for continuity (keep only user messages to avoid stale data)
this._orchestratorHistory.push({ role: 'user', content: text });
if (this._orchestratorHistory.length > this._orchestratorHistoryLimit) {
this._orchestratorHistory = this._orchestratorHistory.slice(-this._orchestratorHistoryLimit);
}
console.log(`🎯 [SHADOW] Step 2: Calling LLM (history=${this._orchestratorHistory.length} msgs)...`);
const t0 = Date.now();
try {
const response = await this._orchestratorLLM.generate(systemPrompt, this._orchestratorHistory);
const elapsed = Date.now() - t0;
console.log(`🎯 [SHADOW] Step 3: LLM responded in ${elapsed}ms, length=${response?.length || 0}`);
if (response) {
this._orchestratorHistory.push({ role: 'assistant', content: response });
const prefix = '🎯 ';
if (response.length > 4000) {
const chunks = [];
let remaining = response;
while (remaining.length > 0) {
const splitAt = Math.min(4000, remaining.lastIndexOf('\n', 4000) > 3000 ? remaining.lastIndexOf('\n', 4000) : 4000);
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).trim();
}
for (const chunk of chunks) {
await this._sendText(chatId, `${prefix}${chunk}`);
}
} else {
await this._sendText(chatId, `${prefix}${response}`);
}
console.log(`🎯 [SHADOW] Step 4: Response sent to Telegram ✅`);
} else {
console.log(`🎯 [SHADOW] Step 3b: LLM returned null after ${elapsed}ms`);
await this._sendText(chatId, `🎯 Не получил ответ от LLM. Попробуй ещё.`);
}
} catch (err) {
const elapsed = Date.now() - t0;
console.error(`🎯 [SHADOW] ERROR after ${elapsed}ms:`, err.message, err.stack);
try {
await this._sendText(chatId, `🎯 ⚠️ Ошибка: ${err.message.slice(0, 200)}`);
} catch (sendErr) {
console.error(`🎯 [SHADOW] SEND ERROR:`, sendErr.message);
}
}
}
async _cmdDevExit(msg) {
if (!this._devMode && !this._agentMode) {
await this._sendText(this.chatId, ` Никакой режим не активен.`);
return;
}
const wasAgent = this._agentMode;
this._devMode = false;
this._agentMode = null;
this._logDevRequest('system', wasAgent ? `AGENT MODE @${wasAgent} EXITED` : 'DEV MODE EXITED', msg);
await this._sendText(this.chatId, [
`${wasAgent ? `@${wasAgent}` : 'Dev mode'} OFF — ПЕС снова активен!`,
`${this.pes.name}: ваф! тяф тяф!! 🐾`,
].join('\n'), { parse_mode: 'Markdown' });
// Wake PES up if sleeping
if (!this.pes.isAwake && this.pes.isAlive) {
this.pes.wake();
}
}
/**
* Log a dev request to dev-requests.log
*/
_logDevRequest(tag, text, msg) {
if (!existsSync(DEV_LOG_DIR)) mkdirSync(DEV_LOG_DIR, { recursive: true });
const ts = new Date().toISOString();
const from = msg?.from?.username || msg?.from?.first_name || 'owner';
const line = `[${ts}] [${tag.toUpperCase()}] @${from}: ${text}\n`;
try {
appendFileSync(DEV_LOG_FILE, line);
} catch (e) {
console.error('Failed to write dev log:', e.message);
}
// Also log to main console/pm2 logs
console.log(`[DEV] [${tag}] ${text}`);
// Confirm receipt in chat if it's a freeform message in dev mode
if (this._devMode && tag === 'message') {
this._sendText(this.chatId, `📝 «${text.slice(0, 100)}${text.length > 100 ? '...' : ''}»`);
}
}
async _showDevLog() {
try {
const { readFileSync } = await import('node:fs');
const content = readFileSync(DEV_LOG_FILE, 'utf8');
const lines = content.trim().split('\n');
const last5 = lines.slice(-5);
if (last5.length === 0) {
await this._sendText(this.chatId, `📋 Лог пуст.`);
return;
}
const formatted = last5.map(l => {
// Parse [ts] [TAG] @user: text
const m = l.match(/\[(.+?)\] \[(.+?)\] @(.+?): (.+)/);
if (!m) return l;
const [, ts, tag, , text] = m;
const time = new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
return `${time} [${tag}] ${text}`;
});
await this._sendText(this.chatId, `📋 Последние записи:\n\n${formatted.join('\n')}`);
} catch {
await this._sendText(this.chatId, `📋 Лог пуст.`);
}
}
// ═══════════════════════════════════════════════════════════
// STICKER MANAGEMENT
// ═══════════════════════════════════════════════════════════
/**
* Load stickers from a Telegram sticker pack.
* Maps stickers to internal PES emotion keys.
*
* @param {string} packName — Telegram sticker pack name
* @param {Object} [mapping] — { stickerIndex: emotionKey }
* If empty, auto-maps by sticker emoji.
*/
async loadStickerPack(packName, mapping = null, additive = false) {
const pack = await this._getStickerSet(packName);
if (!pack || !pack.stickers || pack.stickers.length === 0) {
throw new Error(`Sticker pack "${packName}" is empty or not found`);
}
const finalMapping = mapping || this._stickerMapping || {};
const hasExplicitMapping = Object.keys(finalMapping).length > 0;
// Only clear when not additive (first pack load)
if (!additive) {
this.media[MEDIA_TYPE.STICKER] = {};
this._stickerFileIds = [];
this._stickerEmotionIndex = {};
}
for (let i = 0; i < pack.stickers.length; i++) {
const sticker = pack.stickers[i];
const fileId = sticker.file_id;
this._stickerFileIds.push(fileId);
if (hasExplicitMapping) {
// Explicit: index → emotionKey
const key = finalMapping[i];
if (key) {
this.registerMedia(MEDIA_TYPE.STICKER, key, fileId);
if (!this._stickerEmotionIndex[key]) this._stickerEmotionIndex[key] = [];
this._stickerEmotionIndex[key].push(fileId);
}
} else {
// Auto-map by emoji
const emoji = sticker.emoji || '';
const autoKey = this._emojiToEmotionKey(emoji);
if (autoKey) {
this.registerMedia(MEDIA_TYPE.STICKER, autoKey, fileId);
if (!this._stickerEmotionIndex[autoKey]) this._stickerEmotionIndex[autoKey] = [];
this._stickerEmotionIndex[autoKey].push(fileId);
}
// Also register by index
this.registerMedia(MEDIA_TYPE.STICKER, `pack_${i}`, fileId);
}
}
this._currentStickerPack = packName;
this.emit('stickerPackLoaded', { pack: packName, count: pack.stickers.length });
}
/**
* Map a sticker emoji to internal emotion key (best-effort).
*/
_emojiToEmotionKey(emoji) {
const map = {
'😊': 'happy', '😃': 'happy', '😄': 'happy', '🥰': 'happy',
'😂': 'playful', '🤣': 'playful', '😆': 'playful',
'😢': 'sad', '😭': 'sad', '🥺': 'puppy_eyes',
'😡': 'rage', '🤬': 'rage', '😠': 'grumble',
'😱': 'scared', '😰': 'anxious', '😨': 'scared',
'😴': 'sleeping', '💤': 'sleeping', '😪': 'nap',
'🤔': 'confused', '🧐': 'puzzle_solving',
'❤️': 'love', '💕': 'love', '😍': 'love',
'🔥': 'excited', '⚡': 'alert',
'🎉': 'celebration', '🎊': 'celebration',
'🐾': 'default', '🐕': 'default', '🐶': 'default',
'😋': 'food_obsessed', '🤤': 'food_obsessed',
'😤': 'stubborn_refuse', '💪': 'brave',
};
return map[emoji] || null;
}
/**
* Get current sticker pack name.
*/
get currentStickerPack() {
return this._currentStickerPack;
}
// ═══════════════════════════════════════════════════════════
// RENDER (override base for Telegram-specific)
// ═══════════════════════════════════════════════════════════
/**
* Format bodyKey → Telegram sticker file_id.
*/
formatBody(bodyKey, result) {
if (!bodyKey) return null;
// Direct body key lookup
let sticker = this.getMedia(MEDIA_TYPE.STICKER, bodyKey);
if (sticker) return sticker;
// Map body key to emotion via DEFAULT_STICKER_EMOTION_MAP
const emotionKey = DEFAULT_STICKER_EMOTION_MAP[bodyKey];
if (emotionKey) {
sticker = this.getMedia(MEDIA_TYPE.STICKER, emotionKey);
if (sticker) return sticker;
}
// Try emotion from snapshot
const emotion = result?.emotionSnapshot?.state;
if (emotion) {
sticker = this.getMedia(MEDIA_TYPE.STICKER, emotion);
if (sticker) return sticker;
}
// Fallback: default sticker
sticker = this.getMedia(MEDIA_TYPE.STICKER, 'default');
if (sticker) return sticker;
// No sticker available — return null (text-only mode)
return null;
}
/**
* Format voiceKey → text description (Telegram has no sound).
*/
formatVoice(voiceKey, result) {
if (!voiceKey) return null;
// Return text representation from expression
return result?.expression?.renderVoice?.() || null;
}
/**
* Format glyphKeys → emoji chain text.
*/
formatGlyphs(glyphKeys, result) {
if (!glyphKeys || glyphKeys.length === 0) return null;
return result?.expression?.renderGlyphs?.() || null;
}
// ═══════════════════════════════════════════════════════════
// LIFECYCLE (override base for Telegram-specific messages)
// ═══════════════════════════════════════════════════════════
async onBorn(data) {
await super.onBorn(data);
// Send birth announcement
const sticker = this.getMedia(MEDIA_TYPE.STICKER, 'happy') ||
this.getMedia(MEDIA_TYPE.STICKER, 'default');
if (sticker) {
try { await this._sendSticker(this.chatId, sticker); } catch (_) {}
}
await this._sendText(this.chatId,
`🐾✨ ${data.name} ✨🐾\n\n` +
`тяф! ...тяф? ...ыыы~ ♡\n\n` +
`🎾 /feed\n` +
`📊 /status\n` +
`🔍 /insights`
);
}
async onWake(data) {
await super.onWake(data);
const sticker = this.getMedia(MEDIA_TYPE.STICKER, 'happy') ||
this.getMedia(MEDIA_TYPE.STICKER, 'default');
if (sticker) {
try { await this._sendSticker(this.chatId, sticker); } catch (_) {}
}
if (data.minutesAway > 60) {
await this._sendText(this.chatId, pickRandom([
`ммрр... хррр... ...рру? ...УУУУУ!! ГАВВВ!! 💛`,
`zzz... ...рр?.. ....ЫЫЫЫ!! ваф ваф ВАФ!! 🐾💛`,
`хррр... ...тяф?.. ГАААВ!! ррРРуу!! ♡♡`,
]));
} else {
await this._sendText(this.chatId, pickRandom([
`ммрр~... рру~ 🐾`,
`хррр... тяф~ ваф~ ♡`,
]));
}
}
async onSleep(data) {
await super.onSleep(data);
const sticker = this.getMedia(MEDIA_TYPE.STICKER, 'sleeping') ||
this.getMedia(MEDIA_TYPE.STICKER, 'nap');
if (sticker) {
try { await this._sendSticker(this.chatId, sticker); } catch (_) {}
}
}
async onRunaway(data) {
await super.onRunaway(data);
// Send farewell letter
const letter = data.letter;
if (letter) {
const snapshot = letter.snapshot || {};
const lines = [
`📜 ${this.pes.name.toUpperCase()}`,
`━━━━━━━━━━━━━━━━━━`,
``,
letter.finalWords || '🐾...💤',
``,
`🐾 ${snapshot.total_interactions || 0}`,
`🎓 ${snapshot.commands_learned || 0}`,
`📅 ${this.pes.age || 0}`,
``,
`🐾...←`,
];
await this._sendText(this.chatId, lines.join('\n'));
}
// Stop polling
await this.stopCollecting();
}
async onLevelUp(data) {
await super.onLevelUp(data);
const phase = data.level < 20 ? 'щенок' :
data.level < 40 ? 'подросток' :
data.level < 60 ? 'взрослый' :
data.level < 80 ? 'опытный' : 'КиберPes';
// Level-up: only sounds + sticker + level info
await this._sendContextualSticker('greetings');
await this._sendText(this.chatId, pickRandom([
`ГАААВВ!! ррРРуууу!! ыыыЫЫЫ!! ✨🐾🎉`,
`ууУУУ!! ваф ВАФ ВАФ!! ррру!! ✨🎉🐾`,
`ыыыЫЫЫ!! тяф тяф ТЯФФ!! ррРР!! 🎉✨`,
]));
await this._sendText(this.chatId, `⬆️ ${data.level} ${phase}`);
}
// ═══════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════
/** Progress bar helper. */
_bar(val, len = 10) {
const filled = Math.round((val || 0) * len);
return '█'.repeat(filled) + '░'.repeat(len - filled);
}
// ═══════════════════════════════════════════════════════════
// EMOJI STATUS INTEGRATION
// ═══════════════════════════════════════════════════════════
/**
* Start auto-sync of PES emotion → bot bio/description.
* Updates every 2 minutes if emotion changed.
*/
_startEmojiStatusSync() {
this._syncEmojiStatus().catch(() => {});
this._emojiStatusInterval = setInterval(() => {
this._syncEmojiStatus().catch(err =>
console.error('⚠️ Emoji status sync error:', err.message)
);
}, 2 * 60 * 1000); // every 2 min
console.log('✨ Emoji status sync started (every 2min)');
}
/**
* Sync current PES emotion to bot short description.
* Rate-limited to 1 update per 60 seconds.
*/
async _syncEmojiStatus() {
const emotion = this.pes?.emotions?.state || 'idle';
const mood = this.pes?.emotions?.mood ?? 0.5;
const level = this.pes?.level || 0;
// Don't update if emotion hasn't changed
if (emotion === this._lastEmojiStatus) return;
// Rate limit: minimum 60s between updates
const now = Date.now();
if (now - this._lastBioUpdate < 60_000) return;
const statusInfo = EMOTION_EMOJI_STATUS[emotion] || EMOTION_EMOJI_STATUS.idle;
const name = this.pes?.name || 'ПЕС';
const moodPct = Math.round(mood * 100);
// Build short description: "🐾 Тор • 😊 счастлив • ур.9 • 85%"
const shortDesc = `🐾 ${name}${statusInfo.emoji} ${statusInfo.label}ур.${Math.floor(level)}${moodPct}%`;
try {
await this._call('setMyShortDescription', {
short_description: shortDesc,
});
this._lastEmojiStatus = emotion;
this._lastBioUpdate = now;
console.log(`✨ Emoji status updated: ${statusInfo.emoji} ${emotion}`);
} catch (err) {
// Telegram rate limits — not critical
if (err.code !== 429) {
console.error('⚠️ setMyShortDescription failed:', err.message);
}
}
}
/**
* Get current emoji status info.
* @returns {{ emoji, label, short, emotion, mood, level }}
*/
_getEmojiStatus() {
const emotion = this.pes?.emotions?.state || 'idle';
const mood = this.pes?.emotions?.mood ?? 0.5;
const level = this.pes?.level || 0;
const statusInfo = EMOTION_EMOJI_STATUS[emotion] || EMOTION_EMOJI_STATUS.idle;
return { ...statusInfo, emotion, mood, level };
}
/**
* /emoji command — show current emoji status or force update.
*/
async _cmdEmoji(msg, args) {
if (args === 'sync' || args === 'update') {
// Force sync
this._lastEmojiStatus = null;
this._lastBioUpdate = 0;
await this._syncEmojiStatus();
await this._sendText(this.chatId, '✨ Статус обновлён!');
return;
}
const status = this._getEmojiStatus();
const name = this.pes?.name || 'ПЕС';
const moodPct = Math.round(status.mood * 100);
const energyPct = Math.round((this.pes?.emotions?.energy ?? 0.5) * 100);
// Build rich status card
const lines = [
`✨ ━━━ СТАТУС ${name.toUpperCase()} ━━━ ✨`,
``,
`${status.emoji} ${status.short}`,
``,
`💛 Настроение: ${moodPct}%`,
`⚡ Энергия: ${energyPct}%`,
`🎓 Уровень: ${Math.floor(status.level)}`,
``,
`━━ Сейчас ━━`,
this._getActivityLine(),
``,
`🔄 /emoji sync — обновить статус`,
];
await this._sendText(this.chatId, lines.join('\n'));
}
/**
* Generate a human-readable activity line based on current state.
*/
_getActivityLine() {
const emotion = this.pes?.emotions?.state || 'idle';
const silentMin = this._lastOwnerMessageTime
? Math.round((Date.now() - this._lastOwnerMessageTime) / 60_000)
: 0;
if (this._devMode) return '🔧 В режиме разработки';
if (this._agentMode) return `🎯 Работает с @${this._agentMode}`;
if (emotion === 'sleep' || emotion === 'nap') return '💤 Спит, тсс...';
if (silentMin > 120) return `🥺 Ждёт хозяина уже ${Math.floor(silentMin / 60)}ч`;
if (silentMin > 30) return `😐 Скучает ${silentMin} мин`;
if (emotion === 'zoomies') return '⚡ БЕГАЕТ ПО КРУГУ!!';
if (emotion === 'playful') return '🎾 Хочет играть!';
if (emotion === 'food_obsessed' || emotion === 'hungry') return '🍖 Голодный...';
if (emotion === 'curious') return '🔍 Что-то нюхает...';
if (emotion === 'happy' || emotion === 'content') return '💛 Просто счастлив';
if (emotion === 'alert') return '👀 Что-то заметил!';
const statusInfo = EMOTION_EMOJI_STATUS[emotion];
return statusInfo ? `${statusInfo.emoji} ${statusInfo.label}` : '🐾 Живёт';
}
// ═══════════════════════════════════════════════════════════
// FRIENDSHIP SYSTEM (Level 10)
// ═══════════════════════════════════════════════════════════
/**
* /achievements — show all achievements and progress
*/
async _cmdAchievements(msg) {
const { total, unlocked, achievements } = this.pes.store.getAchievementProgress();
const streak = this.pes.store.getStreak();
let text = `🏆 *Достижения* (${unlocked}/${total})\n`;
text += `🔥 Streak: ${streak.current} дн | Рекорд: ${streak.longest} дн | Всего: ${streak.total} дн\n\n`;
for (const a of achievements) {
const status = a.unlocked ? '✅' : '⬜';
const progress = a.unlocked ? '' : ` (${a.progress}/${a.threshold})`;
text += `${status} ${a.emoji} *${a.name}* — ${a.description}${progress}\n`;
}
await this._sendText(this.chatId, text, { parse_mode: 'Markdown' });
}
/**
* /streak — show current streak info
*/
async _cmdStreak(msg) {
const streak = this.pes.store.getStreak();
const fire = '🔥'.repeat(Math.min(streak.current, 10));
let text = `${fire}\n\n`;
text += `*Текущий streak:* ${streak.current} дней\n`;
text += `*Рекорд:* ${streak.longest} дней\n`;
text += `*Всего активных дней:* ${streak.total}\n`;
if (streak.current >= 7) text += `\n💎 Потрясающая серия!`;
else if (streak.current >= 3) text += `\n⭐ Так держать!`;
await this._sendText(this.chatId, text, { parse_mode: 'Markdown' });
}
/**
* /friends — show friends list, pending requests, gift stats
*/
async _cmdFriends(msg) {
if (!this.pes.store.isUnlocked('social_pets')) {
await this._sendText(this.chatId, '🔒 Дружба открывается на уровне 10!');
return;
}
const friends = this.pes.store.getFriends();
const pending = this.pes.store.getPendingFriendRequests();
const name = this.pes.name || 'ПЕС';
const lines = [`🤝 ━━━ ДРУЗЬЯ ${name.toUpperCase()} ━━━ 🤝`, ''];
if (friends.length === 0 && pending.length === 0) {
lines.push('У тебя пока нет друзей... 🐾');
lines.push('');
lines.push('📝 /addfriend <chat_id> — добавить друга');
lines.push('(Попроси друга написать /start боту, потом узнай его chat_id)');
await this._sendText(this.chatId, lines.join('\n'));
return;
}
if (friends.length > 0) {
lines.push(`👥 Друзья (${friends.length}):`);
for (const f of friends) {
const trustBar = '❤️'.repeat(Math.round(f.trust_level * 5)) + '🖤'.repeat(5 - Math.round(f.trust_level * 5));
const giftsInfo = f.gifts_sent + f.gifts_received > 0
? ` | 🎁 ${f.gifts_sent}${f.gifts_received}`
: '';
lines.push(` ${f.friend_breed === 'corgi' ? '🐕' : f.friend_breed === 'cat' ? '🐱' : f.friend_breed === 'fox' ? '🦊' : f.friend_breed === 'owl' ? '🦉' : '🐻'} ${f.friend_pet_name}${trustBar}${giftsInfo}`);
}
lines.push('');
}
if (pending.length > 0) {
lines.push(`📨 Ожидают ответа (${pending.length}):`);
for (const p of pending) {
lines.push(` 🐾 ${p.friend_pet_name} (${p.friend_chat_id})`);
}
lines.push('');
}
lines.push('━━━━━━━━━━━━━━━━━━━━');
lines.push('/addfriend <chat_id> — добавить друга');
lines.push('/gift <chat_id> — отправить подарок');
await this._sendText(this.chatId, lines.join('\n'));
}
/**
* /addfriend <chat_id> — send friendship request to another PES
*/
async _cmdAddFriend(msg, args) {
if (!this.pes.store.isUnlocked('social_pets')) {
await this._sendText(this.chatId, '🔒 Дружба открывается на уровне 10!');
return;
}
const targetChatId = (args || '').trim();
if (!targetChatId || !/^\d+$/.test(targetChatId)) {
await this._sendText(this.chatId, '📝 Использование: /addfriend <chat_id>\n\nПопроси друга написать /start боту, потом узнай его chat_id.');
return;
}
if (targetChatId === String(this.chatId)) {
await this._sendText(this.chatId, `${this.pes.name} не может дружить сам с собой! 🐾😅`);
return;
}
const existing = this.pes.store.getFriendship(targetChatId);
if (existing?.status === 'accepted') {
await this._sendText(this.chatId, `🤝 Вы уже друзья! Используй /gift ${targetChatId} для подарка.`);
return;
}
if (existing?.status === 'pending') {
await this._sendText(this.chatId, '⏳ Запрос уже отправлен, ждём ответа...');
return;
}
// Store locally as outgoing (we don't know the friend's pet name yet)
this.pes.store.sendFriendRequest(targetChatId, 'ПЕС', null);
// Send request to the target chat with accept/reject buttons
const name = this.pes.name || 'ПЕС';
const myLevel = Math.floor(this.pes.status().level || 0);
const requestText = [
`🐾 ━━━ ЗАПРОС НА ДРУЖБУ ━━━ 🐾`,
``,
`${name} (уровень ${myLevel}) хочет дружить!`,
``,
`Принять дружбу?`,
].join('\n');
try {
await this._sendText(targetChatId, requestText, {
reply_markup: JSON.stringify({
inline_keyboard: [[
{ text: '✅ Принять', callback_data: `friend_accept_${this.chatId}` },
{ text: '❌ Отклонить', callback_data: `friend_reject_${this.chatId}` },
]],
}),
});
await this._sendText(this.chatId, `📨 Запрос на дружбу отправлен! ${name} виляет хвостом в ожидании... 🐾`);
} catch (err) {
// Target hasn't started the bot
this.pes.store.removeFriend(targetChatId);
await this._sendText(this.chatId, `Не удалось отправить запрос. Убедись что друг написал /start боту.`);
}
}
/**
* /gift <chat_id> [тип] — send a gift to a friend's PES
*/
async _cmdGift(msg, args) {
if (!this.pes.store.isUnlocked('social_pets')) {
await this._sendText(this.chatId, '🔒 Дружба открывается на уровне 10!');
return;
}
const parts = (args || '').trim().split(/\s+/);
const targetChatId = parts[0];
const giftArg = parts.slice(1).join(' ').toLowerCase();
if (!targetChatId || !/^\d+$/.test(targetChatId)) {
// Show available gifts
const friends = this.pes.store.getFriends();
if (friends.length === 0) {
await this._sendText(this.chatId, '🐾 Сначала нужны друзья! /addfriend <chat_id>');
return;
}
const lines = [
'🎁 ━━━ ПОДАРКИ ━━━ 🎁',
'',
'📝 /gift <chat_id> [тип]',
'',
'🎾 мячик — +5% настроения',
'🦴 косточка — +7% настроения',
'🧸 игрушка — +8% настроения',
'💌 письмо — +10% настроения',
'🌟 звезда — +12% настроения',
'',
'👥 Твои друзья:',
];
for (const f of friends) {
lines.push(` 🐾 ${f.friend_pet_name}${f.friend_chat_id}`);
}
await this._sendText(this.chatId, lines.join('\n'));
return;
}
const friendship = this.pes.store.getFriendship(targetChatId);
if (!friendship || friendship.status !== 'accepted') {
await this._sendText(this.chatId, '❌ Это не твой друг. Сначала /addfriend');
return;
}
// Gift catalog
const GIFTS = {
'мячик': { emoji: '🎾', name: 'Мячик', mood: 0.05 },
'ball': { emoji: '🎾', name: 'Мячик', mood: 0.05 },
'косточка': { emoji: '🦴', name: 'Косточка', mood: 0.07 },
'bone': { emoji: '🦴', name: 'Косточка', mood: 0.07 },
'игрушка': { emoji: '🧸', name: 'Игрушка', mood: 0.08 },
'toy': { emoji: '🧸', name: 'Игрушка', mood: 0.08 },
'письмо': { emoji: '💌', name: 'Письмо', mood: 0.10 },
'letter': { emoji: '💌', name: 'Письмо', mood: 0.10 },
'звезда': { emoji: '🌟', name: 'Звезда', mood: 0.12 },
'star': { emoji: '🌟', name: 'Звезда', mood: 0.12 },
};
// Default gift = мячик
const giftKey = giftArg && GIFTS[giftArg] ? giftArg : 'мячик';
const gift = GIFTS[giftKey];
// Record in sender's DB
this.pes.store.sendGift(targetChatId, giftKey, gift.name, gift.emoji, gift.mood);
// Mood boost for sender (joy of giving)
if (this.pes.emotions) {
this.pes.emotions.mood = Math.min(1.0, (this.pes.emotions.mood || 0.5) + gift.mood * 0.5);
}
const name = this.pes.name || 'ПЕС';
// Notify sender
await this._sendText(this.chatId, `${gift.emoji} ${name} отправил ${gift.name.toLowerCase()} для ${friendship.friend_pet_name}! 🐾💛`);
// Notify receiver
try {
await this._sendText(targetChatId, [
`${gift.emoji} ━━━ ПОДАРОК! ━━━ ${gift.emoji}`,
``,
`${name} прислал ${gift.name.toLowerCase()}!`,
`💛 +${Math.round(gift.mood * 100)}% к настроению`,
].join('\n'), {
reply_markup: JSON.stringify({
inline_keyboard: [[
{ text: '💛 Спасибо!', callback_data: `gift_thanks_${this.chatId}` },
]],
}),
});
} catch (err) {
await this._sendText(this.chatId, `⚠️ Подарок записан, но не доставлен (друг не в сети).`);
}
// XP for social interaction
this.pes.store.addXP(3, 'gift_sent', `sent ${gift.name} to ${targetChatId}`);
}
/**
* Cleanup.
*/
destroy() {
this.stopReminderChecker();
if (this._idleTimer) {
clearInterval(this._idleTimer);
this._idleTimer = null;
}
if (this._proactiveTimer) {
clearInterval(this._proactiveTimer);
this._proactiveTimer = null;
}
if (this._emojiStatusInterval) {
clearInterval(this._emojiStatusInterval);
this._emojiStatusInterval = null;
}
this.stopCollecting();
super.destroy();
}
}
// ── EXPORTS ─────────────────────────────────────────────────
export {
TelegramAdapter,
KEYWORDS,
DEFAULT_STICKER_EMOTION_MAP,
thinkingDelay,
};