2 * Copyright (c) 2010-2024 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.Comparator;
26 import java.util.HashMap;
27 import java.util.List;
29 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.google.gson.Gson;
53 import com.google.gson.JsonArray;
54 import com.google.gson.JsonElement;
55 import com.google.gson.JsonObject;
56 import com.google.gson.JsonParser;
57 import com.pengrad.telegrambot.TelegramBot;
58 import com.pengrad.telegrambot.TelegramException;
59 import com.pengrad.telegrambot.UpdatesListener;
60 import com.pengrad.telegrambot.model.CallbackQuery;
61 import com.pengrad.telegrambot.model.Message;
62 import com.pengrad.telegrambot.model.PhotoSize;
63 import com.pengrad.telegrambot.model.Update;
64 import com.pengrad.telegrambot.model.request.ParseMode;
65 import com.pengrad.telegrambot.request.BaseRequest;
66 import com.pengrad.telegrambot.request.GetFile;
67 import com.pengrad.telegrambot.request.GetUpdates;
68 import com.pengrad.telegrambot.response.BaseResponse;
70 import okhttp3.OkHttpClient;
73 * The {@link TelegramHandler} is responsible for handling commands, which are
74 * sent to one of the channels.
76 * @author Jens Runge - Initial contribution
77 * @author Alexander Krasnogolowy - using Telegram library from pengrad
78 * @author Jan N. Klug - handle file attachments
79 * @author Michael Murton - add trigger channel
82 public class TelegramHandler extends BaseThingHandler {
84 private class ReplyKey {
88 public ReplyKey(Long chatId, String replyId) {
90 this.replyId = replyId;
94 public int hashCode() {
95 return Objects.hash(chatId, replyId);
99 public boolean equals(@Nullable Object obj) {
106 if (getClass() != obj.getClass()) {
109 ReplyKey other = (ReplyKey) obj;
110 return Objects.equals(chatId, other.chatId) && Objects.equals(replyId, other.replyId);
114 private static Gson gson = new Gson();
115 private final List<Long> authorizedSenderChatId = new ArrayList<>();
116 private final List<Long> receiverChatId = new ArrayList<>();
117 private final Logger logger = LoggerFactory.getLogger(TelegramHandler.class);
118 private @Nullable ScheduledFuture<?> thingOnlineStatusJob;
120 // Keep track of the callback id created by Telegram. This must be sent back in
121 // the answerCallbackQuery
122 // to stop the progress bar in the Telegram client
123 private final Map<ReplyKey, String> replyIdToCallbackId = new HashMap<>();
124 // Keep track of message id sent with reply markup because we want to remove the
125 // markup after the user provided an
126 // answer and need the id of the original message
127 private final Map<ReplyKey, Integer> replyIdToMessageId = new HashMap<>();
129 private @Nullable TelegramBot bot;
130 private @Nullable OkHttpClient botLibClient;
131 private @Nullable HttpClient downloadDataClient;
132 private @Nullable ParseMode parseMode;
134 public TelegramHandler(Thing thing, @Nullable HttpClient httpClient) {
136 downloadDataClient = httpClient;
140 public void handleCommand(ChannelUID channelUID, Command command) {
141 // no commands to handle
145 public void initialize() {
146 TelegramConfiguration config = getConfigAs(TelegramConfiguration.class);
148 String botToken = config.getBotToken();
150 List<String> chatIds = config.getChatIds();
151 if (chatIds != null) {
152 createReceiverChatIdsAndAuthorizedSenderChatIds(chatIds);
154 String parseModeAsString = config.getParseMode();
155 if (!parseModeAsString.isEmpty()) {
157 parseMode = ParseMode.valueOf(parseModeAsString);
158 } catch (IllegalArgumentException e) {
159 logger.warn("parseMode is invalid and will be ignored. Only Markdown or HTML are allowed values");
163 OkHttpClient.Builder prepareConnection = new OkHttpClient.Builder().connectTimeout(75, TimeUnit.SECONDS)
164 .writeTimeout(75, TimeUnit.SECONDS).readTimeout(75, TimeUnit.SECONDS);
166 String proxyHost = config.getProxyHost();
167 Integer proxyPort = config.getProxyPort();
168 String proxyType = config.getProxyType();
170 if (proxyHost != null && proxyPort != null) {
171 InetSocketAddress proxyAddr = new InetSocketAddress(proxyHost, proxyPort);
173 Proxy.Type proxyTypeParam = Proxy.Type.SOCKS;
175 if ("HTTP".equals(proxyType)) {
176 proxyTypeParam = Proxy.Type.HTTP;
179 Proxy proxy = new Proxy(proxyTypeParam, proxyAddr);
181 logger.debug("{} Proxy {}:{} is used for telegram ", proxyTypeParam, proxyHost, proxyPort);
182 prepareConnection.proxy(proxy);
185 botLibClient = prepareConnection.build();
186 updateStatus(ThingStatus.UNKNOWN);
187 delayThingOnlineStatus();
188 TelegramBot localBot = bot = new TelegramBot.Builder(botToken).okHttpClient(botLibClient).build();
189 localBot.setUpdatesListener(this::handleUpdates, this::handleExceptions,
190 getGetUpdatesRequest(config.getLongPollingTime()));
193 private void createReceiverChatIdsAndAuthorizedSenderChatIds(List<String> chatIds) {
194 authorizedSenderChatId.clear();
195 receiverChatId.clear();
197 for (String chatIdStr : chatIds) {
198 String trimmedChatId = chatIdStr.trim();
200 if (trimmedChatId.startsWith("<")) {
202 authorizedSenderChatId.add(Long.valueOf(trimmedChatId.substring(1)));
203 } else if (trimmedChatId.startsWith(">")) {
205 receiverChatId.add(Long.valueOf(trimmedChatId.substring(1)));
207 // bi-directional (default)
208 Long chatId = Long.valueOf(trimmedChatId);
209 authorizedSenderChatId.add(chatId);
210 receiverChatId.add(chatId);
212 } catch (NumberFormatException e) {
213 logger.warn("The chat id {} is not a number and will be ignored", chatIdStr);
218 private GetUpdates getGetUpdatesRequest(int longPollingTime) {
219 return new GetUpdates().timeout(longPollingTime * 1000);
222 private void handleExceptions(@Nullable TelegramException exception) {
223 final TelegramBot localBot = bot;
224 if (exception != null) {
225 if (exception.response() != null) {
226 BaseResponse localResponse = exception.response();
227 if (localResponse.errorCode() == 401) { // unauthorized
228 cancelThingOnlineStatusJob();
229 if (localBot != null) {
230 localBot.removeGetUpdatesListener();
232 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
233 "Unauthorized attempt to connect to the Telegram server, please check if the bot token is valid");
237 if (exception.getCause() != null) { // cause is only non-null in case of an IOException
238 cancelThingOnlineStatusJob();
239 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, exception.getMessage());
240 delayThingOnlineStatus();
243 logger.warn("Telegram exception: {}", exception.getMessage());
247 private String getFullDownloadUrl(String fileId) {
248 final TelegramBot bot = this.bot;
252 return bot.getFullFilePath(bot.execute(new GetFile(fileId)).file());
255 private void addFileUrlsToPayload(JsonObject filePayload) {
256 filePayload.addProperty("file_url",
257 getFullDownloadUrl(filePayload.getAsJsonPrimitive("file_id").getAsString()));
258 if (filePayload.has("thumb")) {
259 filePayload.getAsJsonObject("thumb").addProperty("file_url", getFullDownloadUrl(
260 filePayload.getAsJsonObject("thumb").getAsJsonPrimitive("file_id").getAsString()));
264 private int handleUpdates(List<Update> updates) {
265 final TelegramBot localBot = bot;
266 if (localBot == null) {
267 logger.warn("Cannot process updates if no telegram bot is present.");
268 return UpdatesListener.CONFIRMED_UPDATES_NONE;
271 cancelThingOnlineStatusJob();
272 updateStatus(ThingStatus.ONLINE);
273 for (Update update : updates) {
274 String lastMessageText = null;
275 Integer lastMessageDate = null;
276 String lastMessageFirstName = null;
277 String lastMessageLastName = null;
278 String lastMessageUsername = null;
279 String lastMessageURL = null;
281 String replyId = null;
283 Message message = update.message();
284 if (message == null) {
285 message = update.channelPost();
287 CallbackQuery callbackQuery = update.callbackQuery();
289 if (message != null) {
290 chatId = message.chat().id();
291 if (!authorizedSenderChatId.contains(chatId)) {
293 "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",
295 continue; // this is very important regarding security to avoid commands from an unknown
299 // build and publish messageEvent trigger channel payload
300 JsonObject messageRaw = JsonParser.parseString(gson.toJson(message)).getAsJsonObject();
301 JsonObject messagePayload = new JsonObject();
302 messagePayload.addProperty("message_id", message.messageId());
303 if (messageRaw.has("from")) {
304 messagePayload.addProperty("from",
305 String.join(" ", new String[] { message.from().firstName(), message.from().lastName() }));
307 messagePayload.addProperty("chat_id", message.chat().id());
308 if (messageRaw.has("text")) {
309 messagePayload.addProperty("text", message.text());
311 if (messageRaw.has("animation")) {
312 addFileUrlsToPayload(messageRaw.getAsJsonObject("animation"));
313 messagePayload.add("animation_url", messageRaw.getAsJsonObject("animation").get("file_url"));
315 if (messageRaw.has("audio")) {
316 addFileUrlsToPayload(messageRaw.getAsJsonObject("audio"));
317 messagePayload.add("audio_url", messageRaw.getAsJsonObject("audio").get("file_url"));
319 if (messageRaw.has("document")) {
320 addFileUrlsToPayload(messageRaw.getAsJsonObject("document"));
321 messagePayload.add("document_url", messageRaw.getAsJsonObject("document").get("file_url"));
323 if (messageRaw.has("photo")) {
324 JsonArray photoURLArray = new JsonArray();
325 for (JsonElement photoPayload : messageRaw.getAsJsonArray("photo")) {
326 JsonObject photoPayloadObject = photoPayload.getAsJsonObject();
327 String photoURL = getFullDownloadUrl(
328 photoPayloadObject.getAsJsonPrimitive("file_id").getAsString());
329 photoPayloadObject.addProperty("file_url", photoURL);
330 photoURLArray.add(photoURL);
332 messagePayload.add("photo_url", photoURLArray);
334 if (messageRaw.has("sticker")) {
335 addFileUrlsToPayload(messageRaw.getAsJsonObject("sticker"));
336 messagePayload.add("sticker_url", messageRaw.getAsJsonObject("sticker").get("file_url"));
338 if (messageRaw.has("video")) {
339 addFileUrlsToPayload(messageRaw.getAsJsonObject("video"));
340 messagePayload.add("video_url", messageRaw.getAsJsonObject("video").get("file_url"));
342 if (messageRaw.has("video_note")) {
343 addFileUrlsToPayload(messageRaw.getAsJsonObject("video_note"));
344 messagePayload.add("video_note_url", messageRaw.getAsJsonObject("video_note").get("file_url"));
346 if (messageRaw.has("voice")) {
347 JsonObject voicePayload = messageRaw.getAsJsonObject("voice");
348 String voiceURL = getFullDownloadUrl(voicePayload.getAsJsonPrimitive("file_id").getAsString());
349 voicePayload.addProperty("file_url", voiceURL);
350 messagePayload.addProperty("voice_url", voiceURL);
352 triggerEvent(MESSAGEEVENT, messagePayload.toString());
353 triggerEvent(MESSAGERAWEVENT, messageRaw.toString());
356 if (message.audio() != null) {
357 lastMessageURL = getFullDownloadUrl(message.audio().fileId());
358 } else if (message.document() != null) {
359 lastMessageURL = getFullDownloadUrl(message.document().fileId());
360 } else if (message.photo() != null) {
361 PhotoSize[] photoSizes = message.photo();
362 logger.trace("Received photos {}", Arrays.asList(photoSizes));
363 Arrays.sort(photoSizes, Comparator.comparingInt(PhotoSize::fileSize).reversed());
364 lastMessageURL = getFullDownloadUrl(photoSizes[0].fileId());
365 } else if (message.text() != null) {
366 lastMessageText = message.text();
367 } else if (message.video() != null) {
368 lastMessageURL = getFullDownloadUrl(message.video().fileId());
369 } else if (message.voice() != null) {
370 lastMessageURL = getFullDownloadUrl(message.voice().fileId());
372 logger.debug("Received message with unsupported content: {}", message);
377 if (lastMessageURL != null || lastMessageText != null) {
378 lastMessageDate = message.date();
379 if (message.from() != null) {
380 lastMessageFirstName = message.from().firstName();
381 lastMessageLastName = message.from().lastName();
382 lastMessageUsername = message.from().username();
385 } else if (callbackQuery != null && callbackQuery.message() != null
386 && callbackQuery.message().text() != null) {
387 String[] callbackData = callbackQuery.data().split(" ", 2);
389 if (callbackData.length == 2) {
390 replyId = callbackData[0];
391 lastMessageText = callbackData[1];
392 lastMessageDate = callbackQuery.message().date();
393 lastMessageFirstName = callbackQuery.from().firstName();
394 lastMessageLastName = callbackQuery.from().lastName();
395 lastMessageUsername = callbackQuery.from().username();
396 chatId = callbackQuery.message().chat().id();
397 replyIdToCallbackId.put(new ReplyKey(chatId, replyId), callbackQuery.id());
399 // build and publish callbackEvent trigger channel payload
400 JsonObject callbackRaw = JsonParser.parseString(gson.toJson(callbackQuery)).getAsJsonObject();
401 JsonObject callbackPayload = new JsonObject();
402 callbackPayload.addProperty("message_id", callbackQuery.message().messageId());
403 callbackPayload.addProperty("from", lastMessageFirstName + " " + lastMessageLastName);
404 callbackPayload.addProperty("chat_id", callbackQuery.message().chat().id());
405 callbackPayload.addProperty("callback_id", callbackQuery.id());
406 callbackPayload.addProperty("reply_id", callbackData[0]);
407 callbackPayload.addProperty("text", callbackData[1]);
408 triggerEvent(CALLBACKEVENT, callbackPayload.toString());
409 triggerEvent(CALLBACKRAWEVENT, callbackRaw.toString());
411 logger.debug("Received callbackId {} for chatId {} and replyId {}", callbackQuery.id(), chatId,
414 logger.warn("The received callback query {} has not the right format (must be seperated by spaces)",
415 callbackQuery.data());
418 updateChannel(CHATID, chatId != null ? new StringType(chatId.toString()) : UnDefType.NULL);
419 updateChannel(REPLYID, replyId != null ? new StringType(replyId) : UnDefType.NULL);
420 updateChannel(LASTMESSAGEURL, lastMessageURL != null ? new StringType(lastMessageURL) : UnDefType.NULL);
421 updateChannel(LASTMESSAGENAME, (lastMessageFirstName != null || lastMessageLastName != null)
422 ? new StringType((lastMessageFirstName != null ? lastMessageFirstName + " " : "")
423 + (lastMessageLastName != null ? lastMessageLastName : ""))
425 updateChannel(LASTMESSAGEUSERNAME,
426 lastMessageUsername != null ? new StringType(lastMessageUsername) : UnDefType.NULL);
427 updateChannel(LASTMESSAGETEXT, lastMessageText != null ? new StringType(lastMessageText) : UnDefType.NULL);
428 updateChannel(LASTMESSAGEDATE, lastMessageDate != null
430 ZonedDateTime.ofInstant(Instant.ofEpochSecond(lastMessageDate.intValue()), ZoneOffset.UTC))
433 return UpdatesListener.CONFIRMED_UPDATES_ALL;
436 private synchronized void delayThingOnlineStatus() {
437 thingOnlineStatusJob = scheduler.schedule(() -> {
438 // if no error was returned within 10s, we assume the initialization went well
439 updateStatus(ThingStatus.ONLINE);
440 }, 10, TimeUnit.SECONDS);
443 private synchronized void cancelThingOnlineStatusJob() {
444 final ScheduledFuture<?> thingOnlineStatusJob = this.thingOnlineStatusJob;
445 if (thingOnlineStatusJob != null) {
446 thingOnlineStatusJob.cancel(true);
447 this.thingOnlineStatusJob = null;
452 public void dispose() {
453 logger.debug("Trying to dispose Telegram client");
454 cancelThingOnlineStatusJob();
455 OkHttpClient localClient = botLibClient;
456 TelegramBot localBot = bot;
457 if (localClient != null && localBot != null) {
458 localBot.removeGetUpdatesListener();
459 localClient.dispatcher().executorService().shutdown();
460 localClient.connectionPool().evictAll();
461 logger.debug("Telegram client closed");
466 public void updateChannel(String channelName, State state) {
467 updateState(new ChannelUID(getThing().getUID(), channelName), state);
470 public void triggerEvent(String channelName, String payload) {
471 triggerChannel(channelName, payload);
475 public Collection<Class<? extends ThingHandlerService>> getServices() {
476 return Set.of(TelegramActions.class);
480 * get the list of all authorized senders
482 * @return list of chatIds
484 public List<Long> getAuthorizedSenderChatIds() {
485 return authorizedSenderChatId;
489 * get the list of all receivers
491 * @return list of chatIds
493 public List<Long> getReceiverChatIds() {
494 return receiverChatId;
497 public void addMessageId(Long chatId, String replyId, Integer messageId) {
498 replyIdToMessageId.put(new ReplyKey(chatId, replyId), messageId);
502 public String getCallbackId(Long chatId, String replyId) {
503 return replyIdToCallbackId.get(new ReplyKey(chatId, replyId));
506 public @Nullable Integer removeMessageId(Long chatId, String replyId) {
507 return replyIdToMessageId.remove(new ReplyKey(chatId, replyId));
511 public ParseMode getParseMode() {
515 @SuppressWarnings("rawtypes")
517 public <T extends BaseRequest, R extends BaseResponse> R execute(BaseRequest<?, R> request) {
518 TelegramBot localBot = bot;
519 return localBot != null ? localBot.execute(request) : null;
523 public HttpClient getClient() {
524 return downloadDataClient;