mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: implement Notion metadata synchronization and user profile management
- Added a new service `metadataSync.ts` to handle synchronization of Notion user metadata to the backend via the `integration:metadata-sync` socket event. - Introduced a new Redux slice `notionSlice.ts` for managing the Notion user profile state. - Updated `SkillProvider` to fetch and sync Notion user profile upon connection, ensuring seamless integration with the backend. - Enhanced the store configuration to include the new Notion reducer.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Send Notion user metadata to the backend via the
|
||||
* `integration:metadata-sync` socket event so the server can merge it
|
||||
* into the user's Notion OAuth integration metadata.
|
||||
*/
|
||||
import { emitViaRustSocket } from '../../../utils/tauriSocket';
|
||||
|
||||
const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync';
|
||||
const PROVIDER_NOTION = 'notion';
|
||||
|
||||
export interface NotionUserProfileLike {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
type?: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `integration:metadata-sync` with Notion user profile so the
|
||||
* backend can merge it into the user's Notion OAuth integration.
|
||||
* No-op when profile is missing or invalid.
|
||||
*/
|
||||
export function syncNotionMetadataToBackend(
|
||||
profile: NotionUserProfileLike | null | undefined
|
||||
): void {
|
||||
if (!profile || !profile.id) return;
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
id: profile.id,
|
||||
name: profile.name ?? null,
|
||||
email: profile.email ?? null,
|
||||
type: profile.type ?? null,
|
||||
avatar_url: profile.avatar_url ?? null,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
requestId: crypto.randomUUID(),
|
||||
provider: PROVIDER_NOTION,
|
||||
metadata,
|
||||
};
|
||||
|
||||
void emitViaRustSocket(INTEGRATION_METADATA_SYNC_EVENT, payload);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
type GmailProfileLike,
|
||||
syncGmailMetadataToBackend,
|
||||
} from '../lib/gmail/services/metadataSync';
|
||||
import { syncNotionMetadataToBackend } from '../lib/notion/services/metadataSync';
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import type { SkillManifest } from '../lib/skills/types';
|
||||
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
|
||||
import { type GmailProfile, setGmailProfile } from '../store/gmailSlice';
|
||||
import { setNotionProfile, type NotionUserProfile } from '../store/notionSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice';
|
||||
import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config';
|
||||
@@ -86,12 +88,40 @@ function syncGmailStateToSlice(
|
||||
syncGmailMetadataToBackend(gmailState.profile as GmailProfileLike);
|
||||
}
|
||||
|
||||
async function syncNotionUserOnConnect(
|
||||
dispatch: ReturnType<typeof useAppDispatch>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const toolResult = await skillManager.callTool('notion', 'get-user', { user_id: 'me' });
|
||||
if (!toolResult || toolResult.isError || toolResult.content.length === 0) {
|
||||
console.warn('[SkillProvider] Notion get-user tool returned no content or an error');
|
||||
return;
|
||||
}
|
||||
const first = toolResult.content[0];
|
||||
const raw = first?.text;
|
||||
if (!raw) return;
|
||||
|
||||
const parsed = JSON.parse(raw) as NotionUserProfile | { error?: string };
|
||||
if ('error' in parsed && parsed.error) {
|
||||
console.warn('[SkillProvider] Notion get-user error:', parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = parsed as NotionUserProfile;
|
||||
dispatch(setNotionProfile(profile));
|
||||
syncNotionMetadataToBackend(profile);
|
||||
} catch (e) {
|
||||
console.error('[SkillProvider] Failed to call Notion get-user tool after connect:', e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const lastNotionConnectionStatusRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration)
|
||||
const gmailSkillState = skillStates?.gmail as Record<string, unknown> | undefined;
|
||||
@@ -100,6 +130,20 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
|
||||
syncGmailStateToSlice(gmailSkillState, dispatch);
|
||||
}, [gmailSkillState, dispatch]);
|
||||
|
||||
// When Notion connection_status transitions to "connected", fetch the current user
|
||||
// via the notion get-user tool, store it in notionSlice, and sync metadata to backend.
|
||||
const notionSkillState = skillStates?.notion as Record<string, unknown> | undefined;
|
||||
useEffect(() => {
|
||||
if (!notionSkillState || typeof notionSkillState !== 'object') return;
|
||||
const connectionStatus = notionSkillState.connection_status as string | undefined;
|
||||
const prev = lastNotionConnectionStatusRef.current;
|
||||
lastNotionConnectionStatusRef.current = connectionStatus;
|
||||
|
||||
if (connectionStatus === 'connected' && prev !== 'connected') {
|
||||
void syncNotionUserOnConnect(dispatch);
|
||||
}
|
||||
}, [notionSkillState, dispatch]);
|
||||
|
||||
// Listen for skill state changes emitted from the Rust runtime event loop
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
@@ -20,6 +20,7 @@ import authReducer, { setOnboardedForUser, setToken } from './authSlice';
|
||||
import daemonReducer from './daemonSlice';
|
||||
import gmailReducer from './gmailSlice';
|
||||
import inviteReducer from './inviteSlice';
|
||||
import notionReducer from './notionSlice';
|
||||
import skillsReducer from './skillsSlice';
|
||||
import socketReducer from './socketSlice';
|
||||
import teamReducer from './teamSlice';
|
||||
@@ -96,6 +97,7 @@ export const store = configureStore({
|
||||
team: teamReducer,
|
||||
thread: persistedThreadReducer,
|
||||
invite: inviteReducer,
|
||||
notion: notionReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
const middleware = getDefaultMiddleware({
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface NotionUserProfile {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
type?: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
interface NotionState {
|
||||
/** Profile of the connected Notion user (from Notion skill) */
|
||||
profile: NotionUserProfile | null;
|
||||
}
|
||||
|
||||
const initialState: NotionState = { profile: null };
|
||||
|
||||
const notionSlice = createSlice({
|
||||
name: 'notion',
|
||||
initialState,
|
||||
reducers: {
|
||||
setNotionProfile(state, action: PayloadAction<NotionUserProfile | null>) {
|
||||
state.profile = action.payload;
|
||||
},
|
||||
clearNotionProfile(state) {
|
||||
state.profile = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setNotionProfile, clearNotionProfile } = notionSlice.actions;
|
||||
export default notionSlice.reducer;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi';
|
||||
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
|
||||
import { store } from '../store';
|
||||
import { setToken } from '../store/authSlice';
|
||||
import { setSkillState } from '../store/skillsSlice';
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import {
|
||||
decryptIntegrationTokens,
|
||||
hexToBase64,
|
||||
@@ -219,7 +219,8 @@ const handleOAuthDeepLink = async (parsed: URL) => {
|
||||
})
|
||||
);
|
||||
|
||||
// For Gmail, pass decrypted access token so the skill uses it instead of the proxy
|
||||
// For OAuth-capable skills (e.g. Gmail, Notion), pass decrypted access token so the
|
||||
// skill can use it directly when supported instead of always going through the proxy.
|
||||
let extraCredential: { accessToken?: string } | undefined;
|
||||
|
||||
try {
|
||||
@@ -229,7 +230,7 @@ const handleOAuthDeepLink = async (parsed: URL) => {
|
||||
extraCredential = { accessToken: payload.accessToken };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[DeepLink] Could not decrypt Gmail token for skill:', e);
|
||||
console.warn('[DeepLink] Could not decrypt integration token for skill:', e);
|
||||
}
|
||||
|
||||
await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential);
|
||||
|
||||
Reference in New Issue
Block a user