Skip to content

Commit 7e100ef

Browse files
committed
Merge remote-tracking branch 'origin/main' into issue-530-rust-test-coverage
# Conflicts: # Cargo.lock # src/openhuman/composio/ops.rs
2 parents 51dcda9 + cca6c08 commit 7e100ef

37 files changed

Lines changed: 1773 additions & 134 deletions

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "openhuman"
3-
version = "0.52.12"
3+
version = "0.52.13"
44
edition = "2021"
55
description = "OpenHuman core business logic and RPC server"
66
autobins = false

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "openhuman-app",
3-
"version": "0.52.12",
3+
"version": "0.52.13",
44
"type": "module",
55
"scripts": {
66
"dev": "vite",

app/src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "OpenHuman"
3-
version = "0.52.12"
3+
version = "0.52.13"
44
description = "OpenHuman - AI-powered Super Assistant"
55
authors = ["OpenHuman"]
66
edition = "2021"

app/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "OpenHuman",
4-
"version": "0.52.12",
4+
"version": "0.52.13",
55
"identifier": "com.openhuman.app",
66
"build": {
77
"beforeDevCommand": "npm run core:stage && npm run dev",

app/src/components/channels/DiscordConfig.tsx

Lines changed: 190 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import debug from 'debug';
2-
import { useCallback, useState } from 'react';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
33

44
import { AUTH_MODE_LABELS } from '../../lib/channels/definitions';
55
import { channelConnectionsApi } from '../../services/api/channelConnectionsApi';
@@ -23,6 +23,8 @@ import ChannelStatusBadge from './ChannelStatusBadge';
2323
import DiscordServerChannelPicker from './DiscordServerChannelPicker';
2424

2525
const log = debug('channels:discord');
26+
const LINK_TIMEOUT_MS = 5 * 60 * 1_000;
27+
const LINK_POLL_INTERVAL_MS = 3_000;
2628

2729
interface DiscordConfigProps {
2830
definition: ChannelDefinition;
@@ -35,15 +37,18 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
3537
const [busyKeys, setBusyKeys] = useState<Record<string, boolean>>({});
3638
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
3739
const [error, setError] = useState<string | null>(null);
40+
/** Pending link tokens, keyed by compositeKey (discord:managed_dm). Only present while polling. */
41+
const [linkToken, setLinkToken] = useState<string | null>(null);
42+
const [copied, setCopied] = useState(false);
43+
const pollAbort = useRef<AbortController | null>(null);
3844

3945
const runBusy = useCallback(async (key: string, task: () => Promise<void>) => {
4046
setBusyKeys(prev => ({ ...prev, [key]: true }));
4147
setError(null);
4248
try {
4349
await task();
4450
} catch (e) {
45-
const msg = e instanceof Error ? e.message : String(e);
46-
setError(msg);
51+
setError(e instanceof Error ? e.message : String(e));
4752
} finally {
4853
setBusyKeys(prev => ({ ...prev, [key]: false }));
4954
}
@@ -56,6 +61,92 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
5661
}));
5762
}, []);
5863

64+
// Stop polling on unmount
65+
useEffect(() => {
66+
return () => {
67+
pollAbort.current?.abort();
68+
};
69+
}, []);
70+
71+
useEffect(() => {
72+
const handleOauthSuccess = (event: Event) => {
73+
const customEvent = event as CustomEvent<{ toolkit?: string }>;
74+
const toolkit = customEvent.detail?.toolkit?.toLowerCase();
75+
if (toolkit !== 'discord') return;
76+
77+
log('discord oauth success deep link received');
78+
dispatch(
79+
upsertChannelConnection({
80+
channel: 'discord',
81+
authMode: 'oauth',
82+
patch: { status: 'connected', lastError: undefined, capabilities: ['read', 'write'] },
83+
})
84+
);
85+
};
86+
87+
window.addEventListener('oauth:success', handleOauthSuccess);
88+
return () => {
89+
window.removeEventListener('oauth:success', handleOauthSuccess);
90+
};
91+
}, [dispatch]);
92+
93+
const startLinkPolling = useCallback(
94+
(token: string) => {
95+
pollAbort.current?.abort();
96+
const controller = new AbortController();
97+
pollAbort.current = controller;
98+
const startedAt = Date.now();
99+
100+
void (async () => {
101+
while (Date.now() - startedAt < LINK_TIMEOUT_MS) {
102+
if (controller.signal.aborted) return;
103+
104+
try {
105+
const check = await channelConnectionsApi.discordLinkCheck(token);
106+
if (check.linked) {
107+
log('discord managed link completed');
108+
setLinkToken(null);
109+
dispatch(
110+
upsertChannelConnection({
111+
channel: 'discord',
112+
authMode: 'managed_dm',
113+
patch: { status: 'connected', lastError: undefined, capabilities: ['dm'] },
114+
})
115+
);
116+
return;
117+
}
118+
} catch (err) {
119+
log('discord link check failed: %o', err);
120+
}
121+
122+
await new Promise<void>(resolve => {
123+
const timer = window.setTimeout(resolve, LINK_POLL_INTERVAL_MS);
124+
controller.signal.addEventListener(
125+
'abort',
126+
() => {
127+
window.clearTimeout(timer);
128+
resolve();
129+
},
130+
{ once: true }
131+
);
132+
});
133+
}
134+
135+
if (controller.signal.aborted) return;
136+
137+
setLinkToken(null);
138+
dispatch(
139+
upsertChannelConnection({
140+
channel: 'discord',
141+
authMode: 'managed_dm',
142+
patch: { status: 'error', lastError: 'Link token expired. Please try again.' },
143+
})
144+
);
145+
})();
146+
},
147+
[dispatch]
148+
);
149+
59150
const handleConnect = useCallback(
60151
(spec: AuthModeSpec) => {
61152
const key = `discord:${spec.mode}`;
@@ -69,7 +160,6 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
69160
);
70161
log('connecting discord via %s', spec.mode);
71162

72-
// Build credentials from field values.
73163
const credentials: Record<string, string> = {};
74164
for (const field of spec.fields) {
75165
const val = fieldValues[key]?.[field.key]?.trim() ?? '';
@@ -94,18 +184,26 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
94184
log('connect result: %o', result);
95185

96186
if (result.status === 'pending_auth' && result.auth_action) {
97-
dispatch(
98-
upsertChannelConnection({
99-
channel: 'discord',
100-
authMode: spec.mode,
101-
patch: {
102-
status: 'connecting',
103-
lastError: result.message ?? `Initiate ${result.auth_action} flow`,
104-
},
105-
})
106-
);
107-
108-
if (result.auth_action.includes('oauth')) {
187+
if (result.auth_action === 'discord_managed_link') {
188+
const linkStart = await channelConnectionsApi.discordLinkStart();
189+
log('discord link token issued, length=%d', linkStart.linkToken.length);
190+
setLinkToken(linkStart.linkToken);
191+
dispatch(
192+
upsertChannelConnection({
193+
channel: 'discord',
194+
authMode: spec.mode,
195+
patch: { status: 'connecting', lastError: undefined },
196+
})
197+
);
198+
startLinkPolling(linkStart.linkToken);
199+
} else if (result.auth_action.includes('oauth')) {
200+
dispatch(
201+
upsertChannelConnection({
202+
channel: 'discord',
203+
authMode: spec.mode,
204+
patch: { status: 'connecting', lastError: undefined },
205+
})
206+
);
109207
try {
110208
const oauthResponse = await callCoreRpc<{ result: { oauthUrl?: string } }>({
111209
method: 'openhuman.auth.oauth_connect',
@@ -115,18 +213,15 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
115213
await openUrl(oauthResponse.result.oauthUrl);
116214
}
117215
} catch {
118-
// OAuth URL fetch is best-effort.
216+
// best-effort
119217
}
120218
}
121219
return;
122220
}
123221

124-
// Credential-based connection succeeded.
125222
if (result.restart_required) {
126-
log('restart required after connect — restarting core process');
127223
try {
128224
await restartCoreProcess();
129-
log('core process restarted successfully');
130225
dispatch(
131226
upsertChannelConnection({
132227
channel: 'discord',
@@ -138,9 +233,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
138233
},
139234
})
140235
);
141-
} catch (restartErr) {
142-
const msg = restartErr instanceof Error ? restartErr.message : String(restartErr);
143-
log('core restart failed: %s', msg);
236+
} catch {
144237
setError('Channel saved. Restart the app to activate it.');
145238
}
146239
} else {
@@ -154,21 +247,30 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
154247
}
155248
});
156249
},
157-
[dispatch, fieldValues, runBusy]
250+
[dispatch, fieldValues, runBusy, startLinkPolling]
158251
);
159252

160253
const handleDisconnect = useCallback(
161254
(authMode: ChannelAuthMode) => {
162-
const key = `discord:${authMode}`;
163-
void runBusy(key, async () => {
255+
void runBusy(`discord:${authMode}`, async () => {
164256
log('disconnecting discord via %s', authMode);
257+
pollAbort.current?.abort();
258+
setLinkToken(null);
165259
await channelConnectionsApi.disconnectChannel('discord', authMode);
166260
dispatch(disconnectChannelConnection({ channel: 'discord', authMode }));
167261
});
168262
},
169263
[dispatch, runBusy]
170264
);
171265

266+
const copyToken = useCallback(() => {
267+
if (!linkToken) return;
268+
void navigator.clipboard.writeText(linkToken).then(() => {
269+
setCopied(true);
270+
window.setTimeout(() => setCopied(false), 2000);
271+
});
272+
}, [linkToken]);
273+
172274
return (
173275
<div className="space-y-3">
174276
{error && (
@@ -181,6 +283,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
181283
const compositeKey = `discord:${spec.mode}`;
182284
const connection = channelConnections.connections.discord?.[spec.mode];
183285
const status: ChannelConnectionStatus = connection?.status ?? 'disconnected';
286+
const busy = busyKeys[compositeKey] ?? false;
184287

185288
return (
186289
<div key={spec.mode} className="rounded-lg border border-stone-200 bg-stone-50 p-3">
@@ -197,36 +300,79 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
197300
<ChannelStatusBadge status={status} />
198301
</div>
199302

200-
{spec.fields.length > 0 && (
303+
{/* Field inputs — only for non-managed modes */}
304+
{spec.fields.length > 0 && status !== 'connected' && (
201305
<div className="mt-3 space-y-2">
202306
{spec.fields.map(field => (
203307
<ChannelFieldInput
204308
key={field.key}
205309
field={field}
206310
value={fieldValues[compositeKey]?.[field.key] ?? ''}
207311
onChange={val => updateField(compositeKey, field.key, val)}
208-
disabled={busyKeys[compositeKey]}
312+
disabled={busy}
209313
/>
210314
))}
211315
</div>
212316
)}
213317

214-
<div className="mt-3 flex gap-2">
215-
<button
216-
type="button"
217-
disabled={busyKeys[compositeKey]}
218-
onClick={() => handleConnect(spec)}
219-
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
220-
{status === 'connected' ? 'Reconnect' : 'Connect'}
221-
</button>
222-
<button
223-
type="button"
224-
disabled={busyKeys[compositeKey] || status === 'disconnected'}
225-
onClick={() => handleDisconnect(spec.mode)}
226-
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:border-stone-300 disabled:opacity-50">
227-
Disconnect
228-
</button>
229-
</div>
318+
{/* Token card — managed_dm connecting state */}
319+
{spec.mode === 'managed_dm' && linkToken && status === 'connecting' && (
320+
<div className="mt-3 rounded-lg border border-primary-200 bg-primary-50/60 p-3 space-y-2">
321+
<p className="text-xs font-medium text-primary-700">Your one-time link token</p>
322+
<div className="flex items-center gap-2">
323+
<code className="flex-1 rounded bg-white border border-primary-200 px-2 py-1 text-xs font-mono text-stone-800 select-all break-all">
324+
{linkToken}
325+
</code>
326+
<button
327+
type="button"
328+
onClick={copyToken}
329+
className="shrink-0 rounded-lg border border-primary-300 px-2 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100">
330+
{copied ? 'Copied!' : 'Copy'}
331+
</button>
332+
</div>
333+
<p className="text-xs text-stone-500">
334+
In Discord, send <code className="font-mono font-medium">!start {linkToken}</code>{' '}
335+
to the OpenHuman bot. Token expires in 5 minutes.
336+
</p>
337+
<p className="text-xs text-amber-600 font-medium">
338+
Save this command — this token is shown only once.
339+
</p>
340+
</div>
341+
)}
342+
343+
{/* Connected state for managed_dm — show only Disconnect */}
344+
{spec.mode === 'managed_dm' && status === 'connected' ? (
345+
<div className="mt-3 flex items-center justify-between">
346+
<p className="text-xs text-sage-700 font-medium">Your Discord account is linked.</p>
347+
<button
348+
type="button"
349+
disabled={busy}
350+
onClick={() => handleDisconnect(spec.mode)}
351+
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:border-stone-300 disabled:opacity-50">
352+
Disconnect
353+
</button>
354+
</div>
355+
) : /* Connect / Disconnect buttons for all other modes and states */
356+
spec.mode !== 'managed_dm' || status !== 'connecting' ? (
357+
<div className="mt-3 flex gap-2">
358+
{status !== 'connected' && (
359+
<button
360+
type="button"
361+
disabled={busy}
362+
onClick={() => handleConnect(spec)}
363+
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
364+
Connect
365+
</button>
366+
)}
367+
<button
368+
type="button"
369+
disabled={busy || status === 'disconnected'}
370+
onClick={() => handleDisconnect(spec.mode)}
371+
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:border-stone-300 disabled:opacity-50">
372+
Disconnect
373+
</button>
374+
</div>
375+
) : null}
230376

231377
{/* Server + Channel picker — shown after successful bot_token connection */}
232378
{spec.mode === 'bot_token' && status === 'connected' && (

0 commit comments

Comments
 (0)