mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user