@@ -43,11 +43,25 @@ type AssistantAttachmentAvailability =
4343 | { status : "checking" }
4444 | { status : "available" ; mediaTicket ?: string ; mediaTicketExpiresAt ?: number }
4545 | { status : "unavailable" ; reason : string ; checkedAt : number } ;
46+ type PairingQrExpiryNotice = {
47+ title : string ;
48+ reason : string ;
49+ } ;
50+ type PairingQrExpiryRefreshTimer = {
51+ expiresAtMs : number ;
52+ onRequestUpdate : ( ) => void ;
53+ timer : ReturnType < typeof setTimeout > ;
54+ } ;
4655
4756const assistantAttachmentAvailabilityCache = new Map < string , AssistantAttachmentAvailability > ( ) ;
4857const assistantAttachmentRefreshTimers = new Map < string , ReturnType < typeof setTimeout > > ( ) ;
58+ const pairingQrExpiryRefreshTimers = new Map < string , PairingQrExpiryRefreshTimer > ( ) ;
4959const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000 ;
5060const ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS = 30_000 ;
61+ const PAIRING_QR_EXPIRED_NOTICE : PairingQrExpiryNotice = {
62+ title : "Pairing QR expired" ,
63+ reason : "Run /pair qr again to generate a fresh setup code." ,
64+ } ;
5165let assistantAttachmentAvailabilityRenderVersion = 0 ;
5266
5367export type ChatTimestampDisplay = {
@@ -107,6 +121,10 @@ export function resetAssistantAttachmentAvailabilityCacheForTest() {
107121 clearTimeout ( timer ) ;
108122 }
109123 assistantAttachmentRefreshTimers . clear ( ) ;
124+ for ( const { timer } of pairingQrExpiryRefreshTimers . values ( ) ) {
125+ clearTimeout ( timer ) ;
126+ }
127+ pairingQrExpiryRefreshTimers . clear ( ) ;
110128 for ( const blobUrl of managedImageBlobUrlResolvedCache . values ( ) ) {
111129 URL . revokeObjectURL ( blobUrl ) ;
112130 }
@@ -320,6 +338,9 @@ function extractImages(message: unknown): ImageBlock[] {
320338 } ) ;
321339 }
322340 } else if ( b . type === "openclaw_pairing_qr" ) {
341+ if ( isExpiredPairingQrBlock ( b ) ) {
342+ continue ;
343+ }
323344 const imageUrl = b . image_url ;
324345 if ( typeof imageUrl === "string" ) {
325346 appendImageBlock ( images , {
@@ -341,6 +362,103 @@ function extractImages(message: unknown): ImageBlock[] {
341362 return images ;
342363}
343364
365+ function readPairingQrExpiresAtMs ( block : Record < string , unknown > ) : number | undefined {
366+ const expiresAtMs = block . expiresAtMs ;
367+ return typeof expiresAtMs === "number" && Number . isFinite ( expiresAtMs ) ? expiresAtMs : undefined ;
368+ }
369+
370+ function isExpiredPairingQrBlock ( block : Record < string , unknown > , nowMs = Date . now ( ) ) : boolean {
371+ const expiresAtMs = readPairingQrExpiresAtMs ( block ) ;
372+ return expiresAtMs !== undefined && expiresAtMs <= nowMs ;
373+ }
374+
375+ function extractPairingQrExpiryNotices (
376+ message : unknown ,
377+ nowMs = Date . now ( ) ,
378+ ) : PairingQrExpiryNotice [ ] {
379+ const m = message as Record < string , unknown > ;
380+ const content = m . content ;
381+ if ( ! Array . isArray ( content ) ) {
382+ return [ ] ;
383+ }
384+ const notices : PairingQrExpiryNotice [ ] = [ ] ;
385+ for ( const block of content ) {
386+ if ( ! block || typeof block !== "object" ) {
387+ continue ;
388+ }
389+ const b = block as Record < string , unknown > ;
390+ if ( b . type === "openclaw_pairing_qr" && isExpiredPairingQrBlock ( b , nowMs ) ) {
391+ notices . push ( PAIRING_QR_EXPIRED_NOTICE ) ;
392+ }
393+ }
394+ return notices ;
395+ }
396+
397+ function resolveNearestFuturePairingQrExpiresAtMs (
398+ message : unknown ,
399+ nowMs = Date . now ( ) ,
400+ ) : number | undefined {
401+ const m = message as Record < string , unknown > ;
402+ const content = m . content ;
403+ if ( ! Array . isArray ( content ) ) {
404+ return undefined ;
405+ }
406+ let nearestExpiresAtMs : number | undefined ;
407+ for ( const block of content ) {
408+ if ( ! block || typeof block !== "object" ) {
409+ continue ;
410+ }
411+ const b = block as Record < string , unknown > ;
412+ if ( b . type !== "openclaw_pairing_qr" ) {
413+ continue ;
414+ }
415+ const expiresAtMs = readPairingQrExpiresAtMs ( b ) ;
416+ if ( expiresAtMs === undefined || expiresAtMs <= nowMs ) {
417+ continue ;
418+ }
419+ nearestExpiresAtMs =
420+ nearestExpiresAtMs === undefined ? expiresAtMs : Math . min ( nearestExpiresAtMs , expiresAtMs ) ;
421+ }
422+ return nearestExpiresAtMs ;
423+ }
424+
425+ function clearPairingQrExpiryRefreshTimer ( messageKey : string ) {
426+ const existing = pairingQrExpiryRefreshTimers . get ( messageKey ) ;
427+ if ( ! existing ) {
428+ return ;
429+ }
430+ clearTimeout ( existing . timer ) ;
431+ pairingQrExpiryRefreshTimers . delete ( messageKey ) ;
432+ }
433+
434+ function schedulePairingQrExpiryRefresh (
435+ messageKey : string ,
436+ message : unknown ,
437+ onRequestUpdate : ( ( ) => void ) | undefined ,
438+ ) {
439+ const nowMs = Date . now ( ) ;
440+ const expiresAtMs = resolveNearestFuturePairingQrExpiresAtMs ( message , nowMs ) ;
441+ const existing = pairingQrExpiryRefreshTimers . get ( messageKey ) ;
442+ if ( ! expiresAtMs || ! onRequestUpdate ) {
443+ if ( existing ) {
444+ clearPairingQrExpiryRefreshTimer ( messageKey ) ;
445+ }
446+ return ;
447+ }
448+ if ( existing ?. expiresAtMs === expiresAtMs && existing . onRequestUpdate === onRequestUpdate ) {
449+ return ;
450+ }
451+ clearPairingQrExpiryRefreshTimer ( messageKey ) ;
452+ const timer = setTimeout (
453+ ( ) => {
454+ pairingQrExpiryRefreshTimers . delete ( messageKey ) ;
455+ onRequestUpdate ( ) ;
456+ } ,
457+ Math . max ( 0 , expiresAtMs - nowMs ) ,
458+ ) ;
459+ pairingQrExpiryRefreshTimers . set ( messageKey , { expiresAtMs, onRequestUpdate, timer } ) ;
460+ }
461+
344462function extractTranscriptAttachments ( message : unknown ) : AttachmentItem [ ] {
345463 const attachments : AttachmentItem [ ] = [ ] ;
346464 for ( const { path : mediaPath , mediaType } of extractTranscriptMediaEntries ( message ) ) {
@@ -1020,6 +1138,32 @@ function renderReplyPill(replyTarget: NormalizedMessage["replyTarget"]) {
10201138 ` ;
10211139}
10221140
1141+ function renderPairingQrExpiryNotices ( notices : PairingQrExpiryNotice [ ] ) {
1142+ if ( notices . length === 0 ) {
1143+ return nothing ;
1144+ }
1145+ return html `
1146+ < div class ="chat-pairing-qr-notices ">
1147+ ${ notices . map (
1148+ ( notice ) => html `
1149+ < div
1150+ class ="chat-assistant-attachment-card chat-assistant-attachment-card--blocked chat-pairing-qr-expired "
1151+ >
1152+ < div class ="chat-assistant-attachment-card__header ">
1153+ < span class ="chat-assistant-attachment-card__icon "> ${ icons . alertTriangle } </ span >
1154+ < span class ="chat-assistant-attachment-card__title "> ${ notice . title } </ span >
1155+ < span class ="chat-assistant-attachment-badge chat-assistant-attachment-badge--muted "
1156+ > Expired</ span
1157+ >
1158+ </ div >
1159+ < div class ="chat-assistant-attachment-card__reason "> ${ notice . reason } </ div >
1160+ </ div >
1161+ ` ,
1162+ ) }
1163+ </ div >
1164+ ` ;
1165+ }
1166+
10231167function isLocalAssistantAttachmentSource ( source : string ) : boolean {
10241168 const trimmed = source . trim ( ) ;
10251169 if ( / ^ \/ (?: _ _ o p e n c l a w _ _ | m e d i a | a p i \/ c h a t \/ m e d i a \/ o u t g o i n g ) \/ / . test ( trimmed ) ) {
@@ -1674,8 +1818,11 @@ function renderGroupedMessage(
16741818 authToken : opts . assistantAttachmentAuthToken ,
16751819 onRequestUpdate : opts . onRequestUpdate ,
16761820 } ;
1821+ schedulePairingQrExpiryRefresh ( messageKey , message , opts . onRequestUpdate ) ;
16771822 const images = resolveRenderableMessageImages ( extractImages ( message ) , imageRenderOptions ) ;
16781823 const hasImages = images . length > 0 ;
1824+ const pairingQrExpiryNotices = extractPairingQrExpiryNotices ( message ) ;
1825+ const hasPairingQrExpiryNotices = pairingQrExpiryNotices . length > 0 ;
16791826
16801827 const normalizedMessage = normalizeMessage ( message ) ;
16811828 const extractedText = normalizedMessage . content
@@ -1741,6 +1888,7 @@ function renderGroupedMessage(
17411888 ! markdown &&
17421889 ! visibleToolCards &&
17431890 ! hasImages &&
1891+ ! hasPairingQrExpiryNotices &&
17441892 visibleAttachments . length === 0 &&
17451893 assistantViewBlocks . length === 0 &&
17461894 ! normalizedMessage . replyTarget
@@ -1845,6 +1993,7 @@ function renderGroupedMessage(
18451993 ${ toolMessageExpanded
18461994 ? html `
18471995 < div class ="chat-tool-msg-body ">
1996+ ${ renderPairingQrExpiryNotices ( pairingQrExpiryNotices ) }
18481997 ${ renderMessageImages ( images , imageRenderOptions ) }
18491998 ${ renderAssistantAttachments (
18501999 visibleAttachments ,
@@ -1903,6 +2052,7 @@ function renderGroupedMessage(
19032052 </ div >
19042053 `
19052054 : html `
2055+ ${ renderPairingQrExpiryNotices ( pairingQrExpiryNotices ) }
19062056 ${ renderMessageImages ( images , imageRenderOptions ) }
19072057 ${ renderAssistantAttachments (
19082058 visibleAttachments ,
0 commit comments