From 159e111a292a3ebb6cc7b11c9f2acde48febb707 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 20 Feb 2026 20:45:36 +0530 Subject: [PATCH] feat: enhance Gmail skill integration and state management - Updated `SkillManager` to accept an optional access token for Gmail during OAuth completion. - Introduced `gmailSlice` to manage Gmail user profile and emails in the Redux store. - Implemented synchronization of Gmail skill state with the Redux store to keep user data updated. - Refactored `SkillProvider` to handle Gmail state changes and fetch initial state from the backend. - Adjusted deep link handling to pass decrypted access tokens for Gmail integration. - Modified API endpoint for onboarding completion to remove the Telegram prefix. --- src-tauri/src/runtime/ping_scheduler.rs | 2 +- src-tauri/src/runtime/qjs_skill_instance.rs | 16 ++-- .../services/quickjs-libs/qjs_ops/types.rs | 24 +++++- src/lib/skills/manager.ts | 3 + src/pages/Home.tsx | 1 - src/providers/SkillProvider.tsx | 74 ++++++++++++++++++- src/services/api/userApi.ts | 4 +- src/store/gmailSlice.ts | 52 +++++++++++++ src/store/index.ts | 2 + src/store/skillsSlice.ts | 9 ++- src/utils/desktopDeepLinkListener.ts | 32 ++++++-- 11 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 src/store/gmailSlice.ts diff --git a/src-tauri/src/runtime/ping_scheduler.rs b/src-tauri/src/runtime/ping_scheduler.rs index 9eaf4ecd8..4ef822427 100644 --- a/src-tauri/src/runtime/ping_scheduler.rs +++ b/src-tauri/src/runtime/ping_scheduler.rs @@ -296,7 +296,7 @@ impl PingScheduler { "skillId": skill_id, "state": state_map, }); - let _ = handle.emit("skill-state-changed", &payload); + let _ = handle.emit_to("main", "skill-state-changed", payload); } } } diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index e99e471f0..5a00717f2 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -387,16 +387,16 @@ async fn run_event_loop( .collect(); state.write().published_state = new_map.clone(); - // Emit Tauri event so the frontend picks up the change + // Emit Tauri event to the main window so the frontend receives it (Tauri 2: target explicitly) if let Some(handle) = app_handle { use tauri::Emitter; - let _ = handle.emit( - "skill-state-changed", - serde_json::json!({ - "skillId": skill_id, - "state": new_map, - }), - ); + let payload = serde_json::json!({ + "skillId": skill_id, + "state": new_map, + }); + if let Err(e) = handle.emit_to("main", "skill-state-changed", payload) { + log::warn!("[skill:{}] Failed to emit skill-state-changed to main: {}", skill_id, e); + } } } } diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs index 742340e2d..3113f53f1 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs @@ -136,6 +136,26 @@ pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> { } } -pub fn js_err(msg: String) -> rquickjs::Error { - rquickjs::Error::new_from_js_message("skill", "RuntimeError", msg) +/// Sanitize error message for use with QuickJS/rquickjs. +/// Some messages (e.g. from SQLite "Invalid symbol 45, offset 19") can trigger +/// "Invalid symbol" when rquickjs creates the JS exception — avoid comma and hyphen. +fn sanitize_error_message(msg: &str) -> String { + msg.chars() + .map(|c| { + if c == ',' || c == '-' { + ' ' + } else if c.is_ascii() && !c.is_ascii_control() { + c + } else if c == '\n' || c == '\r' || c == '\t' { + ' ' + } else { + '?' + } + }) + .collect() +} + +pub fn js_err(msg: impl AsRef) -> rquickjs::Error { + let sanitized = sanitize_error_message(msg.as_ref()); + rquickjs::Error::new_from_js_message("skill", "RuntimeError", sanitized) } diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 93dd3c0c5..726ccdc8c 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -270,11 +270,13 @@ class SkillManager { /** * Notify a skill that OAuth completed successfully. * Called by the deep link handler after backend OAuth callback. + * For Gmail, pass extraCredential.accessToken so the skill uses the token directly. */ async notifyOAuthComplete( skillId: string, integrationId: string, provider?: string, + extraCredential?: { accessToken?: string }, ): Promise { const runtime = this.runtimes.get(skillId); if (!runtime || !runtime.isRunning) { @@ -288,6 +290,7 @@ class SkillManager { credentialId: integrationId, provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown", grantedScopes: manifest?.setup?.oauth?.scopes ?? [], + ...extraCredential, }; await runtime.oauthComplete(credential); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4b6f6a396..b088f1ad8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -2,7 +2,6 @@ import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; import SkillsGrid from '../components/SkillsGrid'; -import WalletInfoSection from '../components/WalletInfoSection'; import { useUser } from '../hooks/useUser'; import { TELEGRAM_BOT_USERNAME } from '../utils/config'; import { openUrl } from '../utils/openUrl'; diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 602e7d9dd..31b1820a6 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -12,6 +12,12 @@ import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + setGmailEmails, + setGmailProfile, + type GmailEmailSummary, + type GmailProfile, +} from '../store/gmailSlice'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; @@ -53,19 +59,66 @@ async function discoverSkills(): Promise { // Provider // --------------------------------------------------------------------------- +/** Normalize event payload: Rust emits { skillId, state }; ensure we get both. */ +function parseSkillStatePayload( + payload: unknown +): { skillId: string; state: Record } | null { + if (payload == null || typeof payload !== 'object') return null; + const raw = payload as Record; + const skillId = raw.skillId as string | undefined; + const state = (raw.state ?? raw) as Record | undefined; + if (!skillId || state == null || typeof state !== 'object') return null; + return { skillId, state }; +} + +/** Sync profile and emails from gmail skill state into gmailSlice. */ +function syncGmailStateToSlice( + gmailState: Record | undefined, + dispatch: ReturnType +): void { + if (!gmailState || typeof gmailState !== 'object') return; + dispatch( + setGmailProfile( + gmailState.profile !== undefined && gmailState.profile != null + ? (gmailState.profile as GmailProfile) + : null + ) + ); + dispatch( + setGmailEmails( + Array.isArray(gmailState.emails) ? (gmailState.emails as GmailEmailSummary[]) : [] + ) + ); +} + export default function SkillProvider({ children }: { children: ReactNode }) { const { token } = useAppSelector(state => state.auth); const skillsState = useAppSelector(state => state.skills.skills); + const skillStates = useAppSelector(state => state.skills.skillStates); const dispatch = useAppDispatch(); const initRef = useRef(false); + // Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration) + const gmailSkillState = skillStates?.gmail as Record | undefined; + useEffect(() => { + if (!gmailSkillState) return; + syncGmailStateToSlice(gmailSkillState, dispatch); + }, [gmailSkillState, dispatch]); + // Listen for skill state changes emitted from the Rust runtime event loop useEffect(() => { let unlisten: (() => void) | undefined; listen<{ skillId: string; state: Record }>('skill-state-changed', event => { - const { skillId, state: newState } = event.payload; + const parsed = parseSkillStatePayload(event.payload); + if (!parsed) return; + const { skillId, state: newState } = parsed; dispatch(setSkillState({ skillId, state: newState })); + + // Transfer Gmail skill state to gmail store (also synced by effect from skillStates.gmail) + if (skillId === 'gmail') { + syncGmailStateToSlice(newState, dispatch); + } }) .then(fn => { unlisten = fn; @@ -79,6 +132,25 @@ export default function SkillProvider({ children }: { children: ReactNode }) { }; }, [dispatch]); + // Fallback: when gmail skill is ready, fetch state from backend (covers events missed before listener attached) + const gmailStatus = skillsState?.gmail?.status; + useEffect(() => { + if (gmailStatus !== 'ready') return; + const timeoutId = window.setTimeout(() => { + invoke<{ state?: Record } | null>('runtime_get_skill_state', { + skillId: 'gmail', + }) + .then(snapshot => { + if (snapshot?.state && typeof snapshot.state === 'object') { + dispatch(setSkillState({ skillId: 'gmail', state: snapshot.state })); + syncGmailStateToSlice(snapshot.state, dispatch); + } + }) + .catch(() => {}); + }, 800); + return () => window.clearTimeout(timeoutId); + }, [gmailStatus, dispatch]); + // Listen for skill runtime errors and surface them in the error notification useEffect(() => { let unlisten: (() => void) | undefined; diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 5685c14f4..419b182d0 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -19,11 +19,11 @@ export const userApi = { /** * Mark onboarding complete for the current user. - * POST /telegram/settings/onboarding-complete + * POST /settings/onboarding-complete */ onboardingComplete: async (): Promise => { await apiClient.post<{ success: boolean; data: unknown }>( - '/telegram/settings/onboarding-complete', + '/settings/onboarding-complete', {} ); }, diff --git a/src/store/gmailSlice.ts b/src/store/gmailSlice.ts new file mode 100644 index 000000000..be32b2191 --- /dev/null +++ b/src/store/gmailSlice.ts @@ -0,0 +1,52 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export interface GmailEmailSummary { + id: string; + threadId: string; + snippet?: string; + subject?: string; + from?: string; + date?: string; +} + +export interface GmailProfile { + email_address: string; + messages_total: number; + threads_total: number; + history_id: string; +} + +interface GmailState { + /** Emails fetched after OAuth connection (from Gmail skill) */ + emails: GmailEmailSummary[]; + /** Profile of the connected Gmail user (from Gmail skill) */ + profile: GmailProfile | null; +} + +const initialState: GmailState = { + emails: [], + profile: null, +}; + +const gmailSlice = createSlice({ + name: 'gmail', + initialState, + reducers: { + setGmailEmails(state, action: PayloadAction) { + state.emails = action.payload; + }, + clearGmailEmails(state) { + state.emails = []; + }, + setGmailProfile(state, action: PayloadAction) { + state.profile = action.payload; + }, + clearGmailProfile(state) { + state.profile = null; + }, + }, +}); + +export const { setGmailEmails, clearGmailEmails, setGmailProfile, clearGmailProfile } = + gmailSlice.actions; +export default gmailSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index 69120398d..d34dd6bcd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -17,6 +17,7 @@ import { IS_DEV } from '../utils/config'; import { storeSession } from '../utils/tauriCommands'; import aiReducer from './aiSlice'; import authReducer, { setOnboardedForUser, setToken } from './authSlice'; +import gmailReducer from './gmailSlice'; import inviteReducer from './inviteSlice'; import skillsReducer from './skillsSlice'; import socketReducer from './socketSlice'; @@ -93,6 +94,7 @@ export const store = configureStore({ user: userReducer, ai: persistedAiReducer, skills: persistedSkillsReducer, + gmail: gmailReducer, team: teamReducer, thread: persistedThreadReducer, invite: inviteReducer, diff --git a/src/store/skillsSlice.ts b/src/store/skillsSlice.ts index fcf3b2c7a..d93e871e2 100644 --- a/src/store/skillsSlice.ts +++ b/src/store/skillsSlice.ts @@ -79,7 +79,14 @@ const skillsSlice = createSlice({ state, action: PayloadAction<{ skillId: string; state: Record }> ) { - state.skillStates[action.payload.skillId] = action.payload.state; + const { skillId, state: incomingState } = action.payload; + const existing = state.skillStates[skillId] as Record | undefined; + // Preserve frontend-only keys (e.g. oauthTokens from deep link) that the skill never sends + const preserved = + existing && typeof existing.oauthTokens === 'object' && existing.oauthTokens !== null + ? { oauthTokens: existing.oauthTokens } + : {}; + state.skillStates[skillId] = { ...incomingState, ...preserved }; }, removeSkill(state, action: PayloadAction) { diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index beaed8af5..aae2c6157 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -4,9 +4,13 @@ import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { skillManager } from '../lib/skills/manager'; import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi'; import { store } from '../store'; +import { + decryptIntegrationTokens, + hexToBase64, + type IntegrationTokensPayload, +} from './integrationTokensCrypto'; import { setToken } from '../store/authSlice'; import { setSkillState } from '../store/skillsSlice'; -import { hexToBase64 } from './integrationTokensCrypto'; function getCurrentUserId(): string | null { const state = store.getState(); @@ -118,7 +122,24 @@ const handleOAuthDeepLink = async (parsed: URL) => { }) ); - await skillManager.notifyOAuthComplete(skillId, integrationId); + // For Gmail, pass decrypted access token so the skill uses it instead of the proxy + let extraCredential: { accessToken?: string } | undefined; + if (skillId === 'gmail') { + try { + const decryptedJson = await decryptIntegrationTokens( + response.data.encrypted, + encryptionKeyHex + ); + const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload; + if (payload.accessToken) { + extraCredential = { accessToken: payload.accessToken }; + } + } catch (e) { + console.warn('[DeepLink] Could not decrypt Gmail token for skill:', e); + } + } + + await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential); } catch (err) { console.error('[DeepLink] Failed to notify OAuth complete:', err); } @@ -191,9 +212,10 @@ export const setupDesktopDeepLinkListener = async () => { if (typeof window !== 'undefined') { // window.__simulateDeepLink('alphahuman://auth?token=1234567890') // window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989ef9c8e8bf1b6d991a08c&skillId=notion') - ( - window as Window & { __simulateDeepLink?: (url: string) => Promise } - ).__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]); + const win = window as Window & { + __simulateDeepLink?: (url: string) => Promise; + }; + win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]); } } catch (err) { console.error('[DeepLink] Setup failed:', err);