This document provides a comprehensive guide to building, deploying, and using a one-way integration that synchronizes conversations from Google Chat into your DevRev organization. The integration is built as a
DevRev Airdrop Snap-in, a specialized type of integration designed for data migration and synchronization.
The primary purpose of this solution is to capture unstructured conversations happening in Google Chat and transform them into structured, trackable work items within DevRev. This ensures that important discussions regarding customer issues, bugs, or product feedback are not lost and can be formally managed.
This integration provides a one-way synchronization path from Google Chat to DevRev. Its capabilities include:
-
One-Time Import: Perform an initial bulk import of message history from a specified Google Chat Space into DevRev.
-
Ongoing Synchronization: Automatically run on a periodic schedule to fetch new messages and threads from the specified Google Chat Space and create corresponding items in DevRev.
-
Logical Grouping: Intelligently groups messages into threads. The first message of a thread is used to create a new DevRev Ticket, and all subsequent replies in that same thread are added as Comments to that ticket.
-
DevRev Airdrop: A DevRev platform feature designed to migrate data from an external system into DevRev or keep the two systems synchronized.
-
Airdrop Snap-in: A third-party application developed to connect DevRev’s Airdrop feature to an external system for which a native integration does not exist. This is what we are building.
-
Sync Unit: A self-contained unit of data from the external system that can be synced. For this integration, a
Google Chat Space is treated as a Sync Unit.
The integration follows a simple, polling-based architecture:
Google Chat API -> Airdrop Snap-in -> DevRev Platform
- The Airdrop Snap-in, running on a schedule, makes secure API calls to the Google Chat API using OAuth 2.0.
- It fetches messages from a designated Google Chat Space.
- The Snap-in normalizes this data and passes it to the DevRev Airdrop Platform.
- The platform applies mapping rules and creates or updates Tickets and Comments in DevRev.
This integration is designed specifically for data synchronization. It is important to understand its limitations:
- No Real-time Interactivity: It does not support real-time slash commands (e.g.,
/devrev create ticket) in Google Chat. - No UI Cards or Buttons: It cannot post interactive messages with buttons or menus.
- Polling-Based: Notifications and updates are not instantaneous; they occur only when the next scheduled sync run is executed.
- A DevRev organization with Admin privileges.
- A Google Cloud Platform (GCP) project with billing enabled.
Node.js(version 18.x or higher).Homebrew(for macOS users).- The DevRev CLI.
- Navigate to the Google Cloud Console.
- Create a new project or select an existing one.
- In your GCP project, go to "APIs & Services" -> "Library".
- Search for "Google Chat API" and click Enable.
- Go to "APIs & Services" -> "OAuth consent screen".
- Choose External for the User Type and fill in the required application details.
- On the "Scopes" page, click "Add or Remove Scopes" and add the following two scopes:
https://www.googleapis.com/auth/chat.messages.readonlyhttps://www.googleapis.com/auth/chat.spaces.readonly
- Add your email address as a Test User.
- Go to "APIs & Services" -> "Credentials".
- Click "Create Credentials" -> "OAuth client ID".
- Select Web application as the application type.
- Under "Authorized redirect URIs", add
https://app.devrev.ai/auth/callback. - Click "Create". Copy the Client ID and Client Secret. You will need these later.
As discovered during our troubleshooting, the DevRev CLI must be installed using a direct formula download, not via a standard Homebrew tap.
Run the following command in your terminal to download the latest official Homebrew formula from DevRev's GitHub releases.
Bash
wget https://github.com/devrev/cli/releases/latest/download/devrev.rb
Install the CLI using the local formula file you just downloaded.
Bash
brew install ./devrev.rb
After installation, verify the version. The latest public version is v0.4.11.
Bash
devrev --version
Note: The Airdrop documentation's prerequisite of
v4.7or higher is incorrect. The provided template must be modified to work with the latest public CLI.
Clone or download the starter Airdrop snap-in template from the official DevRev repository. This will be the base for our project.
This section provides the complete source code to replace the placeholder files in the Airdrop template.
YAML
version: "2"
name: google-chat-airdrop-connector description: Google Chat Connector for importing messages and threads into DevRev.
service_account: display_name: Google Chat Bot
functions:
- name: extraction description: Extraction function for the Google Chat snap-in
developer_keyrings:
- name: google-chat-oauth-secret description: DevRev developer keyring to store OAuth2 credentials for Google Chat. display_name: Google Chat OAuth Secret
keyrings: organization: - name: google_chat_connection display_name: Google Chat Connection description: The Google Chat connection for the organization. types: - google-chat-connection
keyring_types:
- id: google-chat-connection
name: "Google Chat Connection"
description: "Connect to Google Chat using OAuth2"
kind: "oauth2"
scopes: # Scopes that the connection can request, add more scopes if needed for your use case. Each scope should have a name, description and value.
- name: chat.messages.readonly description: Read access to chat messages value: "https://www.googleapis.com/auth/chat.messages.readonly"
- name: chat.spaces.readonly description: Read access to chat spaces value: "https://www.googleapis.com/auth/chat.spaces.readonly" is_subdomain: false external_system_name: Google Chat oauth_secret: google-chat-oauth-secret scope_delimiter: " " authorize: # The authorize section is used to get the authorization code from the user and exchange it for an access token. type: "config" auth_url: "https://accounts.google.com/o/oauth2/v2/auth" token_url: "https://oauth2.googleapis.com/token" grant_type: "authorization_code" auth_query_parameters: "client_id": "[CLIENT_ID]" "scope": "[SCOPES]" "response_type": "code" token_query_parameters: "client_id": "[CLIENT_ID]" "client_secret": "[CLIENT_SECRET]" refresh: # The refresh section is used to refresh the access token using the refresh token. type: "config" url: "https://oauth2.googleapis.com/token" method: "POST" query_parameters: "client_id": "[CLIENT_ID]" "client_secret": "[CLIENT_SECRET]" "refresh_token": "[REFRESH_TOKEN]" headers: "Content-type": "application/x-www-form-urlencoded" revoke: # The revoke section is used to revoke the access token. type: "config" url: "https://oauth2.googleapis.com/revoke" method: "POST" headers: "Content-type": "application/x-www-form-urlencoded" query_parameters: "client_id": "[CLIENT_ID]" "client_secret": "[CLIENT_SECRET]" "token": "[ACCESS_TOKEN]"
imports:
- slug: google-chat-import
display_name: Google Chat Import
description: Import threads and messages from Google Chat into DevRev
extractor_function: extraction
allowed_connection_types:
- google-chat-connection
3.2. Defining the External Data Schema (src/functions/external-system/external_domain_metadata.json)
JSON
{ "schema_version": "v0.2.0", "record_types": { "gchat_space": { "name": "Google Chat Space" }, "gchat_thread": { "name": "Google Chat Thread", "fields": { "title": { "name": "Title", "is_required": true, "type": "text" }, "body": { "name": "Body", "is_required": true, "type": "rich_text" }, "space_id": { "name": "Space ID", "is_required": true, "type": "text" } } }, "gchat_message": { "name": "Google Chat Message", "fields": { "text": { "name": "Text", "is_required": true, "type": "rich_text" }, "creator": { "name": "Creator", "is_required": true, "type": "reference", "reference": { "refers_to": { "#record:gchat_user": {} }}}, "parent_id": { "name": "Parent Thread", "is_required": true, "type": "reference", "reference": { "refers_to": { "#record:gchat_thread": {} }}} } }, "gchat_user": { "name": "Google Chat User", "fields": { "email": { "name": "Email", "is_required": true, "type": "text" }, "displayName": { "name": "Display Name", "is_required": true, "type": "text" } } } } }
JSON
{ "additional_mappings": { "format_version": "v0.2.0", "record_type_mappings": { "gchat_thread": { "default_mapping": { "object_category": "stock", "object_type": "ticket" }, "possible_record_type_mappings": [{ "devrev_leaf_type": "ticket", "forward": true, "reverse": false, "shard": { "devrev_leaf_type": { "object_category": "stock", "object_type": "ticket" }, "mode": "create_shard", "stock_field_mappings": { "title": { "forward": true, "primary_external_field": "title", "transformation_method_for_set": { "transformation_method": "use_directly" }}, "body": { "forward": true, "primary_external_field": "body", "transformation_method_for_set": { "transformation_method": "use_directly" }}, "applies_to_part_id": { "forward": true, "transformation_method_for_set": { "leaf_type": { "object_category": "stock", "object_type": "product" }, "transformation_method": "use_devrev_record" }}, "owned_by_ids": { "forward": true, "transformation_method_for_set": { "leaf_type": { "object_category": "stock", "object_type": "devu" }, "transformation_method": "use_devrev_record" }}, "stage": { "forward": true, "transformation_method_for_set": { "enum": "queued", "transformation_method": "use_fixed_value", "value": "enum_value" }}, "severity": { "forward": true, "transformation_method_for_set": { "enum": "medium", "transformation_method": "use_fixed_value", "value": "enum_value" }} } } }] }, "gchat_message": { "default_mapping": { "object_category": "stock", "object_type": "comment" }, "possible_record_type_mappings": [{ "devrev_leaf_type": "comment", "forward": true, "reverse": false, "shard": { "devrev_leaf_type": { "object_category": "stock", "object_type": "comment" }, "mode": "create_shard", "stock_field_mappings": { "body": { "forward": true, "primary_external_field": "text", "transformation_method_for_set": { "transformation_method": "use_directly" }}, "parent_object_id": { "forward": true, "primary_external_field": "parent_id", "transformation_method_for_set": { "transformation_method": "use_directly" }}, "created_by_id": { "forward": true, "primary_external_field": "creator", "transformation_method_for_set": { "transformation_method": "use_directly" }} } } }] }, "gchat_user": { "default_mapping": { "object_category": "stock", "object_type": "devu" }, "possible_record_type_mappings": [{ "devrev_leaf_type": "devu", "forward": true, "reverse": false, "shard": { "devrev_leaf_type": { "object_category": "stock", "object_type": "devu" }, "mode": "create_shard", "stock_field_mappings": { "display_name": { "forward": true, "primary_external_field": "displayName", "transformation_method_for_set": { "transformation_method": "use_directly" }}, "email": { "forward": true, "primary_external_field": "email", "transformation_method_for_set": { "transformation_method": "use_directly" }} } } }] } } } }
TypeScript
`import { AirdropEvent } from '@devrev/ts-adaas'; import axios from 'axios';
export class HttpClient { private apiEndpoint: string = 'https://chat.googleapis.com/v1'; private apiToken: string;
constructor(event: AirdropEvent) { this.apiToken = event.payload.connection_data.key; }
private getAuthHeaders() {
return {
Authorization: Bearer ${this.apiToken},
'Content-Type': 'application/json',
};
}
async listSpaces(): Promise<any[]> {
const response = await axios.get(${this.apiEndpoint}/spaces, {
headers: this.getAuthHeaders(),
});
return response.data.spaces || [];
}
async listMessages(spaceName: string): Promise<any[]> { let allMessages: any[] = []; let pageToken: string | undefined = undefined;
do {
const url = `${this.apiEndpoint}/${spaceName}/messages?pageSize=1000${pageToken ? `&pageToken=${pageToken}` : ''}`;
const response = await axios.get(url, { headers: this.getAuthHeaders() });
if (response.data.messages) {
allMessages = allMessages.concat(response.data.messages);
}
pageToken = response.data.nextPageToken;
} while (pageToken);
return allMessages;
} }`
TypeScript
`import { NormalizedItem, ExternalSyncUnit } from '@devrev/ts-adaas';
export function normalizeSpace(space: any): ExternalSyncUnit { return { id: space.name, name: space.displayName, description: space.spaceDetails?.description || 'A Google Chat Space', item_count: -1, }; }
export function normalizeUser(user: any): NormalizedItem { const now = new Date().toISOString(); return { id: user.name, created_date: now, modified_date: now, data: { email: user.email, displayName: user.displayName, }, }; }
export function normalizeMessage(message: any, threadId: string): NormalizedItem { return { id: message.name, created_date: message.createTime, modified_date: message.lastUpdateTime || message.createTime, data: { text: [message.text], creator: message.sender.name, parent_id: threadId, }, }; }
export function normalizeThread(firstMessage: any): NormalizedItem { return { id: firstMessage.thread.name, created_date: firstMessage.createTime, modified_date: firstMessage.lastUpdateTime || firstMessage.createTime, data: { title: firstMessage.text.substring(0, 100), body: [firstMessage.text], space_id: firstMessage.space.name, }, }; }`
src/functions/extraction/workers/external-sync-units-extraction.ts
TypeScript
`import { ExternalSyncUnit, ExtractorEventType, processTask } from '@devrev/ts-adaas'; import { normalizeSpace } from '../../external-system/data-normalization'; import { HttpClient } from '../../external-system/http-client';
processTask({ task: async ({ adapter }) => { const httpClient = new HttpClient(adapter.event); const spaces = await httpClient.listSpaces(); const externalSyncUnits: ExternalSyncUnit[] = spaces.map((space) => normalizeSpace(space)); await adapter.emit(ExtractorEventType.ExtractionExternalSyncUnitsDone, { external_sync_units: externalSyncUnits, }); }, onTimeout: async ({ adapter }) => { await adapter.emit(ExtractorEventType.ExtractionExternalSyncUnitsError, { error: { message: 'Failed to extract Google Chat spaces. Lambda timeout.' }, }); }, });`
src/functions/extraction/workers/data-extraction.ts
TypeScript
`import { ExtractorEventType, processTask } from '@devrev/ts-adaas'; import { normalizeMessage, normalizeThread, normalizeUser } from '../../external-system/data-normalization'; import { HttpClient } from '../../external-system/http-client'; import { ExtractorState } from '../index';
const repos = [ { itemType: 'gchat_thread' }, { itemType: 'gchat_message' }, { itemType: 'gchat_user' }, ];
processTask({ task: async ({ adapter }) => { adapter.initializeRepos(repos); const httpClient = new HttpClient(adapter.event);
const spaceName = adapter.event.payload.event_context.sync_unit;
const allMessages = await httpClient.listMessages(spaceName);
const threadsMap = new Map<string, any[]>();
const usersMap = new Map<string, any>();
for (const message of allMessages) {
if (!message.text || !message.thread?.name) continue;
const threadId = message.thread.name;
if (!threadsMap.has(threadId)) {
threadsMap.set(threadId, []);
}
threadsMap.get(threadId)?.push(message);
if (!usersMap.has(message.sender.name)) {
usersMap.set(message.sender.name, message.sender);
}
}
for (const [threadId, messages] of threadsMap.entries()) {
messages.sort((a, b) => new Date(a.createTime).getTime() - new Date(b.createTime).getTime());
const firstMessage = messages[0];
const threadItem = normalizeThread(firstMessage);
await adapter.getRepo('gchat_thread')?.push([threadItem]);
const messageItems = messages.map((msg) => normalizeMessage(msg, threadId));
await adapter.getRepo('gchat_message')?.push(messageItems);
}
const userItems = Array.from(usersMap.values()).map((user) => normalizeUser(user));
if (userItems.length > 0) {
await adapter.getRepo('gchat_user')?.push(userItems);
}
await adapter.emit(ExtractorEventType.ExtractionDataDone);
}, onTimeout: async ({ adapter }) => { await adapter.emit(ExtractorEventType.ExtractionDataProgress); }, });`
Before deploying, create a test fixture file in src/fixtures/ (e.g., gchat-fixture.json) with a sample payload and run it using the test-runner.ts script to validate your logic locally.
- Authenticate your DevRev CLI.
- From the project's root directory, run
make deployordevrev snap_in_version create-one --manifest ./manifest.yaml --create-package.
- In DevRev, navigate to Settings > Integrations > Airdrops.
- Go to the Connections tab and click Create Connection.
- Select the "Google Chat Connection" type.
- You will be redirected to Google to authenticate and authorize the connection. Sign in and grant the requested permissions.
-
Navigate back to the
Airdrops tab and click Start Airdrop.
-
Select your "Google Chat Import" from the list.
-
Choose the connection you just created.
-
The snap-in will fetch and display a list of your Google Chat Spaces. Select the one you want to import and click Continue.
-
You will be taken to the mapping screen. Since we provided a comprehensive
initial_domain_mapping.json, the defaults should be correct. Review and proceed. -
The import will start.
Once the import is complete, navigate to your work items list. You will find new Tickets created from the threads in your selected Google Chat Space. Clicking on a ticket will reveal the subsequent messages as comments.
After the initial import, you can configure the Airdrop to run on a periodic schedule (e.g., every hour) to automatically pull in new messages and keep DevRev in sync.
- Cause: Your DevRev CLI version is outdated and does not support the modern
keyring_typesfeature in the manifest. - Solution: This documentation and code are incompatible with the latest public CLI (
v0.4.11). You must contact DevRev support to get a compatible combination of the CLI tool and the snap-in template.
- Cause: Running
brew upgrade devrevfails because DevRev does not use a standard Homebrew tap for updates. - Solution: Follow the official installation guide in Section 2.3 of this document, which involves using
wgetto download the formula directly.
- Cause: Attempting to use a standard Homebrew tap name like
devrev/tapwhich does not exist. - Solution: Use the direct download method specified in Section 2.3.
- Cause: The OAuth scopes configured in your GCP project do not match what the API call requires, or they were not approved by the user during the connection process.
- Solution: Verify that the
https://www.googleapis.com/auth/chat.messages.readonlyandhttps://www.googleapis.com/auth/chat.spaces.readonlyscopes are enabled on your GCP OAuth Consent Screen.
To debug runtime issues, you can retrieve logs for your snap-in using the DevRev CLI.
Bash
devrev snap_in_package logs | jq
All required source code is listed in Section 3.0 of this document.
- Scopes Used:
https://www.googleapis.com/auth/chat.messages.readonlyhttps://www.googleapis.com/auth/chat.spaces.readonly
- API Endpoints Used:
GET https://chat.googleapis.com/v1/spacesGET https://chat.googleapis.com/v1/spaces/{spaceName}/messages
- Authentication: OAuth 2.0 Bearer Token.