Что такое автоматизация рутинных задач в боте постов
Однажды я потратил несколько часов, чтобы понять, почему старая воронка в Telegram-боте вдруг начала срабатывать там, где её никто не ждал. Оказалось - одно ключевое слово навсегда привязывалось к любому посту в канале, и когда владелец выпускал новый ролик с тем же словом, автоматика шла вразнос. Именно тогда я по-настоящему разобрался, что такое автоматизация рутинных задач на практике - не в теории, а когда система работает против тебя. Это и стало отправной точкой проекта pasha-vin-post-bot, в котором мы закрыли эту проблему и заодно добавили черновики постов и полноценный экран визуализации цепочки «слово - лендинг - воронка - пост».
Контекст: почему триггер без привязки к посту — это проблема
В нашей системе автоматизации есть таблица funnel_triggers_v2. Каждая запись — это ключевое слово, которое пользователь пишет в комментарии к посту, и воронка, которая запускается в ответ. До этой доработки триггер «занимал» слово навсегда и на всём канале. Это означало: если сегодня слово «курс» ведёт в воронку A, а завтра выходит новый пост с тем же словом в другой воронке B — система не знает, какую воронку запускать. Она просто берёт первую попавшуюся.
Владелец сформулировал задачу просто: «делай привязку» и «как мы в UI будем видеть слова с лендосами, чтобы не запутаться?». За этими двумя вопросами скрывалась довольно серьёзная архитектурная работа.
Миграция базы данных: добавляем post_id и platform_post_id
Первый шаг — схема. Нам нужно было добавить два поля:
post_idв таблицуfunnel_triggers_v2— чтобы триггер знал, к какому посту он привязанplatform_post_idв таблицуpublish_targets— чтобы после публикации мы могли сопоставить наш внутренний пост с ID на платформе (Telegram, Instagram и т.д.)
ALTER TABLE funnel_triggers_v2
ADD COLUMN post_id uuid REFERENCES publish_posts(id) ON DELETE SET NULL;
ALTER TABLE publish_targets
ADD COLUMN platform_post_id text;Почему ON DELETE SET NULL, а не CASCADE? Потому что если пост удаляется, мы не хотим терять триггер целиком — он может быть переиспользован или просто нужен для истории. Триггер без поста становится «глобальным» (срабатывает на любой пост), что соответствует старому поведению.
Извлечение platformPostId из ответа вендора
Когда пост публикуется, вендор (Telegram Bot API, например) возвращает ID опубликованного сообщения. Раньше мы этот ID просто игнорировали. Теперь нам нужно его сохранить, чтобы потом связать с триггером.
В run.ts функция outcomeFor разбирает ответ вендора. Мы добавили извлечение platformPostId:
// До
function outcomeFor(vendor: string, raw: unknown): TargetOutcome {
// ... парсинг статуса
return { status, error };
}
// После
function outcomeFor(vendor: string, raw: unknown): TargetOutcome {
// ... парсинг статуса
const platformPostId = extractPlatformPostId(vendor, raw);
return { status, error, platformPostId };
}Функция extractPlatformPostId знает о форматах разных вендоров: у Telegram это result.message_id, у других платформ — свои поля. Это изолированная логика, которую легко расширять.
tick.ts: связываем публикацию с триггером
Самое интересное происходит в tick.ts — это воркер, который обрабатывает очередь публикаций. После того как пост успешно опубликован и мы получили platformPostId, нужно:
- Сохранить
platform_post_idвpublish_targets - Найти все триггеры, привязанные к этому посту (
post_id = <наш id>) - Установить им
media_filter— фильтр по ID поста на платформе
Мы добавили функцию setMediaFiltersForPost:
async function setMediaFiltersForPost(
supabase: SupabaseClient,
postId: string,
channelId: string,
platformPostId: string
): Promise<void> {
await supabase
.from('funnel_triggers_v2')
.update({ media_filter: platformPostId })
.eq('post_id', postId)
.eq('channel_id', channelId)
.is('media_filter', null); // только те, у которых ещё нет фильтра
}Почему .is('media_filter', null)? Потому что если фильтр уже установлен (например, при повторном запуске тика), мы не хотим его перезаписывать. Это идемпотентная операция.
Умный конфликт-гард в funnel-kit/route.ts
Раньше при попытке создать триггер с уже существующим словом система просто возвращала ошибку конфликта. Теперь логика стала умнее:
- Два триггера с одним словом на разных постах — это не конфликт. Каждый пост может иметь свою воронку.
- Два триггера с одним словом на одном посту — это конфликт.
- Триггер без поста (
post_id = null) конфликтует с любым другим триггером без поста на том же канале.
// Проверка конфликта
const existing = await supabase
.from('funnel_triggers_v2')
.select('id, post_id')
.eq('channel_id', channelId)
.eq('keyword', keyword)
.maybeSingle();
if (existing.data) {
const samePost =
existing.data.post_id === postId || // оба привязаны к одному посту
(existing.data.post_id === null && postId === null); // оба глобальные
if (samePost) {
return NextResponse.json({ error: 'conflict' }, { status: 409 });
}
// Разные посты — продолжаем создание
}Это небольшое изменение, но оно принципиально меняет UX: владелец канала теперь может спокойно использовать одно слово в разных постах с разными воронками.
readiness.ts: статус привязки в интерфейсе
Мы обновили тип TriggerRef, добавив поля postId и mediaFilter. Это позволяет UI показывать статус привязки:
type TriggerRef = {
id: string;
keyword: string;
funnelId: string;
postId?: string | null; // undefined = старый триггер без поля
mediaFilter?: string | null; // null = ждём публикации
};Логика отображения:
postId === undefined— старый триггер, поле не заполнено (не показываем строку)postId === null— глобальный триггер, срабатывает на любой постpostId !== null && !mediaFilter— пост ещё не опубликован, ждёмpostId !== null && mediaFilter— привязка зафиксирована, всё работает
Экран «слово → лендинг → воронка → пост»
Второй большой блок работы — визуализация. Владелец хотел видеть все связки в одном месте, чтобы не запутаться. Мы добавили новую вкладку «Связки» в навигацию автоматизации и создали API-эндпоинт /api/funnel-chain.
Эндпоинт возвращает денормализованные данные: для каждого ключевого слова — лендинг, воронку и пост (если привязан). Запрос использует JOIN через несколько таблиц:
const { data } = await supabase
.from('funnel_triggers_v2')
.select(`
id,
keyword,
media_filter,
post_id,
funnels!inner(
id,
name,
landing_id,
landings(id, slug)
),
publish_posts(id, title, status)
`)
.eq('channel_id', channelId)
.order('created_at', { ascending: false });UI-экран отображает каждую связку как карточку: слово → лендинг → воронка → пост. Если пост ещё не опубликован — показываем статус «ожидает публикации». Если media_filter установлен — зелёная галочка.
Черновики постов: параллельная задача
Пока работали над привязкой триггеров, параллельно решили давно висевшую задачу — черновики постов. Раньше createPost всегда ставил статус scheduled. Это создавало проблему: пост сразу попадал в очередь публикации, даже если владелец ещё не закончил его редактировать.
Добавили поле asDraft?: boolean в CreatePostInput:
// src/lib/publish/create.ts
export async function createPost(
input: CreatePostInput & { asDraft?: boolean }
): Promise<{ id: string }> {
const status = input.asDraft ? 'draft' : 'scheduled';
if (!input.asDraft) {
// Проверяем дневной лимит только для scheduled постов
const overLimit = await channelsOverLimit(input.projectId);
if (overLimit) throw new Error('daily_limit_exceeded');
}
const { data } = await supabase
.from('publish_posts')
.insert({ ...input, status })
.select('id')
.single();
return { id: data.id };
}Важный момент: черновики не считаются в дневном лимите. Для этого в channelsOverLimit добавили .neq('publish_posts.status', 'draft'). Логика простая — черновик не занимает слот публикации, пока не активирован.
Добавили функцию activateDraft(postId, projectId), которая переводит черновик в scheduled с проверкой лимита. И deleteDraft(postId, projectId) для удаления.
В UI появилась кнопка «Сохранить черновиком» рядом с основной кнопкой публикации. На странице очереди черновики отображаются отдельным блоком с кнопками «Активировать» и «Удалить».
Тесты: покрываем все сценарии
Для каждого изменения написали тесты. Ключевые сценарии:
Конфликт-гард:
it('два триггера с одним словом на разных постах — не конфликт', async () => {
// Существующий триггер привязан к post-1
mockExisting({ post_id: 'post-1' });
// Создаём триггер для post-2 с тем же словом
const res = await POST(makeRequest({ keyword: 'курс', postId: 'post-2' }));
expect(res.status).toBe(201); // Не 409!
});
it('два триггера с одним словом на одном посту — конфликт', async () => {
mockExisting({ post_id: 'post-1' });
const res = await POST(makeRequest({ keyword: 'курс', postId: 'post-1' }));
expect(res.status).toBe(409);
});Черновики и лимиты:
it('черновик не считается в дневном лимите', async () => {
// Канал уже на лимите
mockChannelAtLimit();
// Но черновик создаётся без ошибки
const result = await createPost({ ...baseInput, asDraft: true });
expect(result.id).toBeDefined();
});
it('активация черновика проверяет лимит', async () => {
mockChannelAtLimit();
await expect(activateDraft('draft-id', 'project-id'))
.rejects.toThrow('daily_limit_exceeded');
});Всего 508 тестов воркера и 384 юнит-теста — все зелёные.
Результат
Что получили в итоге:
- Триггер привязан к посту — одно слово может использоваться в разных постах с разными воронками. Никакого конфликта.
- Автоматическая установка media_filter — после публикации поста триггер автоматически начинает фильтровать только комментарии к этому посту.
- Экран «Связки» — владелец видит всю цепочку: слово → лендинг → воронка → пост. Статус привязки виден сразу.
- Черновики — посты можно сохранять как черновики, они не занимают лимит и не публикуются до активации.
Выводы и уроки
Главный урок этой работы — инкрементальная миграция схемы без поломки старого поведения. Мы добавили post_id как nullable поле. Старые триггеры без post_id продолжают работать как глобальные. Новые триггеры могут быть привязаны к конкретному посту. Никакого breaking change, никакой миграции данных.
Второй урок — идемпотентность критична для воркеров. Функция setMediaFiltersForPost проверяет .is('media_filter', null) перед обновлением. Это значит, что если тик запустится дважды (а это случается при сбоях), он не перезапишет уже установленный фильтр. Воркеры должны быть идемпотентными по умолчанию.
Третий урок — умный конфликт-гард лучше тупого. Раньше любой дубликат ключевого слова был ошибкой. Теперь система понимает контекст: дубликат в рамках одного поста — ошибка, дубликат в разных постах — нормально. Это требует чуть больше кода, но кардинально улучшает UX.
Четвёртый урок — визуализация связей не менее важна, чем сами связи. Можно построить идеальную систему привязки триггеров к постам, но если владелец не видит, что к чему привязано — он всё равно запутается. Экран «Связки» — это не фича, это необходимость для любой системы с нетривиальными зависимостями между сущностями.
Использованные технологии: Supabase PostgreSQL, Next.js App Router, TypeScript, Zod для валидации, Vitest для тестов.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.
Связанный проект
AI Content Aggregator →