diff --git a/apps/mail/components/party.tsx b/apps/mail/components/party.tsx index 4954273393..57a282cea0 100644 --- a/apps/mail/components/party.tsx +++ b/apps/mail/components/party.tsx @@ -35,7 +35,7 @@ export const NotificationProvider = ({ headers }: { headers: Record
diff --git a/apps/server/package.json b/apps/server/package.json index df0d9d9d5a..47831b3b7c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,6 +43,7 @@ "autumn-js": "catalog:", "base64-js": "1.5.1", "better-auth": "catalog:", + "cheerio": "1.1.0", "date-fns": "^4.1.0", "dedent": "^1.6.0", "drizzle-orm": "catalog:", @@ -81,4 +82,4 @@ "jiti": "2.4.2", "typescript": "catalog:" } -} \ No newline at end of file +} diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 82b65c3316..a2c0feb288 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -13,9 +13,10 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { getSocialProviders } from './auth-providers'; import { redis, resend, twilio } from './services'; import { getContext } from 'hono/context-storage'; -import { getActiveDriver } from './driver/utils'; import { defaultUserSettings } from './schemas'; +import { disableBrainFunction } from './brain'; import { APIError } from 'better-auth/api'; +import type { EProviders } from '../types'; import type { HonoContext } from '../ctx'; import { env } from 'cloudflare:workers'; import { createDriver } from './driver'; @@ -57,11 +58,13 @@ const connectionHandlerHook = async (account: Account) => { expiresAt: new Date(Date.now() + (account.accessTokenExpiresAt?.getTime() || 3600000)), }; - await c.var.db + const connectionId = crypto.randomUUID(); + + const [result] = await c.var.db .insert(connection) .values({ providerId: account.providerId as 'google' | 'microsoft', - id: crypto.randomUUID(), + id: connectionId, email: userInfo.address, userId: account.userId, createdAt: new Date(), @@ -74,7 +77,13 @@ const connectionHandlerHook = async (account: Account) => { ...updatingInfo, updatedAt: new Date(), }, - }); + }) + .returning({ id: connection.id }); + + await env.subscribe_queue.send({ + connectionId: result.id, + providerId: account.providerId, + }); }; export const createAuth = () => { @@ -111,6 +120,10 @@ export const createAuth = () => { await Promise.allSettled( connections.map(async (connection) => { if (!connection.accessToken || !connection.refreshToken) return false; + await disableBrainFunction({ + id: connection.id, + providerId: connection.providerId as EProviders, + }); const driver = createDriver(connection.providerId, { auth: { accessToken: connection.accessToken, diff --git a/apps/server/src/lib/brain.fallback.prompts.ts b/apps/server/src/lib/brain.fallback.prompts.ts index e05368d8ae..e64cf30183 100644 --- a/apps/server/src/lib/brain.fallback.prompts.ts +++ b/apps/server/src/lib/brain.fallback.prompts.ts @@ -1,4 +1,7 @@ -export const SummarizeMessage = ` +import { defaultLabels } from '../types'; +import dedent from 'dedent'; + +export const SummarizeMessage = dedent` You are a high-accuracy email summarization agent. Your task is to extract and summarize emails in XML format with absolute precision, ensuring no critical details are lost while maintaining high efficiency. @@ -46,7 +49,7 @@ export const SummarizeMessage = ` Strictly follow these rules. No missing details. No extra fluff. Just precise, high-performance summarization. Never say "Here is" `; -export const SummarizeThread = ` +export const SummarizeThread = dedent` You are a high-accuracy email thread summarization agent. Your task is to process a full email thread with multiple messages and generate a structured, limited-length summary that retains all critical details, ensuring no information is lost. @@ -119,7 +122,7 @@ export const SummarizeThread = ` Never say "Here is" `; -export const ReSummarizeThread = ` +export const ReSummarizeThread = dedent` You are a high-accuracy email thread summarization agent. Your task is to process a full email thread, including new messages and an existing summary, and generate a structured, limited-length updated summary that retains all critical details. @@ -190,3 +193,65 @@ export const ReSummarizeThread = ` Maintain absolute accuracy. No missing details. No extra assumptions. No modifications to previous content beyond appending updates. Ensure clarity and brevity within the length limit. Never say "Here is" `; + +export const ThreadLabels = (labels: { name: string; usecase: string }[]) => dedent` + + You are a precise thread labeling agent. Your task is to analyze email thread summaries and assign relevant labels from a predefined set, ensuring accurate categorization while maintaining consistency. + Maintain absolute accuracy in labeling. Use only the predefined labels. Never generate new labels. Never include personal names. Always return labels in comma-separated format without spaces. + Never say "Here is" or explain the process of labeling. + + + Thread summary containing participants, messages, and context + + + + Use only the predefined set of labels + Return labels as comma-separated values without spaces + Include company names as labels when heavily referenced + Include bank names as labels when heavily referenced + Do not use personal names as labels + Choose the most relevant labels, typically 1-3 labels per thread + + + + ${labels + .map( + (label) => ` + ${label.name} + ${defaultLabels.find((e) => e.name === label.name)?.usecase || ''} + `, + ) + .join('\n')} + + + + + + Thread: Product Launch Planning + Participants: Sarah, Mike, David + + - March 15, 10:00 AM - Sarah requests urgent review of the new feature documentation before the launch. + - March 15, 11:30 AM - Mike suggests changes to the marketing strategy for better customer engagement. + - March 15, 2:00 PM - David approves the final product specifications and sets a launch date. + + + + + urgent,product,marketing + + + + + Thread: Stripe Integration Update + Participants: Alex, Jamie, Stripe Support + + - March 16, 9:00 AM - Alex reports issues with Stripe payment processing. + - March 16, 10:15 AM - Stripe Support provides troubleshooting steps. + - March 16, 11:30 AM - Jamie confirms the fix and requests additional security review. + + + + + support,finance,stripe + + `; diff --git a/apps/server/src/lib/brain.ts b/apps/server/src/lib/brain.ts index b18abb98c7..536aaed3a7 100644 --- a/apps/server/src/lib/brain.ts +++ b/apps/server/src/lib/brain.ts @@ -1,19 +1,26 @@ import { ReSummarizeThread, SummarizeMessage, SummarizeThread } from './brain.fallback.prompts'; +import { getSubscriptionFactory } from './factories/subscription-factory.registry'; +import { EPrompts, EProviders } from '../types'; import { env } from 'cloudflare:workers'; -import { EPrompts } from '../types'; -export const enableBrainFunction = async (connection: { id: string; providerId: string }) => { - return await env.zero.subscribe({ - connectionId: connection.id, - providerId: connection.providerId, - }); +export const enableBrainFunction = async (connection: { id: string; providerId: EProviders }) => { + const subscriptionFactory = getSubscriptionFactory(connection.providerId); + const response = await subscriptionFactory.subscribe({ body: { connectionId: connection.id } }); + if (!response.ok) { + throw new Error(`Failed to enable brain function: ${response.status} ${response.statusText}`); + } + return response; }; -export const disableBrainFunction = async (connection: { id: string; providerId: string }) => { - return await env.zero.unsubscribe({ - connectionId: connection.id, - providerId: connection.providerId, +export const disableBrainFunction = async (connection: { id: string; providerId: EProviders }) => { + const subscriptionFactory = getSubscriptionFactory(connection.providerId); + const response = await subscriptionFactory.unsubscribe({ + body: { connectionId: connection.id, providerId: connection.providerId }, }); + if (!response.ok) { + throw new Error(`Failed to disable brain function: ${response.status} ${response.statusText}`); + } + return response; }; const getPromptName = (connectionId: string, prompt: EPrompts) => { diff --git a/apps/server/src/lib/driver/google.ts b/apps/server/src/lib/driver/google.ts index 56693fc197..c45d5a5282 100644 --- a/apps/server/src/lib/driver/google.ts +++ b/apps/server/src/lib/driver/google.ts @@ -44,7 +44,24 @@ export class GoogleMailManager implements MailManager { 'https://www.googleapis.com/auth/userinfo.email', ].join(' '); } - public getAttachment(messageId: string, attachmentId: string) { + public async listHistory(historyId: string): Promise<{ history: T[]; historyId: string }> { + return this.withErrorHandler( + 'listHistory', + async () => { + const response = await this.gmail.users.history.list({ + userId: 'me', + startHistoryId: historyId, + }); + + const history = response.data.history || []; + const nextHistoryId = response.data.historyId || historyId; + + return { history: history as T[], historyId: nextHistoryId }; + }, + { historyId }, + ); + } + public async getAttachment(messageId: string, attachmentId: string) { return this.withErrorHandler( 'getAttachment', async () => { diff --git a/apps/server/src/lib/driver/types.ts b/apps/server/src/lib/driver/types.ts index 547cae50a6..7cd8650f92 100644 --- a/apps/server/src/lib/driver/types.ts +++ b/apps/server/src/lib/driver/types.ts @@ -68,6 +68,7 @@ export interface MailManager { tokens?: ManagerConfig['auth'], ): Promise<{ address: string; name: string; photo: string }>; getScope(): string; + listHistory(historyId: string): Promise<{ history: T[]; historyId: string }>; markAsRead(threadIds: string[]): Promise; markAsUnread(threadIds: string[]): Promise; normalizeIds(id: string[]): { threadIds: string[] }; diff --git a/apps/server/src/lib/factories/base-subscription.factory.ts b/apps/server/src/lib/factories/base-subscription.factory.ts new file mode 100644 index 0000000000..135b497657 --- /dev/null +++ b/apps/server/src/lib/factories/base-subscription.factory.ts @@ -0,0 +1,44 @@ +import { defaultLabels, EProviders, type AppContext } from '../../types'; +import { connection } from '../../db/schema'; +import { env } from 'cloudflare:workers'; +import { createDb } from '../../db'; + +export interface SubscriptionData { + connectionId?: string; + silent?: boolean; + force?: boolean; +} + +export interface UnsubscriptionData { + connectionId?: string; + providerId?: EProviders; +} + +export abstract class BaseSubscriptionFactory { + abstract readonly providerId: EProviders; + + abstract subscribe(data: { body: SubscriptionData }): Promise; + + abstract unsubscribe(data: { body: UnsubscriptionData }): Promise; + + abstract verifyToken(token: string): Promise; + + protected async getConnectionFromDb(connectionId: string): Promise { + const db = createDb(env.HYPERDRIVE.connectionString); + const { eq } = await import('drizzle-orm'); + + const [connectionData] = await db + .select() + .from(connection) + .where(eq(connection.id, connectionId)); + + return connectionData; + } + + protected async initializeConnectionLabels(connectionId: string): Promise { + const existingLabels = await env.connection_labels.get(connectionId); + if (!existingLabels?.trim().length) { + await env.connection_labels.put(connectionId, JSON.stringify(defaultLabels)); + } + } +} diff --git a/apps/server/src/lib/factories/google-subscription.factory.ts b/apps/server/src/lib/factories/google-subscription.factory.ts new file mode 100644 index 0000000000..ff21510f15 --- /dev/null +++ b/apps/server/src/lib/factories/google-subscription.factory.ts @@ -0,0 +1,343 @@ +import { + BaseSubscriptionFactory, + type SubscriptionData, + type UnsubscriptionData, +} from './base-subscription.factory'; +import { c, getNotificationsUrl } from '../../lib/utils'; +import jwt from '@tsndr/cloudflare-worker-jwt'; +import { env } from 'cloudflare:workers'; +import { EProviders } from '../../types'; + +interface GoogleServiceAccount { + type: string; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + client_id: string; +} + +interface IamPolicy { + bindings?: { role: string; members: string[] }[]; +} + +class GoogleSubscriptionFactory extends BaseSubscriptionFactory { + readonly providerId = EProviders.google; + private accessToken: string | null = null; + private tokenExpiry: number = 0; + private serviceAccount: GoogleServiceAccount | null = null; + private pubsubServiceAccount: string = 'serviceAccount:gmail-api-push@system.gserviceaccount.com'; + + private getServiceAccount(): GoogleServiceAccount { + if (!this.serviceAccount) { + const serviceAccountJson = env.GOOGLE_S_ACCOUNT; + if (!serviceAccountJson) { + throw new Error('GOOGLE_S_ACCOUNT environment variable is required'); + } + + try { + this.serviceAccount = JSON.parse(serviceAccountJson); + } catch (error) { + throw new Error('Invalid GOOGLE_S_ACCOUNT JSON format'); + } + return this.serviceAccount as GoogleServiceAccount; + } + + return this.serviceAccount; + } + + private async getAccessToken(): Promise { + const now = Math.floor(Date.now() / 1000); + + // Return cached token if still valid (with 5 minute buffer) + if (this.accessToken && this.tokenExpiry > now + 300) { + return this.accessToken; + } + + const serviceAccount = this.getServiceAccount(); + + const payload = { + iss: serviceAccount.client_email, + scope: 'https://www.googleapis.com/auth/cloud-platform', + aud: 'https://oauth2.googleapis.com/token', + exp: now + 3600, + iat: now, + }; + + const signedJWT = await jwt.sign(payload, serviceAccount.private_key, { + algorithm: 'RS256', + }); + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: signedJWT, + }), + }); + + if (!response.ok) { + const error = (await response.json()) as { error?: string }; + throw new Error(`Failed to get access token: ${error.error}`); + } + + const data = (await response.json()) as { access_token: string }; + this.accessToken = data.access_token; + this.tokenExpiry = now + 3600; + + return this.accessToken; + } + + private async makeAuthenticatedRequest( + url: string, + options: RequestInit = {}, + ): Promise { + const token = await this.getAccessToken(); + + return fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}`, + }, + }); + } + + private async resourceExists(url: string): Promise { + const response = await this.makeAuthenticatedRequest(url); + return response.ok; + } + + private async setupPubSubTopic(topicName: string): Promise { + const serviceAccount = this.getServiceAccount(); + const baseUrl = `https://pubsub.googleapis.com/v1/projects/${serviceAccount.project_id}`; + const topicUrl = `${baseUrl}/topics/${topicName}`; + + // Delete subscription if it exists + const subUrl = `${baseUrl}/subscriptions/${topicName}`; + if (await this.resourceExists(subUrl)) { + await this.makeAuthenticatedRequest(subUrl, { method: 'DELETE' }); + } + + // Create topic if it doesn't exist + if (!(await this.resourceExists(topicUrl))) { + const createResponse = await this.makeAuthenticatedRequest(topicUrl, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!createResponse.ok) { + throw new Error(`Failed to create topic: ${await createResponse.text()}`); + } + } + + // Set IAM policy + await this.setTopicIamPolicy(topicName); + } + + private async setTopicIamPolicy(topicName: string): Promise { + const serviceAccount = this.getServiceAccount(); + const baseUrl = `https://pubsub.googleapis.com/v1/projects/${serviceAccount.project_id}/topics/${topicName}`; + + // Get current policy + const policyResponse = await this.makeAuthenticatedRequest(`${baseUrl}:getIamPolicy`); + + if (!policyResponse.ok) { + throw new Error(`Failed to fetch IAM policy: ${await policyResponse.text()}`); + } + + const policy: IamPolicy = await policyResponse.json(); + policy.bindings = policy.bindings || []; + policy.bindings.push({ + role: 'roles/pubsub.publisher', + members: [this.pubsubServiceAccount], + }); + + // Update policy + const updateResponse = await this.makeAuthenticatedRequest(`${baseUrl}:setIamPolicy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ policy }), + }); + + if (!updateResponse.ok) { + throw new Error(`Failed to update IAM policy: ${await updateResponse.text()}`); + } + } + + private async createPubSubSubscription( + subscriptionName: string, + pushEndpoint: string, + ): Promise { + const serviceAccount = this.getServiceAccount(); + const url = `https://pubsub.googleapis.com/v1/projects/${serviceAccount.project_id}/subscriptions/${subscriptionName}`; + + const requestBody = { + topic: `projects/${serviceAccount.project_id}/topics/${subscriptionName}`, + pushConfig: { + oidcToken: { + serviceAccountEmail: serviceAccount.client_email, + }, + pushEndpoint, + noWrapper: { + writeMetadata: true, + }, + }, + }; + + const response = await this.makeAuthenticatedRequest(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to create subscription: ${await response.text()}`); + } + } + + private async setupGmailWatch(connectionData: any, topicName: string): Promise { + // Create Gmail client with OAuth2 + const { OAuth2Client } = await import('google-auth-library'); + const auth = new OAuth2Client({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + }); + + auth.setCredentials({ + refresh_token: connectionData.refreshToken, + scope: 'https://www.googleapis.com/auth/gmail.readonly', + }); + + // Refresh access token + const { credentials } = await auth.refreshAccessToken(); + if (credentials.access_token) { + auth.setCredentials({ + access_token: credentials.access_token, + scope: 'https://www.googleapis.com/auth/gmail.readonly', + }); + } + + // Setup Gmail watch using direct API call instead of heavy googleapis package + const accessToken = credentials.access_token || auth.credentials.access_token; + const serviceAccount = this.getServiceAccount(); + + const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/watch', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + labelIds: ['INBOX'], + topicName: `projects/${serviceAccount.project_id}/topics/${topicName}`, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to setup Gmail watch: ${await response.text()}`); + } + } + + public async subscribe(data: { body: SubscriptionData }): Promise { + const { connectionId } = data.body; + + if (!connectionId) { + return c.json({ error: 'connectionId is required' }, { status: 400 }); + } + + try { + console.log(`[SUBSCRIPTION] Getting connection data for: ${connectionId}`); + const connectionData = await this.getConnectionFromDb(connectionId); + if (!connectionData) { + console.log(`[SUBSCRIPTION] Connection not found: ${connectionId}`); + return c.json({ error: 'connection not found' }, { status: 400 }); + } + + const pubSubName = `notifications__${connectionData.id}`; + const pushEndpoint = getNotificationsUrl(EProviders.google); + console.log(`[SUBSCRIPTION] Generated PubSub name: ${pubSubName}`); + console.log(`[SUBSCRIPTION] Using push endpoint: ${pushEndpoint}`); + + try { + console.log(`[SUBSCRIPTION] Setting up PubSub topic: ${pubSubName}`); + await this.setupPubSubTopic(pubSubName); + + console.log(`[SUBSCRIPTION] Creating PubSub subscription for endpoint: ${pushEndpoint}`); + await this.createPubSubSubscription(pubSubName, pushEndpoint); + + console.log(`[SUBSCRIPTION] Setting up Gmail watch for connection: ${connectionData.id}`); + await this.setupGmailWatch(connectionData, pubSubName); + + await env.gmail_sub_age.put( + `${connectionId}__${EProviders.google}`, + new Date().toISOString(), + ); + + console.log(`[SUBSCRIPTION] Initializing labels for connection: ${connectionId}`); + await this.initializeConnectionLabels(connectionId); + + console.log(`[SUBSCRIPTION] Setup completed successfully for connection: ${connectionId}`); + return c.json({}); + } catch (error) { + console.error('[SUBSCRIPTION] Setup failed:', error); + + // Clean up on failure using base class method + // await this.cleanupOnFailure(connectionId, env); + + if (error instanceof Error && error.message.includes('Already Exists')) { + console.log('Resource already exists, continuing...'); + return c.json({}); + } + + throw error; + } + } catch (error) { + console.error('[SUBSCRIPTION] Error:', error); + + // Clean up on error using base class method + // await this.cleanupOnFailure(connectionId, env); + + return c.json({ error: 'Internal server error' }, { status: 500 }); + } + } + + public async unsubscribe(data: { + body: { connectionId?: string; providerId?: EProviders }; + }): Promise { + const connectionId = data.body.connectionId; + const providerId = data.body.providerId; + + if (!connectionId) { + return c.json({ error: 'connectionId is required' }, { status: 400 }); + } + + const existingState = await env.subscribed_accounts.get(`${connectionId}__${providerId}`); + + if (!existingState || existingState === 'pending') { + return c.json({ message: 'not subscribed' }, { status: 200 }); + } + + await env.subscribed_accounts.delete(`${connectionId}__${providerId}`); + return c.json({}); + } + + public async verifyToken(token: string): Promise { + try { + const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${token}`); + + if (!response.ok) { + return false; + } + + const data = await response.json(); + return !!data; + } catch { + return false; + } + } +} + +// Export class for registry use +export { GoogleSubscriptionFactory }; diff --git a/apps/server/src/lib/factories/outlook-subscription.factory.ts b/apps/server/src/lib/factories/outlook-subscription.factory.ts new file mode 100644 index 0000000000..ffef97de78 --- /dev/null +++ b/apps/server/src/lib/factories/outlook-subscription.factory.ts @@ -0,0 +1,29 @@ +import { + BaseSubscriptionFactory, + type SubscriptionData, + type UnsubscriptionData, +} from './base-subscription.factory'; +import { EProviders } from '../../types'; + +export class OutlookSubscriptionFactory extends BaseSubscriptionFactory { + readonly providerId = EProviders.microsoft; + + public async subscribe(data: { body: SubscriptionData }): Promise { + // TODO: Implement Outlook subscription logic + // This will handle Microsoft Graph API subscriptions for Outlook + + throw new Error('Outlook subscription not implemented yet'); + } + + public async unsubscribe(data: { body: UnsubscriptionData }): Promise { + // TODO: Implement Outlook unsubscription logic + + throw new Error('Outlook unsubscription not implemented yet'); + } + + public async verifyToken(token: string): Promise { + // TODO: Implement Microsoft Graph token verification + + throw new Error('Outlook token verification not implemented yet'); + } +} diff --git a/apps/server/src/lib/factories/subscription-factory.registry.ts b/apps/server/src/lib/factories/subscription-factory.registry.ts new file mode 100644 index 0000000000..5e390c68e5 --- /dev/null +++ b/apps/server/src/lib/factories/subscription-factory.registry.ts @@ -0,0 +1,26 @@ +// import { OutlookSubscriptionFactory } from './outlook-subscription.factory'; +import { GoogleSubscriptionFactory } from './google-subscription.factory'; +import { BaseSubscriptionFactory } from './base-subscription.factory'; +import { EProviders } from '../../types'; + +// Provider factory registry +const subscriptionFactoryRegistry = new Map(); + +// Register Google factory +const googleFactory = new GoogleSubscriptionFactory(); +subscriptionFactoryRegistry.set(EProviders.google, googleFactory); + +export function getSubscriptionFactory(provider: EProviders): BaseSubscriptionFactory { + const factory = subscriptionFactoryRegistry.get(provider); + if (!factory) { + throw new Error(`No subscription factory registered for provider: ${provider}`); + } + return factory; +} + +export function getAllRegisteredProviders(): EProviders[] { + return Array.from(subscriptionFactoryRegistry.keys()); +} + +// Export individual factories for direct access if needed +export { googleFactory }; diff --git a/apps/server/src/lib/server-utils.ts b/apps/server/src/lib/server-utils.ts index 8905d38665..34cb6d8268 100644 --- a/apps/server/src/lib/server-utils.ts +++ b/apps/server/src/lib/server-utils.ts @@ -1,6 +1,7 @@ import { getContext } from 'hono/context-storage'; import { connection, user } from '../db/schema'; import type { HonoContext } from '../ctx'; +import { env } from 'cloudflare:workers'; import { createDriver } from './driver'; import { and, eq } from 'drizzle-orm'; @@ -48,3 +49,83 @@ export const connectionToDriver = (activeConnection: typeof connection.$inferSel }, }); }; + +type NotificationType = 'listThreads' | 'getThread'; + +type ListThreadsNotification = { + type: 'listThreads'; + payload: {}; +}; + +type GetThreadNotification = { + type: 'getThread'; + payload: { + threadId: string; + }; +}; + +const createNotification = ( + type: NotificationType, + payload: ListThreadsNotification['payload'] | GetThreadNotification['payload'], +) => { + return JSON.stringify({ + type, + payload, + }); +}; + +export const notifyUser = async ({ + connectionId, + payload, + type, +}: { + connectionId: string; + payload: ListThreadsNotification['payload'] | GetThreadNotification['payload']; + type: NotificationType; +}) => { + console.log(`[notifyUser] Starting notification for connection ${connectionId}`, { + type, + payload, + }); + + const durableObject = env.ZERO_AGENT.idFromName(connectionId); + const mailbox = env.ZERO_AGENT.get(durableObject); + + try { + console.log(`[notifyUser] Broadcasting message`, { + connectionId, + type, + payload, + }); + await mailbox.broadcast(createNotification(type, payload)); + console.log(`[notifyUser] Successfully broadcasted message`, { + connectionId, + type, + payload, + }); + } catch (error) { + console.error(`[notifyUser] Failed to broadcast message`, { + connectionId, + payload, + type, + error, + }); + throw error; + } +}; + +export const verifyToken = async (token: string) => { + const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${token}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to verify token: ${await response.text()}`); + } + + const data = (await response.json()) as any; + return !!data; +}; diff --git a/apps/server/src/lib/utils.ts b/apps/server/src/lib/utils.ts index dc4e4e7b3d..be262a0668 100644 --- a/apps/server/src/lib/utils.ts +++ b/apps/server/src/lib/utils.ts @@ -1,4 +1,5 @@ -import type { Sender } from '../types'; +import type { AppContext, EProviders, Sender } from '../types'; +import { env } from 'cloudflare:workers'; export const parseHeaders = (token: string) => { const headers = new Headers(); @@ -6,6 +7,39 @@ export const parseHeaders = (token: string) => { return headers; }; +/** + * Mock context for testing + */ +export const c = { + env, + json: (data: any, status: number) => ({ + data, + status, + }), + text: (data: any, status: number) => ({ + data, + status, + }), +} as unknown as AppContext; + +export const getNotificationsUrl = (provider: EProviders) => { + return env.VITE_PUBLIC_BACKEND_URL + '/a8n/notify/' + provider; +}; + +export async function setSubscribedState( + connectionId: string, + providerId: EProviders, +): Promise { + return await env.subscribed_accounts.put( + `${connectionId}__${providerId}`, + new Date().toISOString(), + ); +} + +export async function cleanupOnFailure(connectionId: string): Promise { + return await env.subscribed_accounts.delete(connectionId); +} + export const FOLDERS = { SPAM: 'spam', INBOX: 'inbox', diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index d2555ab86c..1ae8a0bde1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,7 +1,11 @@ +import { MainWorkflow, ThreadWorkflow, ZeroWorkflow } from './pipelines'; import { env, WorkerEntrypoint } from 'cloudflare:workers'; +import { EProviders, type ISubscribeBatch } from './types'; import { contextStorage } from 'hono/context-storage'; import { createLocalJWKSet, jwtVerify } from 'jose'; import { routePartykitRequest } from 'partyserver'; +import { enableBrainFunction } from './lib/brain'; +import { verifyToken } from './lib/server-utils'; import { trpcServer } from '@hono/trpc-server'; import { agentsMiddleware } from 'hono-agents'; import { DurableMailbox } from './lib/party'; @@ -141,7 +145,29 @@ const app = new Hono() }), ) .get('/health', (c) => c.json({ message: 'Zero Server is Up!' })) - .get('/', (c) => c.redirect(`${env.VITE_PUBLIC_APP_URL}`)); + .get('/', (c) => c.redirect(`${env.VITE_PUBLIC_APP_URL}`)) + .post('/a8n/notify/:providerId', async (c) => { + if (!c.req.header('Authorization')) return c.json({ error: 'Unauthorized' }, { status: 401 }); + const providerId = c.req.param('providerId'); + if (providerId === EProviders.google) { + const body = await c.req.json<{ historyId: string }>(); + const subHeader = c.req.header('x-goog-pubsub-subscription-name'); + const isValid = await verifyToken(c.req.header('Authorization')!.split(' ')[1]); + if (!isValid) { + console.log('[GOOGLE] invalid request', body); + return c.json({}, { status: 200 }); + } + const instance = await env.MAIN_WORKFLOW.create({ + params: { + providerId, + historyId: body.historyId, + subscriptionName: subHeader, + }, + }); + console.log('[GOOGLE] created instance', instance.id, instance.status); + return c.json({ message: 'OK' }, { status: 200 }); + } + }); export default class extends WorkerEntrypoint { async fetch(request: Request): Promise { @@ -154,6 +180,72 @@ export default class extends WorkerEntrypoint { return app.fetch(request, this.env, this.ctx); } + async queue(batch: MessageBatch) { + switch (batch.queue) { + case 'subscribe-queue': { + console.log('batch', batch); + try { + await Promise.all( + batch.messages.map(async (msg) => { + const connectionId = msg.body.connectionId; + const providerId = msg.body.providerId; + console.log('connectionId', connectionId); + console.log('providerId', providerId); + try { + await enableBrainFunction({ id: connectionId, providerId }); + } catch (error) { + console.error(`Failed to enable brain function for connection ${connectionId}:`, error); + } + }), + ); + console.log('batch done'); + } finally { + batch.ackAll(); + } + return; + } + } + } + + async scheduled() { + console.log('Checking for expired subscriptions...'); + const allAccounts = await env.subscribed_accounts.list(); + console.log('allAccounts', allAccounts.keys); + const now = new Date(); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + + const expiredSubscriptions: Array<{ connectionId: string; providerId: EProviders }> = []; + + await Promise.all( + allAccounts.keys.map(async (key) => { + const [connectionId, providerId] = key.name.split('__'); + const lastSubscribed = await env.gmail_sub_age.get(key.name); + + if (lastSubscribed) { + const subscriptionDate = new Date(lastSubscribed); + if (subscriptionDate < fiveDaysAgo) { + console.log(`Found expired Google subscription for connection: ${connectionId}`); + expiredSubscriptions.push({ connectionId, providerId: providerId as EProviders }); + } + } + }), + ); + + // Send expired subscriptions to queue for renewal + if (expiredSubscriptions.length > 0) { + console.log(`Sending ${expiredSubscriptions.length} expired subscriptions to renewal queue`); + await Promise.all( + expiredSubscriptions.map(async ({ connectionId, providerId }) => { + await env.subscribe_queue.send({ connectionId, providerId }); + }), + ); + } + + console.log( + `Processed ${allAccounts.keys.length} accounts, found ${expiredSubscriptions.length} expired subscriptions`, + ); + } + public async notifyUser({ connectionId, threadIds, @@ -180,4 +272,4 @@ export default class extends WorkerEntrypoint { } } -export { DurableMailbox, ZeroAgent, ZeroMCP }; +export { DurableMailbox, ZeroAgent, ZeroMCP, MainWorkflow, ZeroWorkflow, ThreadWorkflow }; diff --git a/apps/server/src/pipelines.ts b/apps/server/src/pipelines.ts new file mode 100644 index 0000000000..604cda2346 --- /dev/null +++ b/apps/server/src/pipelines.ts @@ -0,0 +1,713 @@ +import { + ReSummarizeThread, + SummarizeMessage, + SummarizeThread, + ThreadLabels, +} from './lib/brain.fallback.prompts'; +import { defaultLabels, EPrompts, EProviders, type ParsedMessage, type Sender } from './types'; +import { WorkflowEntrypoint, WorkflowStep, type WorkflowEvent } from 'cloudflare:workers'; +import { connectionToDriver } from './lib/server-utils'; +import { type gmail_v1 } from '@googleapis/gmail'; +import { env } from 'cloudflare:workers'; +import { connection } from './db/schema'; +import * as cheerio from 'cheerio'; +import { eq } from 'drizzle-orm'; +import { createDb } from './db'; +import { z } from 'zod'; + +const showLogs = true; + +const log = (message: string, ...args: any[]) => { + if (showLogs) { + console.log(message, ...args); + } +}; + +type VectorizeVectorMetadata = 'connection' | 'thread' | 'summary'; + +type IThreadSummaryMetadata = Record; + +export class MainWorkflow extends WorkflowEntrypoint { + async run( + event: Readonly>>, + step: WorkflowStep, + ) { + await step.do('[MAIN_WORKFLOW] Delete all processing threads', async () => { + log('[MAIN_WORKFLOW] Deleting all processing threads'); + const processingThreads = await env.gmail_processing_threads.list(); + log('[MAIN_WORKFLOW] Found processing threads:', processingThreads.keys.length); + for (const threadId of processingThreads.keys) { + await env.gmail_processing_threads.delete(threadId.name.toString()); + } + log('[MAIN_WORKFLOW] Deleted all processing threads'); + }); + log('[MAIN_WORKFLOW] Starting workflow with payload:', event.payload); + const { providerId, historyId, subscriptionName } = event.payload; + const connectionId = await step.do( + `[ZERO] Validate Arguments ${providerId} ${subscriptionName} ${historyId}`, + async () => { + log('[MAIN_WORKFLOW] Validating arguments'); + const serviceAccount = JSON.parse(env.GOOGLE_S_ACCOUNT); + const regex = new RegExp( + `projects/${serviceAccount.project_id}/subscriptions/notifications__([a-z0-9-]+)`, + ); + const match = subscriptionName.toString().match(regex); + if (!match) { + log('[MAIN_WORKFLOW] Invalid subscription name:', subscriptionName); + throw new Error('Invalid subscription name'); + } + const [, connectionId] = match; + log('[MAIN_WORKFLOW] Extracted connectionId:', connectionId); + const status = await env.subscribed_accounts.get(`${connectionId}__${providerId}`); + log('[MAIN_WORKFLOW] Connection status:', status); + if (!status) throw new Error('Connection not found'); + if (status === 'pending') throw new Error('Connection is pending'); + return connectionId; + }, + ); + if (!connectionId) { + log('[MAIN_WORKFLOW] Connection id is missing'); + throw new Error('Connection id is required'); + } + if (!isValidUUID(connectionId)) { + log('[MAIN_WORKFLOW] Invalid connection id format:', connectionId); + throw new Error('Invalid connection id'); + } + if (providerId === EProviders.google) { + log('[MAIN_WORKFLOW] Processing Google provider workflow'); + await step.do(`[ZERO] Send to Zero Workflow ${connectionId} ${historyId}`, async () => { + const previousHistoryId = await env.gmail_history_id.get(connectionId); + log('[MAIN_WORKFLOW] Previous history ID:', previousHistoryId); + if (previousHistoryId) { + log('[MAIN_WORKFLOW] Creating workflow instance with previous history'); + const instance = await env.ZERO_WORKFLOW.create({ + params: { + connectionId, + historyId: previousHistoryId, + nextHistoryId: historyId, + }, + }); + log('[MAIN_WORKFLOW] Created instance:', { + id: instance.id, + status: instance.status, + }); + } else { + log('[MAIN_WORKFLOW] Creating workflow instance with current history'); + const instance = await env.ZERO_WORKFLOW.create({ + params: { + connectionId, + historyId: historyId, + nextHistoryId: historyId, + }, + }); + log('[MAIN_WORKFLOW] Created instance:', { + id: instance.id, + status: instance.status, + }); + } + }); + } else { + log('[MAIN_WORKFLOW] Unsupported provider:', providerId); + } + log('[MAIN_WORKFLOW] Workflow completed successfully'); + } +} + +export class ZeroWorkflow extends WorkflowEntrypoint { + async run( + event: Readonly>>, + step: WorkflowStep, + ) { + log('[ZERO_WORKFLOW] Starting workflow with payload:', event.payload); + const { connectionId, historyId, nextHistoryId } = event.payload; + const db = createDb(env.HYPERDRIVE.connectionString); + const foundConnection = await step.do(`[ZERO] Find Connection ${connectionId}`, async () => { + log('[ZERO_WORKFLOW] Finding connection:', connectionId); + const [foundConnection] = await db + .select() + .from(connection) + .where(eq(connection.id, connectionId.toString())); + if (!foundConnection) throw new Error('Connection not found'); + if (!foundConnection.accessToken || !foundConnection.refreshToken) + throw new Error('Connection is not authorized'); + log('[ZERO_WORKFLOW] Found connection:', foundConnection.id); + return foundConnection; + }); + + const driver = connectionToDriver(foundConnection); + if (foundConnection.providerId === EProviders.google) { + log('[ZERO_WORKFLOW] Processing Google provider workflow'); + const history = await step.do( + `[ZERO] Get Gmail History for ${foundConnection.id}`, + async () => { + log('[ZERO_WORKFLOW] Getting Gmail history with ID:', historyId); + const { history } = await driver.listHistory( + historyId.toString(), + ); + if (!history.length) throw new Error('No history found'); + log('[ZERO_WORKFLOW] Found history entries:', history.length); + return history; + }, + ); + await step.do(`[ZERO] Update next history id for ${foundConnection.id}`, async () => { + log('[ZERO_WORKFLOW] Updating next history ID:', nextHistoryId); + await env.gmail_history_id.put(connectionId.toString(), nextHistoryId.toString()); + }); + const threadsAdded = await step.do('[ZERO] Get new Threads', async () => { + log('[ZERO_WORKFLOW] Finding threads with changed messages'); + const historiesWithChangedMessages = history.filter( + (history) => history.messagesAdded?.length, + ); + const threadsAdded = [ + ...new Set( + historiesWithChangedMessages.flatMap((history) => + history + .messagesAdded!.map((message) => message.message?.threadId) + .filter((threadId): threadId is string => threadId !== undefined), + ), + ), + ]; + log('[ZERO_WORKFLOW] Found new threads:', threadsAdded.length); + return threadsAdded; + }); + + const threadsAddLabels = await step.do('[ZERO] Get Threads with new labels', async () => { + log('[ZERO_WORKFLOW] Finding threads with new labels'); + const historiesWithNewLabels = history.filter((history) => history.labelsAdded?.length); + const threadsWithLabelsAdded = [ + ...new Set( + historiesWithNewLabels.flatMap((history) => + history + .labelsAdded!.filter((label) => label.message?.threadId) + .map((label) => label.message!.threadId) + .filter((threadId): threadId is string => threadId !== undefined), + ), + ), + ]; + log('[ZERO_WORKFLOW] Found threads with new labels:', threadsWithLabelsAdded.length); + return threadsWithLabelsAdded; + }); + + const threadsRemoveLabels = await step.do( + '[ZERO] Get Threads with removed labels', + async () => { + log('[ZERO_WORKFLOW] Finding threads with removed labels'); + const historiesWithRemovedLabels = history.filter( + (history) => history.labelsRemoved?.length, + ); + const threadsWithLabelsRemoved = [ + ...new Set( + historiesWithRemovedLabels.flatMap((history) => + history + .labelsRemoved!.filter((label) => label.message?.threadId) + .map((label) => label.message!.threadId) + .filter((threadId): threadId is string => threadId !== undefined), + ), + ), + ]; + log( + '[ZERO_WORKFLOW] Found threads with removed labels:', + threadsWithLabelsRemoved.length, + ); + return threadsWithLabelsRemoved; + }, + ); + + // TODO: Notify user about new threads + + const lastPage = await step.do('[ZERO] Get last page', async () => { + log('[ZERO_WORKFLOW] Getting last page of threads'); + const lastThreads = await driver.list({ + folder: 'inbox', + query: 'NOT is:spam', + maxResults: 1, + }); + log('[ZERO_WORKFLOW] Found threads in last page:', lastThreads.threads.length); + return lastThreads.threads.map((thread) => thread.id); + }); + + const threadsToProcess = await step.do('[ZERO] Get threads to process', async () => { + log('[ZERO_WORKFLOW] Combining threads to process'); + const threadsToProcess = [ + ...new Set([...threadsAdded, ...lastPage, ...threadsAddLabels, ...threadsRemoveLabels]), + ]; + log('[ZERO_WORKFLOW] Total threads to process:', threadsToProcess.length); + return threadsToProcess; + }); + + // we send individually to avoid rate limiting + await step.do(`[ZERO] Send Thread Workflow Instances`, async () => { + for (const threadId of threadsToProcess) { + const isProcessing = await env.gmail_processing_threads.get(threadId.toString()); + if (isProcessing) { + log('[ZERO_WORKFLOW] Thread already processing:', isProcessing, threadId); + continue; + } + await env.gmail_processing_threads.put(threadId.toString(), 'true'); + await env.THREAD_WORKFLOW.create({ + params: { connectionId, threadId, providerId: foundConnection.providerId }, + }); + log('[ZERO_WORKFLOW] Sleeping for 4 seconds:', threadId); + await step.sleep('[ZERO_WORKFLOW]', 4000); + log('[ZERO_WORKFLOW] Done sleeping:', threadId); + await env.gmail_processing_threads.delete(threadId.toString()); + } + }); + } + } +} + +export class ThreadWorkflow extends WorkflowEntrypoint { + async run( + event: Readonly>>, + step: WorkflowStep, + ) { + log('[THREAD_WORKFLOW] Starting workflow with payload:', event.payload); + const { connectionId, threadId, providerId } = event.payload; + if (providerId === EProviders.google) { + log('[THREAD_WORKFLOW] Processing Google provider workflow'); + const db = createDb(env.HYPERDRIVE.connectionString); + const foundConnection = await step.do(`[ZERO] Find Connection ${connectionId}`, async () => { + log('[THREAD_WORKFLOW] Finding connection:', connectionId); + const [foundConnection] = await db + .select() + .from(connection) + .where(eq(connection.id, connectionId.toString())); + if (!foundConnection) throw new Error('Connection not found'); + if (!foundConnection.accessToken || !foundConnection.refreshToken) + throw new Error('Connection is not authorized'); + log('[THREAD_WORKFLOW] Found connection:', foundConnection.id); + return foundConnection; + }); + const driver = connectionToDriver(foundConnection); + const thread = await step.do(`[ZERO] Get Thread ${threadId}`, async () => { + log('[THREAD_WORKFLOW] Getting thread:', threadId); + const thread = await driver.get(threadId.toString()); + log('[THREAD_WORKFLOW] Found thread with messages:', thread.messages.length); + return thread; + }); + const messagesToVectorize = await step.do( + `[ZERO] Get Thread Messages ${threadId}`, + async () => { + log('[THREAD_WORKFLOW] Finding messages to vectorize'); + log('[THREAD_WORKFLOW] Getting message IDs from thread'); + const messageIds = thread.messages.map((message) => message.id); + log('[THREAD_WORKFLOW] Found message IDs:', messageIds); + + log('[THREAD_WORKFLOW] Fetching existing vectorized messages'); + const existingMessages = await env.VECTORIZE_MESSAGE.getByIds(messageIds); + log('[THREAD_WORKFLOW] Found existing messages:', existingMessages.length); + + const existingMessageIds = new Set(existingMessages.map((message) => message.id)); + log('[THREAD_WORKFLOW] Existing message IDs:', Array.from(existingMessageIds)); + + const messagesToVectorize = thread.messages.filter( + (message) => !existingMessageIds.has(message.id), + ); + log('[THREAD_WORKFLOW] Messages to vectorize:', messagesToVectorize.length); + + return messagesToVectorize; + }, + ); + const finalEmbeddings: VectorizeVector[] = await step.do( + `[ZERO] Vectorize Messages`, + async () => { + log( + '[THREAD_WORKFLOW] Starting message vectorization for', + messagesToVectorize.length, + 'messages', + ); + return await Promise.all( + messagesToVectorize.map(async (message) => { + return step.do(`[ZERO] Vectorize Message ${message.id}`, async () => { + log('[THREAD_WORKFLOW] Converting message to XML:', message.id); + const prompt = await messageToXML(message); + if (!prompt) throw new Error('Message has no prompt'); + log('[THREAD_WORKFLOW] Got XML prompt for message:', message.id); + + const SummarizeMessagePrompt = await step.do( + `[ZERO] Get Summarize Message Prompt ${message.id}`, + async () => { + if (!message.connectionId) throw new Error('Message has no connection id'); + log( + '[THREAD_WORKFLOW] Getting summarize prompt for connection:', + message.connectionId, + ); + return await getPrompt( + getPromptName(message.connectionId, EPrompts.SummarizeMessage), + SummarizeMessage, + ); + }, + ); + log('[THREAD_WORKFLOW] Got summarize prompt for message:', message.id); + + const summary: string = await step.do( + `[ZERO] Summarize Message ${message.id}`, + async () => { + log('[THREAD_WORKFLOW] Generating summary for message:', message.id); + const messages = [ + { role: 'system', content: SummarizeMessagePrompt }, + { + role: 'user', + content: prompt, + }, + ]; + const response: any = await env.AI.run( + '@cf/meta/llama-4-scout-17b-16e-instruct', + { + messages, + }, + ); + log(`[THREAD_WORKFLOW] Summary generated for message ${message.id}:`, response); + return 'response' in response ? response.response : response; + }, + ); + + const embeddingVector = await step.do( + `[ZERO] Get Message Embedding Vector ${message.id}`, + async () => { + log('[THREAD_WORKFLOW] Getting embedding vector for message:', message.id); + const embeddingVector = await getEmbeddingVector(summary); + log('[THREAD_WORKFLOW] Got embedding vector for message:', message.id); + return embeddingVector; + }, + ); + + if (!embeddingVector) throw new Error('Message Embedding vector is null'); + + return { + id: message.id, + metadata: { + connection: message.connectionId ?? '', + thread: message.threadId ?? '', + summary, + }, + values: embeddingVector, + } satisfies VectorizeVector; + }); + }), + ); + }, + ); + log('[THREAD_WORKFLOW] Generated embeddings for all messages'); + + await step.do( + `[ZERO] Thread Messages Vectors ${threadId} / ${finalEmbeddings.length}`, + async () => { + log('[THREAD_WORKFLOW] Upserting message vectors:', finalEmbeddings.length); + await env.VECTORIZE_MESSAGE.upsert(finalEmbeddings); + log('[THREAD_WORKFLOW] Successfully upserted message vectors'); + }, + ); + + const existingThreadSummary = await step.do( + `[ZERO] Get Thread Summary ${threadId}`, + async () => { + log('[THREAD_WORKFLOW] Getting existing thread summary for:', threadId); + const threadSummary = await env.VECTORIZE.getByIds([threadId.toString()]); + if (!threadSummary.length) { + log('[THREAD_WORKFLOW] No existing thread summary found'); + return null; + } + log('[THREAD_WORKFLOW] Found existing thread summary'); + return threadSummary[0].metadata as IThreadSummaryMetadata; + }, + ); + + const finalSummary = await step.do(`[ZERO] Get Final Summary ${threadId}`, async () => { + log('[THREAD_WORKFLOW] Generating final thread summary'); + if (existingThreadSummary) { + log('[THREAD_WORKFLOW] Using existing summary as context'); + return await summarizeThread( + connectionId.toString(), + thread.messages, + existingThreadSummary.summary, + ); + } else { + log('[THREAD_WORKFLOW] Generating new summary without context'); + return await summarizeThread(connectionId.toString(), thread.messages); + } + }); + + const userAccountLabels = await step.do( + `[ZERO] Get user-account labels ${connectionId}`, + async () => { + const userAccountLabels = await driver.getUserLabels(); + return userAccountLabels; + }, + ); + + if (finalSummary) { + log('[THREAD_WORKFLOW] Got final summary, processing labels'); + const userLabels = await step.do( + `[ZERO] Get user-defined labels ${connectionId}`, + async () => { + log('[THREAD_WORKFLOW] Getting user labels for connection:', connectionId); + let userLabels: { name: string; usecase: string }[] = []; + const connectionLabels = await env.connection_labels.get(connectionId.toString()); + if (connectionLabels) { + try { + log('[THREAD_WORKFLOW] Parsing existing connection labels'); + const parsed = JSON.parse(connectionLabels); + userLabels = parsed; + } catch { + log('[THREAD_WORKFLOW] Failed to parse labels, using defaults'); + await env.connection_labels.put( + connectionId.toString(), + JSON.stringify(defaultLabels), + ); + userLabels = defaultLabels; + } + } else { + log('[THREAD_WORKFLOW] No labels found, using defaults'); + await env.connection_labels.put( + connectionId.toString(), + JSON.stringify(defaultLabels), + ); + userLabels = defaultLabels; + } + return userLabels.length ? userLabels : defaultLabels; + }, + ); + + const generatedLabels = await step.do( + `[ZERO] Generate Thread Labels ${threadId} / ${thread.messages.length}`, + async () => { + log('[THREAD_WORKFLOW] Generating labels for thread:', threadId); + const labelsResponse: any = await env.AI.run( + '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + { + messages: [ + { role: 'system', content: ThreadLabels(userLabels) }, + { role: 'user', content: finalSummary }, + ], + }, + ); + if (labelsResponse?.response?.replaceAll('!', '').trim()?.length) { + log('[THREAD_WORKFLOW] Labels generated:', labelsResponse.response); + const labels: string[] = labelsResponse?.response + ?.split(',') + .filter((e: string) => + userLabels.find((label) => label.name.toLowerCase() === e.toLowerCase()), + ); + return labels; + } else { + log('[THREAD_WORKFLOW] No labels generated'); + } + }, + ); + + if (generatedLabels) { + await step.do(`[ZERO] Modify Thread Labels ${threadId}`, async () => { + log('[THREAD_WORKFLOW] Modifying thread labels:', generatedLabels); + const validLabelIds = generatedLabels + .map((name) => userAccountLabels.find((e) => e.name === name)?.id) + .filter((id): id is string => id !== undefined && id !== ''); + + if (validLabelIds.length > 0) { + await driver.modifyLabels([threadId.toString()], { + addLabels: validLabelIds, + removeLabels: [], + }); + } + log('[THREAD_WORKFLOW] Successfully modified thread labels'); + }); + } + + const embeddingVector = await step.do( + `[ZERO] Get Thread Embedding Vector ${threadId}`, + async () => { + log('[THREAD_WORKFLOW] Getting thread embedding vector'); + const embeddingVector = await getEmbeddingVector(finalSummary); + log('[THREAD_WORKFLOW] Got thread embedding vector'); + return embeddingVector; + }, + ); + + if (!embeddingVector) throw new Error('Thread Embedding vector is null'); + + log('[THREAD_WORKFLOW] Upserting thread vector'); + await env.VECTORIZE.upsert([ + { + id: threadId.toString(), + metadata: { + connection: connectionId.toString(), + thread: threadId.toString(), + summary: finalSummary, + }, + values: embeddingVector, + }, + ]); + log('[THREAD_WORKFLOW] Successfully upserted thread vector'); + } else { + log('[THREAD_WORKFLOW] No summary generated for thread', threadId, thread.messages.length); + } + } + } +} + +export async function htmlToText(decodedBody: string): Promise { + try { + const $ = cheerio.load(decodedBody); + $('script').remove(); + $('style').remove(); + return $('body') + .text() + .replace(/\r?\n|\r/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } catch (error) { + log('Error extracting text from HTML:', error); + throw new Error('Failed to extract text from HTML'); + } +} + +const messageToXML = async (message: ParsedMessage) => { + if (!message.decodedBody) return null; + const body = await htmlToText(message.decodedBody || ''); + log('[MESSAGE_TO_XML] Body', body); + if (!body || (body?.length || 20) < 20) { + log('Skipping message with body length < 20', body); + return null; + } + return ` + + ${message.sender.name} + ${message.to.map((r) => `${r.email}`).join('')} + ${message.cc ? message.cc.map((r) => `${r.email}`).join('') : ''} + ${message.receivedOn} + ${message.subject} + ${body} + + `; +}; + +export const getPromptName = (connectionId: string, prompt: EPrompts) => { + return `${connectionId}-${prompt}`; +}; + +export const getPrompt = async (promptName: string, fallback: string) => { + const existingPrompt = await env.prompts_storage.get(promptName); + if (!existingPrompt) { + await env.prompts_storage.put(promptName, fallback); + return fallback; + } + return existingPrompt; +}; + +export const getEmbeddingVector = async (text: string) => { + try { + const embeddingResponse = await env.AI.run( + '@cf/baai/bge-large-en-v1.5', + { text }, + { + gateway: { + id: 'vectorize-save', + }, + }, + ); + const embeddingVector = embeddingResponse.data[0]; + return embeddingVector ?? null; + } catch (error) { + log('[getEmbeddingVector] failed', error); + return null; + } +}; + +const isValidUUID = (string: string) => { + return z.string().uuid().safeParse(string).success; +}; + +const getParticipants = (messages: ParsedMessage[]) => { + const result = new Map(); + const setIfUnset = (sender: Sender) => { + if (!result.has(sender.email)) result.set(sender.email, sender.name); + }; + for (const msg of messages) { + setIfUnset(msg.sender); + for (const ccParticipant of msg.cc ?? []) { + setIfUnset(ccParticipant); + } + for (const toParticipant of msg.to) { + setIfUnset(toParticipant); + } + } + return Array.from(result.entries()); +}; + +const threadToXML = async (messages: ParsedMessage[], existingSummary?: string) => { + const { subject, title } = messages[0]; + const participants = getParticipants(messages); + const messagesXML = await Promise.all(messages.map(messageToXML)); + if (existingSummary) { + return ` + ${title} + ${subject} + + ${participants.map(([email, name]) => { + return `${name ?? email} ${name ? `< ${email} >` : ''}`; + })} + + + ${existingSummary} + + + ${messagesXML.map((e) => e + '\n')} + + `; + } + return ` + ${title} + ${subject} + + ${participants.map(([email, name]) => { + return `${name} < ${email} >`; + })} + + + ${messagesXML.map((e) => e + '\n')} + + `; +}; + +const summarizeThread = async ( + connectionId: string, + messages: ParsedMessage[], + existingSummary?: string, +): Promise => { + if (existingSummary) { + const prompt = await threadToXML(messages, existingSummary); + const ReSummarizeThreadPrompt = await getPrompt( + getPromptName(connectionId, EPrompts.ReSummarizeThread), + ReSummarizeThread, + ); + const promptMessages = [ + { role: 'system', content: ReSummarizeThreadPrompt }, + { + role: 'user', + content: prompt, + }, + ]; + const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + messages: promptMessages, + }); + return response.response ?? null; + } else { + const prompt = await threadToXML(messages, existingSummary); + const SummarizeThreadPrompt = await getPrompt( + getPromptName(connectionId, EPrompts.SummarizeThread), + SummarizeThread, + ); + const promptMessages = [ + { role: 'system', content: SummarizeThreadPrompt }, + { + role: 'user', + content: prompt, + }, + ]; + const response: any = await env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { + messages: promptMessages, + }); + return response.response ?? null; + } +}; diff --git a/apps/server/src/trpc/routes/brain.ts b/apps/server/src/trpc/routes/brain.ts index 4c39d9fe30..b55e5117f2 100644 --- a/apps/server/src/trpc/routes/brain.ts +++ b/apps/server/src/trpc/routes/brain.ts @@ -1,23 +1,10 @@ -import { disableBrainFunction, enableBrainFunction, getPrompts } from '../../lib/brain'; +import { disableBrainFunction, getPrompts } from '../../lib/brain'; +import { EProviders, type ISubscribeBatch } from '../../types'; import { activeConnectionProcedure, router } from '../trpc'; +import { setSubscribedState } from '../../lib/utils'; import { env } from 'cloudflare:workers'; import { z } from 'zod'; -/** - * Gets the current connection limit for a given connection ID - * @param connectionId The connection ID to check - * @returns Promise The current limit - */ -export const getConnectionLimit = async (connectionId: string): Promise => { - try { - const limit = await env.connection_limits.get(connectionId); - return limit ? Number(limit) : Number(env.DEFAULT_BRAIN_LIMIT); - } catch (error) { - console.error(`[GET_CONNECTION_LIMIT] Error getting limit for ${connectionId}:`, error); - throw error; - } -}; - const labelSchema = z.object({ name: z.string(), usecase: z.string(), @@ -26,38 +13,20 @@ const labelSchema = z.object({ const labelsSchema = z.array(labelSchema); export const brainRouter = router({ - enableBrain: activeConnectionProcedure - .input( - z.object({ - connection: z - .object({ - id: z.string(), - providerId: z.string(), - }) - .optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - let { connection } = input; - if (!connection) connection = ctx.activeConnection; - return await enableBrainFunction(connection); - }), - disableBrain: activeConnectionProcedure - .input( - z.object({ - connection: z - .object({ - id: z.string(), - providerId: z.string(), - }) - .optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - let { connection } = input; - if (!connection) connection = ctx.activeConnection; - return await disableBrainFunction(connection); - }), + enableBrain: activeConnectionProcedure.mutation(async ({ ctx }) => { + const connection = ctx.activeConnection as { id: string; providerId: EProviders }; + await setSubscribedState(connection.id, connection.providerId); + await env.subscribe_queue.send({ + connectionId: connection.id, + providerId: connection.providerId, + } as ISubscribeBatch); + return true; + // return await enableBrainFunction(connection); + }), + disableBrain: activeConnectionProcedure.mutation(async ({ ctx }) => { + const connection = ctx.activeConnection as { id: string; providerId: EProviders }; + return await disableBrainFunction(connection); + }), generateSummary: activeConnectionProcedure .input( @@ -65,13 +34,14 @@ export const brainRouter = router({ threadId: z.string(), }), ) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const { threadId } = input; const response = await env.VECTORIZE.getByIds([threadId]); if (response.length && response?.[0]?.metadata?.['content']) { - const content = response[0].metadata['content'] as string; + const result = response[0].metadata as { content: string; connection: string }; + if (result.connection !== ctx.activeConnection.id) return null; const shortResponse = await env.AI.run('@cf/facebook/bart-large-cnn', { - input_text: content, + input_text: result.content, }); return { data: { @@ -83,10 +53,9 @@ export const brainRouter = router({ }), getState: activeConnectionProcedure.query(async ({ ctx }) => { const connection = ctx.activeConnection; - const state = await env.subscribed_accounts.get(connection.id); + const state = await env.subscribed_accounts.get(`${connection.id}__${connection.providerId}`); if (!state || state === 'pending') return { enabled: false }; - const limit = await getConnectionLimit(connection.id); - return { limit, enabled: true }; + return { enabled: true }; }), getLabels: activeConnectionProcedure .output( diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index ddf79858e8..6bc203a8b2 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -1,5 +1,48 @@ import type { Context } from 'hono'; +export enum EProviders { + 'google' = 'google', + 'microsoft' = 'microsoft', +} + +export interface ISubscribeBatch { + connectionId: string; + providerId: EProviders; +} + +export const defaultLabels = [ + { + name: 'to respond', + usecase: 'emails you need to respond to. NOT sales, marketing, or promotions.', + }, + { + name: 'FYI', + usecase: + 'emails that are not important, but you should know about. NOT sales, marketing, or promotions.', + }, + { + name: 'comment', + usecase: + 'Team chats in tools like Google Docs, Slack, etc. NOT marketing, sales, or promotions.', + }, + { + name: 'notification', + usecase: 'Automated updates from services you use. NOT sales, marketing, or promotions.', + }, + { + name: 'promotion', + usecase: 'Sales, marketing, cold emails, special offers or promotions. NOT to respond to.', + }, + { + name: 'meeting', + usecase: 'Calendar events, invites, etc. NOT sales, marketing, or promotions.', + }, + { + name: 'billing', + usecase: 'Billing notifications. NOT sales, marketing, or promotions.', + }, +]; + export type Label = { id: string; name: string; diff --git a/apps/server/wrangler.jsonc b/apps/server/wrangler.jsonc index 27fb13bd12..d33f10296c 100644 --- a/apps/server/wrangler.jsonc +++ b/apps/server/wrangler.jsonc @@ -35,6 +35,26 @@ }, ], }, + "queues": { + "producers": [ + { + "queue": "thread-queue", + "binding": "thread_queue", + }, + { + "queue": "subscribe-queue", + "binding": "subscribe_queue", + }, + ], + "consumers": [ + { + "queue": "subscribe-queue", + }, + { + "queue": "thread-queue", + }, + ], + }, "migrations": [ { "tag": "v1", @@ -45,10 +65,21 @@ "new_sqlite_classes": ["ZeroAgent", "ZeroMCP"], }, ], - "services": [ + "workflows": [ + { + "name": "main-workflow", + "binding": "MAIN_WORKFLOW", + "class_name": "MainWorkflow", + }, + { + "name": "zero-workflow", + "binding": "ZERO_WORKFLOW", + "class_name": "ZeroWorkflow", + }, { - "binding": "zero", - "service": "zero-worker", + "name": "thread-workflow", + "binding": "THREAD_WORKFLOW", + "class_name": "ThreadWorkflow", }, ], "observability": { @@ -70,23 +101,32 @@ "ELEVENLABS_API_KEY": "1234567890", "DISABLE_CALLS": "true", "VOICE_SECRET": "1234567890", + "GOOGLE_S_ACCOUNT": "{}", }, "kv_namespaces": [ + { + "binding": "gmail_history_id", + "id": "4e814c70e35d413d99c923029928efae", + }, + { + "binding": "gmail_processing_threads", + "id": "b7db3a98a80f4e16a8b6edc5fa8c7b76", + }, { "binding": "subscribed_accounts", - "id": "cd3ff4a80734444c98aee76ea9166e3d", + "id": "7e6eadacf19c4c56a9ec3c357adb584a", }, { "binding": "connection_labels", - "id": "26c5a521b1294ef88d36b96e5617c428", + "id": "4d3a28d3265a4388aae2e9e9b534d019", }, { "binding": "prompts_storage", - "id": "1caf4e863e2149519cef97f2ba3c9851", + "id": "620e710aaea744e59df4788f9ec18ff9", }, { - "binding": "connection_limits", - "id": "19d415777f554b7c8fbe6eca7dc87918", + "binding": "gmail_sub_age", + "id": "c55e692bb71d4e5bae23dded092b09d5", }, ], }, @@ -123,6 +163,26 @@ }, ], }, + "queues": { + "producers": [ + { + "queue": "thread-queue", + "binding": "thread_queue", + }, + { + "queue": "subscribe-queue", + "binding": "subscribe_queue", + }, + ], + "consumers": [ + { + "queue": "subscribe-queue", + }, + { + "queue": "thread-queue", + }, + ], + }, "migrations": [ { "tag": "v1", @@ -133,10 +193,21 @@ "new_sqlite_classes": ["ZeroAgent", "ZeroMCP"], }, ], - "services": [ + "workflows": [ + { + "name": "main-workflow", + "binding": "MAIN_WORKFLOW", + "class_name": "MainWorkflow", + }, { - "binding": "zero", - "service": "zero-worker", + "name": "zero-workflow", + "binding": "ZERO_WORKFLOW", + "class_name": "ZeroWorkflow", + }, + { + "name": "thread-workflow", + "binding": "THREAD_WORKFLOW", + "class_name": "ThreadWorkflow", }, ], "observability": { @@ -155,23 +226,32 @@ "VITE_PUBLIC_BACKEND_URL": "https://sapi.0.email", "VITE_PUBLIC_APP_URL": "https://staging.0.email", "DISABLE_CALLS": "", + "GOOGLE_S_ACCOUNT": "{}", }, "kv_namespaces": [ + { + "binding": "gmail_history_id", + "id": "4e814c70e35d413d99c923029928efae", + }, + { + "binding": "gmail_processing_threads", + "id": "b7db3a98a80f4e16a8b6edc5fa8c7b76", + }, { "binding": "subscribed_accounts", - "id": "cd3ff4a80734444c98aee76ea9166e3d", + "id": "7e6eadacf19c4c56a9ec3c357adb584a", }, { "binding": "connection_labels", - "id": "26c5a521b1294ef88d36b96e5617c428", + "id": "4d3a28d3265a4388aae2e9e9b534d019", }, { "binding": "prompts_storage", - "id": "1caf4e863e2149519cef97f2ba3c9851", + "id": "620e710aaea744e59df4788f9ec18ff9", }, { - "binding": "connection_limits", - "id": "19d415777f554b7c8fbe6eca7dc87918", + "binding": "gmail_sub_age", + "id": "c55e692bb71d4e5bae23dded092b09d5", }, ], }, @@ -189,6 +269,23 @@ "index_name": "messages-vector", }, ], + "workflows": [ + { + "name": "main-workflow", + "binding": "MAIN_WORKFLOW", + "class_name": "MainWorkflow", + }, + { + "name": "zero-workflow", + "binding": "ZERO_WORKFLOW", + "class_name": "ZeroWorkflow", + }, + { + "name": "thread-workflow", + "binding": "THREAD_WORKFLOW", + "class_name": "ThreadWorkflow", + }, + ], "observability": { "enabled": true, }, @@ -215,6 +312,26 @@ }, ], }, + "queues": { + "producers": [ + { + "queue": "thread-queue", + "binding": "thread_queue", + }, + { + "queue": "subscribe-queue", + "binding": "subscribe_queue", + }, + ], + "consumers": [ + { + "queue": "subscribe-queue", + }, + { + "queue": "thread-queue", + }, + ], + }, "migrations": [ { "tag": "v1", @@ -225,35 +342,38 @@ "new_sqlite_classes": ["ZeroAgent", "ZeroMCP"], }, ], - "services": [ - { - "binding": "zero", - "service": "zero-worker", - }, - ], "vars": { "NODE_ENV": "production", "COOKIE_DOMAIN": "0.email", "VITE_PUBLIC_BACKEND_URL": "https://api.0.email", "VITE_PUBLIC_APP_URL": "https://0.email", "DISABLE_CALLS": "true", + "GOOGLE_S_ACCOUNT": "{}", }, "kv_namespaces": [ + { + "binding": "gmail_history_id", + "id": "10005d74e84f4f18a17c9618d9e9cecf", + }, + { + "binding": "gmail_processing_threads", + "id": "3348ff0976284269a8d8a5e6e4c04c56", + }, { "binding": "subscribed_accounts", - "id": "0528897082354647a1c371d338db13d5", + "id": "5902b3b948ff4c4ba1aedbbbbe25503d", }, { "binding": "connection_labels", - "id": "26c5a521b1294ef88d36b96e5617c428", + "id": "9a13290a55ad4f62824c67005dd66f6f", }, { "binding": "prompts_storage", - "id": "1caf4e863e2149519cef97f2ba3c9851", + "id": "2a4ebda553f3456085cfcf92cc0f570f", }, { - "binding": "connection_limits", - "id": "19d415777f554b7c8fbe6eca7dc87918", + "binding": "gmail_sub_age", + "id": "0591e91fffcc4675aaf00f909bee77d2", }, ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 527b5fe54c..3c9ab0dca2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -530,6 +530,9 @@ importers: better-auth: specifier: 'catalog:' version: 1.2.9 + cheerio: + specifier: 1.1.0 + version: 1.1.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -3860,6 +3863,9 @@ packages: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -3948,6 +3954,13 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.1.0: + resolution: {integrity: sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==} + engines: {node: '>=18.17'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4119,6 +4132,9 @@ packages: css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + css-styled@1.0.8: resolution: {integrity: sha512-tCpP7kLRI8dI95rCh3Syl7I+v7PP+2JYOzWkl0bUEoSbJM+u8ITbutjlQVf0NC2/g4ULROJPi16sfwDIO8/84g==} @@ -4475,6 +4491,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4483,6 +4502,10 @@ packages: resolution: {integrity: sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -5022,6 +5045,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -5825,6 +5851,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.4.0: resolution: {integrity: sha512-+yOdX0q/wdYlseSYtWC8StDDt2QFcYX/zV5/E67J1cOJo3PR0mggxMx4acZGC0Hu66xFHENKQkqI2zSpH742xQ==} peerDependencies: @@ -5941,6 +5970,15 @@ packages: parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -6957,8 +6995,8 @@ packages: tailwind-merge@3.0.2: resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==} - tailwind-merge@3.3.0: - resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==} + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} tailwind-scrollbar@3.1.0: resolution: {integrity: sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==} @@ -7187,6 +7225,10 @@ packages: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} + undici@7.10.0: + resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.17: resolution: {integrity: sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg==} @@ -7393,6 +7435,14 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -10769,6 +10819,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -10851,6 +10903,29 @@ snapshots: character-reference-invalid@2.0.1: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.1.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.0.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.10.0 + whatwg-mimetype: 4.0.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -11020,6 +11095,14 @@ snapshots: dependencies: hyphenate-style-name: 1.1.0 + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + css-styled@1.0.8: dependencies: '@daybrush/utils': 1.13.0 @@ -11279,7 +11362,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) react-easy-sort: 1.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - tailwind-merge: 3.3.0 + tailwind-merge: 3.3.1 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -11292,10 +11375,17 @@ snapshots: encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + entities@4.5.0: {} entities@5.0.0: {} + entities@6.0.1: {} + err-code@2.0.3: {} error-stack-parser@2.1.4: @@ -12045,6 +12135,13 @@ snapshots: html-url-attributes@3.0.1: {} + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.1 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -12996,6 +13093,10 @@ snapshots: dependencies: path-key: 3.1.1 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nuqs@2.4.0(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: mitt: 3.0.1 @@ -13124,6 +13225,19 @@ snapshots: parse-srcset@1.0.2: {} + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -14365,7 +14479,7 @@ snapshots: tailwind-merge@3.0.2: {} - tailwind-merge@3.3.0: {} + tailwind-merge@3.3.1: {} tailwind-scrollbar@3.1.0(tailwindcss@3.4.17): dependencies: @@ -14624,6 +14738,8 @@ snapshots: undici@6.21.3: {} + undici@7.10.0: {} + unenv@2.0.0-rc.17: dependencies: defu: 6.1.4 @@ -14831,6 +14947,12 @@ snapshots: webidl-conversions@3.0.1: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3