Unverified Commit b5fb4fe7 authored by seykron's avatar seykron

Closes #22: adds support to Telegram inline queries

parent 6e4ee45e
#!/bin/bash
docker run -d --net host kaixhin/localtunnel 8945
......@@ -77,7 +77,7 @@ class AccessControl(
fun checkPermissions(state: State): Boolean {
return state.user?.let { user ->
val name = state.handler.name
val chat: Chat = state.chat
val chat: Chat = requireNotNull(state.chat)
val validPermissions = checkPermissions(name, chat, user)
if (!validPermissions) {
......@@ -165,6 +165,10 @@ class AccessControl(
return addUserToChat(chat, resolvedUser)
}
fun addUserIfRequired(user: User): User {
return findUserById(user.id) ?: addUser(user)
}
fun findChat(chatId: Long): Chat? {
return chats.find { chat ->
chat.clientId == chatId
......
package be.rlab.tehanu.annotations
@Target(AnnotationTarget.FUNCTION)
annotation class InlineQueryHandler
......@@ -148,7 +148,7 @@ class SlackClient(
private fun processUpdate(jsonUpdate: String) {
val update: JsonObject = gson.fromJson(jsonUpdate, JsonObject::class.java)
val handlers: Map<String, (JsonObject) -> Update> = mapOf(
val handlers: Map<String, (JsonObject) -> ChatUpdate> = mapOf(
"message" to this::handleMessage,
"block_actions" to this::handleBlockActions,
"member_joined_channel" to this::handleEvent,
......@@ -159,21 +159,21 @@ class SlackClient(
)
handlers[update.get("type").asString]?.let { handler ->
val resolvedUpdate: Update = handler(update)
val resolvedUpdate: ChatUpdate = handler(update)
tehanu.handleUpdate(this, resolvedUpdate)
}
}
private fun handleMessage(jsonUpdate: JsonObject): Update {
private fun handleMessage(jsonUpdate: JsonObject): ChatUpdate {
val message: SlackMessage = gson.fromJson(jsonUpdate, SlackMessage::class.java)
return when(message.subtype) {
null -> Update.new(
null -> ChatUpdate.new(
chat = resolveChat(message.channel),
user = resolveUser(message.user),
message = messageFactory.createMessage(id(message.ts), message)
)
"channel_topic" -> Update.new(
"channel_topic" -> ChatUpdate.new(
chat = resolveChat(message.channel),
user = resolveUser(message.user),
message = messageFactory.createMessage(
......@@ -182,7 +182,7 @@ class SlackClient(
data = gson.fromJson(jsonUpdate, ChannelsSetTopicResponse::class.java)
)
)
"bot_message" -> Update.new(
"bot_message" -> ChatUpdate.new(
chat = resolveChat(message.channel),
user = resolveBotUser(message.botId),
message = messageFactory.createMessage(id(message.ts), message)
......@@ -192,12 +192,12 @@ class SlackClient(
}
}
private fun handleBlockActions(jsonUpdate: JsonObject): Update {
private fun handleBlockActions(jsonUpdate: JsonObject): ChatUpdate {
val jsonAction: JsonObject = jsonUpdate.getAsJsonArray("actions")[0].asJsonObject
val jsonMessage: JsonObject = jsonUpdate.getAsJsonObject("message")
val message: SlackMessage = gson.fromJson(jsonMessage, SlackMessage::class.java)
return Update.new(
return ChatUpdate.new(
chat = resolveChat(jsonUpdate.getAsJsonObject("channel").get("id").asString),
user = resolveUser(jsonUpdate.getAsJsonObject("user").get("id").asString),
message = messageFactory.createMessage(id(message.ts), message),
......@@ -208,7 +208,7 @@ class SlackClient(
)
}
private fun handleEvent(jsonUpdate: JsonObject): Update {
private fun handleEvent(jsonUpdate: JsonObject): ChatUpdate {
val eventTypes: Map<String, Class<out Event>> = mapOf(
"member_joined_channel" to MemberJoinedChannelEvent::class.java,
"member_left_channel" to MemberLeftChannelEvent::class.java,
......@@ -221,27 +221,27 @@ class SlackClient(
?: throw RuntimeException("Unsupported event type: $eventType")
return when(val event: Event = gson.fromJson(jsonUpdate, type)) {
is MemberJoinedChannelEvent -> Update.new(
is MemberJoinedChannelEvent -> ChatUpdate.new(
chat = resolveChat(event.channel),
user = resolveUser(event.user),
message = messageFactory.createMessage(System.currentTimeMillis(), eventType, event)
)
is MemberLeftChannelEvent -> Update.new(
is MemberLeftChannelEvent -> ChatUpdate.new(
chat = resolveChat(event.channel),
user = resolveUser(event.user),
message = messageFactory.createMessage(System.currentTimeMillis(), eventType, event)
)
is ChannelRenameEvent -> Update.new(
is ChannelRenameEvent -> ChatUpdate.new(
chat = resolveChat(event.channel.id),
user = null,
message = messageFactory.createMessage(System.currentTimeMillis(), eventType, event)
)
is ChannelDeletedEvent -> Update.new(
is ChannelDeletedEvent -> ChatUpdate.new(
chat = resolveChat(event.channel),
user = null,
message = messageFactory.createMessage(System.currentTimeMillis(), eventType, event)
)
is ChannelCreatedEvent -> Update.new(
is ChannelCreatedEvent -> ChatUpdate.new(
chat = resolveChat(event.channel.id),
user = resolveUser(event.channel.creator),
message = messageFactory.createMessage(System.currentTimeMillis(), eventType, event)
......
......@@ -3,9 +3,9 @@ package be.rlab.tehanu.clients.telegram
import be.rlab.tehanu.acl.AccessControl
import be.rlab.tehanu.clients.telegram.model.Options
import be.rlab.tehanu.messages.Client
import be.rlab.tehanu.messages.State
import be.rlab.tehanu.messages.Tehanu
import be.rlab.tehanu.messages.model.*
import be.rlab.tehanu.messages.State
import be.rlab.tehanu.view.UserInput
import me.ivmg.telegram.Bot
import me.ivmg.telegram.bot
......@@ -163,6 +163,53 @@ class TelegramClient(
* @param update Telegram update to handle.
*/
private fun handleUpdate(update: TelegramUpdate) {
update.inlineQuery?.let {
handleInlineQueryUpdate(update)
} ?: update.callbackQuery?.let {
} ?: handleChatUpdate(update)
}
/** Handles a [TelegramUpdate] from an [InlineQuery].
* @param update Telegram update to handle.
*/
private fun handleInlineQueryUpdate(update: TelegramUpdate) {
val inlineQuery: InlineQuery = update.inlineQuery!!.let { inlineQuery ->
InlineQuery(
id = inlineQuery.id,
query = inlineQuery.query,
offset = inlineQuery.offset
)
}
val user: User? = resolveUser(update)
val resolvedUpdate = InlineQueryUpdate(
updateId = update.updateId,
user = user,
inlineQuery = inlineQuery
)
var state: State? = null
try {
state = tehanu.handleUpdate(this, resolvedUpdate)
} catch (cause: Exception) {
logger.error("error processing inline query: $inlineQuery")
throw cause
} finally {
state?.inlineQueryResponse?.let { inlineQueryResponse ->
bot.answerInlineQuery(
inlineQueryId = inlineQuery.id,
inlineQueryResults = inlineQueryResponse.results,
nextOffset = inlineQueryResponse.nextOffset,
isPersonal = inlineQueryResponse.personal
)
}
}
}
/** Handles a [TelegramUpdate] from a [Chat].
* @param update Telegram update to handle.
*/
private fun handleChatUpdate(update: TelegramUpdate) {
val message: TelegramMessage = update.message
?: update.callbackQuery?.message
?: throw RuntimeException("Message not found")
......@@ -179,7 +226,7 @@ class TelegramClient(
data = callbackQuery.data
)
}
val resolvedUpdate = Update(
val resolvedUpdate = ChatUpdate(
updateId = update.updateId,
chat = chat,
user = user,
......@@ -216,6 +263,7 @@ class TelegramClient(
val user: TelegramUser =
update.message?.from
?: update.callbackQuery?.from
?: update.inlineQuery?.from
?: throw RuntimeException("User not found")
return User(
......
package be.rlab.tehanu.messages
import be.rlab.tehanu.annotations.Trigger
import be.rlab.tehanu.messages.model.ChatUpdate
import be.rlab.tehanu.messages.model.InlineQueryUpdate
import be.rlab.tehanu.messages.model.Update
abstract class HandlerProvider {
......@@ -13,10 +15,18 @@ abstract class HandlerProvider {
}
fun find(update: Update): MessageHandler? {
return update.message?.let { message ->
handlers.find { handlerDefinition ->
handlerDefinition.applies(update.chat, update.user, message)
return when (update) {
is ChatUpdate -> {
update.message?.let { message ->
handlers.find { handlerDefinition ->
handlerDefinition.applies(update.chat, update.user, message)
}
}
}
is InlineQueryUpdate -> handlers.find { handler ->
handler.inline
}
else -> null
}
}
......
......@@ -38,7 +38,8 @@ data class MessageHandler(
val messageSource: MessageSource,
val minParams: Int = 0,
val maxParams: Int = 0,
val params: List<Param>
val params: List<Param>,
val inline: Boolean
) {
companion object {
......@@ -46,17 +47,23 @@ data class MessageHandler(
}
init {
require(handler.parameters.size in 2..3)
require(handler.parameters[1].type.classifier == MessageContext::class)
if (inline) {
require(handler.parameters.size == 2)
require(handler.parameters[1].type.classifier == InlineQuery::class)
require(handler.returnType.classifier == InlineQueryResponse::class)
} else {
require(handler.parameters.size in 2..3)
require(handler.parameters[1].type.classifier == MessageContext::class)
if (handler.parameters.size > 2) {
val paramType: KClass<*> = handler.parameters[2].type.classifier as KClass<*>
require(Message::class.java.isAssignableFrom(paramType.java)) {
"Handler '$name' has invalid signature: $handler"
if (handler.parameters.size > 2) {
val paramType: KClass<*> = handler.parameters[2].type.classifier as KClass<*>
require(Message::class.java.isAssignableFrom(paramType.java)) {
"Handler '$name' has invalid signature: $handler"
}
}
}
require(handler.returnType.classifier == MessageContext::class)
require(handler.returnType.classifier == MessageContext::class)
}
handler.isAccessible = true
}
......@@ -84,7 +91,7 @@ data class MessageHandler(
else -> true
}
validTriggers && validParams
!inline && validTriggers && validParams
}
}
}
......@@ -100,6 +107,10 @@ data class MessageHandler(
}
}
fun exec(inlineQuery: InlineQuery): InlineQueryResponse {
return handler.call(listener, inlineQuery) as InlineQueryResponse
}
private fun validateParams(text: String): Boolean {
logger.info("evaluating parameters within the message")
......
......@@ -31,7 +31,7 @@ class State(
/** Client this state belongs to. */
val client: Client,
/** Chat this state belongs to. */
val chat: Chat,
val chat: Chat?,
/** User this state belongs to. */
val user: User?,
/** Message handler that represents the state-transition function of this state. */
......@@ -65,7 +65,11 @@ class State(
stateManager = stateManager,
id = id,
client = client,
chat = update.chat,
chat = if (update is ChatUpdate) {
update.chat
} else {
null
},
user = update.user,
handler = handler,
name = handler.name,
......@@ -78,6 +82,8 @@ class State(
/** Active user input. */
internal var input: UserInput = client.createInput(this)
/** Handler results for an [InlineQuery]. */
internal var inlineQueryResponse: InlineQueryResponse? = null
/** Makes a transition to another state.
*
......@@ -128,7 +134,7 @@ class State(
text: String,
options: MessageOptions? = null
): Message =
client.sendMessage(chat.id, text, options)
client.sendMessage(requireNotNull(chat).id, text, options)
/** Edits an existing message.
* @param messageId Id of the message to edit.
......@@ -140,7 +146,7 @@ class State(
options: MessageOptions? = null
): Message =
client.editMessage(
chatId = chat.id,
chatId = requireNotNull(chat).id,
messageId = messageId,
options = options
)
......@@ -154,9 +160,14 @@ class State(
* @return true if this state belongs to the chat, user and callback query, false otherwise.
*/
fun applies(update: Update): Boolean {
return update.action?.let { action ->
input.applies(action)
} ?: (update.chat == chat && update.user == user && waitingInput())
return when(update) {
is ChatUpdate -> {
update.action?.let { action ->
input.applies(action)
} ?: (update.chat == chat && update.user == user && waitingInput())
}
else -> false
}
}
/** If the state is waiting for user input, it reports a new update to
......@@ -168,11 +179,11 @@ class State(
* @param update Update to process as user input.
* @return returns the last transition state.
*/
fun reportInput(update: Update): State {
fun reportInput(update: ChatUpdate): State {
val message: Message = requireNotNull(update.message)
val context: MessageContext = MessageContext.new(
this,
chat,
requireNotNull(chat),
user,
handler.messageSource,
params = parseParams(message)
......@@ -195,6 +206,19 @@ class State(
return exec(this, message)
}
/** Executes the transition to this state.
*
* The transition to the state invokes the underlying [MessageHandler].
*
* @param inlineQuery Inline query required to execute the handler.
* @return returns this state.
*/
fun exec(inlineQuery: InlineQuery): State {
logger.info("Executing handler ${handler.name} for inline query $inlineQuery")
inlineQueryResponse = handler.exec(inlineQuery)
return this
}
/** Executes the transition to the specified state.
*
* It will follow transitions recursively until a handler result does not require a transition.
......@@ -209,7 +233,7 @@ class State(
): State {
val context: MessageContext = MessageContext.new(
this,
chat,
requireNotNull(chat),
user,
handler.messageSource,
params = parseParams(message)
......
......@@ -48,10 +48,35 @@ class Tehanu(
*
* @param client Client the update comes from.
* @param update Update to handle.
* @return the last state.
*/
fun handleUpdate(
client: Client,
update: Update
): State? {
return when (update) {
is ChatUpdate -> handleChatUpdate(client, update)
is InlineQueryUpdate -> handleInlineQueryUpdate(client, update)
else -> null
}
}
private fun handleInlineQueryUpdate(
client: Client,
update: InlineQueryUpdate
): State? {
val user: User? = update.user?.let {
accessControl.addUserIfRequired(update.user)
}
return stateManager.resolve(client, update.copy(user = user))?.let { state ->
state.exec(update.inlineQuery)
}
}
private fun handleChatUpdate(
client: Client,
update: ChatUpdate
): State? {
val chat: Chat = accessControl.addChatIfRequired(update.chat)
val user: User? = update.user?.let {
......@@ -70,7 +95,7 @@ class Tehanu(
}
private fun processState(
update: Update,
update: ChatUpdate,
state: State
): State? {
return when {
......@@ -94,7 +119,7 @@ class Tehanu(
}
private fun checkScope(state: State): Boolean {
val chat: Chat = state.chat
val chat: Chat = requireNotNull(state.chat)
val validScope = state.handler.scope.isEmpty() || state.handler.scope.contains(chat.type)
logger.info("${state.name} command scope: ${state.name}")
......
package be.rlab.tehanu.messages.model
import be.rlab.tehanu.messages.Client
/** Represents an update in a chat.
*
* The update is the root entity for any kind of activity notified by a [Client] in a chat.
* The [Client] is responsible of building an update each time it receives an event.
*
* This update currently supports the following features depending on the client:
*
* - All supported [Message]s.
* - Message edit events.
* - User interaction with a message ([Action]s)
* - User interactions with the bot via [InlineQuery]s.
*
* Though this update is used by all clients, the semantics are similar to Telegram API since
* it was the first implementation.
*/
data class ChatUpdate(
/** Unique id for this update. */
override val updateId: Long,
/** User that triggered the update. It is optional since some kind of chats like
* Telegram channels do not provide user information.
*/
override val user: User?,
/** Chat this update belongs to. */
val chat: Chat,
/** Message related to the update, if any. */
val message: Message?,
/** Edited message related to the update. Available only on edition. */
val editedMessage: Message?,
/** Information about user input on the message. */
val action: Action?
) : Update {
companion object {
fun new(
chat: Chat,
user: User?,
message: Message?,
editedMessage: Message? = null,
action: Action? = null
): ChatUpdate = ChatUpdate(
updateId = System.currentTimeMillis(),
chat = chat,
user = user,
message = message,
editedMessage = editedMessage,
action = action
)
}
}
package be.rlab.tehanu.messages.model
import be.rlab.tehanu.messages.Client
/** An inline query represents a user input that produces a dynamic result.
* It is like a typeahead control that let users ask for contextual information.
*/
data class InlineQuery(
/** Unique id for this query assigned by a [Client]. */
val id: String,
/** Query introduced by the user. */
val query: String,
/** Offset set by a previous inline query. */
val offset: String
)
package be.rlab.tehanu.messages.model
import me.ivmg.telegram.entities.inlinequeryresults.InlineQueryResult
data class InlineQueryResponse(
val nextOffset: String,
val results: List<InlineQueryResult>,
val personal: Boolean
)
package be.rlab.tehanu.messages.model
/** Represents an update that requires contextual data.
*/
data class InlineQueryUpdate(
/** Unique id for this update. */
override val updateId: Long,
/** User that triggered the update. It will never null in this type of updates. */
override val user: User?,
/** Information about the current inline query, if any. */
val inlineQuery: InlineQuery
) : Update {
companion object {
fun new(
user: User?,
inlineQuery: InlineQuery
): InlineQueryUpdate = InlineQueryUpdate(
updateId = System.currentTimeMillis(),
user = user,
inlineQuery = inlineQuery
)
}
}
package be.rlab.tehanu.messages.model
import be.rlab.tehanu.messages.Client
/** Represents an update from a [Client].
*
* The update is the root entity for any kind of activity notified by a [Client].
* The [Client] is responsible of building an update each time it receives an event.
*
* This update currently supports the following features depending on the client:
/** Base interface to represent [be.rlab.tehanu.messages.Client]s updates.
*
* - All supported [Message]s.
* - Message edit events.
* - User interaction with a message ([Action]s)
*
* Though this update is used by all clients, the semantics are similar to Telegram API since
* it was the first implementation.
* The different kind of updates have different flows and user interactions.
*/
data class Update(
interface Update {
/** Unique id for this update. */
val updateId: Long,
/** Chat this update belongs to. */
val chat: Chat,
val updateId: Long
/** User that triggered the update. It is optional since some kind of chats like
* Telegram channels do not provide user information.
*/
val user: User?,
/** Message related to the update, if any. */
val message: Message?,
/** Edited message related to the update. Available only on edition. */
val editedMessage: Message?,
/** Information about user input on the message. */
val action: Action?
) {
companion object {