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.
This commit is contained in:
M3gA-Mind
2026-02-20 20:45:36 +05:30
parent 596a742035
commit 159e111a29
11 changed files with 198 additions and 21 deletions
+1 -1
View File
@@ -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);
}
}
}
+8 -8
View File
@@ -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);
}
}
}
}
@@ -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<str>) -> rquickjs::Error {
let sanitized = sanitize_error_message(msg.as_ref());
rquickjs::Error::new_from_js_message("skill", "RuntimeError", sanitized)
}
+3
View File
@@ -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<void> {
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);
-1
View File
@@ -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';
+73 -1
View File
@@ -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<SkillManifest[]> {
// Provider
// ---------------------------------------------------------------------------
/** Normalize event payload: Rust emits { skillId, state }; ensure we get both. */
function parseSkillStatePayload(
payload: unknown
): { skillId: string; state: Record<string, unknown> } | null {
if (payload == null || typeof payload !== 'object') return null;
const raw = payload as Record<string, unknown>;
const skillId = raw.skillId as string | undefined;
const state = (raw.state ?? raw) as Record<string, unknown> | 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<string, unknown> | undefined,
dispatch: ReturnType<typeof useAppDispatch>
): 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<string, unknown> | 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<string, unknown> }>('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<string, unknown> } | 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;
+2 -2
View File
@@ -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<void> => {
await apiClient.post<{ success: boolean; data: unknown }>(
'/telegram/settings/onboarding-complete',
'/settings/onboarding-complete',
{}
);
},
+52
View File
@@ -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<GmailEmailSummary[]>) {
state.emails = action.payload;
},
clearGmailEmails(state) {
state.emails = [];
},
setGmailProfile(state, action: PayloadAction<GmailProfile | null>) {
state.profile = action.payload;
},
clearGmailProfile(state) {
state.profile = null;
},
},
});
export const { setGmailEmails, clearGmailEmails, setGmailProfile, clearGmailProfile } =
gmailSlice.actions;
export default gmailSlice.reducer;
+2
View File
@@ -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,
+8 -1
View File
@@ -79,7 +79,14 @@ const skillsSlice = createSlice({
state,
action: PayloadAction<{ skillId: string; state: Record<string, unknown> }>
) {
state.skillStates[action.payload.skillId] = action.payload.state;
const { skillId, state: incomingState } = action.payload;
const existing = state.skillStates[skillId] as Record<string, unknown> | 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<string>) {
+27 -5
View File
@@ -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<void> }
).__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);
const win = window as Window & {
__simulateDeepLink?: (url: string) => Promise<void>;
};
win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);
}
} catch (err) {
console.error('[DeepLink] Setup failed:', err);