Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bot/connector-google-chat/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-chat</artifactId>
<version>v1-rev20250706-2.0.0</version>
<version>v1-rev20260205-2.0.0</version>
</dependency>

<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,17 @@ import io.vertx.ext.web.RoutingContext
import mu.KotlinLogging
import org.apache.http.HttpStatus

// Bearer Tokens received by bots will always specify this issuer.
private const val CHAT_ISSUER = "chat@system.gserviceaccount.com"

// Url to obtain the public certificate for the issuer.
private const val PUBLIC_CERT_URL_PREFIX = "https://www.googleapis.com/service_accounts/v1/metadata/x509/"

private const val BEARER_PREFIX = "Bearer "

enum class VerificationFailure {
NO_BEARER_AUTHORISATION,
CANNOT_BE_PARSED,
GLOBAL_VERIFICATION_FAILED,
AUDIENCE_VERIFICATION_FAILED,
ISSUER_VERIFICATION_FAILED,
}

class GoogleChatAuthorisationHandler(
private val botProjectNumber: String,
private val audience: String,
) : Handler<RoutingContext> {
private val logger = KotlinLogging.logger {}

Expand All @@ -58,9 +51,8 @@ class GoogleChatAuthorisationHandler(
.Builder(
GooglePublicKeysManager
.Builder(ApacheHttpTransport(), jsonFactory)
.setPublicCertsEncodedUrl(PUBLIC_CERT_URL_PREFIX + CHAT_ISSUER)
.build(),
).setIssuer(CHAT_ISSUER)
)
.build()
}

Expand Down Expand Up @@ -88,8 +80,7 @@ class GoogleChatAuthorisationHandler(

return when {
!verifier.verify(idToken) -> VerificationFailure.GLOBAL_VERIFICATION_FAILED
!idToken.verifyAudience(listOf(botProjectNumber)) -> VerificationFailure.AUDIENCE_VERIFICATION_FAILED
!idToken.verifyIssuer(CHAT_ISSUER) -> VerificationFailure.ISSUER_VERIFICATION_FAILED
!idToken.verifyAudience(listOf(audience)) -> VerificationFailure.AUDIENCE_VERIFICATION_FAILED
else -> null
}
}
Expand Down
39 changes: 27 additions & 12 deletions bot/connector-google-chat/src/main/kotlin/GoogleChatConnector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ import ai.tock.bot.engine.event.Event
import ai.tock.shared.Executor
import ai.tock.shared.injector
import com.github.salomonbrys.kodein.instance
import com.google.api.client.json.jackson2.JacksonFactory
import com.google.api.services.chat.v1.HangoutsChat
import com.google.api.services.chat.v1.model.DeprecatedEvent
import com.google.gson.Gson
import com.google.gson.JsonObject
import mu.KotlinLogging
import java.time.Duration

Expand All @@ -50,6 +50,7 @@ class GoogleChatConnector(
) : ConnectorBase(GoogleChatConnectorProvider.connectorType) {
private val logger = KotlinLogging.logger {}
private val executor: Executor by injector.instance()
private val gson: Gson = Gson()

override fun register(controller: ConnectorController) {
controller.registerServices(path) { router ->
Expand All @@ -59,16 +60,32 @@ class GoogleChatConnector(
.handler { context ->
try {
val body = context.body().asString()
logger.info { "message received from Google chat: $body" }
logger.debug { "message received from Google chat: $body" }

// answer immediately
context.response().end()
context.response()
.putHeader("Content-Type", "application/json; charset=UTF-8")
.setStatusCode(200)
.end("{}")
val messageEvent: JsonObject = gson.fromJson(body, JsonObject::class.java)
val chatEvent: JsonObject = messageEvent.getAsJsonObject("chat")

// https://developers.google.com/workspace/add-ons/concepts/event-objects#chat-payload
if (!chatEvent.has("messagePayload")) {
logger.debug {
"Only messagePayload is handled. Skipped events: " +
"AddedToSpacePayload, " +
"RemovedFromSpacePayload, " +
"ButtonClickedPayload, " +
"WidgetUpdatedPayload, " +
"AppCommandPayload."
}
} else {
val message = chatEvent.getAsJsonObject("messagePayload").getAsJsonObject("message")
val spaceName = message.getAsJsonObject("space").get("name").asString
val threadName = message.getAsJsonObject("thread").get("name").asString

val messageEvent = JacksonFactory().fromString(body, DeprecatedEvent::class.java)
val spaceName = messageEvent.space?.name
val threadName = messageEvent.message?.thread?.name
val event = GoogleChatRequestConverter.toEvent(messageEvent, connectorId)
if (event != null && spaceName != null && threadName != null) {
val event = GoogleChatRequestConverter.toEvent(chatEvent, connectorId)
executor.executeBlocking {
controller.handle(
event,
Expand All @@ -84,11 +101,9 @@ class GoogleChatConnector(
),
)
}
} else {
logger.debug { "skip message: $messageEvent" }
}
} catch (e: Throwable) {
logger.error { e }
logger.error(e) { "Error while handling Google Chat event" }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import kotlin.reflect.KClass
private const val CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"
private const val SERVICE_CREDENTIAL_PATH_PARAMETER = "serviceCredentialPath"
private const val SERVICE_CREDENTIAL_CONTENT_PARAMETER = "serviceCredentialContent"
private const val BOT_PROJECT_NUMBER_PARAMETER = "botProjectNumber"
private const val AUTH_AUDIENCE_PARAMETER = "authenticationAudience"
private const val CONDENSED_FOOTNOTES_PARAMETER = "useCondensedFootnotes"
private const val GSA_TO_IMPERSONATE_PARAMETER = "gsaToImpersonate"
private const val INTRO_MESSAGE_PARAMETER = "introMessage"
Expand Down Expand Up @@ -95,8 +95,8 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {

val authorisationHandler =
GoogleChatAuthorisationHandler(
connectorConfiguration.parameters[BOT_PROJECT_NUMBER_PARAMETER]
?: error("Parameter Bot project number not present"),
connectorConfiguration.parameters[AUTH_AUDIENCE_PARAMETER]
?: error("Parameter Authentication Audience not present"),
)

val introMessage =
Expand Down Expand Up @@ -175,8 +175,8 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {
googleChatConnectorType,
listOf(
ConnectorTypeConfigurationField(
"Bot project number (application ID in google hangouts configuration page)",
BOT_PROJECT_NUMBER_PARAMETER,
"Authentication Audience (Google Chat app connection setting)",
AUTH_AUDIENCE_PARAMETER,
true,
),
ConnectorTypeConfigurationField(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,78 +15,22 @@
*/
package ai.tock.bot.connector.googlechat

import ai.tock.bot.connector.googlechat.builder.GOOGLE_CHAT_ACTION_INTENT_PARAMETER
import ai.tock.bot.connector.googlechat.builder.GOOGLE_CHAT_ACTION_SEND_CHOICE
import ai.tock.bot.connector.googlechat.builder.GOOGLE_CHAT_ACTION_SEND_SENTENCE
import ai.tock.bot.connector.googlechat.builder.GOOGLE_CHAT_ACTION_TEXT_PARAMETER
import ai.tock.bot.engine.action.SendChoice
import ai.tock.bot.engine.action.SendSentence
import ai.tock.bot.engine.event.EndConversationEvent
import ai.tock.bot.engine.event.Event
import ai.tock.bot.engine.event.StartConversationEvent
import ai.tock.bot.engine.user.PlayerId
import ai.tock.bot.engine.user.PlayerType
import com.google.api.services.chat.v1.model.DeprecatedEvent
import com.google.gson.JsonObject

internal object GoogleChatRequestConverter {
fun toEvent(
event: DeprecatedEvent,
chatEvent: JsonObject,
applicationId: String,
): Event? {
val userId = event.user?.name ?: return null
): Event {
val userId = chatEvent.getAsJsonObject("user").get("name").asString
val text = chatEvent.getAsJsonObject("messagePayload").getAsJsonObject("message").get("text").asString

val playerId = PlayerId(userId)
val botId = PlayerId(applicationId, PlayerType.bot)
return when (event.type) {
"ADDED_TO_SPACE" -> {
StartConversationEvent(playerId, botId, applicationId)
}

"REMOVED_FROM_SPACE" -> {
EndConversationEvent(playerId, botId, applicationId)
}

"MESSAGE" -> {
SendSentence(playerId, applicationId, botId, event.message?.text)
}

"CARD_CLICKED" -> {
when (event.action.actionMethodName) {
GOOGLE_CHAT_ACTION_SEND_SENTENCE -> {
SendSentence(
playerId,
applicationId,
botId,
event.action.parameters
.first { it.key == GOOGLE_CHAT_ACTION_TEXT_PARAMETER }
.value,
)
}

GOOGLE_CHAT_ACTION_SEND_CHOICE -> {
SendChoice(
playerId,
applicationId,
botId,
intentName =
event.action.parameters
.first { it.key == GOOGLE_CHAT_ACTION_INTENT_PARAMETER }
.value,
parameters =
event.action.parameters
.map { it.key to it.value }
.toMap(),
)
}

else -> {
null
}
}
}

else -> {
null
}
}
return SendSentence(playerId, applicationId, botId, text)
}
}
Loading