Skip to content
Open
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
6 changes: 5 additions & 1 deletion bot/connector-google-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ Grant the following permissions:
- `chat.bots.get`
- `chat.bots.update`

> 💡 **Recommendation**: Create a specific role with these permissions and assign it to a service account.
If you use **service account impersonation**, the *source* service account must also have:

- `roles/iam.serviceAccountTokenCreator` on the **target** service account

### Retrieve information from Google Cloud Console

| Required Element | Description |
|---------------------|-----------------|
| **Bot project number** | The numeric ID of your project (e.g., `37564789203`) |
| **JSON credentials** | Service account credentials file |
| **(Optional) GSA to impersonate** | Email of the target service account if using impersonation |

### Configure Google Chat API

Expand Down Expand Up @@ -48,6 +51,7 @@ Authentication Audience → Project Number
| **Application base URL** | `https://area-simple-teal.ngrok-free.app` |
| **Bot project number** | `37564789203` |
| **Service account credential json content** | `{"type": "service_account", ...}` |
| **Service account to impersonate** (optional) | `bot-sa@project.iam.gserviceaccount.com` |
| **Use condensed footnotes** | `1` = condensed, `0` = detailed |

---
Expand Down
27 changes: 21 additions & 6 deletions bot/connector-google-chat/src/main/kotlin/GoogleChatConnector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,27 @@ class GoogleChatConnector(
if (message != null) {
callback as GoogleChatConnectorCallback
executor.executeBlocking(Duration.ofMillis(delayInMs)) {
chatService.spaces().messages().create(
callback.spaceName,
message.toGoogleMessage().setThread(Thread().setName(callback.threadName))
)
.setMessageReplyOption("REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD") // Creates the message as a reply to the thread specified by [thread ID] If it fails, the message starts a new thread instead.
.execute()
try {
logger.info {
"Sending to Google Chat: space=${callback.spaceName}, thread=${callback.threadName}, message=${message.toGoogleMessage()}"
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the complete message content with message=${message.toGoogleMessage()} could inadvertently log sensitive user data or PII. Consider logging only metadata (space, thread) or use a sanitized representation of the message.

Suggested change
"Sending to Google Chat: space=${callback.spaceName}, thread=${callback.threadName}, message=${message.toGoogleMessage()}"
"Sending to Google Chat: space=${callback.spaceName}, thread=${callback.threadName}, messageType=${message::class.simpleName}"

Copilot uses AI. Check for mistakes.
}

val response = chatService
.spaces()
.messages()
.create(
callback.spaceName,
message.toGoogleMessage().setThread(Thread().setName(callback.threadName))
)
.setMessageReplyOption("REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
.execute()

logger.info { "Google Chat API response: ${response?.name}" }
} catch (e: Exception) {
logger.error(e) {
"Failed to send message to Google Chat (space=${callback.spaceName}, thread=${callback.threadName})"
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import com.google.api.client.json.jackson2.JacksonFactory
import com.google.api.services.chat.v1.HangoutsChat
import com.google.auth.http.HttpCredentialsAdapter
import com.google.auth.oauth2.GoogleCredentials
import com.google.auth.oauth2.ImpersonatedCredentials
import com.google.auth.oauth2.ServiceAccountCredentials
import mu.KotlinLogging
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlin.reflect.KClass
Expand All @@ -41,23 +43,36 @@ 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 CONDENSED_FOOTNOTES_PARAMETER = "useCondensedFootnotes"
private const val GSA_TO_IMPERSONATE_PARAMETER = "gsaToImpersonate"

internal object GoogleChatConnectorProvider : ConnectorProvider {

private val logger = KotlinLogging.logger {}

override val connectorType: ConnectorType get() = googleChatConnectorType

override fun connector(connectorConfiguration: ConnectorConfiguration): Connector {
with(connectorConfiguration) {

val credentialInputStream =
connectorConfiguration.parameters[SERVICE_CREDENTIAL_PATH_PARAMETER]
?.let { resourceAsStream(it) }
?: connectorConfiguration.parameters[SERVICE_CREDENTIAL_CONTENT_PARAMETER]
?.let { ByteArrayInputStream(it.toByteArray()) }
?: error("Service credential missing : either $SERVICE_CREDENTIAL_PATH_PARAMETER or $SERVICE_CREDENTIAL_CONTENT_PARAMETER must be provided")
val gsaToImpersonate = connectorConfiguration.parameters[GSA_TO_IMPERSONATE_PARAMETER]

val credentials: GoogleCredentials = if (gsaToImpersonate.isNullOrBlank()) {
logger.info { "Using classic authentication mode with JSON credentials" }
val credentialInputStream = getCredentialInputStream(connectorConfiguration)
loadCredentials(credentialInputStream)
} else {
logger.info { "Using impersonation mode with GSA: $gsaToImpersonate" }
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the service account email to impersonate could expose sensitive configuration information. Consider using a more generic log message or redacting part of the email (e.g., showing only the domain). For example: "Using impersonation mode with GSA: ${gsaToImpersonate.substringAfter('@')}" or just "Using impersonation mode".

Suggested change
logger.info { "Using impersonation mode with GSA: $gsaToImpersonate" }
logger.info { "Using impersonation mode with GSA domain: ${gsaToImpersonate.substringAfter('@')}" }

Copilot uses AI. Check for mistakes.
createImpersonatedCredentials(connectorConfiguration, gsaToImpersonate)
}

try {
val token = credentials.refreshAccessToken()
logger.info { "Google credentials OK, access token valid until ${token.expirationTime}" }
} catch (e: Exception) {
logger.error(e) { "Unable to obtain access token for Google Chat API" }
}

val requestInitializer: HttpRequestInitializer =
HttpCredentialsAdapter(loadCredentials(credentialInputStream))
val requestInitializer: HttpRequestInitializer = HttpCredentialsAdapter(credentials)

val useCondensedFootnotes =
connectorConfiguration.parameters[CONDENSED_FOOTNOTES_PARAMETER] == "1"
Expand Down Expand Up @@ -85,6 +100,54 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {
}
}

private fun createImpersonatedCredentials(
connectorConfiguration: ConnectorConfiguration,
targetServiceAccount: String
): GoogleCredentials {

val sourceCredentials = getSourceCredentials(connectorConfiguration)

logger.info { "Source credentials: ${(sourceCredentials as? ServiceAccountCredentials)?.clientEmail}" }
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the source service account email could expose sensitive configuration information. Consider removing this log statement or redacting the email address.

Suggested change
logger.info { "Source credentials: ${(sourceCredentials as? ServiceAccountCredentials)?.clientEmail}" }

Copilot uses AI. Check for mistakes.
logger.info { "Impersonating target GSA = $targetServiceAccount with scopes = $CHAT_SCOPE" }
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the target service account email could expose sensitive configuration information. Consider removing this log statement or redacting the email address.

Suggested change
logger.info { "Impersonating target GSA = $targetServiceAccount with scopes = $CHAT_SCOPE" }
logger.info { "Impersonating target GSA with scopes = $CHAT_SCOPE" }

Copilot uses AI. Check for mistakes.

return ImpersonatedCredentials.create(
sourceCredentials,
targetServiceAccount,
null,
listOf(CHAT_SCOPE),
3600,
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded lifetime value of 3600 seconds (1 hour) should be extracted to a named constant for better maintainability. Consider adding private const val IMPERSONATION_TOKEN_LIFETIME_SECONDS = 3600 at the top of the file alongside other constants.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also use a property to make it configurable

null
)
}

private fun getSourceCredentials(connectorConfiguration: ConnectorConfiguration): GoogleCredentials {
return try {
val credentialInputStream = getCredentialInputStream(connectorConfiguration)
val creds = ServiceAccountCredentials.fromStream(credentialInputStream)
.createScoped("https://www.googleapis.com/auth/cloud-platform")

logger.info { "Loaded explicit service account: ${(creds as ServiceAccountCredentials).clientEmail}" }
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the service account email could expose sensitive configuration information. Consider removing this log statement or redacting the email address.

Suggested change
logger.info { "Loaded explicit service account: ${(creds as ServiceAccountCredentials).clientEmail}" }

Copilot uses AI. Check for mistakes.

creds
} catch (e: Exception) {
logger.info { "No explicit credentials found, using Application Default Credentials" }
GoogleCredentials.getApplicationDefault()
.createScoped("https://www.googleapis.com/auth/cloud-platform")
}
Comment on lines +132 to +136
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block handles all exceptions generically with catch (e: Exception), which might hide configuration errors (e.g., invalid JSON format, missing files). Consider catching only specific exceptions related to missing credentials (e.g., FileNotFoundException, IOException) and let other exceptions propagate with more context. This will help distinguish between "credentials not provided" vs "credentials provided but invalid".

Copilot uses AI. Check for mistakes.
}

private fun getCredentialInputStream(connectorConfiguration: ConnectorConfiguration): InputStream {
return connectorConfiguration.parameters[SERVICE_CREDENTIAL_PATH_PARAMETER]
?.let { resourceAsStream(it) }
?: connectorConfiguration.parameters[SERVICE_CREDENTIAL_CONTENT_PARAMETER]
?.let { ByteArrayInputStream(it.toByteArray()) }
?: error(
"Service credential missing: either " +
"$SERVICE_CREDENTIAL_PATH_PARAMETER or " +
"$SERVICE_CREDENTIAL_CONTENT_PARAMETER must be provided"
)
}

private fun loadCredentials(inputStream: InputStream): GoogleCredentials =
ServiceAccountCredentials
.fromStream(inputStream)
Expand All @@ -99,6 +162,11 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {
BOT_PROJECT_NUMBER_PARAMETER,
true
),
ConnectorTypeConfigurationField(
"Service account email to impersonate (if provided, priority over JSON credentials)",
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description "Service account email to impersonate (if provided, priority over JSON credentials)" is misleading. Impersonation doesn't replace JSON credentials; it uses them (or ADC) as the source for impersonation. Consider revising to: "Service account email to impersonate (optional, uses JSON credentials or ADC as source)" to clarify that both can work together.

Suggested change
"Service account email to impersonate (if provided, priority over JSON credentials)",
"Service account email to impersonate (optional, uses JSON credentials or ADC as source)",

Copilot uses AI. Check for mistakes.
GSA_TO_IMPERSONATE_PARAMETER,
false
),
ConnectorTypeConfigurationField(
"Service account credential file path (default : /service-account-{connectorId}.json)",
SERVICE_CREDENTIAL_PATH_PARAMETER,
Expand All @@ -122,4 +190,4 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {
setOf(GoogleChatConnectorTextMessageOut::class)
}

internal class GoogleChatConnectorProviderService : ConnectorProvider by GoogleChatConnectorProvider
internal class GoogleChatConnectorProviderService : ConnectorProvider by GoogleChatConnectorProvider