[telegram] Add event channels and Answer overload (#9251)

* Add event channels and Answer overload

Signed-off-by: Michael Murton <6764025+CrazyIvan359@users.noreply.github.com>
This commit is contained in:
Michael Murton 2021-09-18 09:08:00 -04:00 committed by GitHub
parent 88975dcd13
commit 51cb1aabd5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 292 additions and 42 deletions

View File

@ -41,7 +41,7 @@ Otherwise you will not be able to receive those messages.
**telegramBot** - A Telegram Bot that can send and receive messages. **telegramBot** - A Telegram Bot that can send and receive messages.
The Telegram binding supports the following things which originate from the last message sent to the Telegram bot: The Telegram binding supports the following state channels which originate from the last message sent to the Telegram bot:
* message text or URL * message text or URL
* message date * message date
@ -50,6 +50,8 @@ The Telegram binding supports the following things which originate from the last
* chat id (used to identify the chat of the last message) * chat id (used to identify the chat of the last message)
* reply id (used to identify an answer from a user of a previously sent message by the binding) * reply id (used to identify an answer from a user of a previously sent message by the binding)
There are also event channels that provide received messages or query callback responses as JSON payloads for easier handling in rules.
Please note that the binding channels cannot be used to send messages. Please note that the binding channels cannot be used to send messages.
In order to send a message, an action must be used instead. In order to send a message, an action must be used instead.
@ -105,7 +107,7 @@ or HTTP proxy server
Thing telegram:telegramBot:Telegram_Bot [ chatIds="ID", botToken="TOKEN", proxyHost="localhost", proxyPort="8123", proxyType="HTTP" ] Thing telegram:telegramBot:Telegram_Bot [ chatIds="ID", botToken="TOKEN", proxyHost="localhost", proxyPort="8123", proxyType="HTTP" ]
``` ```
## Channels ## State Channels
| Channel Type ID | Item Type | Description | | Channel Type ID | Item Type | Description |
|--------------------------------------|-----------|-----------------------------------------------------------------| |--------------------------------------|-----------|-----------------------------------------------------------------|
@ -122,6 +124,52 @@ Either `lastMessageText` or `lastMessageURL` are populated for a given message.
If the message did contain text, the content is written to `lastMessageText`. If the message did contain text, the content is written to `lastMessageText`.
If the message did contain an audio, photo, video or voice, the URL to retrieve that content can be found in `lastMessageURL`. If the message did contain an audio, photo, video or voice, the URL to retrieve that content can be found in `lastMessageURL`.
## Event Channels
### messageEvent
When a message is received this channel will be triggered with a simplified version of the message data as the `event`, payload encoded as a JSON string.
The following table shows the possible fields, any `null` values will be missing from the JSON payload.
| Field | Type | Description |
|------------------|--------|---------------------------------------|
| `message_id` | Long | Unique message ID in this chat |
| `from` | String | First and/or last name of sender |
| `chat_id` | Long | Unique chat ID |
| `text` | String | Message text |
| `animation_url` | String | URL to download animation from |
| `audio_url` | String | URL to download audio from |
| `document_url` | String | URL to download file from |
| `photo_url` | Array | Array of URLs to download photos from |
| `sticker_url` | String | URL to download sticker from |
| `video_url` | String | URL to download video from |
| `video_note_url` | String | URL to download video note from |
| `voice_url` | String | URL to download voice clip from |
### messageRawEvent
When a message is received this channel will be triggered with the raw message data as the `event` payload, encoded as a JSON string.
See the [`Message` class for details](https://github.com/pengrad/java-telegram-bot-api/blob/4.9.0/library/src/main/java/com/pengrad/telegrambot/model/Message.java)
### callbackEvent
When a Callback Query response is received this channel will be triggered with a simplified version of the callback data as the `event`, payload encoded as a JSON string.
The following table shows the possible fields, any `null` values will be missing from the JSON payload.
| Field | Type | Description |
|---------------|--------|------------------------------------------------------------|
| `message_id` | Long | Unique message ID of the original Query message |
| `from` | String | First and/or last name of sender |
| `chat_id` | Long | Unique chat ID |
| `callback_id` | String | Unique callback ID to send receipt confirmation to |
| `reply_id` | String | Plain text name of original Query |
| `text` | String | Selected response text from options give in original Query |
### callbackRawEvent
When a Callback Query response is received this channel will be triggered with the raw callback data as the `event` payload, encoded as a JSON string.
See the [`CallbackQuery` class for details](https://github.com/pengrad/java-telegram-bot-api/blob/4.9.0/library/src/main/java/com/pengrad/telegrambot/model/CallbackQuery.java)
## Rule Actions ## Rule Actions
This binding includes a number of rule actions, which allow the sending of Telegram messages from within rules. This binding includes a number of rule actions, which allow the sending of Telegram messages from within rules.
@ -172,6 +220,15 @@ Just put the chat id (must be a long value!) followed by an "L" as the first arg
telegramAction.sendTelegram(1234567L, "Hello world!") telegramAction.sendTelegram(1234567L, "Hello world!")
``` ```
### Advanced Callback Query Response
This binding stores the `callbackId` and recalls it using the `replyId`, but this information is lost if openHAB restarts.
If you store the `callbackId`, `chatId`, and optionally `messageId` somewhere that will be persisted when openHAB shuts down, you can use the following overload of `sendTelegramAnswer` to respond to any Callback Query.
```
telegramAction.sendTelegramAnswer(chatId, callbackId, messageId, message)
```
## Full Example ## Full Example
### Send a text message to telegram chat ### Send a text message to telegram chat

View File

@ -41,4 +41,9 @@ public class TelegramBindingConstants {
public static final String LASTMESSAGEUSERNAME = "lastMessageUsername"; public static final String LASTMESSAGEUSERNAME = "lastMessageUsername";
public static final String CHATID = "chatId"; public static final String CHATID = "chatId";
public static final String REPLYID = "replyId"; public static final String REPLYID = "replyId";
public static final String LONGPOLLINGTIME = "longPollingTime";
public static final String MESSAGEEVENT = "messageEvent";
public static final String MESSAGERAWEVENT = "messageRawEvent";
public static final String CALLBACKEVENT = "callbackEvent";
public static final String CALLBACKRAWEVENT = "callbackRawEvent";
} }

View File

@ -49,9 +49,15 @@ import org.openhab.core.types.UnDefType;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.pengrad.telegrambot.TelegramBot; import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.TelegramException; import com.pengrad.telegrambot.TelegramException;
import com.pengrad.telegrambot.UpdatesListener; import com.pengrad.telegrambot.UpdatesListener;
import com.pengrad.telegrambot.model.CallbackQuery;
import com.pengrad.telegrambot.model.Message; import com.pengrad.telegrambot.model.Message;
import com.pengrad.telegrambot.model.PhotoSize; import com.pengrad.telegrambot.model.PhotoSize;
import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.model.Update;
@ -70,13 +76,12 @@ import okhttp3.OkHttpClient;
* @author Jens Runge - Initial contribution * @author Jens Runge - Initial contribution
* @author Alexander Krasnogolowy - using Telegram library from pengrad * @author Alexander Krasnogolowy - using Telegram library from pengrad
* @author Jan N. Klug - handle file attachments * @author Jan N. Klug - handle file attachments
* @author Michael Murton - add trigger channel
*/ */
@NonNullByDefault @NonNullByDefault
public class TelegramHandler extends BaseThingHandler { public class TelegramHandler extends BaseThingHandler {
@NonNullByDefault
private class ReplyKey { private class ReplyKey {
final Long chatId; final Long chatId;
final String replyId; final String replyId;
@ -106,9 +111,9 @@ public class TelegramHandler extends BaseThingHandler {
} }
} }
private static Gson gson = new Gson();
private final List<Long> authorizedSenderChatId = new ArrayList<>(); private final List<Long> authorizedSenderChatId = new ArrayList<>();
private final List<Long> receiverChatId = new ArrayList<>(); private final List<Long> receiverChatId = new ArrayList<>();
private final Logger logger = LoggerFactory.getLogger(TelegramHandler.class); private final Logger logger = LoggerFactory.getLogger(TelegramHandler.class);
private @Nullable ScheduledFuture<?> thingOnlineStatusJob; private @Nullable ScheduledFuture<?> thingOnlineStatusJob;
@ -247,6 +252,15 @@ public class TelegramHandler extends BaseThingHandler {
return bot.getFullFilePath(bot.execute(new GetFile(fileId)).file()); return bot.getFullFilePath(bot.execute(new GetFile(fileId)).file());
} }
private void addFileUrlsToPayload(JsonObject filePayload) {
filePayload.addProperty("file_url",
getFullDownloadUrl(filePayload.getAsJsonPrimitive("file_id").getAsString()));
if (filePayload.has("thumb")) {
filePayload.getAsJsonObject("thumb").addProperty("file_url", getFullDownloadUrl(
filePayload.getAsJsonObject("thumb").getAsJsonPrimitive("file_id").getAsString()));
}
}
private int handleUpdates(List<Update> updates) { private int handleUpdates(List<Update> updates) {
final TelegramBot localBot = bot; final TelegramBot localBot = bot;
if (localBot == null) { if (localBot == null) {
@ -267,6 +281,7 @@ public class TelegramHandler extends BaseThingHandler {
String replyId = null; String replyId = null;
Message message = update.message(); Message message = update.message();
CallbackQuery callbackQuery = update.callbackQuery();
if (message != null) { if (message != null) {
chatId = message.chat().id(); chatId = message.chat().id();
@ -278,6 +293,60 @@ public class TelegramHandler extends BaseThingHandler {
// chat // chat
} }
// build and publish messageEvent trigger channel payload
JsonObject messageRaw = JsonParser.parseString(gson.toJson(message)).getAsJsonObject();
JsonObject messagePayload = new JsonObject();
messagePayload.addProperty("message_id", message.messageId());
messagePayload.addProperty("from",
String.join(" ", new String[] { message.from().firstName(), message.from().lastName() }));
messagePayload.addProperty("chat_id", message.chat().id());
if (messageRaw.has("text")) {
messagePayload.addProperty("text", message.text());
}
if (messageRaw.has("animation")) {
addFileUrlsToPayload(messageRaw.getAsJsonObject("animation"));
messagePayload.add("animation_url", messageRaw.getAsJsonObject("animation").get("file_url"));
}
if (messageRaw.has("audio")) {
addFileUrlsToPayload(messageRaw.getAsJsonObject("audio"));
messagePayload.add("audio_url", messageRaw.getAsJsonObject("audio").get("file_url"));
}
if (messageRaw.has("document")) {
addFileUrlsToPayload(messageRaw.getAsJsonObject("document"));
messagePayload.add("document_url", messageRaw.getAsJsonObject("document").get("file_url"));
}
if (messageRaw.has("photo")) {
JsonArray photoURLArray = new JsonArray();
for (JsonElement photoPayload : messageRaw.getAsJsonArray("photo")) {
JsonObject photoPayloadObject = photoPayload.getAsJsonObject();
String photoURL = getFullDownloadUrl(
photoPayloadObject.getAsJsonPrimitive("file_id").getAsString());
photoPayloadObject.addProperty("file_url", photoURL);
photoURLArray.add(photoURL);
}
messagePayload.add("photo_url", photoURLArray);
}
if (messageRaw.has("sticker")) {
addFileUrlsToPayload(messageRaw.getAsJsonObject("sticker"));
messagePayload.add("sticker_url", messageRaw.getAsJsonObject("sticker").get("file_url"));
}
if (messageRaw.has("video")) {
addFileUrlsToPayload(messageRaw.getAsJsonObject("video"));
messagePayload.add("video_url", messageRaw.getAsJsonObject("video").get("file_url"));
}
if (messageRaw.has("video_note")) {
addFileUrlsToPayload(messageRaw.getAsJsonObject("video_note"));
messagePayload.add("video_note_url", messageRaw.getAsJsonObject("video_note").get("file_url"));
}
if (messageRaw.has("voice")) {
JsonObject voicePayload = messageRaw.getAsJsonObject("voice");
String voiceURL = getFullDownloadUrl(voicePayload.getAsJsonPrimitive("file_id").getAsString());
voicePayload.addProperty("file_url", voiceURL);
messagePayload.addProperty("voice_url", voiceURL);
}
triggerEvent(MESSAGEEVENT, messagePayload.toString());
triggerEvent(MESSAGERAWEVENT, messageRaw.toString());
// process content // process content
if (message.audio() != null) { if (message.audio() != null) {
lastMessageURL = getFullDownloadUrl(message.audio().fileId()); lastMessageURL = getFullDownloadUrl(message.audio().fileId());
@ -300,28 +369,43 @@ public class TelegramHandler extends BaseThingHandler {
} }
// process metadata // process metadata
lastMessageDate = message.date(); if (lastMessageURL != null || lastMessageText != null) {
lastMessageFirstName = message.from().firstName(); lastMessageDate = message.date();
lastMessageLastName = message.from().lastName(); lastMessageFirstName = message.from().firstName();
lastMessageUsername = message.from().username(); lastMessageLastName = message.from().lastName();
} else if (update.callbackQuery() != null && update.callbackQuery().message() != null lastMessageUsername = message.from().username();
&& update.callbackQuery().message().text() != null) { }
String[] callbackData = update.callbackQuery().data().split(" ", 2); } else if (callbackQuery != null && callbackQuery.message() != null
&& callbackQuery.message().text() != null) {
String[] callbackData = callbackQuery.data().split(" ", 2);
if (callbackData.length == 2) { if (callbackData.length == 2) {
replyId = callbackData[0]; replyId = callbackData[0];
lastMessageText = callbackData[1]; lastMessageText = callbackData[1];
lastMessageDate = update.callbackQuery().message().date(); lastMessageDate = callbackQuery.message().date();
lastMessageFirstName = update.callbackQuery().from().firstName(); lastMessageFirstName = callbackQuery.from().firstName();
lastMessageLastName = update.callbackQuery().from().lastName(); lastMessageLastName = callbackQuery.from().lastName();
lastMessageUsername = update.callbackQuery().from().username(); lastMessageUsername = callbackQuery.from().username();
chatId = update.callbackQuery().message().chat().id(); chatId = callbackQuery.message().chat().id();
replyIdToCallbackId.put(new ReplyKey(chatId, replyId), update.callbackQuery().id()); replyIdToCallbackId.put(new ReplyKey(chatId, replyId), callbackQuery.id());
logger.debug("Received callbackId {} for chatId {} and replyId {}", update.callbackQuery().id(),
chatId, replyId); // build and publish callbackEvent trigger channel payload
JsonObject callbackRaw = JsonParser.parseString(gson.toJson(callbackQuery)).getAsJsonObject();
JsonObject callbackPayload = new JsonObject();
callbackPayload.addProperty("message_id", callbackQuery.message().messageId());
callbackPayload.addProperty("from", lastMessageFirstName + " " + lastMessageLastName);
callbackPayload.addProperty("chat_id", callbackQuery.message().chat().id());
callbackPayload.addProperty("callback_id", callbackQuery.id());
callbackPayload.addProperty("reply_id", callbackData[0]);
callbackPayload.addProperty("text", callbackData[1]);
triggerEvent(CALLBACKEVENT, callbackPayload.toString());
triggerEvent(CALLBACKRAWEVENT, callbackRaw.toString());
logger.debug("Received callbackId {} for chatId {} and replyId {}", callbackQuery.id(), chatId,
replyId);
} else { } else {
logger.warn("The received callback query {} has not the right format (must be seperated by spaces)", logger.warn("The received callback query {} has not the right format (must be seperated by spaces)",
update.callbackQuery().data()); callbackQuery.data());
} }
} }
updateChannel(CHATID, chatId != null ? new StringType(chatId.toString()) : UnDefType.NULL); updateChannel(CHATID, chatId != null ? new StringType(chatId.toString()) : UnDefType.NULL);
@ -376,6 +460,10 @@ public class TelegramHandler extends BaseThingHandler {
updateState(new ChannelUID(getThing().getUID(), channelName), state); updateState(new ChannelUID(getThing().getUID(), channelName), state);
} }
public void triggerEvent(String channelName, String payload) {
triggerChannel(channelName, payload);
}
@Override @Override
public Collection<Class<? extends ThingHandlerService>> getServices() { public Collection<Class<? extends ThingHandlerService>> getServices() {
return Collections.singleton(TelegramActions.class); return Collections.singleton(TelegramActions.class);

View File

@ -105,6 +105,43 @@ public class TelegramActions implements ThingActions {
} }
} }
@RuleAction(label = "send an answer", description = "Send a Telegram answer using the Telegram API.")
public boolean sendTelegramAnswer(@ActionInput(name = "chatId") @Nullable Long chatId,
@ActionInput(name = "callbackId") @Nullable String callbackId,
@ActionInput(name = "messageId") @Nullable Long messageId,
@ActionInput(name = "message") @Nullable String message) {
if (chatId == null) {
logger.warn("chatId not defined; action skipped.");
return false;
}
if (messageId == null) {
logger.warn("messageId not defined; action skipped.");
return false;
}
TelegramHandler localHandler = handler;
if (localHandler != null) {
if (callbackId != null) {
AnswerCallbackQuery answerCallbackQuery = new AnswerCallbackQuery(callbackId);
// we could directly set the text here, but this
// doesn't result in a real message only in a
// little popup or in an alert, so the only purpose
// is to stop the progress bar on client side
logger.debug("Answering query with callbackId '{}'", callbackId);
if (!evaluateResponse(localHandler.execute(answerCallbackQuery))) {
return false;
}
}
EditMessageReplyMarkup editReplyMarkup = new EditMessageReplyMarkup(chatId, messageId.intValue())
.replyMarkup(new InlineKeyboardMarkup(new InlineKeyboardButton[0]));// remove reply markup from
// old message
if (!evaluateResponse(localHandler.execute(editReplyMarkup))) {
return false;
}
return message != null ? sendTelegram(chatId, message) : true;
}
return false;
}
@RuleAction(label = "send an answer", description = "Send a Telegram answer using the Telegram API.") @RuleAction(label = "send an answer", description = "Send a Telegram answer using the Telegram API.")
public boolean sendTelegramAnswer(@ActionInput(name = "chatId") @Nullable Long chatId, public boolean sendTelegramAnswer(@ActionInput(name = "chatId") @Nullable Long chatId,
@ActionInput(name = "replyId") @Nullable String replyId, @ActionInput(name = "replyId") @Nullable String replyId,
@ -121,32 +158,13 @@ public class TelegramActions implements ThingActions {
if (localHandler != null) { if (localHandler != null) {
String callbackId = localHandler.getCallbackId(chatId, replyId); String callbackId = localHandler.getCallbackId(chatId, replyId);
if (callbackId != null) { if (callbackId != null) {
AnswerCallbackQuery answerCallbackQuery = new AnswerCallbackQuery(
localHandler.getCallbackId(chatId, replyId));
logger.debug("AnswerCallbackQuery for chatId {} and replyId {} is the callbackId {}", chatId, replyId, logger.debug("AnswerCallbackQuery for chatId {} and replyId {} is the callbackId {}", chatId, replyId,
localHandler.getCallbackId(chatId, replyId)); callbackId);
// we could directly set the text here, but this
// doesn't result in a real message only in a
// little popup or in an alert, so the only purpose
// is to stop the progress bar on client side
if (!evaluateResponse(localHandler.execute(answerCallbackQuery))) {
return false;
}
} }
Integer messageId = localHandler.removeMessageId(chatId, replyId); Integer messageId = localHandler.removeMessageId(chatId, replyId);
if (messageId == null) {
logger.warn("messageId could not be found for chatId {} and replyId {}", chatId, replyId);
return false;
}
logger.debug("remove messageId {} for chatId {} and replyId {}", messageId, chatId, replyId); logger.debug("remove messageId {} for chatId {} and replyId {}", messageId, chatId, replyId);
EditMessageReplyMarkup editReplyMarkup = new EditMessageReplyMarkup(chatId, messageId.intValue()) return sendTelegramAnswer(chatId, callbackId, messageId != null ? Long.valueOf(messageId) : null, message);
.replyMarkup(new InlineKeyboardMarkup(new InlineKeyboardButton[0]));// remove reply markup from
// old message
if (!evaluateResponse(localHandler.execute(editReplyMarkup))) {
return false;
}
return message != null ? sendTelegram(chatId, message) : true;
} }
return false; return false;
} }
@ -652,6 +670,24 @@ public class TelegramActions implements ThingActions {
} }
} }
public static boolean sendTelegramAnswer(ThingActions actions, @Nullable Long chatId, @Nullable String callbackId,
@Nullable Long messageId, @Nullable String message) {
return ((TelegramActions) actions).sendTelegramAnswer(chatId, callbackId, messageId, message);
}
public static boolean sendTelegramAnswer(ThingActions actions, @Nullable String chatId, @Nullable String callbackId,
@Nullable String messageId, @Nullable String message) {
if (actions instanceof TelegramActions) {
if (chatId == null) {
return false;
}
return ((TelegramActions) actions).sendTelegramAnswer(Long.valueOf(chatId), callbackId,
messageId != null ? Long.parseLong(messageId) : null, message);
} else {
throw new IllegalArgumentException("Actions is not an instance of TelegramActions");
}
}
@Override @Override
public void setThingHandler(@Nullable ThingHandler handler) { public void setThingHandler(@Nullable ThingHandler handler) {
this.handler = (TelegramHandler) handler; this.handler = (TelegramHandler) handler;

View File

@ -16,6 +16,10 @@
<channel id="lastMessageUsername" typeId="lastMessageUsername"/> <channel id="lastMessageUsername" typeId="lastMessageUsername"/>
<channel id="chatId" typeId="chatId"/> <channel id="chatId" typeId="chatId"/>
<channel id="replyId" typeId="replyId"/> <channel id="replyId" typeId="replyId"/>
<channel id="messageEvent" typeId="messageEvent"/>
<channel id="messageRawEvent" typeId="messageRawEvent"/>
<channel id="callbackEvent" typeId="callbackEvent"/>
<channel id="callbackRawEvent" typeId="callbackRawEvent"/>
</channels> </channels>
<config-description> <config-description>
@ -114,4 +118,64 @@
<state readOnly="true"/> <state readOnly="true"/>
</channel-type> </channel-type>
<channel-type id="messageEvent" advanced="true">
<kind>trigger</kind>
<label>Message Received</label>
<description>
<![CDATA[
Message encoded as JSON.<br />
Event payload could contain the following, but `null` values will not be present:
<ul>
<li>Long `message_id` - Unique message ID in this chat</li>
<li>String `from` - First and/or last name of sender</li>
<li>Long `chat_id` - Unique chat ID</li>
<li>String `text` - Message text</li>
<li>String `animation_url` - URL to download animation from</li>
<li>String `audio_url` - URL to download audio from</li>
<li>String `document_url` - URL to download file from</li>
<li>Array `photo_url` - Array of URLs to download photos from</li>
<li>String `sticker_url` - URL to download sticker from</li>
<li>String `video_url` - URL to download video from</li>
<li>String `video_note_url` - URL to download video note from</li>
<li>String `voice_url` - URL to download voice clip from</li>
</ul>
]]>
</description>
<event></event>
</channel-type>
<channel-type id="messageRawEvent" advanced="true">
<kind>trigger</kind>
<label>Raw Message Received</label>
<description>Raw Message from the Telegram library as JSON.</description>
<event></event>
</channel-type>
<channel-type id="callbackEvent" advanced="true">
<kind>trigger</kind>
<label>Query Callback Received</label>
<description>
<![CDATA[
Callback Query response encoded as JSON.<br />
Event payload could contain the following, but `null` values will not be present:
<ul>
<li>Long `message_id` - Unique message ID of the original Query message</li>
<li>String `from` - First and/or last name of sender</li>
<li>Long `chat_id` - Unique chat ID</li>
<li>String `callback_id` - Unique callback ID to send receipt confirmation to</li>
<li>String `reply_id` - Plain text name of original Query</li>
<li>String `text` - Selected response text from options give in original Query</li>
</ul>
]]>
</description>
<event></event>
</channel-type>
<channel-type id="callbackRawEvent" advanced="true">
<kind>trigger</kind>
<label>Raw Callback Query Received</label>
<description>Raw Callback Query response from the Telegram library encoded as JSON.</description>
<event></event>
</channel-type>
</thing:thing-descriptions> </thing:thing-descriptions>