2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.telegram.internal;
15 import static org.openhab.binding.telegram.internal.TelegramBindingConstants.*;
17 import java.net.InetSocketAddress;
18 import java.net.Proxy;
19 import java.time.Instant;
20 import java.time.ZoneOffset;
21 import java.time.ZonedDateTime;
22 import java.util.ArrayList;
23 import java.util.Arrays;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.Comparator;
27 import java.util.HashMap;
28 import java.util.List;
30 import java.util.Objects;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.openhab.binding.telegram.internal.action.TelegramActions;
38 import org.openhab.core.library.types.DateTimeType;
39 import org.openhab.core.library.types.StringType;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.binding.BaseThingHandler;
45 import org.openhab.core.thing.binding.ThingHandlerService;
46 import org.openhab.core.types.Command;
47 import org.openhab.core.types.State;
48 import org.openhab.core.types.UnDefType;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
52 import com.pengrad.telegrambot.TelegramBot;
53 import com.pengrad.telegrambot.TelegramException;
54 import com.pengrad.telegrambot.UpdatesListener;
55 import com.pengrad.telegrambot.model.Message;
56 import com.pengrad.telegrambot.model.PhotoSize;
57 import com.pengrad.telegrambot.model.Update;
58 import com.pengrad.telegrambot.model.request.ParseMode;
59 import com.pengrad.telegrambot.request.BaseRequest;
60 import com.pengrad.telegrambot.request.GetFile;
61 import com.pengrad.telegrambot.request.GetUpdates;
62 import com.pengrad.telegrambot.response.BaseResponse;
64 import okhttp3.OkHttpClient;
67 * The {@link TelegramHandler} is responsible for handling commands, which are
68 * sent to one of the channels.
70 * @author Jens Runge - Initial contribution
71 * @author Alexander Krasnogolowy - using Telegram library from pengrad
72 * @author Jan N. Klug - handle file attachments
75 public class TelegramHandler extends BaseThingHandler {
78 private class ReplyKey {
83 public ReplyKey(Long chatId, String replyId) {
85 this.replyId = replyId;
89 public int hashCode() {
90 return Objects.hash(chatId, replyId);
94 public boolean equals(@Nullable Object obj) {
101 if (getClass() != obj.getClass()) {
104 ReplyKey other = (ReplyKey) obj;
105 return Objects.equals(chatId, other.chatId) && Objects.equals(replyId, other.replyId);
109 private final List<Long> authorizedSenderChatId = new ArrayList<>();
110 private final List<Long> receiverChatId = new ArrayList<>();
112 private final Logger logger = LoggerFactory.getLogger(TelegramHandler.class);
113 private @Nullable ScheduledFuture<?> thingOnlineStatusJob;
115 // Keep track of the callback id created by Telegram. This must be sent back in
116 // the answerCallbackQuery
117 // to stop the progress bar in the Telegram client
118 private final Map<ReplyKey, String> replyIdToCallbackId = new HashMap<>();
119 // Keep track of message id sent with reply markup because we want to remove the
120 // markup after the user provided an
121 // answer and need the id of the original message
122 private final Map<ReplyKey, Integer> replyIdToMessageId = new HashMap<>();
124 private @Nullable TelegramBot bot;
125 private @Nullable OkHttpClient botLibClient;
126 private @Nullable HttpClient downloadDataClient;
127 private @Nullable ParseMode parseMode;
129 public TelegramHandler(Thing thing, @Nullable HttpClient httpClient) {
131 downloadDataClient = httpClient;
135 public void handleCommand(ChannelUID channelUID, Command command) {
136 // no commands to handle
140 public void initialize() {
141 TelegramConfiguration config = getConfigAs(TelegramConfiguration.class);
143 String botToken = config.getBotToken();
145 List<String> chatIds = config.getChatIds();
146 if (chatIds != null) {
147 createReceiverChatIdsAndAuthorizedSenderChatIds(chatIds);
149 String parseModeAsString = config.getParseMode();
150 if (!parseModeAsString.isEmpty()) {
152 parseMode = ParseMode.valueOf(parseModeAsString);
153 } catch (IllegalArgumentException e) {
154 logger.warn("parseMode is invalid and will be ignored. Only Markdown or HTML are allowed values");
158 OkHttpClient.Builder prepareConnection = new OkHttpClient.Builder().connectTimeout(75, TimeUnit.SECONDS)
159 .readTimeout(75, TimeUnit.SECONDS);
161 String proxyHost = config.getProxyHost();
162 Integer proxyPort = config.getProxyPort();
163 String proxyType = config.getProxyType();
165 if (proxyHost != null && proxyPort != null) {
166 InetSocketAddress proxyAddr = new InetSocketAddress(proxyHost, proxyPort);
168 Proxy.Type proxyTypeParam = Proxy.Type.SOCKS;
170 if ("HTTP".equals(proxyType)) {
171 proxyTypeParam = Proxy.Type.HTTP;
174 Proxy proxy = new Proxy(proxyTypeParam, proxyAddr);
176 logger.debug("{} Proxy {}:{} is used for telegram ", proxyTypeParam, proxyHost, proxyPort);
177 prepareConnection.proxy(proxy);
180 botLibClient = prepareConnection.build();
181 updateStatus(ThingStatus.UNKNOWN);
182 delayThingOnlineStatus();
183 TelegramBot localBot = bot = new TelegramBot.Builder(botToken).okHttpClient(botLibClient).build();
184 localBot.setUpdatesListener(this::handleUpdates, this::handleExceptions,
185 getGetUpdatesRequest(config.getLongPollingTime()));
188 private void createReceiverChatIdsAndAuthorizedSenderChatIds(List<String> chatIds) {
189 authorizedSenderChatId.clear();
190 receiverChatId.clear();
192 for (String chatIdStr : chatIds) {
193 String trimmedChatId = chatIdStr.trim();
195 if (trimmedChatId.startsWith("<")) {
197 authorizedSenderChatId.add(Long.valueOf(trimmedChatId.substring(1)));
198 } else if (trimmedChatId.startsWith(">")) {
200 receiverChatId.add(Long.valueOf(trimmedChatId.substring(1)));
202 // bi-directional (default)
203 Long chatId = Long.valueOf(trimmedChatId);
204 authorizedSenderChatId.add(chatId);
205 receiverChatId.add(chatId);
207 } catch (NumberFormatException e) {
208 logger.warn("The chat id {} is not a number and will be ignored", chatIdStr);
213 private GetUpdates getGetUpdatesRequest(int longPollingTime) {
214 return new GetUpdates().timeout(longPollingTime * 1000);
217 private void handleExceptions(@Nullable TelegramException exception) {
218 final TelegramBot localBot = bot;
219 if (exception != null) {
220 if (exception.response() != null) {
221 BaseResponse localResponse = exception.response();
222 if (localResponse.errorCode() == 401) { // unauthorized
223 cancelThingOnlineStatusJob();
224 if (localBot != null) {
225 localBot.removeGetUpdatesListener();
227 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
228 "Unauthorized attempt to connect to the Telegram server, please check if the bot token is valid");
232 if (exception.getCause() != null) { // cause is only non-null in case of an IOException
233 cancelThingOnlineStatusJob();
234 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, exception.getMessage());
235 delayThingOnlineStatus();
238 logger.warn("Telegram exception: {}", exception.getMessage());
242 private String getFullDownloadUrl(String fileId) {
243 final TelegramBot bot = this.bot;
247 return bot.getFullFilePath(bot.execute(new GetFile(fileId)).file());
250 private int handleUpdates(List<Update> updates) {
251 final TelegramBot localBot = bot;
252 if (localBot == null) {
253 logger.warn("Cannot process updates if no telegram bot is present.");
254 return UpdatesListener.CONFIRMED_UPDATES_NONE;
257 cancelThingOnlineStatusJob();
258 updateStatus(ThingStatus.ONLINE);
259 for (Update update : updates) {
260 String lastMessageText = null;
261 Integer lastMessageDate = null;
262 String lastMessageFirstName = null;
263 String lastMessageLastName = null;
264 String lastMessageUsername = null;
265 String lastMessageURL = null;
267 String replyId = null;
269 Message message = update.message();
271 if (message != null) {
272 chatId = message.chat().id();
273 if (!authorizedSenderChatId.contains(chatId)) {
275 "Ignored message from unknown chat id {}. If you know the sender of that chat, add it to the list of chat ids in the thing configuration to authorize it",
277 continue; // this is very important regarding security to avoid commands from an unknown
282 if (message.audio() != null) {
283 lastMessageURL = getFullDownloadUrl(message.audio().fileId());
284 } else if (message.document() != null) {
285 lastMessageURL = getFullDownloadUrl(message.document().fileId());
286 } else if (message.photo() != null) {
287 PhotoSize[] photoSizes = message.photo();
288 logger.trace("Received photos {}", Arrays.asList(photoSizes));
289 Arrays.sort(photoSizes, Comparator.comparingInt(PhotoSize::fileSize).reversed());
290 lastMessageURL = getFullDownloadUrl(photoSizes[0].fileId());
291 } else if (message.text() != null) {
292 lastMessageText = message.text();
293 } else if (message.video() != null) {
294 lastMessageURL = getFullDownloadUrl(message.video().fileId());
295 } else if (message.voice() != null) {
296 lastMessageURL = getFullDownloadUrl(message.voice().fileId());
298 logger.debug("Received message with unsupported content: {}", message);
303 lastMessageDate = message.date();
304 lastMessageFirstName = message.from().firstName();
305 lastMessageLastName = message.from().lastName();
306 lastMessageUsername = message.from().username();
307 } else if (update.callbackQuery() != null && update.callbackQuery().message() != null
308 && update.callbackQuery().message().text() != null) {
309 String[] callbackData = update.callbackQuery().data().split(" ", 2);
311 if (callbackData.length == 2) {
312 replyId = callbackData[0];
313 lastMessageText = callbackData[1];
314 lastMessageDate = update.callbackQuery().message().date();
315 lastMessageFirstName = update.callbackQuery().from().firstName();
316 lastMessageLastName = update.callbackQuery().from().lastName();
317 lastMessageUsername = update.callbackQuery().from().username();
318 chatId = update.callbackQuery().message().chat().id();
319 replyIdToCallbackId.put(new ReplyKey(chatId, replyId), update.callbackQuery().id());
320 logger.debug("Received callbackId {} for chatId {} and replyId {}", update.callbackQuery().id(),
323 logger.warn("The received callback query {} has not the right format (must be seperated by spaces)",
324 update.callbackQuery().data());
327 updateChannel(LASTMESSAGETEXT, lastMessageText != null ? new StringType(lastMessageText) : UnDefType.NULL);
328 updateChannel(LASTMESSAGEURL, lastMessageURL != null ? new StringType(lastMessageURL) : UnDefType.NULL);
329 updateChannel(LASTMESSAGEDATE, lastMessageDate != null
331 ZonedDateTime.ofInstant(Instant.ofEpochSecond(lastMessageDate.intValue()), ZoneOffset.UTC))
333 updateChannel(LASTMESSAGENAME, (lastMessageFirstName != null || lastMessageLastName != null)
334 ? new StringType((lastMessageFirstName != null ? lastMessageFirstName + " " : "")
335 + (lastMessageLastName != null ? lastMessageLastName : ""))
337 updateChannel(LASTMESSAGEUSERNAME,
338 lastMessageUsername != null ? new StringType(lastMessageUsername) : UnDefType.NULL);
339 updateChannel(CHATID, chatId != null ? new StringType(chatId.toString()) : UnDefType.NULL);
340 updateChannel(REPLYID, replyId != null ? new StringType(replyId) : UnDefType.NULL);
342 return UpdatesListener.CONFIRMED_UPDATES_ALL;
345 private synchronized void delayThingOnlineStatus() {
346 thingOnlineStatusJob = scheduler.schedule(() -> {
347 // if no error was returned within 10s, we assume the initialization went well
348 updateStatus(ThingStatus.ONLINE);
349 }, 10, TimeUnit.SECONDS);
352 private synchronized void cancelThingOnlineStatusJob() {
353 final ScheduledFuture<?> thingOnlineStatusJob = this.thingOnlineStatusJob;
354 if (thingOnlineStatusJob != null) {
355 thingOnlineStatusJob.cancel(true);
356 this.thingOnlineStatusJob = null;
361 public void dispose() {
362 logger.debug("Trying to dispose Telegram client");
363 cancelThingOnlineStatusJob();
364 OkHttpClient localClient = botLibClient;
365 TelegramBot localBot = bot;
366 if (localClient != null && localBot != null) {
367 localBot.removeGetUpdatesListener();
368 localClient.dispatcher().executorService().shutdown();
369 localClient.connectionPool().evictAll();
370 logger.debug("Telegram client closed");
375 public void updateChannel(String channelName, State state) {
376 updateState(new ChannelUID(getThing().getUID(), channelName), state);
380 public Collection<Class<? extends ThingHandlerService>> getServices() {
381 return Collections.singleton(TelegramActions.class);
385 * get the list of all authorized senders
387 * @return list of chatIds
389 public List<Long> getAuthorizedSenderChatIds() {
390 return authorizedSenderChatId;
394 * get the list of all receivers
396 * @return list of chatIds
398 public List<Long> getReceiverChatIds() {
399 return receiverChatId;
402 public void addMessageId(Long chatId, String replyId, Integer messageId) {
403 replyIdToMessageId.put(new ReplyKey(chatId, replyId), messageId);
407 public String getCallbackId(Long chatId, String replyId) {
408 return replyIdToCallbackId.get(new ReplyKey(chatId, replyId));
411 public @Nullable Integer removeMessageId(Long chatId, String replyId) {
412 return replyIdToMessageId.remove(new ReplyKey(chatId, replyId));
416 public ParseMode getParseMode() {
420 @SuppressWarnings("rawtypes")
422 public <T extends BaseRequest, R extends BaseResponse> R execute(BaseRequest<?, R> request) {
423 TelegramBot localBot = bot;
424 return localBot != null ? localBot.execute(request) : null;
428 public HttpClient getClient() {
429 return downloadDataClient;