Як ми запустили онлайн-каву в Telegram-комʼюніті: досвід, бот і нетворкінг

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Привіт! Мене звати Сергій, я Java-розробник із 5-річним досвідом, у минулому працював як fullstack. За ці роки зрозумів: щоб підтримувати себе в професійній формі, варто постійно робити дві речі — працювати над pet-проєктами та ділитися досвідом з іншими. У цій статті я розповім, як виникла ідея Telegram-бота для регулярного нетворкінгу в нашій спільноті, з якими технічними викликами я зіткнувся та які результати ми отримали.

Як усе почалося

Разом із друзями ми створили спільноту Leetcoders — спочатку як вузький чат для спільного розв’язання алгоритмічних задач. З часом кожен почав запрошувати своїх знайомих — і менш ніж за два роки ми зросли з 4 людей до понад 700. Формат розширився: тепер ми проводимо офлайн зустрічі в Києві, алгоритмічні челенджі (наприклад, algorithm-challenge.com, про який я розповім в іншій раз), книжкові клуби, обговорення новин, щоденні квізи та багато іншого.

Звідки взялась ідея

Пів року тому я потрапив на вебінар з нетворкінгу, де спікер дав завдання: «Прямо зараз напишіть одній людині з вебінара в особисті та запросіть її на каву. Познайомтесь ближче, розширте коло знайомих». Геніальна ідея, подумав я, хочу мати теж саме і в нашій спільноті! Так народився концепт бота, що формує пари для неформального онлайн-спілкування (насправді бот може більше, але сконцентруємось на цій частині функціональності).

Архітектура та перші кроки

Задача: зібрати охочих до нетворкінгу, зберегти їхні імена та Telegram ID, сформувати пари й нагадати про зустріч. Подумати про розумний рандомайзер, щоб пари не співпадали декілька разів поспіль.

Базову функціональність реалізував за допомогою Java + Spring Boot. Для роботи з Telegram використав бібліотеку rubenlagus/TelegramBots.

Загалом отримувати інформацію від користувачів Telegram можна двома способами:

  • Webhook — Telegram сам надсилає нам нову інформацію. Але він має знати, куди саме надіслати її, тому конкретну адресу треба зареєструвати вручну або при старті програми.
  • Long Polling — це коли ти сам «ходиш» на сервер Telegram за інформацію раз за разом, перевіряючи, чи є щось новеньке.

Наш бот працює на Long Polling (для початку це найпростіший варіант), отримує повідомлення, реагує на inline-кнопки, зберігає активних учасників у базу даних.

Реєстрація бота вміщується в один клас:

@Slf4j
@Component
public class LeetcodersFriendBot implements SpringLongPollingBot, LongPollingSingleThreadUpdateConsumer {

	@Getter
	private final TelegramClient telegramClient;
	private final List<TelegramCommand> commands;
	private final BotProps botProps;

	public LeetcodersFriendBot(@Lazy List<TelegramCommand> commands, BotProps botProps) {
		this.commands = commands;
		this.botProps = botProps;
		telegramClient = new OkHttpTelegramClient(getBotToken());
	}


	@Override
	public String getBotToken() {
		return botProps.token();
	}

	@Override
	public LongPollingUpdateConsumer getUpdatesConsumer() {
		return this;
	}

	@Override
	public void consume(Update update) {
		try {
			for (TelegramCommand command : commands) {
				if (command.supports(update)) {
					command.execute(update, telegramClient);
					return;
				}
			}
			log.debug("No command found for update {}", update);
		} catch (Exception e) {
			log.error("Error processing update", e);
		}
	}

Цього вже достатньо для того, щоб отримувати оновлення від Telegram і їх опрацьовувати.

Запрошення на каву

Раз на тиждень у вівторок бот надсилає запрошення приєднатись до «онлайн-кави». Використав @Scheduled для автоматизації:

@Scheduled(cron = "0 0 5 * * TUE")
public void startCoffeePoll() {
	@Scheduled(cron = "0 0 * * * *")
	public void startCoffeePoll() throws TelegramApiException {
		LocalDateTime now = LocalDateTime.now();
		List<TelegramChatEntity> chatEntities = coffeeChatRepository.findByStartDateBetween(now.minusHours(1), now);
		log.info("Starting coffee poll {} for chats {}", now, chatEntities.size());
		for (TelegramChatEntity chat : chatEntities) {
			log.info("Processing chat {}", chat);
			String participateCallback = ONLINE_COFFEE_PARTICIPATE_CALLBACK.formatted(chat.getChatId(), LocalDate.now());
			InlineKeyboardButton participateButton = createInlineKeyboardButton(TO_PARTICIPATE_TEXT, participateCallback);
			InlineKeyboardButton rulesButton = createInlineKeyboardButton(RULES_BUTTON_TEXT, ONLINE_COFFEE_RULES_CALLBACK);

			SendMessage sendMessage = new SendMessage(chat.getChatId().toString(), ONLINE_COFFEE_TEXT);
			sendMessage.enableMarkdownV2(true);
			sendMessage.setReplyMarkup(InlineKeyboardMarkup.builder().keyboardRow(createInlineKeyboardRow(List.of(participateButton, rulesButton))).build());

			OnlineCoffeeEntity entity = new OnlineCoffeeEntity();
			entity.setDate(LocalDate.now());
			entity.setIsActive(true);
			entity.setCoffeeName(participateCallback);
			entity.setChat(chat);

			Message sentMessage = leetcodersFriendBot.getTelegramClient().execute(sendMessage);
			entity.setMessageId(sentMessage.getMessageId());
			onlineCoffeeRepository.save(entity);
			chat.setStartDate(chat.getStartDate().plusDays(7));
			coffeeChatRepository.save(chat);

			leetcodersFriendBot.getTelegramClient().execute(PinChatMessage.builder()
					.messageId(sentMessage.getMessageId())
					.chatId(sentMessage.getChatId())
					.build());
		}
	}

}

Надсилається повідомлення і дві кнопки: взяти участь та почитати правила.

Коли користувачі натискають кнопку «П’ю каву з вами» — бот додає їх у список, повторне натискання прибирає зі списку. Ці події зберігаються в базу та додатково оновлюється повідомлення, в ньому видно всіх, хто бере участь.

Обробка реакцій

Кнопки обробляються через командний шаблон TelegramCommand. Для прикладу — вивід правил:

@Slf4j
@Component
public class OnlineCoffeeRulesCallback implements TelegramCommand {
	@Override
	public boolean supports(Update update) {
		return update.hasCallbackQuery()
				&& update.getCallbackQuery().getData() != null
				&& update.getCallbackQuery().getData().equals(ONLINE_COFFEE_RULES_CALLBACK);
	}
	@SneakyThrows
	@Override
	public void execute(Update update, TelegramClient client) {
		log.info("User {} requested rules", update.getCallbackQuery().getFrom().getId());
		AnswerCallbackQuery answerCallbackQuery = new AnswerCallbackQuery(update.getCallbackQuery().getId());
		answerCallbackQuery.setShowAlert(true);
		answerCallbackQuery.setText(RULES_ARE_SIMPLE);
		client.execute(answerCallbackQuery);

	}
}

Коли користувач натискає кнопку «П’ю каву з вами», бот перевіряє по колбеку, чи ця кава-зустріч активна. Якщо так — додає або видаляє користувача зі списку й оновлює повідомлення зі списком учасників.

Для відображення учасників можна використовувати логін або, якщо його немає, First Name + Last Name.

Варто не забувати, що деякі символи не обробляються в Markdown, тому для коректного форматування відповідей використовуємо маленький утилітний метод:

public static String sanitizeField(String field) {
		String specialCharsRegex = "[\\\\_*\\[\\]()~`><+\\-=|{}.!]";
		return StringUtils.defaultString(field).replaceAll(specialCharsRegex, "\\\\$0");
	}

Ось так воно виглядає з заповненими даними людей:

Формування унікальних пар: як бот уникає повторень

Увечері запускається інший скедулер, що:

  • зупиняє поточне опитування;
  • формує випадкові пари з учасників.

Після запуску активності «онлайн-кава» стало зрозуміло, що простий Collections.shuffle() для формування пар швидко вичерпує себе. У спільноті почали повторюватись знайомства. Тому я реалізував алгоритм унікального парування з урахуванням історії: користувач не повинен потрапити в пару з кимось, із ким уже спілкувався раніше (принаймні протягом останніх 10 сесій).

Основна логіка

Формування пар відбувається в кілька етапів:

  1. Завантаження історичних пар з бази.
  2. Генерація пар з уникненням нещодавніх комбінацій.
  3. Якщо нічого не вдалось згенерувати за N спроб — fallback до більш простих варіантів.
public List<List<TelegramUserEntity>> generateUniquePairs(List<TelegramUserEntity> users, String sessionId) {
    Set<PairKey> pairingsToAvoid = loadRecentPairings(users);
    
    for (int attempt = 0; attempt < maxAttempts; attempt++) {
        List<List<TelegramUserEntity>> pairs = tryGeneratePairs(users, pairingsToAvoid);
        if (isValid(pairs, sessionPairings.get(sessionId))) {
            updateSessionPairings(pairs, sessionId);
            return pairs;
        }
    }

    // fallback
    return tryGeneratePairs(users, new HashSet<>());
}

Мала група: повний перебір усіх можливих комбінацій

Для груп до 4-х людей я використовую рекурсивний бектрекінг, який шукає найкраще покриття без конфліктів.

private boolean tryPairingsRecursive(...) {
    if (available.isEmpty()) {
        bestPairs.addAll(currentPairs);
        return true;
    }

    // перебір усіх можливих унікальних пар
}

Велика група: псевдорадомізований greedy

Для великих груп шальку терезів бере на себе стратегія «випадкової пари, яка не зустрічалась раніше». Якщо такої пари нема — повертаємось до першої з доступних.

Optional<TelegramUserEntity> bestPartner = availableUsers.stream()
    .filter(partner -> !pairingsToAvoid.contains(createPairKey(user1, partner)))
    .findFirst();

Робота з історією пар

Для відстеження попередніх зустрічей я використовую репозиторій pairing history. Він дозволяє швидко дізнатися, з ким людина вже була в парі, та виключити ці варіанти з наступної сесії.

Set<PairKey> loadRecentPairings(List<TelegramUserEntity> users) {
    return pairingHistoryRepository
        .findTop10ByUser1OrUser2OrderByMeetingDateDesc(user, user)
        .stream()
        .filter(pair -> isInCurrentList(pair, users))
        .map(pair -> createPairKey(pair.getUser1(), pair.getUser2()))
        .collect(toSet());
}

Трійки — якщо учасників непарна кількість

Якщо кількість учасників непарна — останні троє об’єднуються в одну трійку. В історії зберігаються всі можливі комбінації в рамках цієї трійки.

if (group.size() == 3) {
    return Stream.of(
        new Pair(user1, user2),
        new Pair(user1, user3),
        new Pair(user2, user3)
    );
}

Підсумок

Ця логіка дозволяє уникати повторів та адаптувати поведінку під розмір спільноти.

Така побудова унікальних комбінацій з урахуванням історії — це вже не просто random, а розумне розбиття на пари, що покращує сприйняття людьми.

На скрині нижче вже можна побачити, як розбились люди на пари:

Масштабування

Коли один з адміністраторів іншої спільноти побачив функціональність — попросив встановити бота у своєму чаті. Щоб масштабуватись, довелось додати підтримку декількох Telegram-чатів, кастомізувати параметри запуску (день-час запуску/зупинки, окремий чат айді та топік).

В принципі вся конфігурація вмістилась в окрему табличку, куди я виніс потрібні на цей момент параметри. Тепер сервіс відпрацьовує щогодини та перевіряє, чи є якась робота в цей таймслот.

Хостинг і деплой

Бот працює на AWS EC2. Проєкт легкий, тому деплой реалізований просто:

  1. Збірка jar-файлу локально.
  2. Завантаження через SSH.
  3. Запуск через nohup.

База даних — RDS.

Я створив новий акаунт, тому в мене є free tier, тож витрати на інфраструктуру невеликі — до $5 на місяць.

Плани

У майбутньому планую:

  • Масштабуватись на більшу кількість груп. Технічно все для цього готово, шукаємо користувачів.
  • Додати фільтрацію пар за містом/інтересами. Це буде мати сенс, коли кількість одночасних учасників буде вище. Зараз маємо 20-25 учасників на одну каво-сесію, тому такий функціонал ще не додавав.
  • Додати аналітику активності спільноти. Це трохи виходить за рамки кавобота, але хочеться рости та розвиватись.

Підсумую: цей бот став для мене не просто pet-проєктом, а інструментом зміни атмосфери в спільноті. Він створює нові знайомства, підтримує живе спілкування і мотивує людей ділитися досвідом. За цей час я зустрів вже 20+ нових людей на каво-зустрічах і я цьому безмежно радий!

Якщо хочеш впровадити щось подібне у своїй спільноті — поділюсь досвідом.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підкажіть, а цього бота можна в своїх групах використовувати чи робили його лише під себе? Дякую.

можна в своїх групах використовувати, досвід такий є. Напишіть мені в особисті на LI (є в профілі), обговоримо

Можна ше надавати перевагу парам які читають ту саму книгу або хочуть ту саму тему обговорити чи вивчити

Хороша ідея, думав про метчінг по інтересах

Нетворкінг супер! розв’язувати задачі літкод разом це плагіат чітінг (вони за таке банять вроді)?

Похоже на thebreakfast.app который кстати тоже есть в Украине. Но я не против, конкуренция это хорошо.

Дякую за статтю! Дуже класна й цікава розробка! Прикольний спосіб зблизити ком’юніті, сподіваюся також якось доберуся до кави 😁

Дуже гарна ідея! Вдячний Сергію за створення цього бота та загалом за саму ідею. Завдяки цьому я вже встиг познайомитися з цікавими людьми і не шкодую, що вирішив доєднатися до цієї активності. Й надалі слідкуватиму за розвитком проєкту.

Сергію, ваш алгоритм унікального парування — це найцікавіша частина статті.
Видно, що ви думали над задачею, а не просто взяли Collections.shuffle(). Реалізація з історією, бектрекінгом для малих груп та обробкою непарної кількості учасників — це вже серйозний інженерний підхід, а не просто пет-проєкт «на колінці».
В результаті вийшов інструмент, який вирішує конкретну проблему спільноти. Дякую, що поділились і кодом, і тим, як саме ви думали над рішенням. Це дійсно мотивує робити свої проєкти так само вдумливо.

Підписатись на коментарі