← Back
const { channels, adConfig } = require('./config');
const db = require('./database');
const { addError } = require('./error-log');
const { generateContent, generateOriginalContent } = require('./services/content-generator');
const { fetchNews } = require('./services/news-parser');
const { prepareImage } = require('./services/image-handler');
const { getCurrencyPost, getPoll } = require('./services/special-content');

// Утренняя рубрика «Курс валют» (Нацбанк РК) — для этих каналов вместо новости в слоте morning
const CURRENCY_CHANNELS = ['almaty-news', 'astana-news', 'dengi-kz'];
// Вечерняя рубрика «Опрос дня» — в слоте night вместо обычного поста (вовлечённость)
const POLL_CHANNELS = ['almaty-news', 'astana-news', 'dengi-kz', 'kz-champions'];

// Проверка: AI-текст упоминает город-конкурент?
const rivalCityWords = {
  'almaty-news': ['астана', 'нур-султан', 'нурсултан', 'байтерек', 'левобережь'],
  'astana-news': ['алматы', 'алма-ата', 'алатау', 'медеу', 'кок-тобе', 'коктобе', 'алмалинск', 'бостандык', 'алматинск']
};

// Ротация тем для AI-генерации (юмор, заработок)
const topicPool = {
  humor: [
    'бешбармак и гости', 'пробки в Алматы', 'ветер в Астане',
    'казахский той', 'тёща в гостях', 'базар в выходные',
    'понедельник на работе', 'калым и свадьба', 'казахские бабушки (апашки)',
    'казахские водители', 'шашлык с друзьями', 'отпуск на Балхаше',
    'казахский Новый год', 'жизнь в общаге', 'келiн и свекровь',
    'казахское застолье', 'школьные годы в КЗ', 'маршрутки и автобусы',
    'жара летом в Казахстане', 'дорогие цены на базаре',
    'казахская свадьба vs русская свадьба', 'когда мама звонит',
    'первая зарплата', 'соседи в многоэтажке', 'дачный сезон',
    'казахские традиции глазами молодёжи', 'курьеры Glovo/Wolt',
    'студенческая жизнь', 'казахский Instagram', 'караоке с друзьями'
  ],
  earnings: [
    'подработка на выходных', 'фриланс для новичков', 'заработок на телефоне',
    'бизнес с нуля без вложений', 'как экономить на продуктах',
    'заработок на Kaspi', 'удалённая работа в КЗ', 'сезонный заработок летом',
    'заработок на авто', 'продажа handmade', 'репетиторство онлайн',
    'заработок на OLX', 'финансовая грамотность', 'инвестиции для начинающих',
    'как начать откладывать деньги', 'побочный доход для офисных работников',
    'заработок для студентов', 'микробизнес в своём районе',
    'заработок на навыках (сантехник, электрик)', 'кешбэк и бонусы банков',
    'пассивный доход в Казахстане', 'торговля на базаре',
    'заработок на контенте (TikTok, YouTube)', 'курьерская доставка',
    'аренда вещей как бизнес', 'заработок на переводах (каз/рус/англ)'
  ]
};

function getRandomTopic(type) {
  const pool = topicPool[type];
  if (!pool || pool.length === 0) return null;
  return pool[Math.floor(Math.random() * pool.length)];
}

function mentionsRivalCity(channelKey, text) {
  const words = rivalCityWords[channelKey];
  if (!words) return false;
  const lower = text.toLowerCase();
  return words.some(w => lower.includes(w));
}

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

// Определить: этот слот рекламный или контентный?
function isAdSlot(channelKey, slotName) {
  if (!adConfig.adSlots.includes(slotName)) return false;

  const todayPosts = db.getTodayPostCount(channelKey);
  const todayAds = db.getTodayAdCount(channelKey);

  // Соблюдаем частоту: 1 реклама на N контентных
  if (todayAds > 0) return false; // Макс 1 реклама в день на канал
  if (todayPosts < adConfig.adFrequency) return false; // Сначала контент

  // Чередование слотов по дням
  if (adConfig.rotateDaily) {
    const dayOfWeek = new Date().getDay();
    const slotIndex = adConfig.adSlots.indexOf(slotName);
    return (dayOfWeek % adConfig.adSlots.length) === slotIndex;
  }

  return true;
}

// Обработать один слот для одного канала
async function processSlot(bot, channelKey, slotName, isTest = false) {
  const channel = channels[channelKey];
  if (!channel) throw new Error(`Unknown channel: ${channelKey}`);
  if (!channel.chatId && !isTest) throw new Error(`No chatId for ${channelKey}`);

  const isAd = !isTest && isAdSlot(channelKey, slotName);

  // Деньги KZ (earnings) — гибрид: ~50% слотов реальные финновости из RSS, ~50% AI-идеи заработка.
  // Юмор всегда AI-оригинал. Новостные каналы всегда RSS.
  const useOriginal = channel.type === 'humor'
    || (channel.type === 'earnings' && Math.random() < 0.5);

  let text, imageBuffer, sourceUrl, title = '';

  let adTemplateId = null;

  // Рубрика «Опрос дня» в вечернем слоте — отдельный путь (sendPoll), завершает обработку
  if (!isAd && slotName === 'night' && POLL_CHANNELS.includes(channelKey)) {
    const poll = await getPoll(channel);
    if (!poll || !poll.options || poll.options.length < 2) {
      log(`⏭️ ${channel.name}: опрос недоступен, пропуск`);
      return false;
    }
    if (isTest) {
      log(`[TEST] ${channel.name} ОПРОС:\n${poll.question}\n${poll.options.map((o, i) => `${i + 1}. ${o}`).join('\n')}`);
      return true;
    }
    const pollMsg = await bot.api.sendPoll(channel.chatId, poll.question, poll.options, { is_anonymous: true });
    db.savePost({
      channelId: channelKey,
      sourceUrl: `poll:${db.getKZDate()}`,
      title: 'poll',
      content: poll.question,
      postType: 'content',
      telegramMessageId: pollMsg.message_id
    });
    db.incrementStats(channelKey, false);
    log(`📊 ${channel.name}: опрос → msg ${pollMsg.message_id}`);
    return true;
  }

  // Рубрика «Курс валют» в утреннем слоте (живой ежедневный ритуал вместо сухой новости)
  const isCurrencySlot = !isAd && slotName === 'morning' && CURRENCY_CHANNELS.includes(channelKey);

  if (isAd) {
    // Рекламный пост
    const adContent = generateAdPost(channel);
    text = adContent.text;
    adTemplateId = adContent.templateId;
    sourceUrl = 'ad:pinup';
  } else if (isCurrencySlot) {
    // Курс валют от Нацбанка РК
    text = await getCurrencyPost(channel.hashtags);
    sourceUrl = `currency:${db.getKZDate()}`;
    if (!text) {
      log(`⏭️ ${channel.name}: пропуск утреннего слота (курс недоступен)`);
      return false;
    }
  } else if (useOriginal) {
    // AI-генерация оригинального контента (юмор, заработок)
    // Ротация тем для разнообразия контента
    const topic = getRandomTopic(channel.type);
    text = await generateOriginalContent(channel, topic);
    // Retry с другой темой если первая попытка пустая
    if (!text) {
      log(`🔄 ${channel.name}: retry с другой темой...`);
      const topic2 = getRandomTopic(channel.type);
      text = await generateOriginalContent(channel, topic2);
    }
    // Fallback: шаблонный пост если AI дважды вернул пустоту
    if (!text) {
      const fallbackTopic = getRandomTopic(channel.type);
      if (channel.type === 'humor') {
        text = `😂 <b>Типичный КЗ момент</b>\n\nКогда тема "${fallbackTopic}" — это про каждого из нас 🇰🇿\n\nА у вас так было? Пишите в комментах 👇\n\n${channel.hashtags.join(' ')}`;
      } else {
        text = `💡 <b>Тема дня: ${fallbackTopic}</b>\n\nОб этом стоит задуматься каждому казахстанцу 🇰🇿\n\nА вы пробовали? Делитесь опытом 👇\n\n${channel.hashtags.join(' ')}`;
      }
      log(`🔄 ${channel.name}: используем fallback-шаблон`);
    }
    sourceUrl = `ai:${channelKey}:${Date.now()}`;
  } else {
    // Контентный пост из RSS (источники из БД)
    const usedUrls = db.getRecentUrls(3);
    const approvedSources = db.getApprovedSources(channelKey);
    const newsItem = await fetchNews(channel, usedUrls, approvedSources);
    if (!newsItem) {
      log(`⚠️ ${channel.name}: нет новостей для ${slotName}`);
      addError(channelKey, 'rss', `Нет новостей для слота ${slotName}`);
      return false;
    }

    // Проверка дублей (per-channel)
    if (db.isDuplicate(channelKey, newsItem.url)) {
      log(`⏭️ ${channel.name}: дубль ${newsItem.url}`);
      return false;
    }

    // AI рерайт
    text = await generateContent(channel, newsItem);
    sourceUrl = newsItem.url;
    title = newsItem.title || '';

    // Проверка: AI мог вставить город-конкурент в текст
    if (text && mentionsRivalCity(channelKey, text)) {
      log(`⏭️ ${channel.name}: AI упомянул город-конкурент, пропуск`);
      text = null; // сбросить — ниже поймает проверка на пустой контент
    }

    // Картинка
    if (newsItem.imageUrl) {
      try {
        imageBuffer = await prepareImage(newsItem.imageUrl, channel);
      } catch (err) {
        log(`⚠️ Картинка не загрузилась: ${err.message}`);
        addError(channelKey, 'image', err.message);
      }
    }

    // Если источник новый (не system) — в очередь на модерацию
    if (newsItem.sourceId && db.sourceNeedsModeration(newsItem.sourceId)) {
      let imagePath = null;
      if (imageBuffer) {
        const fs = require('fs');
        imagePath = `data/queue-img-${Date.now()}.jpg`;
        fs.writeFileSync(imagePath, imageBuffer);
      }
      db.addToQueue({
        channelId: channelKey,
        sourceId: newsItem.sourceId,
        sourceUrl: newsItem.url,
        title: newsItem.title,
        content: text,
        imagePath
      });
      log(`📋 ${channel.name}: пост в очередь модерации (источник: ${newsItem.source})`);
      return false;
    }
  }

  if (!text) {
    log(`⚠️ ${channel.name}: пустой контент для ${slotName}`);
    addError(channelKey, 'ai', `Пустой контент для ${slotName}`);
    return false;
  }

  // Отправка в канал
  let messageId;
  const chatId = isTest ? null : channel.chatId;

  if (isTest) {
    log(`[TEST] ${channel.name}:\n${text}`);
    return true;
  }

  // Отправка с retry: если HTML парсинг упал → отправить plain text
  try {
    if (imageBuffer) {
      const { InputFile } = require('grammy');
      if (text.length > 1024) {
        await bot.api.sendPhoto(chatId, new InputFile(imageBuffer));
        const result = await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML' });
        messageId = result.message_id;
      } else {
        const result = await bot.api.sendPhoto(chatId, new InputFile(imageBuffer), {
          caption: text,
          parse_mode: 'HTML'
        });
        messageId = result.message_id;
      }
    } else {
      const result = await bot.api.sendMessage(chatId, text, {
        parse_mode: 'HTML',
        disable_web_page_preview: false
      });
      messageId = result.message_id;
    }
  } catch (sendErr) {
    // Retry без parse_mode если HTML сломан (400 Bad Request: can't parse entities)
    if (sendErr.message && sendErr.message.includes('parse entities')) {
      log(`⚠️ ${channel.name}: HTML parse error, retry без parse_mode`);
      const plainText = text.replace(/<[^>]+>/g, '');
      const result = await bot.api.sendMessage(chatId, plainText, {
        disable_web_page_preview: false
      });
      messageId = result.message_id;
    } else {
      throw sendErr; // Другая ошибка — пробросить
    }
  }

  // Сохранить в БД
  db.savePost({
    channelId: channelKey,
    sourceUrl,
    title: adTemplateId || title,
    content: text,
    postType: isAd ? 'ad' : 'content',
    telegramMessageId: messageId
  });
  db.incrementStats(channelKey, isAd);

  log(`📨 ${channel.name}: ${isAd ? '💰 реклама' : '📝 контент'} → msg ${messageId}`);
  return true;
}

// Генерация рекламного поста
function generateAdPost(channel) {
  const pinupLink = process.env.PINUP_LINK || 'https://pin-up.kz';

  const templates = {
    news: [
      `🎰 <b>Новость дня!</b>\n\nPin-Up Kazakhstan дарит бонус 500% на первый депозит!\nТысячи казахстанцев уже выигрывают.\n\n👉 <a href="${pinupLink}">Забрать бонус</a>\n\n<i>18+ | Играй ответственно</i>`,
      `🔥 <b>Акция для подписчиков!</b>\n\nЭксклюзивный бонус от Pin-Up — до 500% на депозит + фриспины!\n\n👉 <a href="${pinupLink}">Получить бонус</a>\n\n<i>18+ | Играй ответственно</i>`
    ],
    sports: [
      `⚽ <b>Сделай прогноз — получи бонус!</b>\n\nPin-Up Bet — ставки на спорт с бонусом до 500%!\nСтавь на казахстанских чемпионов 🇰🇿\n\n👉 <a href="${pinupLink}">Начать</a>\n\n<i>18+ | Играй ответственно</i>`,
      `🏆 <b>Болей и зарабатывай!</b>\n\nPin-Up Bet: лучшие коэффициенты на KPL, бокс, UFC.\nБонус 500% новым игрокам!\n\n👉 <a href="${pinupLink}">Забрать бонус</a>\n\n<i>18+ | Играй ответственно</i>`
    ],
    earnings: [
      `💰 <b>Способ заработка #${Math.floor(Math.random() * 50) + 1}</b>\n\nТысячи людей зарабатывают на Pin-Up!\nБонус 500% на первый депозит — начни с минимума.\n\n👉 <a href="${pinupLink}">Попробовать</a>\n\n<i>18+ | Играй ответственно</i>`
    ],
    humor: [
      `😂 А теперь серьёзно...\n\n🎰 Pin-Up Kazakhstan — бонус 500% на первый депозит!\nСмех смехом, а бонусы реальные 💸\n\n👉 <a href="${pinupLink}">Забрать</a>\n\n<i>18+ | Играй ответственно</i>`
    ]
  };

  const typeKey = templates[channel.type] ? channel.type : 'news';
  const pool = templates[typeKey];
  const idx = Math.floor(Math.random() * pool.length);
  const text = pool[idx];

  return { text, templateId: `${typeKey}:${idx}` };
}

// Отправить одобренный пост из очереди в канал
// Вызывается из dashboard API при approve
let botInstance = null;

function setBotInstance(bot) {
  botInstance = bot;
}

async function sendApprovedPost(queueItem) {
  if (!botInstance) throw new Error('Bot not initialized');

  const channel = channels[queueItem.channel_id];
  if (!channel || !channel.chatId) throw new Error(`Channel ${queueItem.channel_id} not configured`);

  const text = queueItem.content;
  let messageId;

  if (queueItem.image_path) {
    const fs = require('fs');
    const { InputFile } = require('grammy');
    try {
      const imgBuffer = fs.readFileSync(queueItem.image_path);
      if (text.length > 1024) {
        await botInstance.api.sendPhoto(channel.chatId, new InputFile(imgBuffer));
        const result = await botInstance.api.sendMessage(channel.chatId, text, { parse_mode: 'HTML' });
        messageId = result.message_id;
      } else {
        const result = await botInstance.api.sendPhoto(channel.chatId, new InputFile(imgBuffer), {
          caption: text,
          parse_mode: 'HTML'
        });
        messageId = result.message_id;
      }
    } catch (err) {
      // Если картинка не загрузилась — отправить текстом
      const result = await botInstance.api.sendMessage(channel.chatId, text, { parse_mode: 'HTML' });
      messageId = result.message_id;
    } finally {
      // Всегда удаляем temp файл (и при успехе, и при ошибке)
      try { fs.unlinkSync(queueItem.image_path); } catch {}
    }
  } else {
    const result = await botInstance.api.sendMessage(channel.chatId, text, {
      parse_mode: 'HTML',
      disable_web_page_preview: false
    });
    messageId = result.message_id;
  }

  // Сохранить в posts
  db.savePost({
    channelId: queueItem.channel_id,
    sourceUrl: queueItem.source_url,
    title: queueItem.title,
    content: text,
    postType: 'content',
    telegramMessageId: messageId
  });
  db.incrementStats(queueItem.channel_id, false);

  log(`📨 ${channel.name}: модерированный пост → msg ${messageId}`);
  return messageId;
}

async function sendManualPost(channelKey, text, imageUrl) {
  if (!botInstance) throw new Error('Bot not initialized');
  const channel = channels[channelKey];
  if (!channel || !channel.chatId) throw new Error(`Channel ${channelKey} not configured`);

  let messageId;

  if (imageUrl) {
    if (text.length > 1024) {
      await botInstance.api.sendPhoto(channel.chatId, imageUrl);
      const result = await botInstance.api.sendMessage(channel.chatId, text, { parse_mode: 'HTML' });
      messageId = result.message_id;
    } else {
      const result = await botInstance.api.sendPhoto(channel.chatId, imageUrl, {
        caption: text,
        parse_mode: 'HTML'
      });
      messageId = result.message_id;
    }
  } else {
    const result = await botInstance.api.sendMessage(channel.chatId, text, {
      parse_mode: 'HTML',
      disable_web_page_preview: false
    });
    messageId = result.message_id;
  }

  db.savePost({
    channelId: channelKey,
    sourceUrl: null,
    title: 'manual',
    content: text,
    imageUrl: imageUrl || null,
    postType: 'content',
    telegramMessageId: messageId
  });
  db.incrementStats(channelKey, false);

  log(`📨 ${channel.name}: ✏️ ручной пост${imageUrl ? ' +📷' : ''} → msg ${messageId}`);
  return messageId;
}

module.exports = { processSlot, sendApprovedPost, sendManualPost, setBotInstance };

📜 Git History

31dcfdffeat: вечерняя рубрика Опрос дня (sendPoll, AI + статичный фолбэк)3 weeks ago
ecd6693feat: утренняя рубрика Курс валют от Нацбанка РК (B3)3 weeks ago
28ca823feat: гибридный роутинг dengi-kz (финновости RSS + AI-идеи)3 weeks ago
3fdc0b1fix: 4 бага — WAL checkpoint, fallback контент, HTML sanitize, логи7 weeks ago
dc171bdfix: 6 улучшений — источники, фильтры, контент, обрезка7 weeks ago
0413c12fix: 9 багов — caption limit, orphan cleanup, security hardening7 weeks ago
c3e1a45fix: save RSS title to posts instead of empty string7 weeks ago
045688afeat: dashboard v0.3 — 9 новых источников + 10 улучшений дашборда7 weeks ago
5481deffeat: post moderation queue for new sources7 weeks ago
6ff5fa6feat: source management with moderation via dashboard modal7 weeks ago
Show last diff
Loading...