]> git.basschouten.com Git - openhab-addons.git/blob
e28432b72565c9a68b7810c0e0ff300c6263da1d
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.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;
63
64 import okhttp3.OkHttpClient;
65
66 /**
67  * The {@link TelegramHandler} is responsible for handling commands, which are
68  * sent to one of the channels.
69  *
70  * @author Jens Runge - Initial contribution
71  * @author Alexander Krasnogolowy - using Telegram library from pengrad
72  * @author Jan N. Klug - handle file attachments
73  */
74 @NonNullByDefault
75 public class TelegramHandler extends BaseThingHandler {
76
77     @NonNullByDefault
78     private class ReplyKey {
79
80         final Long chatId;
81         final String replyId;
82
83         public ReplyKey(Long chatId, String replyId) {
84             this.chatId = chatId;
85             this.replyId = replyId;
86         }
87
88         @Override
89         public int hashCode() {
90             return Objects.hash(chatId, replyId);
91         }
92
93         @Override
94         public boolean equals(@Nullable Object obj) {
95             if (this == obj) {
96                 return true;
97             }
98             if (obj == null) {
99                 return false;
100             }
101             if (getClass() != obj.getClass()) {
102                 return false;
103             }
104             ReplyKey other = (ReplyKey) obj;
105             return Objects.equals(chatId, other.chatId) && Objects.equals(replyId, other.replyId);
106         }
107     }
108
109     private final List<Long> authorizedSenderChatId = new ArrayList<>();
110     private final List<Long> receiverChatId = new ArrayList<>();
111
112     private final Logger logger = LoggerFactory.getLogger(TelegramHandler.class);
113     private @Nullable ScheduledFuture<?> thingOnlineStatusJob;
114
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<>();
123
124     private @Nullable TelegramBot bot;
125     private @Nullable OkHttpClient botLibClient;
126     private @Nullable HttpClient downloadDataClient;
127     private @Nullable ParseMode parseMode;
128
129     public TelegramHandler(Thing thing, @Nullable HttpClient httpClient) {
130         super(thing);
131         downloadDataClient = httpClient;
132     }
133
134     @Override
135     public void handleCommand(ChannelUID channelUID, Command command) {
136         // no commands to handle
137     }
138
139     @Override
140     public void initialize() {
141         TelegramConfiguration config = getConfigAs(TelegramConfiguration.class);
142
143         String botToken = config.getBotToken();
144
145         List<String> chatIds = config.getChatIds();
146         if (chatIds != null) {
147             createReceiverChatIdsAndAuthorizedSenderChatIds(chatIds);
148         }
149         String parseModeAsString = config.getParseMode();
150         if (!parseModeAsString.isEmpty()) {
151             try {
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");
155             }
156         }
157
158         OkHttpClient.Builder prepareConnection = new OkHttpClient.Builder().connectTimeout(75, TimeUnit.SECONDS)
159                 .readTimeout(75, TimeUnit.SECONDS);
160
161         String proxyHost = config.getProxyHost();
162         Integer proxyPort = config.getProxyPort();
163         String proxyType = config.getProxyType();
164
165         if (proxyHost != null && proxyPort != null) {
166             InetSocketAddress proxyAddr = new InetSocketAddress(proxyHost, proxyPort);
167
168             Proxy.Type proxyTypeParam = Proxy.Type.SOCKS;
169
170             if ("HTTP".equals(proxyType)) {
171                 proxyTypeParam = Proxy.Type.HTTP;
172             }
173
174             Proxy proxy = new Proxy(proxyTypeParam, proxyAddr);
175
176             logger.debug("{} Proxy {}:{} is used for telegram ", proxyTypeParam, proxyHost, proxyPort);
177             prepareConnection.proxy(proxy);
178         }
179
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()));
186     }
187
188     private void createReceiverChatIdsAndAuthorizedSenderChatIds(List<String> chatIds) {
189         authorizedSenderChatId.clear();
190         receiverChatId.clear();
191
192         for (String chatIdStr : chatIds) {
193             String trimmedChatId = chatIdStr.trim();
194             try {
195                 if (trimmedChatId.startsWith("<")) {
196                     // inbound only
197                     authorizedSenderChatId.add(Long.valueOf(trimmedChatId.substring(1)));
198                 } else if (trimmedChatId.startsWith(">")) {
199                     // outbound only
200                     receiverChatId.add(Long.valueOf(trimmedChatId.substring(1)));
201                 } else {
202                     // bi-directional (default)
203                     Long chatId = Long.valueOf(trimmedChatId);
204                     authorizedSenderChatId.add(chatId);
205                     receiverChatId.add(chatId);
206                 }
207             } catch (NumberFormatException e) {
208                 logger.warn("The chat id {} is not a number and will be ignored", chatIdStr);
209             }
210         }
211     }
212
213     private GetUpdates getGetUpdatesRequest(int longPollingTime) {
214         return new GetUpdates().timeout(longPollingTime * 1000);
215     }
216
217     private void handleExceptions(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();
226                     }
227                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
228                             "Unauthorized attempt to connect to the Telegram server, please check if the bot token is valid");
229                     return;
230                 }
231             }
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();
236                 return;
237             }
238             logger.warn("Telegram exception: {}", exception.getMessage());
239         }
240     }
241
242     private String getFullDownloadUrl(String fileId) {
243         final TelegramBot bot = this.bot;
244         if (bot == null) {
245             return "";
246         }
247         return bot.getFullFilePath(bot.execute(new GetFile(fileId)).file());
248     }
249
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;
255         }
256
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;
266             Long chatId = null;
267             String replyId = null;
268
269             Message message = update.message();
270
271             if (message != null) {
272                 chatId = message.chat().id();
273                 if (!authorizedSenderChatId.contains(chatId)) {
274                     logger.warn(
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",
276                             chatId);
277                     continue; // this is very important regarding security to avoid commands from an unknown
278                     // chat
279                 }
280
281                 // process content
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());
297                 } else {
298                     logger.debug("Received message with unsupported content: {}", message);
299                     continue;
300                 }
301
302                 // process metadata
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);
310
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(),
321                             chatId, replyId);
322                 } else {
323                     logger.warn("The received callback query {} has not the right format (must be seperated by spaces)",
324                             update.callbackQuery().data());
325                 }
326             }
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
330                     ? new DateTimeType(
331                             ZonedDateTime.ofInstant(Instant.ofEpochSecond(lastMessageDate.intValue()), ZoneOffset.UTC))
332                     : UnDefType.NULL);
333             updateChannel(LASTMESSAGENAME, (lastMessageFirstName != null || lastMessageLastName != null)
334                     ? new StringType((lastMessageFirstName != null ? lastMessageFirstName + " " : "")
335                             + (lastMessageLastName != null ? lastMessageLastName : ""))
336                     : UnDefType.NULL);
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);
341         }
342         return UpdatesListener.CONFIRMED_UPDATES_ALL;
343     }
344
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);
350     }
351
352     private synchronized void cancelThingOnlineStatusJob() {
353         final ScheduledFuture<?> thingOnlineStatusJob = this.thingOnlineStatusJob;
354         if (thingOnlineStatusJob != null) {
355             thingOnlineStatusJob.cancel(true);
356             this.thingOnlineStatusJob = null;
357         }
358     }
359
360     @Override
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");
371         }
372         super.dispose();
373     }
374
375     public void updateChannel(String channelName, State state) {
376         updateState(new ChannelUID(getThing().getUID(), channelName), state);
377     }
378
379     @Override
380     public Collection<Class<? extends ThingHandlerService>> getServices() {
381         return Collections.singleton(TelegramActions.class);
382     }
383
384     /**
385      * get the list of all authorized senders
386      *
387      * @return list of chatIds
388      */
389     public List<Long> getAuthorizedSenderChatIds() {
390         return authorizedSenderChatId;
391     }
392
393     /**
394      * get the list of all receivers
395      *
396      * @return list of chatIds
397      */
398     public List<Long> getReceiverChatIds() {
399         return receiverChatId;
400     }
401
402     public void addMessageId(Long chatId, String replyId, Integer messageId) {
403         replyIdToMessageId.put(new ReplyKey(chatId, replyId), messageId);
404     }
405
406     @Nullable
407     public String getCallbackId(Long chatId, String replyId) {
408         return replyIdToCallbackId.get(new ReplyKey(chatId, replyId));
409     }
410
411     public Integer removeMessageId(Long chatId, String replyId) {
412         return replyIdToMessageId.remove(new ReplyKey(chatId, replyId));
413     }
414
415     @Nullable
416     public ParseMode getParseMode() {
417         return parseMode;
418     }
419
420     @SuppressWarnings("rawtypes")
421     @Nullable
422     public <T extends BaseRequest, R extends BaseResponse> R execute(BaseRequest<T, R> request) {
423         TelegramBot localBot = bot;
424         return localBot != null ? localBot.execute(request) : null;
425     }
426
427     @Nullable
428     public HttpClient getClient() {
429         return downloadDataClient;
430     }
431 }