@@ -18,6 +18,12 @@ const MICO_USER_AGENT =
1818const DEFAULT_CHUNK_LIMIT = 200 ;
1919const CHUNK_DELAY_MS = 200 ;
2020
21+ /**
22+ * 401 后重新登录的最小间隔。小米 passport 对同一 (userId, deviceId, sid) 只保留最新一份
23+ * serviceToken,同一账号被多处登录时会互相挤掉;不限流会形成登录风暴。
24+ */
25+ const REAUTH_MIN_INTERVAL_MS = 30_000 ;
26+
2127export enum XiaoaiAuthMode {
2228 Password = 'password' ,
2329 PassToken = 'passToken' ,
@@ -107,75 +113,109 @@ function chunkText(text: string, limit: number = DEFAULT_CHUNK_LIMIT): string[]
107113
108114export class XiaoaiAPI {
109115 private authed ?: AuthedAccount ;
110- private speakerDeviceId = '' ;
116+ private authing ?: Promise < AuthedAccount > ;
117+ private lastAuthAt = 0 ;
111118
112119 constructor ( private options : XiaoaiAPIOptions ) { }
113120
114- setSpeakerDeviceId ( deviceId : string ) : void {
115- this . speakerDeviceId = deviceId ;
116- }
117-
118121 async getDeviceList ( ) : Promise < MiNADevice [ ] > {
119- const resp = await axios . get ( `${ MINA_BASE } /admin/v2/device_list` , {
120- params : { master : 1 } ,
121- headers : this . minaHeaders ( await this . auth ( ) ) ,
122+ return this . withAuthRetry ( async ( account ) => {
123+ const resp = await axios . get ( `${ MINA_BASE } /admin/v2/device_list` , {
124+ params : { master : 1 } ,
125+ headers : this . minaHeaders ( account ) ,
126+ } ) ;
127+ return resp . data ?. data ?? [ ] ;
122128 } ) ;
123- return resp . data ?. data ?? [ ] ;
124129 }
125130
126131 async getConversations (
132+ speakerDeviceId : string ,
127133 hardware : string ,
128134 limit = 2 ,
129135 ) : Promise < MiConversation [ ] > {
130- if ( ! this . speakerDeviceId ) return [ ] ;
131-
132- const account = await this . auth ( ) ;
133- const cookie = `userId=${ account . userId } ; serviceToken=${ account . serviceToken } ; deviceId=${ this . speakerDeviceId } ` ;
134- const resp = await axios . get ( `${ USER_PROFILE_BASE } /device_profile/v2/conversation` , {
135- params : {
136- source : 'dialogu' ,
137- hardware,
138- limit,
139- requestId : crypto . randomUUID ( ) ,
140- } ,
141- headers : {
142- 'User-Agent' : MICO_USER_AGENT ,
143- Referer : 'https://userprofile.mina.mi.com/dialogue-note/index.html' ,
144- Cookie : cookie ,
145- } ,
146- } ) ;
136+ if ( ! speakerDeviceId ) return [ ] ;
137+
138+ return this . withAuthRetry ( async ( account ) => {
139+ const cookie = `userId=${ account . userId } ; serviceToken=${ account . serviceToken } ; deviceId=${ speakerDeviceId } ` ;
140+ const resp = await axios . get ( `${ USER_PROFILE_BASE } /device_profile/v2/conversation` , {
141+ params : {
142+ source : 'dialogu' ,
143+ hardware,
144+ limit,
145+ requestId : crypto . randomUUID ( ) ,
146+ } ,
147+ headers : {
148+ 'User-Agent' : MICO_USER_AGENT ,
149+ Referer : 'https://userprofile.mina.mi.com/dialogue-note/index.html' ,
150+ Cookie : cookie ,
151+ } ,
152+ } ) ;
147153
148- let payload : any = resp . data ?. data ;
149- if ( typeof payload === 'string' ) {
150- try {
151- payload = JSON . parse ( payload ) ;
152- } catch {
153- return [ ] ;
154+ let payload : any = resp . data ?. data ;
155+ if ( typeof payload === 'string' ) {
156+ try {
157+ payload = JSON . parse ( payload ) ;
158+ } catch {
159+ return [ ] ;
160+ }
154161 }
155- }
156- return payload ?. records ?? [ ] ;
162+ return payload ?. records ?? [ ] ;
163+ } ) ;
157164 }
158165
159166 async speak (
167+ speakerDeviceId : string ,
160168 text : string ,
161169 options ?: XiaoaiSpeakOptions ,
162170 ) : Promise < void > {
163- if ( ! this . speakerDeviceId ) return ;
171+ if ( ! speakerDeviceId ) return ;
164172
165173 if ( options ?. volume ) {
166- await this . setVolume ( this . speakerDeviceId , options . volume ) ;
174+ await this . setVolume ( speakerDeviceId , options . volume ) ;
167175 }
168176
169177 const chunks = chunkText ( text , options ?. chunkLimit ?? DEFAULT_CHUNK_LIMIT ) ;
170178 for ( let i = 0 ; i < chunks . length ; i ++ ) {
171179 if ( i > 0 ) await sleep ( CHUNK_DELAY_MS ) ;
172- await this . textToSpeech ( this . speakerDeviceId , chunks [ i ] ) ;
180+ await this . textToSpeech ( speakerDeviceId , chunks [ i ] ) ;
173181 }
174182 }
175183
184+ /**
185+ * 请求遇到 401 时作废缓存的 serviceToken、重新登录并重试一次。
186+ * 不重试其他错误:轮询侧已有指数退避。
187+ */
188+ private async withAuthRetry < T > ( fn : ( account : AuthedAccount ) => Promise < T > ) : Promise < T > {
189+ const account = await this . auth ( ) ;
190+ try {
191+ return await fn ( account ) ;
192+ } catch ( e : any ) {
193+ if ( e ?. response ?. status !== 401 ) throw e ;
194+ // token 已被其他并发调用换掉 → 直接拿新的重试;仍是同一份才作废重登
195+ if ( this . authed === account && ! this . invalidate ( ) ) throw e ;
196+ return fn ( await this . auth ( ) ) ;
197+ }
198+ }
199+
200+ /** 作废当前 token;距上次登录不足 REAUTH_MIN_INTERVAL_MS 则拒绝,返回是否已作废。 */
201+ private invalidate ( ) : boolean {
202+ if ( Date . now ( ) - this . lastAuthAt < REAUTH_MIN_INTERVAL_MS ) return false ;
203+ this . authed = undefined ;
204+ return true ;
205+ }
206+
176207 private async auth ( ) : Promise < AuthedAccount > {
177208 if ( this . authed ) return this . authed ;
209+ // 多台音箱共用同一实例,首次轮询会并发触发登录——去重,只登录一次
210+ if ( this . authing ) return this . authing ;
178211
212+ this . authing = this . login ( ) . finally ( ( ) => {
213+ this . authing = undefined ;
214+ } ) ;
215+ return this . authing ;
216+ }
217+
218+ private async login ( ) : Promise < AuthedAccount > {
179219 const { userId, authMode, credential } = this . options ;
180220 const deviceId = this . options . deviceId || randomDeviceId ( ) ;
181221 const password = authMode === XiaoaiAuthMode . Password ? credential : '' ;
@@ -226,6 +266,7 @@ export class XiaoaiAPI {
226266 const tokenUrl = `${ pass . location } &clientSign=${ encodeURIComponent ( clientSign ) } ` ;
227267 const serviceToken = await this . resolveServiceToken ( tokenUrl ) ;
228268 this . authed = { userId, serviceToken, deviceId } ;
269+ this . lastAuthAt = Date . now ( ) ;
229270 return this . authed ;
230271 }
231272
@@ -249,39 +290,34 @@ export class XiaoaiAPI {
249290 }
250291
251292 private async textToSpeech ( speakerDeviceId : string , text : string ) : Promise < void > {
252- await axios . post (
253- `${ MINA_BASE } /remote/ubus` ,
254- new URLSearchParams ( {
255- deviceId : speakerDeviceId ,
256- path : 'mibrain' ,
257- method : 'text_to_speech' ,
258- message : JSON . stringify ( { text, save : 0 } ) ,
259- } ) . toString ( ) ,
260- {
261- headers : {
262- ...this . minaHeaders ( await this . auth ( ) ) ,
263- 'Content-Type' : 'application/x-www-form-urlencoded' ,
264- } ,
265- } ,
266- ) ;
293+ await this . ubus ( speakerDeviceId , 'mibrain' , 'text_to_speech' , { text, save : 0 } ) ;
267294 }
268295
269296 private async setVolume ( speakerDeviceId : string , volume : number ) : Promise < void > {
270- await axios . post (
297+ await this . ubus ( speakerDeviceId , 'mediaplayer' , 'player_set_volume' , { volume, media : 'app_ios' } ) ;
298+ }
299+
300+ private async ubus (
301+ speakerDeviceId : string ,
302+ path : string ,
303+ method : string ,
304+ message : Record < string , any > ,
305+ ) : Promise < void > {
306+ await this . withAuthRetry ( ( account ) => axios . post (
271307 `${ MINA_BASE } /remote/ubus` ,
272308 new URLSearchParams ( {
273309 deviceId : speakerDeviceId ,
274- path : 'mediaplayer' ,
275- method : 'player_set_volume' ,
276- message : JSON . stringify ( { volume , media : 'app_ios' } ) ,
310+ path,
311+ method,
312+ message : JSON . stringify ( message ) ,
277313 } ) . toString ( ) ,
278314 {
279315 headers : {
280- ...this . minaHeaders ( await this . auth ( ) ) ,
316+ ...this . minaHeaders ( account ) ,
281317 'Content-Type' : 'application/x-www-form-urlencoded' ,
282318 } ,
283319 } ,
284- ) ;
320+ ) ) ;
285321 }
286322
287323 private passportCookies ( deviceId : string , passToken ?: string ) : string {
0 commit comments