11import debug from 'debug' ;
2- import { useCallback , useState } from 'react' ;
2+ import { useCallback , useEffect , useRef , useState } from 'react' ;
33
44import { AUTH_MODE_LABELS } from '../../lib/channels/definitions' ;
55import { channelConnectionsApi } from '../../services/api/channelConnectionsApi' ;
@@ -23,6 +23,8 @@ import ChannelStatusBadge from './ChannelStatusBadge';
2323import DiscordServerChannelPicker from './DiscordServerChannelPicker' ;
2424
2525const log = debug ( 'channels:discord' ) ;
26+ const LINK_TIMEOUT_MS = 5 * 60 * 1_000 ;
27+ const LINK_POLL_INTERVAL_MS = 3_000 ;
2628
2729interface 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