]> git.basschouten.com Git - openhab-addons.git/blob
5b103ef55b8fc2a0376932c83d3f01cdc9d83491
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.Comparator;
26 import java.util.HashMap;
27 import java.util.List;
28 import java.util.Map;
29 import java.util.Objects;
30 import java.util.Set;
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             if (message == null) {
285                 message = update.channelPost();
286             }
287             CallbackQuery callbackQuery = update.callbackQuery();
288
289             if (message != null) {
290                 chatId = message.chat().id();
291                 if (!authorizedSenderChatId.contains(chatId)) {
292                     logger.warn(
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",
294                             chatId);
295                     continue; // this is very important regarding security to avoid commands from an unknown
296                     // chat
297                 }
298
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() }));
306                 }
307                 messagePayload.addProperty("chat_id", message.chat().id());
308                 if (messageRaw.has("text")) {
309                     messagePayload.addProperty("text", message.text());
310                 }
311                 if (messageRaw.has("animation")) {
312                     addFileUrlsToPayload(messageRaw.getAsJsonObject("animation"));
313                     messagePayload.add("animation_url", messageRaw.getAsJsonObject("animation").get("file_url"));
314                 }
315                 if (messageRaw.has("audio")) {
316                     addFileUrlsToPayload(messageRaw.getAsJsonObject("audio"));
317                     messagePayload.add("audio_url", messageRaw.getAsJsonObject("audio").get("file_url"));
318                 }
319                 if (messageRaw.has("document")) {
320                     addFileUrlsToPayload(messageRaw.getAsJsonObject("document"));
321                     messagePayload.add("document_url", messageRaw.getAsJsonObject("document").get("file_url"));
322                 }
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);
331                     }
332                     messagePayload.add("photo_url", photoURLArray);
333                 }
334                 if (messageRaw.has("sticker")) {
335                     addFileUrlsToPayload(messageRaw.getAsJsonObject("sticker"));
336                     messagePayload.add("sticker_url", messageRaw.getAsJsonObject("sticker").get("file_url"));
337                 }
338                 if (messageRaw.has("video")) {
339                     addFileUrlsToPayload(messageRaw.getAsJsonObject("video"));
340                     messagePayload.add("video_url", messageRaw.getAsJsonObject("video").get("file_url"));
341                 }
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"));
345                 }
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);
351                 }
352                 triggerEvent(MESSAGEEVENT, messagePayload.toString());
353                 triggerEvent(MESSAGERAWEVENT, messageRaw.toString());
354
355                 // process content
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.comparingLong(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());
371                 } else {
372                     logger.debug("Received message with unsupported content: {}", message);
373                     continue;
374                 }
375
376                 // process metadata
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();
383                     }
384                 }
385             } else if (callbackQuery != null && callbackQuery.message() != null
386                     && callbackQuery.message().text() != null) {
387                 String[] callbackData = callbackQuery.data().split(" ", 2);
388
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());
398
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());
410
411                     logger.debug("Received callbackId {} for chatId {} and replyId {}", callbackQuery.id(), chatId,
412                             replyId);
413                 } else {
414                     logger.warn("The received callback query {} has not the right format (must be seperated by spaces)",
415                             callbackQuery.data());
416                 }
417             }
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 : ""))
424                     : UnDefType.NULL);
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
429                     ? new DateTimeType(
430                             ZonedDateTime.ofInstant(Instant.ofEpochSecond(lastMessageDate.intValue()), ZoneOffset.UTC))
431                     : UnDefType.NULL);
432         }
433         return UpdatesListener.CONFIRMED_UPDATES_ALL;
434     }
435
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);
441     }
442
443     private synchronized void cancelThingOnlineStatusJob() {
444         final ScheduledFuture<?> thingOnlineStatusJob = this.thingOnlineStatusJob;
445         if (thingOnlineStatusJob != null) {
446             thingOnlineStatusJob.cancel(true);
447             this.thingOnlineStatusJob = null;
448         }
449     }
450
451     @Override
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");
462         }
463         super.dispose();
464     }
465
466     public void updateChannel(String channelName, State state) {
467         updateState(new ChannelUID(getThing().getUID(), channelName), state);
468     }
469
470     public void triggerEvent(String channelName, String payload) {
471         triggerChannel(channelName, payload);
472     }
473
474     @Override
475     public Collection<Class<? extends ThingHandlerService>> getServices() {
476         return Set.of(TelegramActions.class);
477     }
478
479     /**
480      * get the list of all authorized senders
481      *
482      * @return list of chatIds
483      */
484     public List<Long> getAuthorizedSenderChatIds() {
485         return authorizedSenderChatId;
486     }
487
488     /**
489      * get the list of all receivers
490      *
491      * @return list of chatIds
492      */
493     public List<Long> getReceiverChatIds() {
494         return receiverChatId;
495     }
496
497     public void addMessageId(Long chatId, String replyId, Integer messageId) {
498         replyIdToMessageId.put(new ReplyKey(chatId, replyId), messageId);
499     }
500
501     @Nullable
502     public String getCallbackId(Long chatId, String replyId) {
503         return replyIdToCallbackId.get(new ReplyKey(chatId, replyId));
504     }
505
506     public @Nullable Integer removeMessageId(Long chatId, String replyId) {
507         return replyIdToMessageId.remove(new ReplyKey(chatId, replyId));
508     }
509
510     @Nullable
511     public ParseMode getParseMode() {
512         return parseMode;
513     }
514
515     @SuppressWarnings("rawtypes")
516     @Nullable
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;
520     }
521
522     @Nullable
523     public HttpClient getClient() {
524         return downloadDataClient;
525     }
526 }