← Back
const axios = require('axios');
const { aiConfig } = require('../config');

function log(msg) {
  const ts = new Date().toLocaleString('en-CA', { timeZone: 'America/Vancouver' });
  console.log(`[${ts}] ${msg}`);
}

// Паттерны мета-отказов AI (вместо нормального поста пишет "я не могу...")
const META_REFUSAL_PATTERNS = [
  'не могу переписать',
  'не могу написать',
  'не имеет отношения',
  'не имеет прямого отношения',
  'не по нашей теме',
  'не подходит для',
  'нарушит требование',
  'выходит за рамки',
  'не связана с',
  'не относится к',
  'не соответствует тематике',
  'не могу опубликовать',
  'я не в состоянии',
  'извини, но',
  'к сожалению, эта новость',
  'давай лучше разберём',
  'кидай новость',
  'это не наше',
];

// Паттерны мета-болтовни AI (разговаривает с оператором, а не пишет пост)
// Проверяются только в первых 150 символах текста
const META_CHAT_PATTERNS = [
  'понял задачу',
  'понял!',
  'понял, ',
  'лови варианты',
  'лови три варианта',
  'лови пост',
  'вот пост',
  'вот несколько вариантов',
  'вот вариант',
  'выбери вариант',
  'какой вариант',
  'сможешь выбрать',
  'что лучше зайдет',
  'какой больше нравится',
  'под разное настроение',
  'готово!',
  'готов пост',
  'с удовольствием',
  'конечно!',
  'конечно,',
  'вот что получилось',
  'для твоего канала',
  'для твоей аудитории',
  'для вашего канала',
  'для вашей аудитории',
  'окей, вот',
  'ок, вот',
  'хорошо, вот',
  'давай сделаем',
  'давай напишем',
  'вот как это может выглядеть',
];

// Паттерны хвостовой мета-болтовни (AI добавляет реплику оператору в КОНЦЕ поста)
// Проверяются только в последних 150 символах текста
const END_META_PATTERNS = [
  'если нужно',
  'если хочешь',
  'могу переписать',
  'могу также',
  'могу сделать',
  'дай знать',
  'дайте знать',
  'надеюсь подойдет',
  'надеюсь, подойдет',
  'надеюсь зайдет',
  'дай обратную связь',
  'жду фидбек',
  'нужны правки',
  'хочешь другой вариант',
];

function isMetaRefusal(text) {
  if (!text) return false;
  const lower = text.toLowerCase();
  // Проверка мета-отказов (в любом месте текста)
  if (META_REFUSAL_PATTERNS.some(p => lower.includes(p))) return true;
  // Проверка мета-болтовни (только в начале текста — первые 150 символов)
  const start = lower.substring(0, 150);
  if (META_CHAT_PATTERNS.some(p => start.includes(p))) return true;
  // Проверка хвостовой мета-болтовни (последние 150 символов)
  const end = lower.substring(Math.max(0, lower.length - 150));
  if (END_META_PATTERNS.some(p => end.includes(p))) return true;
  // Проверка: AI дал несколько вариантов (разделитель ---)
  if (lower.includes('вариант 1') && lower.includes('вариант 2')) return true;
  return false;
}

// Проверка: AI обрезал текст (не дописал до конца)
function isTruncated(text) {
  if (!text) return false;
  // Убираем хештеги в конце для проверки
  const cleaned = text.replace(/\n*#\S+(\s+#\S+)*\s*$/, '').trim();
  if (cleaned.length < 80) return true; // слишком короткий пост
  // Проверяем последний символ контента — должен быть завершающий
  const lastChar = cleaned.slice(-1);
  const validEndings = ['.', '!', '?', ')', '»', '"', '…', '💪', '🔥', '👇', '❤️', '😂', '😭', '🤔', '🏆', '👀', '🇰🇿'];
  // Если заканчивается на букву/цифру — обрезано
  if (/[а-яёa-z0-9]/i.test(lastChar)) return true;
  return false;
}

// Telegram поддерживает только эти HTML-теги
const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'a', 'code', 'pre', 'tg-spoiler']);

// Санитизация HTML для Telegram — убирает кривые теги от AI
function sanitizeHTML(text) {
  if (!text) return text;

  // 1. Убрать теги которые TG не поддерживает (p, br, div, span, h1-h6, ul, li, etc)
  text = text.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/g, (match, tag) => {
    const lower = tag.toLowerCase();
    if (ALLOWED_TAGS.has(lower)) return match;
    // <br> → перенос строки, остальное — убрать
    if (lower === 'br') return '\n';
    return '';
  });

  // 2. Проверить парность тегов — закрыть незакрытые
  const openStack = [];
  const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/g;
  let m;
  while ((m = tagRegex.exec(text)) !== null) {
    const full = m[0];
    const tag = m[1].toLowerCase();
    if (!ALLOWED_TAGS.has(tag)) continue;
    if (tag === 'br') continue;
    if (full.startsWith('</')) {
      // Closing tag — pop from stack if matches
      const idx = openStack.lastIndexOf(tag);
      if (idx !== -1) openStack.splice(idx, 1);
      else text = text.replace(full, ''); // orphan close tag — remove
    } else if (!full.endsWith('/>')) {
      openStack.push(tag);
    }
  }
  // Закрыть все незакрытые теги
  while (openStack.length > 0) {
    text += `</${openStack.pop()}>`;
  }

  // 3. Экранировать голые & которые не часть entity
  text = text.replace(/&(?!(amp|lt|gt|quot|#\d+|#x[\da-fA-F]+);)/g, '&amp;');

  return text;
}

// Жёсткое напоминание для ретрая, когда AI болтает с оператором вместо поста
const RETRY_REINFORCEMENT = 'ВАЖНО: верни ТОЛЬКО готовый текст поста, без вступлений ("Понял задачу", "Лови варианты"), без нескольких вариантов, без вопросов и реплик мне в конце. Один цельный пост, готовый к публикации как есть.';

// Один вызов AI → сырой текст (или null при ошибке/пустом ответе)
async function callAI(channel, userMessage, temperature, reinforce = false) {
  const apiKey = process.env.OPENROUTER_API_KEY;
  const messages = [{ role: 'system', content: channel.prompt }];
  if (reinforce) messages.push({ role: 'system', content: RETRY_REINFORCEMENT });
  messages.push({ role: 'user', content: userMessage });

  const { data } = await axios.post(`${aiConfig.baseUrl}/chat/completions`, {
    model: aiConfig.model,
    messages,
    max_tokens: aiConfig.maxTokens,
    temperature
  }, {
    headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    timeout: 15000
  });

  return data.choices?.[0]?.message?.content || null;
}

// Вызов AI с авто-ретраем: если мета/обрезка — повтор со строгим напоминанием
// Возвращает чистый текст или null, если обе попытки провалились
async function callAIValidated(channel, userMessage, temperature) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const text = await callAI(channel, userMessage, temperature, attempt === 1);
    if (!text) continue;
    if (isMetaRefusal(text)) {
      log(`🚫 ${channel.name}: мета-болтовня (попытка ${attempt + 1}). "${text.substring(0, 70)}..."`);
      continue;
    }
    if (isTruncated(text)) {
      log(`✂️ ${channel.name}: обрезка (попытка ${attempt + 1}, ${text.length} chars). "...${text.slice(-30)}"`);
      continue;
    }
    return text.trim();
  }
  return null;
}

// Генерация контента через OpenRouter (DeepSeek)
async function generateContent(channel, newsItem) {
  const apiKey = process.env.OPENROUTER_API_KEY;
  if (!apiKey) {
    log('⚠️ OPENROUTER_API_KEY не задан, отправляю raw новость');
    return formatRawNews(channel, newsItem);
  }

  const userMessage = newsItem
    ? `Перепиши эту новость для Telegram-канала:\n\nЗаголовок: ${newsItem.title}\nТекст: ${newsItem.content}\nИсточник: ${newsItem.source}`
    : `Сгенерируй интересный пост для Telegram-канала "${channel.name}". Тематика: ${channel.type}.`;

  try {
    let text = await callAIValidated(channel, userMessage, aiConfig.temperature);

    // Обе попытки дали мета/обрезку → fallback на raw новость
    if (!text) {
      log(`↩️ ${channel.name}: оба ответа невалидны, подставляем raw новость`);
      return formatRawNews(channel, newsItem);
    }

    // Добавить хештеги
    if (channel.hashtags && channel.hashtags.length > 0) {
      const hasHashtags = channel.hashtags.some(h => text.includes(h));
      if (!hasHashtags) {
        text += '\n\n' + channel.hashtags.join(' ');
      }
    }

    // Конвертация Markdown bold в HTML bold для TG
    text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
    text = text.replace(/\*(.+?)\*/g, '<i>$1</i>');

    return sanitizeHTML(text);
  } catch (err) {
    log(`❌ AI error: ${err.message}`);
    return formatRawNews(channel, newsItem);
  }
}

// Fallback: форматирование без AI
function formatRawNews(channel, newsItem) {
  if (!newsItem) return null;

  const hashtags = channel.hashtags ? channel.hashtags.join(' ') : '';
  return sanitizeHTML(`<b>${newsItem.title}</b>\n\n${newsItem.content}\n\n${hashtags}`);
}

// Генерация контента без новости (юмор, заработок)
async function generateOriginalContent(channel, topic) {
  const apiKey = process.env.OPENROUTER_API_KEY;
  if (!apiKey) return null;

  const userMessage = topic
    ? `Напиши пост на тему: ${topic}`
    : `Напиши интересный пост для канала "${channel.name}".`;

  try {
    let text = await callAIValidated(channel, userMessage, 0.9);

    // Обе попытки дали мета/обрезку → слот пропускаем (нет raw-фолбэка для original)
    if (!text) {
      log(`↩️ ${channel.name}: оба ответа невалидны в original content, пропуск`);
      return null;
    }

    if (channel.hashtags && !channel.hashtags.some(h => text.includes(h))) {
      text += '\n\n' + channel.hashtags.join(' ');
    }

    text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
    text = text.replace(/\*(.+?)\*/g, '<i>$1</i>');

    return sanitizeHTML(text);
  } catch (err) {
    log(`❌ AI original content error: ${err.message}`);
    return null;
  }
}

module.exports = { generateContent, generateOriginalContent };

📜 Git History

b0f1494feat: усилить анти-мета фильтр (ретрай + хвостовая болтовня)3 weeks ago
3fdc0b1fix: 4 бага — WAL checkpoint, fallback контент, HTML sanitize, логи7 weeks ago
dc171bdfix: 6 улучшений — источники, фильтры, контент, обрезка7 weeks ago
e76f7eefix: runtime-фильтр мета-отказов AI + усиление промптов7 weeks ago
2757087init: KZ Channels bot — 5 Telegram channels for Kazakhstan7 weeks ago
Show last diff
Loading...