]> git.basschouten.com Git - openhab-addons.git/blob
87d2288bc17a605eeb48aada3fc8adaab9b38607
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.telegram.internal;
14
15 import static org.openhab.binding.telegram.internal.TelegramBindingConstants.*;
16
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;
29 import java.util.Map;
30 import java.util.Objects;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
33
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;
51
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;
69
70 import okhttp3.OkHttpClient;
71
72 /**
73  * The {@link TelegramHandler} is responsible for handling commands, which are
74  * sent to one of the channels.
75  *
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
80  */
81 @NonNullByDefault
82 public class TelegramHandler extends BaseThingHandler {
83
84     private class ReplyKey {
85         final Long chatId;
86         final String replyId;
87
88         public ReplyKey(Long chatId, String replyId) {
89             this.chatId = chatId;
90             this.replyId = replyId;
91         }
92
93         @Override
94         public int hashCode() {
95             return Objects.hash(chatId, replyId);
96         }
97
98         @Override
99         public boolean equals(@Nullable Object obj) {
100             if (this == obj) {
101                 return true;
102             }
103             if (obj == null) {
104                 return false;
105             }
106             if (getClass() != obj.getClass()) {
107                 return false;
108             }
109             ReplyKey other = (ReplyKey) obj;
110             return Objects.equals(chatId, other.chatId) && Objects.equals(replyId, other.replyId);
111         }
112     }
113
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;
119
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<>();
128
129     private @Nullable TelegramBot bot;
130     private @Nullable OkHttpClient botLibClient;
131     private @Nullable HttpClient downloadDataClient;
132     private @Nullable ParseMode parseMode;
133
134     public TelegramHandler(Thing thing, @Nullable HttpClient httpClient) {
135         super(thing);
136         downloadDataClient = httpClient;
137     }
138
139     @Override
140     public void handleCommand(ChannelUID channelUID, Command command) {
141         // no commands to handle
142     }
143
144     @Override
145     public void initialize() {
146         TelegramConfiguration config = getConfigAs(TelegramConfiguration.class);
147
148         String botToken = config.getBotToken();
149
150         List<String> chatIds = config.getChatIds();
151         if (chatIds != null) {
152             createReceiverChatIdsAndAuthorizedSenderChatIds(chatIds);
153         }
154         String parseModeAsString = config.getParseMode();
155         if (!parseModeAsString.isEmpty()) {
156             try {
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");
160             }
161         }
162
163         OkHttpClient.Builder prepareConnection = new OkHttpClient.Builder().connectTimeout(75, TimeUnit.SECONDS)
164                 .writeTimeout(75, TimeUnit.SECONDS).readTimeout(75, TimeUnit.SECONDS);
165
166         String proxyHost = config.getProxyHost();
167         Integer proxyPort = config.getProxyPort();
168         String proxyType = config.getProxyType();
169
170         if (proxyHost != null && proxyPort != null) {
171             InetSocketAddress proxyAddr = new InetSocketAddress(proxyHost, proxyPort);
172
173             Proxy.Type proxyTypeParam = Proxy.Type.SOCKS;
174
175             if ("HTTP".equals(proxyType)) {
176                 proxyTypeParam = Proxy.Type.HTTP;
177             }
178
179             Proxy proxy = new Proxy(proxyTypeParam, proxyAddr);
180
181             logger.debug("{} Proxy {}:{} is used for telegram ", proxyTypeParam, proxyHost, proxyPort);
182             prepareConnection.proxy(proxy);
183         }
184
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()));
191     }
192
193     private void createReceiverChatIdsAndAuthorizedSenderChatIds(List<String> chatIds) {
194         authorizedSenderChatId.clear();
195         receiverChatId.clear();
196
197         for (String chatIdStr : chatIds) {
198             String trimmedChatId = chatIdStr.trim();
199             try {
200                 if (trimmedChatId.startsWith("<")) {
201                     // inbound only
202                     authorizedSenderChatId.add(Long.valueOf(trimmedChatId.substring(1)));
203                 } else if (trimmedChatId.startsWith(">")) {
204                     // outbound only
205                     receiverChatId.add(Long.valueOf(trimmedChatId.substring(1)));
206                 } else {
207                     // bi-directional (default)
208                     Long chatId = Long.valueOf(trimmedChatId);
209                     authorizedSenderChatId.add(chatId);
210                     receiverChatId.add(chatId);
211                 }
212             } catch (NumberFormatException e) {
213                 logger.warn("The chat id {} is not a number and will be ignored", chatIdStr);
214             }
215         }
216     }
217
218     private GetUpdates getGetUpdatesRequest(int longPollingTime) {
219         return new GetUpdates().timeout(longPollingTime * 1000);
220     }
221
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();
231                     }
232                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
233                             "Unauthorized attempt to connect to the Telegram server, please check if the bot token is valid");
234                     return;
235                 }
236             }
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();
241                 return;
242             }
243             logger.warn("Telegram exception: {}", exception.getMessage());
244         }
245     }
246
247     private String getFullDownloadUrl(String fileId) {
248         final TelegramBot bot = this.bot;
249         if (bot == null) {
250             return "";
251         }
252         return bot.getFullFilePath(bot.execute(new GetFile(fileId)).file());
253     }
254
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()));
261         }
262     }
263
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;
269         }
270
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;
280             Long chatId = null;
281             String replyId = null;
282
283             Message message = update.message();
284             CallbackQuery callbackQuery = update.callbackQuery();
285
286             if (message != null) {
287                 chatId = message.chat().id();
288                 if (!authorizedSenderChatId.contains(chatId)) {
289                     logger.warn(
290                             "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",
291                             chatId);
292                     continue; // this is very important regarding security to avoid commands from an unknown
293                     // chat
294                 }
295
296                 // build and publish messageEvent trigger channel payload
297                 JsonObject messageRaw = JsonParser.parseString(gson.toJson(message)).getAsJsonObject();
298                 JsonObject messagePayload = new JsonObject();
299                 messagePayload.addProperty("message_id", message.messageId());
300                 messagePayload.addProperty("from",
301                         String.join(" ", new String[] { message.from().firstName(), message.from().lastName() }));
302                 messagePayload.addProperty("chat_id", message.chat().id());
303                 if (messageRaw.has("text")) {
304                     messagePayload.addProperty("text", message.text());
305                 }
306                 if (messageRaw.has("animation")) {
307                     addFileUrlsToPayload(messageRaw.getAsJsonObject("animation"));
308                     messagePayload.add("animation_url", messageRaw.getAsJsonObject("animation").get("file_url"));
309                 }
310                 if (messageRaw.has("audio")) {
311                     addFileUrlsToPayload(messageRaw.getAsJsonObject("audio"));
312                     messagePayload.add("audio_url", messageRaw.getAsJsonObject("audio").get("file_url"));
313                 }
314                 if (messageRaw.has("document")) {
315                     addFileUrlsToPayload(messageRaw.getAsJsonObject("document"));
316                     messagePayload.add("document_url", messageRaw.getAsJsonObject("document").get("file_url"));
317                 }
318                 if (messageRaw.has("photo")) {
319                     JsonArray photoURLArray = new JsonArray();
320                     for (JsonElement photoPayload : messageRaw.getAsJsonArray("photo")) {
321                         JsonObject photoPayloadObject = photoPayload.getAsJsonObject();
322                         String photoURL = getFullDownloadUrl(
323                                 photoPayloadObject.getAsJsonPrimitive("file_id").getAsString());
324                         photoPayloadObject.addProperty("file_url", photoURL);
325                         photoURLArray.add(photoURL);
326                     }
327                     messagePayload.add("photo_url", photoURLArray);
328                 }
329                 if (messageRaw.has("sticker")) {
330                     addFileUrlsToPayload(messageRaw.getAsJsonObject("sticker"));
331                     messagePayload.add("sticker_url", messageRaw.getAsJsonObject("sticker").get("file_url"));
332                 }
333                 if (messageRaw.has("video")) {
334                     addFileUrlsToPayload(messageRaw.getAsJsonObject("video"));
335                     messagePayload.add("video_url", messageRaw.getAsJsonObject("video").get("file_url"));
336                 }
337                 if (messageRaw.has("video_note")) {
338                     addFileUrlsToPayload(messageRaw.getAsJsonObject("video_note"));
339                     messagePayload.add("video_note_url", messageRaw.getAsJsonObject("video_note").get("file_url"));
340                 }
341                 if (messageRaw.has("voice")) {
342                     JsonObject voicePayload = messageRaw.getAsJsonObject("voice");
343                     String voiceURL = getFullDownloadUrl(voicePayload.getAsJsonPrimitive("file_id").getAsString());
344                     voicePayload.addProperty("file_url", voiceURL);
345                     messagePayload.addProperty("voice_url", voiceURL);
346                 }
347                 triggerEvent(MESSAGEEVENT, messagePayload.toString());
348                 triggerEvent(MESSAGERAWEVENT, messageRaw.toString());
349
350                 // process content
351                 if (message.audio() != null) {
352                     lastMessageURL = getFullDownloadUrl(message.audio().fileId());
353                 } else if (message.document() != null) {
354                     lastMessageURL = getFullDownloadUrl(message.document().fileId());
355                 } else if (message.photo() != null) {
356                     PhotoSize[] photoSizes = message.photo();
357                     logger.trace("Received photos {}", Arrays.asList(photoSizes));
358                     Arrays.sort(photoSizes, Comparator.comparingInt(PhotoSize::fileSize).reversed());
359                     lastMessageURL = getFullDownloadUrl(photoSizes[0].fileId());
360                 } else if (message.text() != null) {
361                     lastMessageText = message.text();
362                 } else if (message.video() != null) {
363                     lastMessageURL = getFullDownloadUrl(message.video().fileId());
364                 } else if (message.voice() != null) {
365                     lastMessageURL = getFullDownloadUrl(message.voice().fileId());
366                 } else {
367                     logger.debug("Received message with unsupported content: {}", message);
368                     continue;
369                 }
370
371                 // process metadata
372                 if (lastMessageURL != null || lastMessageText != null) {
373                     lastMessageDate = message.date();
374                     lastMessageFirstName = message.from().firstName();
375                     lastMessageLastName = message.from().lastName();
376                     lastMessageUsername = message.from().username();
377                 }
378             } else if (callbackQuery != null && callbackQuery.message() != null
379                     && callbackQuery.message().text() != null) {
380                 String[] callbackData = callbackQuery.data().split(" ", 2);
381
382                 if (callbackData.length == 2) {
383                     replyId = callbackData[0];
384                     lastMessageText = callbackData[1];
385                     lastMessageDate = callbackQuery.message().date();
386                     lastMessageFirstName = callbackQuery.from().firstName();
387                     lastMessageLastName = callbackQuery.from().lastName();
388                     lastMessageUsername = callbackQuery.from().username();
389                     chatId = callbackQuery.message().chat().id();
390                     replyIdToCallbackId.put(new ReplyKey(chatId, replyId), callbackQuery.id());
391
392                     // build and publish callbackEvent trigger channel payload
393                     JsonObject callbackRaw = JsonParser.parseString(gson.toJson(callbackQuery)).getAsJsonObject();
394                     JsonObject callbackPayload = new JsonObject();
395                     callbackPayload.addProperty("message_id", callbackQuery.message().messageId());
396                     callbackPayload.addProperty("from", lastMessageFirstName + " " + lastMessageLastName);
397                     callbackPayload.addProperty("chat_id", callbackQuery.message().chat().id());
398                     callbackPayload.addProperty("callback_id", callbackQuery.id());
399                     callbackPayload.addProperty("reply_id", callbackData[0]);
400                     callbackPayload.addProperty("text", callbackData[1]);
401                     triggerEvent(CALLBACKEVENT, callbackPayload.toString());
402                     triggerEvent(CALLBACKRAWEVENT, callbackRaw.toString());
403
404                     logger.debug("Received callbackId {} for chatId {} and replyId {}", callbackQuery.id(), chatId,
405                             replyId);
406                 } else {
407                     logger.warn("The received callback query {} has not the right format (must be seperated by spaces)",
408                             callbackQuery.data());
409                 }
410             }
411             updateChannel(CHATID, chatId != null ? new StringType(chatId.toString()) : UnDefType.NULL);
412             updateChannel(REPLYID, replyId != null ? new StringType(replyId) : UnDefType.NULL);
413             updateChannel(LASTMESSAGEURL, lastMessageURL != null ? new StringType(lastMessageURL) : UnDefType.NULL);
414             updateChannel(LASTMESSAGENAME, (lastMessageFirstName != null || lastMessageLastName != null)
415                     ? new StringType((lastMessageFirstName != null ? lastMessageFirstName + " " : "")
416                             + (lastMessageLastName != null ? lastMessageLastName : ""))
417                     : UnDefType.NULL);
418             updateChannel(LASTMESSAGEUSERNAME,
419                     lastMessageUsername != null ? new StringType(lastMessageUsername) : UnDefType.NULL);
420             updateChannel(LASTMESSAGETEXT, lastMessageText != null ? new StringType(lastMessageText) : UnDefType.NULL);
421             updateChannel(LASTMESSAGEDATE, lastMessageDate != null
422                     ? new DateTimeType(
423                             ZonedDateTime.ofInstant(Instant.ofEpochSecond(lastMessageDate.intValue()), ZoneOffset.UTC))
424                     : UnDefType.NULL);
425         }
426         return UpdatesListener.CONFIRMED_UPDATES_ALL;
427     }
428
429     private synchronized void delayThingOnlineStatus() {
430         thingOnlineStatusJob = scheduler.schedule(() -> {
431             // if no error was returned within 10s, we assume the initialization went well
432             updateStatus(ThingStatus.ONLINE);
433         }, 10, TimeUnit.SECONDS);
434     }
435
436     private synchronized void cancelThingOnlineStatusJob() {
437         final ScheduledFuture<?> thingOnlineStatusJob = this.thingOnlineStatusJob;
438         if (thingOnlineStatusJob != null) {
439             thingOnlineStatusJob.cancel(true);
440             this.thingOnlineStatusJob = null;
441         }
442     }
443
444     @Override
445     public void dispose() {
446         logger.debug("Trying to dispose Telegram client");
447         cancelThingOnlineStatusJob();
448         OkHttpClient localClient = botLibClient;
449         TelegramBot localBot = bot;
450         if (localClient != null && localBot != null) {
451             localBot.removeGetUpdatesListener();
452             localClient.dispatcher().executorService().shutdown();
453             localClient.connectionPool().evictAll();
454             logger.debug("Telegram client closed");
455         }
456         super.dispose();
457     }
458
459     public void updateChannel(String channelName, State state) {
460         updateState(new ChannelUID(getThing().getUID(), channelName), state);
461     }
462
463     public void triggerEvent(String channelName, String payload) {
464         triggerChannel(channelName, payload);
465     }
466
467     @Override
468     public Collection<Class<? extends ThingHandlerService>> getServices() {
469         return Collections.singleton(TelegramActions.class);
470     }
471
472     /**
473      * get the list of all authorized senders
474      *
475      * @return list of chatIds
476      */
477     public List<Long> getAuthorizedSenderChatIds() {
478         return authorizedSenderChatId;
479     }
480
481     /**
482      * get the list of all receivers
483      *
484      * @return list of chatIds
485      */
486     public List<Long> getReceiverChatIds() {
487         return receiverChatId;
488     }
489
490     public void addMessageId(Long chatId, String replyId, Integer messageId) {
491         replyIdToMessageId.put(new ReplyKey(chatId, replyId), messageId);
492     }
493
494     @Nullable
495     public String getCallbackId(Long chatId, String replyId) {
496         return replyIdToCallbackId.get(new ReplyKey(chatId, replyId));
497     }
498
499     public @Nullable Integer removeMessageId(Long chatId, String replyId) {
500         return replyIdToMessageId.remove(new ReplyKey(chatId, replyId));
501     }
502
503     @Nullable
504     public ParseMode getParseMode() {
505         return parseMode;
506     }
507
508     @SuppressWarnings("rawtypes")
509     @Nullable
510     public <T extends BaseRequest, R extends BaseResponse> R execute(BaseRequest<?, R> request) {
511         TelegramBot localBot = bot;
512         return localBot != null ? localBot.execute(request) : null;
513     }
514
515     @Nullable
516     public HttpClient getClient() {
517         return downloadDataClient;
518     }
519 }