diff --git a/assets/slack/icon.png b/assets/slack/icon.png index 4d2f412b..19f52129 100644 Binary files a/assets/slack/icon.png and b/assets/slack/icon.png differ diff --git a/package.json b/package.json index d58e2fa2..7b3b3214 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "@notionhq/client": "^2.2.15", "@oauth-everything/passport-discord": "^1.0.2", "@sapphire/snowflake": "^3.4.2", + "@slack/oauth": "^3.0.1", + "@slack/web-api": "^7.4.0", "@superfaceai/passport-twitter-oauth2": "^1.2.3", "@tensorflow-models/universal-sentence-encoder": "^1.3.3", "@verida/account-node": "^4.4.1", @@ -54,7 +56,7 @@ "@verida/verifiable-credentials": "^4.4.0", "@verida/web3": "^4.4.0", "aws-serverless-express": "^3.4.0", - "axios": "^1.7.2", + "axios": "^1.7.7", "body-parser": "^1.19.0", "closevector-node": "^0.1.6", "cors": "^2.8.5", diff --git a/src/providers/BaseSyncHandler.ts b/src/providers/BaseSyncHandler.ts index 7b22bb19..69c93ff6 100644 --- a/src/providers/BaseSyncHandler.ts +++ b/src/providers/BaseSyncHandler.ts @@ -397,6 +397,6 @@ export default class BaseSyncHandler extends EventEmitter { } protected buildItemId(itemId: string) { - return `${this.provider.getProviderName()}-${this.connection.profile.id}-${itemId}` + return `${this.provider.getProviderId()}-${this.connection.profile.id}-${itemId}` } } \ No newline at end of file diff --git a/src/providers/google/README.md b/src/providers/google/README.md index 899b3526..a92e9917 100644 --- a/src/providers/google/README.md +++ b/src/providers/google/README.md @@ -26,9 +26,9 @@ Before running the unit tests, ensure that you have the following set up: 1. **YouTube Account**: A YouTube account with some activity is required. This activity includes uploaded videos, subscriptions, and liked videos. 2. **YouTube Data**: Make sure your YouTube account has: - - **Favorites**: At least 14 videos you have liked. - - **Following**: At least 14 Channels you have subscribed to. - - **Posts**: At least 14 videos you have uploaded. + - **Favorites**: At least 4 videos you have liked. + - **Following**: At least 4 Channels you have subscribed to. + - **Posts**: At least 4 videos you have uploaded. 3. **Activity**: Make sure you have made activities within the last 24 hours. ## Running the Tests diff --git a/src/providers/slack/NOTES.md b/src/providers/slack/NOTES.md new file mode 100644 index 00000000..4b87e736 --- /dev/null +++ b/src/providers/slack/NOTES.md @@ -0,0 +1,12 @@ + +# Setup + +Slack only permits users to extract their data by installing the Verida Data Connector application into the Slack workspace. You will require an administrator to approve this application installation before you can start to sync your data. + +# Sync + +The following data is syncronized: + +- DMs +- Public channels you can access +- Private channels you can access \ No newline at end of file diff --git a/src/providers/slack/README.md b/src/providers/slack/README.md new file mode 100644 index 00000000..142be6c0 --- /dev/null +++ b/src/providers/slack/README.md @@ -0,0 +1,38 @@ +# Slack APP configuration + +1. Please go to [Slack API Apps](https://api.slack.com/apps) +2. Create new app + - Select `From scratch` + - Add `App Name` and select a workspace to be used for development +3. You can get `client ID` and `client Secret` from the `Basic Information` section +4. Add redirect URL and scopes in `OAuth & Permissions` section + - Redirect URL: `https://127.0.0.1:5021/callback/slack` + - There are two types of tokens: bot and user, and add following scopes: `channels:history`, `channels:read`, `groups:read`, `users:read`, `im:read`, `im:history` + +# Notes +Slack workspaces might have many public channels and spam messages that are less relevant to users' privacy, so **DM/Private** groups have priority. + +Slack `ItemsRangeTracker` is based on timestamps, which are used as the IDs of chat message records due to the nature of its API. + +Slack supports two types of pagination: timeline-based and cursor-based + +### Timeline Based +``` +const response = await apiClient.conversations.history({ + channel, + limit, + oldest, // from timestamp + latest // end timestamp +}); +``` + +### Cursor Based +``` +const response = await apiClient.conversations.history({ + channel, + limit, + cursor, // next_cursor from response_metadata +}); +``` + +We use Cusor-based pagination here which is equal to 'pageToken` from Google to provide consistency. \ No newline at end of file diff --git a/src/providers/slack/chat-message.ts b/src/providers/slack/chat-message.ts new file mode 100644 index 00000000..eeae0054 --- /dev/null +++ b/src/providers/slack/chat-message.ts @@ -0,0 +1,327 @@ +import { + ConversationsHistoryArguments, + ConversationsHistoryResponse, + WebClient, +} from "@slack/web-api"; +import CONFIG from "../../config"; +import { + SyncResponse, + SyncHandlerStatus, + ProviderHandlerOption, + ConnectionOptionType, + SyncHandlerPosition, + SyncItemsResult, + SyncItemsBreak, + SyncProviderLogLevel, +} from "../../interfaces"; +import { + SchemaChatMessageType, + SchemaSocialChatGroup, + SchemaSocialChatMessage, +} from "../../schemas"; +import { SlackChatGroupType, SlackHandlerConfig } from "./interfaces"; +import BaseSyncHandler from "../BaseSyncHandler"; +import { ItemsRangeTracker } from "../../helpers/itemsRangeTracker"; +import { SlackHelpers } from "./helpers"; + +import { MessageElement } from "@slack/web-api/dist/types/response/ConversationsHistoryResponse"; + +const _ = require("lodash"); + +export interface SyncChatItemsResult extends SyncItemsResult { + items: SchemaSocialChatMessage[]; +} + +export default class SlackChatMessageHandler extends BaseSyncHandler { + protected config: SlackHandlerConfig; + + public getName(): string { + return "chat-message"; + } + + public getLabel(): string { + return "Chat Messages"; + } + + public getSchemaUri(): string { + return CONFIG.verida.schemas.CHAT_MESSAGE; + } + + public getProviderApplicationUrl(): string { + return "https://slack.com/"; + } + + public getOptions(): ProviderHandlerOption[] { + return [ + { + id: "channelTypes", + label: "Channel types", + type: ConnectionOptionType.ENUM_MULTI, + enumOptions: [ + { label: "Public Channel", value: SlackChatGroupType.PUBLIC_CHANNEL }, + { label: "Private Channel", value: SlackChatGroupType.PRIVATE_CHANNEL }, + { label: "Direct Messages", value: SlackChatGroupType.IM }, + ], + defaultValue: [ + SlackChatGroupType.IM, // DM first + SlackChatGroupType.PRIVATE_CHANNEL, + SlackChatGroupType.PUBLIC_CHANNEL, + ].join(","), + }, + ]; + } + + public getSlackClient(): WebClient { + const token = this.connection.accessToken; + return new WebClient(token); + } + + protected async buildChatGroupList(): Promise { + const client = this.getSlackClient(); + let channelList: SchemaSocialChatGroup[] = []; + + const conversations = await client.conversations.list({ types: this.config["channelTypes"].toString(), limit: this.config.maxBatchSize }); + + for (const channel of conversations.channels || []) { + if (channel?.is_archived) continue; + const group: SchemaSocialChatGroup = this.buildChatGroup(channel); + channelList.push(group); + } + + return channelList; + } + + private buildChatGroup(channel: any): SchemaSocialChatGroup { + + return { + _id: this.buildItemId(channel.id), + name: channel.name || channel.user, + sourceAccountId: this.provider.getAccountId(), + sourceApplication: this.getProviderApplicationUrl(), + sourceId: channel.id, + schema: CONFIG.verida.schemas.CHAT_GROUP, + sourceData: channel, + insertedAt: new Date(channel.updated).toISOString(), + }; + } + + public async _sync( + api: any, + syncPosition: SyncHandlerPosition + ): Promise { + try { + const apiClient = this.getSlackClient(); + const groupList = await this.buildChatGroupList(); + const groupDs = await this.provider.getDatastore( + CONFIG.verida.schemas.CHAT_GROUP + ); + const groupDbItems = await groupDs.getMany({ + sourceAccountId: this.provider.getAccountId(), + }, { + limit: this.config.maxBatchSize + }); + + const mergedGroupList = this.mergeGroupLists(groupList, groupDbItems); + let totalMessages = 0; + let chatHistory: SchemaSocialChatMessage[] = []; + + for (const group of mergedGroupList) { + + let rangeTracker = new ItemsRangeTracker(group.syncData); + + const fetchedMessages = await this.fetchAndTrackMessages( + group, + rangeTracker, + apiClient + ); + + chatHistory = chatHistory.concat(fetchedMessages); + totalMessages += fetchedMessages.length; + + group.syncData = rangeTracker.export(); + } + + this.updateSyncPosition(syncPosition, totalMessages, mergedGroupList.length); + + return { + results: mergedGroupList.concat(chatHistory), + position: syncPosition, + }; + } catch (err: any) { + console.error(err); + throw err; + } + } + + private async fetchAndTrackMessages( + group: SchemaSocialChatGroup, + rangeTracker: ItemsRangeTracker, + apiClient: WebClient + ): Promise { + if (!group || !group.sourceId) { + throw new Error("Invalid group or missing group sourceId"); + } + + let items: SchemaSocialChatMessage[] = []; + let currentRange = rangeTracker.nextRange(); + + // Slack has limit of 1000, and if this.config.messagesPerGroupLimit is higher than 1000, will return maximum 1000 items + let query: ConversationsHistoryArguments = { + channel: group.sourceId!, + limit: this.config.messagesPerGroupLimit, + }; + + if (currentRange.startId) { + query.cursor = currentRange.startId; + } + + const response = await apiClient.conversations.history(query); + const latestResult = await this.buildResults( + group.sourceId!, + response, + currentRange.endId + ); + + items = latestResult.items; + + if (items.length) { + rangeTracker.completedRange( + { + startId: items[0].sourceId, + endId: response.response_metadata?.next_cursor, + }, + latestResult.breakHit == SyncItemsBreak.ID + ); + } else { + rangeTracker.completedRange( + { startId: undefined, endId: undefined }, + false + ); + } + + currentRange = rangeTracker.nextRange(); + + if (items.length != this.config.messagesPerGroupLimit && currentRange.startId) { + const query: ConversationsHistoryArguments = { + channel: group.sourceId!, + limit: this.config.messagesPerGroupLimit - items.length, + cursor: currentRange.startId, + }; + + const backfillResponse = await apiClient.conversations.history(query); + const backfillResult = await this.buildResults( + group.sourceId!, + backfillResponse, + currentRange.endId + ); + + items = items.concat(backfillResult.items); + if (backfillResult.items.length) { + rangeTracker.completedRange({ + startId: backfillResult.items[0].sourceId, + endId: backfillResponse.response_metadata?.next_cursor, + }, backfillResult.breakHit == SyncItemsBreak.ID); + } else { + rangeTracker.completedRange({ + startId: undefined, + endId: undefined, + }, backfillResult.breakHit == SyncItemsBreak.ID); + } + } + return items; + } + + private async buildResults( + groupId: string, + response: ConversationsHistoryResponse, + breakId: string + ): Promise { + const results: SchemaSocialChatMessage[] = []; + const userIds = new Set(); + let breakHit: SyncItemsBreak; + + // Collect unique user IDs from messages + for (const message of response.messages || []) { + if (message.subtype === 'bot_message') continue; + userIds.add(message.user); + } + + // Fetch user info for all unique user IDs in parallel + const userInfoMap = await SlackHelpers.fetchUserInfoBulk(this.connection.accessToken, Array.from(userIds)); + + for (const message of response.messages || []) { + if (message.subtype === 'bot_message') continue; + + const messageId = message.ts || ""; + if (messageId === breakId) { + this.emit("log", { + level: SyncProviderLogLevel.DEBUG, + message: `Break ID hit (${breakId}) in group (${groupId})`, + }); + breakHit = SyncItemsBreak.ID; + break; + } + + const user = userInfoMap[message.user]; + const messageRecord = await this.buildResult(groupId, message, user); + results.push(messageRecord); + } + + return { + items: results, + breakHit, + }; + } + + private async buildResult( + groupId: string, + message: MessageElement, + user: any + ): Promise { + return { + _id: this.buildItemId(message.ts), + groupId: groupId, + messageText: message.text, + fromHandle: user.profile.email, + sourceAccountId: this.provider.getAccountId(), + sourceApplication: this.getProviderApplicationUrl(), + sourceId: message.ts, + schema: CONFIG.verida.schemas.CHAT_MESSAGE, + sourceData: message, + insertedAt: new Date(parseFloat(message.ts) * 1000).toISOString(), + sentAt: new Date(parseFloat(message.ts) * 1000).toISOString(), + type: + message.user === this.connection.profile.id + ? SchemaChatMessageType.SEND + : SchemaChatMessageType.RECEIVE, + fromId: message.user, + name: message.text.substring(0, 30), + }; + } + + private updateSyncPosition( + syncPosition: SyncHandlerPosition, + totalMessages: number, + groupCount: number + ) { + if (totalMessages) { + syncPosition.status = SyncHandlerStatus.SYNCING; + syncPosition.syncMessage = `Batch complete (${totalMessages}) across (${groupCount} groups)`; + } else { + syncPosition.status = SyncHandlerStatus.ENABLED + syncPosition.syncMessage = `Stopping. No results found.` + } + } + + private mergeGroupLists( + newGroups: SchemaSocialChatGroup[], + existingGroups: SchemaSocialChatGroup[] + ): SchemaSocialChatGroup[] { + return newGroups.map((group) => { + const existingGroup = existingGroups.find( + (g) => g.sourceId === group.sourceId + ); + return existingGroup ? _.merge({}, existingGroup, group) : group; + }); + } +} diff --git a/src/providers/slack/helpers.ts b/src/providers/slack/helpers.ts new file mode 100644 index 00000000..90dd4baa --- /dev/null +++ b/src/providers/slack/helpers.ts @@ -0,0 +1,54 @@ +import axios from 'axios'; +import { SchemaSocialChatGroup } from '../../schemas'; + +export class SlackHelpers { + // Method to fetch user information using Slack's `users.info` API + static async getUserInfo(accessToken: string, userId: string) { + try { + const response = await axios.get('https://slack.com/api/users.info', { + headers: { + Authorization: `Bearer ${accessToken}` + }, + params: { + user: userId // The target user's Slack ID + } + }); + + if (response.data.ok) { + return response.data.user; // Return user profile info + } else { + throw new Error(`Slack API Error: ${response.data.error}`); + } + } catch (error) { + throw new Error(`Failed to fetch user info: ${error.message}`); + } + } + + // Helper function to fetch user info for all unique user IDs in parallel + static async fetchUserInfoBulk(accessToken: string, userIds: string[]): Promise<{ [key: string]: any }> { + const promises = userIds.map((userId) => + this.getUserInfo(accessToken, userId) + ); + const results = await Promise.all(promises); + + // Map user info by userId + const userInfoMap: { [key: string]: any } = {}; + results.forEach((userInfo, index) => { + userInfoMap[userIds[index]] = userInfo; + }); + + return userInfoMap; + } + + static getGroupPositionIndex( + groupList: SchemaSocialChatGroup[], + groupId: string|undefined + ): number { + const groupPosition = groupList.findIndex( + (group) => group.sourceId === groupId + ); + + // If not found, return 0 to start from the beginning + return groupPosition === -1 ? 0 : groupPosition; + } +} diff --git a/src/providers/slack/index.ts b/src/providers/slack/index.ts new file mode 100644 index 00000000..6451359c --- /dev/null +++ b/src/providers/slack/index.ts @@ -0,0 +1,191 @@ +import { Request, Response } from "express"; +import Base from "../BaseProvider"; +import { SlackProviderConfig } from "./interfaces"; +import { FileInstallationStore, InstallProvider } from '@slack/oauth'; +import { WebClient } from "@slack/web-api"; +import SlackChatMessageHandler from "./chat-message"; +const axios = require('axios'); + +import { Installation, InstallationStore, InstallationQuery } from '@slack/oauth'; +import { PassportProfile } from "../../interfaces"; +import { SlackHelpers } from "./helpers"; +export class CustomInstallationStore implements InstallationStore { + private installations: Map = new Map(); + + // Save the installation data + public async storeInstallation(installation: Installation): Promise { + const teamId = installation.team?.id ?? installation.enterprise?.id; + if (!teamId) { + throw new Error('Failed to identify team or enterprise in installation'); + } + this.installations.set(teamId, installation); + } + + // Fetch the installation data + public async fetchInstallation(query: InstallationQuery): Promise { + const teamId = query.teamId ?? query.enterpriseId; + if (!teamId || !this.installations.has(teamId)) { + throw new Error('Installation not found'); + } + return this.installations.get(teamId)!; // Return the installation + } + + // Delete the installation data (if needed) + public async deleteInstallation(query: InstallationQuery): Promise { + const teamId = query.teamId ?? query.enterpriseId; + if (teamId) { + this.installations.delete(teamId); + } + } +} + +export default class SlackProvider extends Base { + protected config: SlackProviderConfig; + protected slackInstaller: InstallProvider; + protected installationStore: CustomInstallationStore = new CustomInstallationStore(); + + public init() { + this.slackInstaller = new InstallProvider({ + clientId: this.config.clientId, + clientSecret: this.config.clientSecret, + authVersion: 'v2', + stateSecret: this.config.stateSecret, // Use the stateSecret for additional security + installationStore: this.installationStore + }); + } + + public getProviderName() { + return "slack"; + } + + public getProviderLabel() { + return "Slack"; + } + + public getProviderApplicationUrl() { + return "https://slack.com/"; + } + + public syncHandlers(): any[] { + return [ + SlackChatMessageHandler, + ]; + } + + public getScopes(): string[] { + return [ + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "users:read", + "users:read.email", + "channels:history", + "groups:history", + "im:history", + "mpim:history" + ]; + } + + public getUserScopes(): string[] { + return [ + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "users:read", + "users:read.email", + "channels:history", + "groups:history", + "im:history", + "mpim:history" + ]; + } + + public async connect(req: Request, res: Response, next: any): Promise { + this.init(); + try { + + await this.slackInstaller.handleInstallPath( + req, + res, + {}, + { + scopes: this.getScopes(), + userScopes: this.getUserScopes(), + }); + + } catch (error) { + next(error); + } + } + public async getAccessToken(code: string) { + const response = await axios.post('https://slack.com/api/oauth.v2.access', null, { + params: { + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code: code, + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + return response.data; + } + + + public async callback(req: Request, res: Response, next: any): Promise { + this.init(); + const { code } = req.query; + try { + const data = await this.getAccessToken(code as string); + + // Fetch the user's profile from Slack using the `authed_user.access_token` + const userInfo = await SlackHelpers.getUserInfo(data.authed_user.access_token, data.authed_user.id); + + // Build the PassportProfile object + const profile: PassportProfile = { + id: userInfo.id, // Slack user ID + provider: this.getProviderName(), // Set your Slack provider name + displayName: userInfo.profile.real_name, // User's real name + name: { + familyName: userInfo.profile.first_name, + givenName: userInfo.profile.last_name + }, + connectionProfile: { + username: userInfo.profile.display_name, // Display name as username + email: userInfo.profile.email, // Email from profile + readableId: userInfo.profile.display_name, + phone: userInfo.profile.phone, + verified: userInfo.is_email_confirmed + } + }; + + // Add access token data + const connectionToken = { + id: data.team.id, + accessToken: data.authed_user.access_token, + refreshToken: data.refresh_token, // If applicable, otherwise remove + profile: profile + }; + + return connectionToken; + + } catch (error) { + next(error); + } + } + + + public async getApi(accessToken?: string, refreshToken?: string): Promise { + if (!accessToken) { + throw new Error('Access token is required'); + } + + // Create a new WebClient instance with the provided access token + const client = new WebClient(accessToken); + + return client; + } +} diff --git a/src/providers/slack/interfaces.ts b/src/providers/slack/interfaces.ts new file mode 100644 index 00000000..905b2624 --- /dev/null +++ b/src/providers/slack/interfaces.ts @@ -0,0 +1,25 @@ +import { BaseHandlerConfig, BaseProviderConfig } from "../../interfaces"; + +export interface SlackHandlerConfig extends BaseHandlerConfig { + // Max batch size to cover all chat groups + // Slack recommends no more than 200, although max value 1000 + // See slack documentation: https://api.slack.com/methods/conversations.list + maxBatchSize: number + // Maximum number of messages to process in a group + messagesPerGroupLimit: number + channelTypes?: string +} + +export interface SlackProviderConfig extends BaseProviderConfig { + clientId: string; + clientSecret: string; + stateSecret: string; + callbackUrl: string; +} + +export enum SlackChatGroupType { + PUBLIC_CHANNEL = "public_channel", // Public channel + PRIVATE_CHANNEL = "private_channel", // Private channel + IM = "im", // DM + MPIM = "mpim" // Multi-person DM +} \ No newline at end of file diff --git a/src/serverconfig.example.json b/src/serverconfig.example.json index a94fd73e..19e3bede 100644 --- a/src/serverconfig.example.json +++ b/src/serverconfig.example.json @@ -158,6 +158,17 @@ "messagesPerGroupLimit": 100, "maxGroupSize": 50, "useDbPos": true + }, + "slack": { + "status": "active", + "label": "Slack", + "clientId": "", + "clientSecret": "", + "stateSecret": "", + "maxSyncLoops": 1, + "maxBatchSize": 200, + "messagesPerGroupLimit": 50, + "channelTypes": ["im", "private_channel", "public_channel"] } }, "providerDefaults": { diff --git a/tests/providers/google/gdrive-document.tests.ts b/tests/providers/google/gdrive-document.tests.ts index f297c216..7f6c65ae 100644 --- a/tests/providers/google/gdrive-document.tests.ts +++ b/tests/providers/google/gdrive-document.tests.ts @@ -13,7 +13,7 @@ import BaseProvider from "../../../src/providers/BaseProvider"; import { CommonTests, GenericTestConfig } from "../../common.tests"; import { SchemaFollowing } from "../../../src/schemas"; -const providerName = "google"; +const providerId = "google"; let network: NetworkInstance; let connection: Connection; let provider: BaseProvider; @@ -22,101 +22,32 @@ let testConfig: GenericTestConfig; let providerConfig: Omit = {}; -describe(`${providerName} GDrive Document Tests`, function () { +describe(`${providerId} GDrive Document Tests`, function () { this.timeout(400000); this.beforeAll(async function () { network = await CommonUtils.getNetwork(); - connection = await CommonUtils.getConnection(providerName); - provider = Providers(providerName, network.context, connection); + connection = await CommonUtils.getConnection(providerId); + provider = Providers(providerId, network.context, connection); testConfig = { - idPrefix: `${provider.getProviderName()}-${connection.profile.id}`, + idPrefix: `${provider.getProviderId()}-${connection.profile.id}`, timeOrderAttribute: "modifiedAt", batchSizeLimitAttribute: "batchSize", }; }); - describe(`Fetch ${providerName} data`, () => { + describe(`Fetch ${providerId} data`, () => { it(`Can pass basic tests: ${handlerName}`, async () => { await CommonTests.runGenericTests( - providerName, + providerId, GoogleDriveDocument, testConfig, providerConfig, connection ); }); - - it(`Can limit results by timestamp`, async () => { - const lastRecordHours = 2; - const lastRecordTimestamp = new Date( - Date.now() - lastRecordHours * 3600000 - ).toISOString(); - - const syncPosition: Omit = { - providerName, - providerId: provider.getProviderId(), - handlerName, - status: SyncHandlerStatus.ENABLED, - }; - - providerConfig.batchSize = 5; - providerConfig.metadata = { - breakTimestamp: lastRecordTimestamp, - }; - - const syncResponse = await CommonTests.runSyncTest( - providerName, - GoogleDriveDocument, - connection, - testConfig, - syncPosition, - providerConfig - ); - assert.ok( - syncResponse.results && syncResponse.results.length, - "Have results (You may not have edited any document in the testing timeframe)" - ); - - const results = syncResponse.results; - assert.ok( - results[results.length - 1].insertedAt > lastRecordTimestamp, - "Last result is within expected date/time range" - ); - assert.ok( - results.length < providerConfig.batchSize, - `Results reached the expected timestamp within the current batch size (try increasing the test batch size or reducing the break timestamp)` - ); - }); - - it(`Can handle empty results`, async () => { - const syncPosition: Omit = { - providerName, - providerId: provider.getProviderId(), - handlerName, - status: SyncHandlerStatus.ENABLED, - }; - - providerConfig.batchSize = 5; - providerConfig.metadata = { - breakTimestamp: new Date().toISOString(), - }; - - const syncResponse = await CommonTests.runSyncTest( - providerName, - GoogleDriveDocument, - connection, - testConfig, - syncPosition, - providerConfig - ); - assert.ok( - syncResponse.results.length === 0, - "No results should be returned for the future timestamp" - ); - }) }); this.afterAll(async function () { diff --git a/tests/providers/google/gmail.tests.ts b/tests/providers/google/gmail.tests.ts index 9b56b9d0..2326d50a 100644 --- a/tests/providers/google/gmail.tests.ts +++ b/tests/providers/google/gmail.tests.ts @@ -13,7 +13,7 @@ import BaseProvider from "../../../src/providers/BaseProvider"; import { CommonTests, GenericTestConfig } from "../../common.tests"; import { SchemaEmail } from "../../../src/schemas"; -const providerName = "google"; +const providerId = "google"; let network: NetworkInstance; let connection: Connection; let provider: BaseProvider; @@ -23,22 +23,22 @@ let providerConfig: Omit = {}; console.log(`WARNING: Sometimes these tests fail because the Google API doesnt return inbox messages in the correct time order. This is a bug in the Google API and there's not much we can do about it.`) -describe(`${providerName} Tests`, function () { +describe(`${providerId} Tests`, function () { this.timeout(100000); this.beforeAll(async function () { network = await CommonUtils.getNetwork(); - connection = await CommonUtils.getConnection(providerName); - provider = Providers(providerName, network.context, connection); + connection = await CommonUtils.getConnection(providerId); + provider = Providers(providerId, network.context, connection); testConfig = { - idPrefix: `${provider.getProviderName()}-${connection.profile.id}`, + idPrefix: `${provider.getProviderId()}-${connection.profile.id}`, timeOrderAttribute: "sentAt", batchSizeLimitAttribute: "batchSize", }; }); - describe(`Fetch ${providerName} data`, () => { + describe(`Fetch ${providerId} data`, () => { it(`Can pass basic tests: ${handlerName}`, async () => { /** @@ -52,7 +52,7 @@ describe(`${providerName} Tests`, function () { */ await CommonTests.runGenericTests( - providerName, + providerId, Gmail, testConfig, providerConfig, @@ -67,7 +67,6 @@ describe(`${providerName} Tests`, function () { ).toISOString(); const syncPosition: Omit = { - providerName, providerId: provider.getProviderId(), handlerName, status: SyncHandlerStatus.ENABLED, @@ -79,7 +78,7 @@ describe(`${providerName} Tests`, function () { } const syncResponse = await CommonTests.runSyncTest( - providerName, + providerId, Gmail, connection, testConfig, diff --git a/tests/providers/google/youtube-favourite.tests.ts b/tests/providers/google/youtube-favourite.tests.ts index 66edd257..55a051c5 100644 --- a/tests/providers/google/youtube-favourite.tests.ts +++ b/tests/providers/google/youtube-favourite.tests.ts @@ -13,7 +13,7 @@ import BaseProvider from "../../../src/providers/BaseProvider"; import { CommonTests, GenericTestConfig } from "../../common.tests"; import { SchemaFavourite } from "../../../src/schemas"; -const providerName = "google"; +const providerId = "google"; let network: NetworkInstance; let connection: Connection; let provider: BaseProvider; @@ -21,25 +21,25 @@ let handlerName = "youtube-favourite"; let testConfig: GenericTestConfig; let providerConfig: Omit = {}; -describe(`${providerName} Youtube Favourite Tests`, function () { +describe(`${providerId} Youtube Favourite Tests`, function () { this.timeout(100000); this.beforeAll(async function () { network = await CommonUtils.getNetwork(); - connection = await CommonUtils.getConnection(providerName); - provider = Providers(providerName, network.context, connection); + connection = await CommonUtils.getConnection(providerId); + provider = Providers(providerId, network.context, connection); testConfig = { - idPrefix: `${provider.getProviderName()}-${connection.profile.id}`, + idPrefix: `${provider.getProviderId()}-${connection.profile.id}`, batchSizeLimitAttribute: "batchSize", }; }); - describe(`Fetch ${providerName} data`, () => { + describe(`Fetch ${providerId} data`, () => { it(`Can pass basic tests: ${handlerName}`, async () => { await CommonTests.runGenericTests( - providerName, + providerId, YouTubeFavourite, testConfig, providerConfig, diff --git a/tests/providers/google/youtube-following.tests.ts b/tests/providers/google/youtube-following.tests.ts index 6998fdf7..3030d24f 100644 --- a/tests/providers/google/youtube-following.tests.ts +++ b/tests/providers/google/youtube-following.tests.ts @@ -13,7 +13,7 @@ import BaseProvider from "../../../src/providers/BaseProvider"; import { CommonTests, GenericTestConfig } from "../../common.tests"; import { SchemaFollowing } from "../../../src/schemas"; -const providerName = "google"; +const providerId = "google"; let network: NetworkInstance; let connection: Connection; let provider: BaseProvider; @@ -22,25 +22,25 @@ let testConfig: GenericTestConfig; let providerConfig: Omit = {}; -describe(`${providerName} Youtube Following Tests`, function () { +describe(`${providerId} Youtube Following Tests`, function () { this.timeout(100000); this.beforeAll(async function () { network = await CommonUtils.getNetwork(); - connection = await CommonUtils.getConnection(providerName); - provider = Providers(providerName, network.context, connection); + connection = await CommonUtils.getConnection(providerId); + provider = Providers(providerId, network.context, connection); testConfig = { - idPrefix: `${provider.getProviderName()}-${connection.profile.id}`, + idPrefix: `${provider.getProviderId()}-${connection.profile.id}`, batchSizeLimitAttribute: "batchSize", }; }); - describe(`Fetch ${providerName} data`, () => { + describe(`Fetch ${providerId} data`, () => { it(`Can pass basic tests: ${handlerName}`, async () => { await CommonTests.runGenericTests( - providerName, + providerId, YoutubeFollowing, testConfig, providerConfig, diff --git a/tests/providers/google/youtube-post.tests.ts b/tests/providers/google/youtube-post.tests.ts index aba0b04f..ae36bb56 100644 --- a/tests/providers/google/youtube-post.tests.ts +++ b/tests/providers/google/youtube-post.tests.ts @@ -13,7 +13,7 @@ import BaseProvider from "../../../src/providers/BaseProvider"; import { CommonTests, GenericTestConfig } from "../../common.tests"; import { SchemaPost } from "../../../src/schemas"; -const providerName = "google"; +const providerId = "google"; let network: NetworkInstance; let connection: Connection; let provider: BaseProvider; @@ -22,25 +22,25 @@ let testConfig: GenericTestConfig; let providerConfig: Omit = {}; -describe(`${providerName} Youtube Post Tests`, function () { +describe(`${providerId} Youtube Post Tests`, function () { this.timeout(100000); this.beforeAll(async function () { network = await CommonUtils.getNetwork(); - connection = await CommonUtils.getConnection(providerName); - provider = Providers(providerName, network.context, connection); + connection = await CommonUtils.getConnection(providerId); + provider = Providers(providerId, network.context, connection); testConfig = { - idPrefix: `${provider.getProviderName()}-${connection.profile.id}`, + idPrefix: `${provider.getProviderId()}-${connection.profile.id}`, batchSizeLimitAttribute: "batchSize", }; }); - describe(`Fetch ${providerName} data`, () => { + describe(`Fetch ${providerId} data`, () => { it(`Can pass basic tests: ${handlerName}`, async () => { await CommonTests.runGenericTests( - providerName, + providerId, YoutubePost, testConfig, providerConfig, diff --git a/tests/providers/slack/chat-message.ts b/tests/providers/slack/chat-message.ts new file mode 100644 index 00000000..43099846 --- /dev/null +++ b/tests/providers/slack/chat-message.ts @@ -0,0 +1,335 @@ +const assert = require("assert"); +import CONFIG from "../../../src/config"; +import { + BaseProviderConfig, + Connection, + SyncHandlerPosition, + SyncHandlerStatus +} from "../../../src/interfaces"; +import Providers from "../../../src/providers"; +import CommonUtils, { NetworkInstance } from "../../common.utils"; + +import SlackChatMessageHandler from "../../../src/providers/slack/chat-message"; +import BaseProvider from "../../../src/providers/BaseProvider"; +import { CommonTests, GenericTestConfig } from "../../common.tests"; +import { SchemaSocialChatGroup, SchemaSocialChatMessage, SchemaRecord } from "../../../src/schemas"; +import { SlackHandlerConfig, SlackChatGroupType } from "../../../src/providers/slack/interfaces"; +import { SlackHelpers } from "../../../src/providers/slack/helpers"; + +// Define the provider ID +const providerId = "slack"; +let network: NetworkInstance; +let connection: Connection; +let provider: BaseProvider; +let handlerName = "chat-message"; +let testConfig: GenericTestConfig; + +// Configure provider and handler without certain attributes +let providerConfig: Omit = {}; +let handlerConfig: SlackHandlerConfig = { + messagesPerGroupLimit: 3, + channelTypes: [ + SlackChatGroupType.IM.toString(), // DM first + SlackChatGroupType.PRIVATE_CHANNEL.toString(), + SlackChatGroupType.PUBLIC_CHANNEL.toString(), + ].join(','), + maxBatchSize: 50 +}; + +// Test suite for Slack Chat Message syncing +describe(`${providerId} chat message tests`, function () { + this.timeout(100000); + + // Before all tests, set up the network, connection, and provider + this.beforeAll(async function () { + network = await CommonUtils.getNetwork(); + connection = await CommonUtils.getConnection(providerId); + provider = Providers(providerId, network.context, connection); + + // Configure test settings + testConfig = { + idPrefix: `${provider.getProviderId()}-${connection.profile.id}`, + timeOrderAttribute: "insertedAt", + batchSizeLimitAttribute: "batchSize", + }; + }); + + // Test fetching data for Slack Chat + describe(`Fetch ${providerId} data`, () => { + + it(`Can pass basic tests: ${handlerName}`, async () => { + // Build the necessary test objects + const { api, handler, provider } = await CommonTests.buildTestObjects( + providerId, + SlackChatMessageHandler, + providerConfig, + connection + ); + + // Set the handler configuration + handler.setConfig(handlerConfig); + + try { + // Set up initial sync position + const syncPosition: SyncHandlerPosition = { + _id: `${providerId}-${handlerName}`, + providerId, + handlerId: handler.getId(), + accountId: provider.getAccountId(), + status: SyncHandlerStatus.ENABLED, + }; + + // Start the sync process + const response = await handler._sync(api, syncPosition); + const results = response.results; + + // Extract chat groups and messages from the results + const chatGroups = results.filter(result => result.schema === CONFIG.verida.schemas.CHAT_GROUP); + const chatMessages = results.filter(result => result.schema === CONFIG.verida.schemas.CHAT_MESSAGE); + + // Ensure results are returned + assert.ok(results && results.length, "Have results returned"); + + // Check IDs in the returned items + CommonTests.checkItem(results[0], handler, provider); + + // Verify sync status is active + assert.equal( + SyncHandlerStatus.SYNCING, + response.position.status, + "Sync is active" + ); + + // Ensure the message batch per group works + const firstGroupId = chatMessages[0].groupId; + const firstGroupMessages = chatMessages.filter(msg => msg.groupId === firstGroupId); + assert.equal(firstGroupMessages.length, handlerConfig.messagesPerGroupLimit, "Processed correct number of messages per group"); + + /** + * Start the second sync batch process + */ + const secondBatchResponse = await handler._sync(api, response.position); + const secondBatchResults = secondBatchResponse.results; + + // Extract chat groups and messages from the second batch results + const secondBatchChatGroups = secondBatchResults.filter(result => result.schema === CONFIG.verida.schemas.CHAT_GROUP); + const secondBatchChatMessages = secondBatchResults.filter(result => result.schema === CONFIG.verida.schemas.CHAT_MESSAGE); + + // Ensure second batch results are returned + assert.ok(secondBatchResults && secondBatchResults.length, "Have second batch results returned"); + + // Check IDs in the returned items for the second batch + CommonTests.checkItem(secondBatchResults[0], handler, provider); + + // Verify sync status is still active for the second batch + assert.equal( + SyncHandlerStatus.SYNCING, + secondBatchResponse.position.status, + "Sync is still active after second batch" + ); + + // Check if every chat group with messages is synced correctly + const groupIdsWithMessages = new Set(secondBatchChatMessages.map((msg) => msg.groupId)); + + groupIdsWithMessages.forEach((groupId) => { + const syncedGroup = secondBatchChatGroups.find((group) => group.sourceId === groupId); + assert.ok(syncedGroup, `Chat group with sourceId ${groupId} exists in the batch`); + assert.ok(syncedGroup!.syncData, `Chat group with sourceId ${groupId} has a valid sync range`); + }); + + + } catch (err) { + // Ensure provider closes even if an error occurs + await provider.close(); + throw err; + } + }); + + it(`Should match email from User Info with message's fromHandle`, async () => { + try { + // Build the necessary test objects + const { api, handler, provider } = await CommonTests.buildTestObjects( + providerId, + SlackChatMessageHandler, + providerConfig, + connection + ); + + // Set the handler configuration + handler.setConfig(handlerConfig); + + // Set up initial sync position + const syncPosition: SyncHandlerPosition = { + _id: `${providerId}-${handlerName}`, + providerId, + handlerId: handler.getId(), + accountId: provider.getAccountId(), + status: SyncHandlerStatus.ENABLED, + }; + + // Start the sync process + const response = await handler._sync(api, syncPosition); + const secondBatchResults = response.results; + + // Extract chat messages from the second batch results + const chatMessages = ( + secondBatchResults.filter( + (result) => result.schema === CONFIG.verida.schemas.CHAT_MESSAGE + ) + ); + + // Check email comparison for the first message + const firstMessage = chatMessages[0]; + const userInfo = await SlackHelpers.getUserInfo( + connection.accessToken, + firstMessage.fromId + ); + + // Compare email from fromHandle and userInfo + assert.equal( + firstMessage.fromHandle, + userInfo.profile.email, + "fromHandle email matches userInfo email" + ); + + } catch (err) { + await provider.close(); + throw err; + } + }); + + it(`Should process most recent messages first`, async () => { + const { api, handler, provider } = await CommonTests.buildTestObjects( + providerId, + SlackChatMessageHandler, + providerConfig, + connection + ); + handler.setConfig(handlerConfig); + + const syncPosition: SyncHandlerPosition = { + _id: `${providerId}-${handlerName}`, + providerId, + handlerId: handler.getId(), + accountId: provider.getAccountId(), + status: SyncHandlerStatus.ENABLED, + }; + + const response = await handler._sync(api, syncPosition); + const results = (response.results).filter( + (result) => result.schema === CONFIG.verida.schemas.CHAT_MESSAGE + ); + + const timestamps = results.map((msg) => new Date(msg.insertedAt!).getTime()); + const isSortedDescending = timestamps.every( + (val, i, arr) => i === 0 || arr[i - 1] >= val + ); + + assert.ok(isSortedDescending, "Messages are processed from most recent to oldest"); + }); + + it(`Should ensure second batch items aren't in the first batch`, async () => { + const { api, handler, provider } = await CommonTests.buildTestObjects( + providerId, + SlackChatMessageHandler, + providerConfig, + connection + ); + handler.setConfig(handlerConfig); + + const firstBatchResponse = await handler._sync(api, { + _id: `${providerId}-${handlerName}`, + providerId, + handlerId: handler.getId(), + accountId: provider.getAccountId(), + status: SyncHandlerStatus.ENABLED, + }); + + const firstBatchMessages = (firstBatchResponse.results).filter( + (result) => result.schema === CONFIG.verida.schemas.CHAT_MESSAGE + ); + + setTimeout(() => { + console.log("Wait for saving sync data"); + }, 3000); + const secondBatchResponse = await handler._sync(api, firstBatchResponse.position); + const secondBatchMessages = (secondBatchResponse.results).filter( + (result) => result.schema === CONFIG.verida.schemas.CHAT_MESSAGE + ); + + const firstBatchIds = firstBatchMessages.map((msg) => msg.sourceId); + const secondBatchIds = secondBatchMessages.map((msg) => msg.sourceId); + + const intersection = firstBatchIds.filter((id) => secondBatchIds.includes(id)); + assert.equal(intersection.length, 0, "No overlapping messages between batches"); + }); + + it(`Should process each type of chat group correctly`, async () => { + const { api, handler, provider } = await CommonTests.buildTestObjects( + providerId, + SlackChatMessageHandler, + providerConfig, + connection + ); + handler.setConfig(handlerConfig); + + const syncPosition: SyncHandlerPosition = { + _id: `${providerId}-${handlerName}`, + providerId, + handlerId: handler.getId(), + accountId: provider.getAccountId(), + status: SyncHandlerStatus.ENABLED, + }; + + const response = await handler._sync(api, syncPosition); + const results = (response.results).filter( + (result) => result.schema === CONFIG.verida.schemas.CHAT_MESSAGE + ); + + // Check if each type of group has at least one message + const groupTypes = { + [SlackChatGroupType.IM]: false, + [SlackChatGroupType.PRIVATE_CHANNEL]: true, + [SlackChatGroupType.PUBLIC_CHANNEL]: true, + }; + + results.forEach((message) => { + const group = (response.results).find( + (result) => + result.schema === CONFIG.verida.schemas.CHAT_GROUP && + result.sourceId === message.groupId + ); + + if (group) { + if (group.sourceData!.hasOwnProperty('is_im') && (group.sourceData! as any).is_im) { + groupTypes[SlackChatGroupType.IM] = true; + } + + if (group.sourceData!.hasOwnProperty('is_private') && (group.sourceData! as any).is_private) { + groupTypes[SlackChatGroupType.PRIVATE_CHANNEL] = true; + } + + if (group.sourceData!.hasOwnProperty('is_channel') && (group.sourceData! as any).is_channel) { + groupTypes[SlackChatGroupType.PUBLIC_CHANNEL] = true; + } + } + }); + + // Assert that all group types are represented + Object.entries(groupTypes).forEach(([type, isPresent]) => { + assert.ok( + isPresent, + `Chat group type ${type} should have messages processed` + ); + }); + }); + + + }); + + // After all tests, close the network context + this.afterAll(async function () { + const { context } = await CommonUtils.getNetwork(); + await context.close(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9316efae..e7bdc8be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1573,6 +1573,48 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== +"@slack/logger@^4", "@slack/logger@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@slack/logger/-/logger-4.0.0.tgz#788303ff1840be91bdad7711ef66ca0cbc7073d2" + integrity sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA== + dependencies: + "@types/node" ">=18.0.0" + +"@slack/oauth@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@slack/oauth/-/oauth-3.0.1.tgz#079cde13f998be2d458c20dfcae288067464536e" + integrity sha512-TuR9PI6bYKX6qHC7FQI4keMnhj45TNfSNQtTU3mtnHUX4XLM2dYLvRkUNADyiLTle2qu2rsOQtCIsZJw6H0sDA== + dependencies: + "@slack/logger" "^4" + "@slack/web-api" "^7.3.4" + "@types/jsonwebtoken" "^9" + "@types/node" ">=18" + jsonwebtoken "^9" + lodash.isstring "^4" + +"@slack/types@^2.9.0": + version "2.13.1" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.13.1.tgz#d1af332103ce96e22b10692bef9b34483f4c002c" + integrity sha512-YVtJCVtDcjOPKsvOedIThb7YmKNCcSoZN0mUSQqD2fc2ZyI59gOLCF4rYGfw/0C0agzFxAmb7hV5tbMGrgK0Tg== + +"@slack/web-api@^7.3.4", "@slack/web-api@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-7.4.0.tgz#56f8376203b4172c02812f0fc092b65c351a74ba" + integrity sha512-U+0pui9VcddRt3yTSkLAArVB6G8EyOb0g+/vL1E6u8k46IYc1H4UKDuULF734A2ynVyxYKHIXK8OHilX6YhwjQ== + dependencies: + "@slack/logger" "^4.0.0" + "@slack/types" "^2.9.0" + "@types/node" ">=18.0.0" + "@types/retry" "0.12.0" + axios "^1.7.4" + eventemitter3 "^5.0.1" + form-data "^4.0.0" + is-electron "2.2.2" + is-stream "^2" + p-queue "^6" + p-retry "^4" + retry "^0.13.1" + "@smithy/abort-controller@^3.1.8": version "3.1.8" resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-3.1.8.tgz#ce0c10ddb2b39107d70b06bbb8e4f6e368bc551d" @@ -2405,6 +2447,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonwebtoken@^9": + version "9.0.6" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz#d1af3544d99ad992fb6681bbe60676e06b032bd3" + integrity sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw== + dependencies: + "@types/node" "*" + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -2445,6 +2494,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.13.tgz#f64277c341150c979e42b00e4ac289290c9df469" integrity sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q== +"@types/node@>=18", "@types/node@>=18.0.0": + version "22.5.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.4.tgz#83f7d1f65bc2ed223bdbf57c7884f1d5a4fa84e8" + integrity sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg== + dependencies: + undici-types "~6.19.2" + "@types/node@^18.11.18": version "18.19.45" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.45.tgz#a9ebfe4c316a356be7ca11f753ecb2feda6d6bdf" @@ -3451,7 +3507,7 @@ axios@^0.27.2: follow-redirects "^1.14.9" form-data "^4.0.0" -axios@^1.2.3, axios@^1.6.2, axios@^1.7.2: +axios@^1.2.3, axios@^1.6.2: version "1.7.2" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== @@ -3460,7 +3516,7 @@ axios@^1.2.3, axios@^1.6.2, axios@^1.7.2: form-data "^4.0.0" proxy-from-env "^1.1.0" -axios@^1.3.3: +axios@^1.3.3, axios@^1.7.4, axios@^1.7.7: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== @@ -4773,6 +4829,11 @@ eventemitter3@^4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + events@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -5603,6 +5664,11 @@ is-docker@^2.0.0, is-docker@^2.1.1: resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== +is-electron@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.2.tgz#3778902a2044d76de98036f5dc58089ac4d80bb9" + integrity sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -5678,7 +5744,7 @@ is-stream@^1.1.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== -is-stream@^2.0.0: +is-stream@^2, is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== @@ -5834,7 +5900,7 @@ jsonpointer@^5.0.1: resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== -jsonwebtoken@^9.0.0: +jsonwebtoken@^9, jsonwebtoken@^9.0.0: version "9.0.2" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== @@ -6176,7 +6242,7 @@ lodash.isplainobject@^4.0.6: resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= -lodash.isstring@^4.0.1: +lodash.isstring@^4, lodash.isstring@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== @@ -6799,7 +6865,7 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -p-queue@^6.6.2: +p-queue@^6, p-queue@^6.6.2: version "6.6.2" resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== @@ -6807,7 +6873,7 @@ p-queue@^6.6.2: eventemitter3 "^4.0.4" p-timeout "^3.2.0" -p-retry@4: +p-retry@4, p-retry@^4: version "4.6.2" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==