/* * Copyright (C) 2008-2020, Juick * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package com.juick; import com.juick.model.User; import com.juick.model.AnonymousUser; import com.juick.model.CommandResult; import com.juick.www.api.SystemActivity; import com.juick.util.HttpUtils; import com.juick.service.MessagesService; import com.juick.service.StorageService; import com.juick.service.TelegramService; import com.juick.service.UserService; import com.juick.service.component.SystemEvent; import com.juick.service.component.NotificationListener; import com.juick.service.component.PingEvent; import com.juick.util.MessageUtils; import com.pengrad.telegrambot.Callback; import com.pengrad.telegrambot.TelegramBot; import com.pengrad.telegrambot.UpdatesListener; import com.pengrad.telegrambot.model.Message; import com.pengrad.telegrambot.model.MessageEntity; import com.pengrad.telegrambot.model.PhotoSize; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.model.request.ParseMode; import com.pengrad.telegrambot.request.GetFile; import com.pengrad.telegrambot.request.SendMessage; import com.pengrad.telegrambot.request.SendPhoto; import com.pengrad.telegrambot.request.SetWebhook; import com.pengrad.telegrambot.response.GetFileResponse; import com.pengrad.telegrambot.response.SendResponse; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; import javax.annotation.Nonnull; import jakarta.annotation.PostConstruct; import javax.inject.Inject; import java.io.IOException; import java.net.URI; import java.net.URL; import java.util.*; import static com.juick.util.formatters.PlainTextFormatter.formatPost; import static com.juick.util.formatters.PlainTextFormatter.formatUrl; /** * Created by vt on 12/05/16. */ public class TelegramBotManager implements NotificationListener { private static final Logger logger = LoggerFactory.getLogger("Telegram"); private TelegramBot bot; @Value("${telegram_api_url:}") private String apiUrl; @Value("${telegram_file_api_url:}") private String fileApiUrl; @Value("${web_domain:localhost}") private String webDomain; @Value("${telegram_webhook_url:}") private String webhookUrl; @Value("${telegram_token:12345678}") private String telegramToken; @Value("${telegram_debug:false}") private boolean telegramDebug; @Value("${telegram_top_channel:@JuickTop}") private String topChannelName; @Inject private TelegramService telegramService; @Inject private MessagesService messagesService; @Inject private UserService userService; @Inject private CommandsManager commandsManager; @Inject private ApplicationEventPublisher applicationEventPublisher; @Inject private StorageService storageService; @Inject private User serviceUser; private static final String MSG_LINK = "🔗"; @PostConstruct public void init() { TelegramBot.Builder tgBuilder = new TelegramBot.Builder(telegramToken); if (StringUtils.isNotEmpty(apiUrl)) { tgBuilder.apiUrl(apiUrl).fileApiUrl(fileApiUrl); } bot = tgBuilder.build(); if (!telegramDebug) { try { SetWebhook webhook = new SetWebhook().url(webhookUrl); if (!bot.execute(webhook).isOk()) { logger.error("error setting webhook"); } } catch (Exception e) { logger.warn("couldn't initialize telegram bot", e); } } else { bot.setUpdatesListener(updates -> { logger.info("got updates: {}", updates); updates.forEach(this::processUpdate); return UpdatesListener.CONFIRMED_UPDATES_ALL; }); } } public void processUpdate(Update update) { Message message = update.message(); if (update.message() == null) { message = update.editedMessage(); if (message == null) { logger.error("error parsing telegram update: {}", update); return; } User user_from = userService.getUserByTelegramId(message.chat().id()).orElse(AnonymousUser.INSTANCE); logger.info("Found juick user {}", user_from.getUid()); Optional> originalMessageData = messagesService.findMessageByProperty("durovId", String.valueOf(message.messageId())); if (originalMessageData.isPresent()) { int mid = originalMessageData.get().getLeft(); int rid = originalMessageData.get().getRight(); // TODO: this is copypaste from api, need switch to api com.juick.model.Message originalMessage = rid == 0 ? messagesService.getMessage(mid).orElseThrow(IllegalStateException::new) : messagesService.getReply(mid, rid); User author = originalMessage.getUser(); String newMessageText = StringUtils.defaultString(message.text()); if (user_from.equals(author) && canUpdateMessage(originalMessage, newMessageText)) { if (messagesService.updateMessage(mid, rid, newMessageText, false)) { telegramNotify(message.chat().id(), "Message updated", new com.juick.model.Message()); return; } } telegramNotify(message.chat().id(), "Error updating message", new com.juick.model.Message()); } } else { User user_from = userService.getUserByTelegramId(message.chat().id()).orElse(AnonymousUser.INSTANCE); logger.info("Found juick user {}", user_from.getUid()); String username = message.from().username(); if (username == null) { username = message.from().firstName(); } if (!user_from.isAnonymous()) { URI attachment = URI.create(StringUtils.EMPTY); if (message.photo() != null) { String fileId = Arrays.stream(message.photo()).max(Comparator.comparingLong(PhotoSize::fileSize)) .orElse(new PhotoSize()).fileId(); if (StringUtils.isNotEmpty(fileId)) { GetFile request = new GetFile(fileId); GetFileResponse response = bot.execute(request); logger.info("got file {}", response.file()); try { URL fileURL = new URL(bot.getFullFilePath(response.file())); attachment = HttpUtils.downloadImage(fileURL, storageService.getTemporaryDirectory()); } catch (Exception e) { logger.warn("attachment exception", e); } logger.info("received {}", attachment); } } String text = message.text(); if (StringUtils.isBlank(text)) { text = message.caption(); } if (StringUtils.isBlank(text)) { text = StringUtils.EMPTY; } if (StringUtils.isNotEmpty(text) || StringUtils.isNotEmpty(attachment.toString())) { if (text.equalsIgnoreCase("LOGIN") || text.equalsIgnoreCase("PING") || text.equalsIgnoreCase("HELP") || text.equalsIgnoreCase("/login") || text.equalsIgnoreCase("/logout") || text.equalsIgnoreCase("/start") || text.equalsIgnoreCase("/help")) { String msgUrl = String.format("https://%s/login?hash=%s", webDomain, userService.getHashByUID(user_from.getUid())); String msg = String.format(""" Hi, %s! You can post messages and images to Juick there. Tap to [log into website](%s) to get more info""", user_from.getName(), msgUrl); telegramNotify(message.from().id(), msg, new com.juick.model.Message()); } else { Message replyMessage = message.replyToMessage(); if (replyMessage != null) { MessageEntity[] entities = replyMessage.entities(); if (entities == null) { entities = replyMessage.captionEntities(); } if (entities != null) { Optional juickLink = Arrays.stream(entities) .filter(this::isJuickLink) .findFirst(); if (juickLink.isPresent()) { if (StringUtils.isNotEmpty(juickLink.get().url())) { UriComponents uriComponents = UriComponentsBuilder.fromUriString( juickLink.get().url()).build(); String path = uriComponents.getPath(); if (StringUtils.isNotEmpty(path) && path.length() > 1) { int mid; try { mid = Integer.parseInt(path.substring(3)); } catch (NumberFormatException e) { logger.warn("wrong mid received"); return; } String prefix = String.format("#%d ", mid); if (StringUtils.isNotEmpty(uriComponents.getFragment())) { int rid = Integer.parseInt(uriComponents.getFragment()); prefix = String.format("#%d/%d ", mid, rid); } executeCommand(message.messageId(), message.from().id(), user_from, prefix + text, attachment); } else { logger.warn("invalid path: {}", path); } } else { logger.warn("invalid entity: {}", juickLink); } } else { telegramNotify(message.from().id(), "Can not reply to this message", replyMessage.messageId(), new com.juick.model.Message()); } } else { telegramNotify(message.from().id(), "Can not reply to this message", replyMessage.messageId(), new com.juick.model.Message()); } } else { executeCommand(message.messageId(), message.from().id(), user_from, text, attachment); } } } messagesService.getUnread(user_from).forEach(mid -> messagesService.setRead(user_from, mid)); } else { List chats = telegramService.getAnonymous(); if (!chats.contains(message.chat().id())) { logger.info("added chat with {}", username); telegramService.createTelegramUser(message.from().id(), username); } telegramSignupNotify(message.from().id(), userService.getSignUpHashByTelegramID(message.from().id(), username)); } } } /* validate user input */ private boolean canUpdateMessage(com.juick.model.Message message, String newData) { if (StringUtils.isEmpty(newData)) { // allow empty text only when image is present return StringUtils.isNotEmpty(message.getAttachmentType()); } return true; } private void executeCommand(Integer messageId, Long userId, User user_from, String text, URI attachment) { try { CommandResult result = commandsManager.processCommand(user_from, text, attachment); if (result.getNewMessage().isPresent()) { com.juick.model.Message newMessage = result.getNewMessage().get(); messagesService.setMessageProperty(newMessage.getMid(), newMessage.getRid(), "durovId", String.valueOf(messageId)); } String messageTxt = StringUtils.isNotEmpty(result.getText()) ? result.getHtml() : "Unknown error or unsupported command"; telegramNotify(userId, messageTxt, new com.juick.model.Message()); } catch (Exception e) { logger.warn("telegram exception", e); } } private boolean isJuickLink(MessageEntity e) { return e.offset() == 0 && e.type().equals(MessageEntity.Type.text_link) && e.length() == 2; } public void telegramNotify(Object chatId, String msg, @Nonnull com.juick.model.Message source) { telegramNotify(chatId, msg, 0, source); } public void telegramNotify(Object chatId, String msg, Integer replyTo, @Nonnull com.juick.model.Message source) { String attachment = MessageUtils.attachmentUrl(source); boolean isSendTxt = true; if (!StringUtils.isEmpty(attachment)) { SendPhoto telegramPhoto = new SendPhoto(chatId, attachment); if (replyTo > 0) { telegramPhoto.replyToMessageId(replyTo); } telegramPhoto.parseMode(ParseMode.HTML); if(msg.length() < 1024) { telegramPhoto.caption(msg); isSendTxt = false; } bot.execute(telegramPhoto, new Callback<>() { @Override public void onResponse(SendPhoto request, SendResponse response) { if (chatId instanceof Long) { processTelegramResponse((Long)chatId, response, source); } else { processTelegramChannelResponse((String)chatId, response, source); } } @Override public void onFailure(SendPhoto request, IOException e) { logger.warn("telegram failure", e); } }); } if (isSendTxt){ SendMessage telegramMessage = new SendMessage(chatId, msg); if (replyTo > 0) { telegramMessage.replyToMessageId(replyTo); } telegramMessage.parseMode(ParseMode.HTML).disableWebPagePreview(true); bot.execute(telegramMessage, new Callback<>() { @Override public void onResponse(SendMessage request, SendResponse response) { if (chatId instanceof Long) { processTelegramResponse((Long)chatId, response, source); } else { processTelegramChannelResponse((String)chatId, response, source); } } @Override public void onFailure(SendMessage request, IOException e) { logger.warn("telegram failure", e); } }); } } private void processTelegramResponse(Long chatId, SendResponse response, com.juick.model.Message source) { var user = userService.getUserByTelegramId(chatId).orElse(AnonymousUser.INSTANCE); if (!response.isOk()) { if (response.errorCode() == 403) { // remove from anonymous users telegramService.getAnonymous().stream().filter(c -> c.equals(chatId)).findFirst().ifPresent( d -> { telegramService.deleteAnonymous(d); logger.info("deleted {} chat", d); } ); if (!user.isAnonymous()) { boolean status = telegramService.deleteTelegramUser(user.getUid()); logger.info("deleting telegram id of @{} : {}", user.getName(), status); } } else { logger.warn("error response, isOk: {}, errorCode: {}, description: {}", response.isOk(), response.errorCode(), response.description()); } } else { if (MessageUtils.isReply(source)) { messagesService.setLastReadComment(user, source.getMid(), source.getRid()); userService.updateLastSeen(user); applicationEventPublisher.publishEvent( new SystemEvent(this, SystemActivity.read(user, source))); } } } private void processTelegramChannelResponse(String channelId, SendResponse response, com.juick.model.Message source) { if (!response.isOk()) { logger.warn("error response from channel, isOk: {}, errorCode: {}, description: {}, Juick message: {}", response.isOk(), response.errorCode(), response.description(), source.getMid()); } } public void telegramSignupNotify(Long telegramId, String hash) { bot.execute(new SendMessage(telegramId, String.format("You are subscribed to all Juick messages. " + "[Create or link](https://%s/signup?type=durov&hash=%s) " + "an existing Juick account to get your subscriptions and ability to post messages", webDomain, hash)) .parseMode(ParseMode.Markdown), new Callback<>() { @Override public void onResponse(SendMessage request, SendResponse response) { logger.info("got response: {}", response.message()); } @Override public void onFailure(SendMessage request, IOException e) { logger.warn("telegram failure", e); } }); } @Override public void processSystemEvent(SystemEvent systemEvent) { var activity = systemEvent.getActivity(); var type = activity.getType(); if (type.equals(SystemActivity.ActivityType.message)) { processMessage(activity.getMessage(), activity.getTo()); } else if (type.equals(SystemActivity.ActivityType.like)) { if (systemEvent.getActivity().getFrom().equals(serviceUser)) { processTop(systemEvent.getActivity().getMessage()); } else { processLike(activity.getFrom(), activity.getMessage(), activity.getTo()); } } else if (type.equals(SystemActivity.ActivityType.follow)) { processFollow(activity.getFrom(), activity.getTo()); } } private String formatTelegramMessage(com.juick.model.Message jmsg) { return String.format("%s %s", formatUrl(jmsg), MSG_LINK, formatPost(jmsg, true, webDomain)); } private void processMessage(com.juick.model.Message jmsg, List subscribedUsers) { if (jmsg.isService()) { return; } String msgUrl = formatUrl(jmsg); if (MessageUtils.isPM(jmsg)) { telegramService.getTelegramIdentifiers(Collections.singletonList(jmsg.getTo())) .forEach(c -> telegramNotify(c, formatPost(jmsg, true, webDomain), jmsg)); } else if (MessageUtils.isReply(jmsg)) { String fmsg = formatTelegramMessage(jmsg); telegramService.getTelegramIdentifiers( subscribedUsers ).forEach(c -> telegramNotify(c, fmsg, jmsg)); } else { String msg = formatTelegramMessage(jmsg); List users = telegramService.getTelegramIdentifiers(subscribedUsers); List chats = telegramService.getAnonymous(); // registered subscribed users users.forEach(c -> telegramNotify(c, msg, jmsg)); // anonymous chats.stream().filter(u -> userService.getUserByTelegramId(u).isEmpty()).forEach(c -> telegramNotify(c, msg, jmsg)); } } private void processLike(User liker, com.juick.model.Message message, List subscribers) { logger.info("Like received in tg listener"); if (!userService.isInBLAny(message.getUser().getUid(), liker.getUid())) { telegramService.getTelegramIdentifiers(Collections.singletonList(message.getUser())) .forEach(c -> telegramNotify(c, String.format("%s recommends your post", MessageUtils.getUserHtmlLink(liker, webDomain), formatUrl(message)), new com.juick.model.Message())); } telegramService.getTelegramIdentifiers(subscribers) .forEach(c -> telegramNotify(c, String.format("%s recommends you someone's post", MessageUtils.getUserHtmlLink(liker, webDomain), formatUrl(message)), new com.juick.model.Message())); } @Override public void processPingEvent(PingEvent pingEvent) { } private void processTop(com.juick.model.Message message) { telegramNotify(topChannelName, formatTelegramMessage(message), message); telegramService.getTelegramIdentifiers(Collections.singletonList(message.getUser())) .forEach(c -> telegramNotify(c, String.format("Your post became popular!", formatUrl(message)), new com.juick.model.Message())); } private void processFollow(User subscriber, List target) { telegramService.getTelegramIdentifiers(target) .forEach(c -> telegramNotify(c, String.format("%s subscribed to your blog", MessageUtils.getUserHtmlLink(subscriber, webDomain)), new com.juick.model.Message())); } }