UNPKG

matterbridge-roborock-vacuum-plugin

Version:
256 lines 9.83 kB
import crypto from 'node:crypto'; import { URLSearchParams } from 'node:url'; import axios from 'axios'; import * as AxiosLogger from 'axios-logger'; import { AuthenticateResponseCode } from '../enums/index.js'; export class RoborockAuthenticateApi { logger; axiosFactory; baseUrl; sessionId; username; authToken; cachedBaseUrl; cachedCountry; cachedCountryCode; constructor(logger, axiosFactory = axios, sessionId, baseUrl = 'https://usiot.roborock.com') { this.logger = logger; this.axiosFactory = axiosFactory; this.baseUrl = baseUrl; this.sessionId = sessionId ?? crypto.randomUUID(); } async loginWithUserData(username, userData) { this.loginWithAuthToken(username, userData.token); return userData; } async loginWithPassword(username, password) { const api = await this.getAPIFor(username); const response = await api.post('api/v1/login', new URLSearchParams({ username: username, password: password, needtwostepauth: 'false', }).toString()); return this.auth(username, response.data); } /** * Request a verification code to be sent to the user's email * @param email - The user's email address * @throws Error if the account is not found, rate limited, or other API error */ async requestCodeV4(email) { const api = await this.getAPIFor(email); const response = await api.post('api/v4/email/code/send', new URLSearchParams({ email: email, type: 'login', platform: '', }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const apiResponse = response.data; if (apiResponse.code === AuthenticateResponseCode.AccountNotFound) { throw new Error(`Account not found for email: ${email}`); } if (apiResponse.code === AuthenticateResponseCode.RateLimited) { throw new Error('Rate limited. Please wait before requesting another code.'); } if (apiResponse.code !== AuthenticateResponseCode.Success && apiResponse.code !== undefined) { throw new Error(`Failed to send verification code: ${apiResponse.msg} (code: ${apiResponse.code})`); } this.logger.debug('Verification code requested successfully'); } /** * Login with a verification code received via email * @param email - The user's email address * @param code - The 6-digit verification code * @returns UserData on successful authentication * @throws Error if the code is invalid, rate limited, or other API error */ async loginWithCodeV4(email, code) { const api = await this.getAPIFor(email); // Generate x_mercy_ks (random 16-char alphanumeric string) const xMercyKs = this.generateRandomString(16); // Get signed key from API const xMercyK = await this.signKeyV3(api, xMercyKs); // Fallback for missing country info to avoid 1002 parameter error let country = this.cachedCountry; let countryCode = this.cachedCountryCode; if (!country || !countryCode) { if (this.baseUrl.includes('euiot')) { country ??= 'Germany'; countryCode ??= 'DE'; } else if (this.baseUrl.includes('usiot')) { country ??= 'United States'; countryCode ??= 'US'; } else if (this.baseUrl.includes('cniot')) { country ??= 'China'; countryCode ??= 'CN'; } else if (this.baseUrl.includes('ruiot')) { country ??= 'Russia'; countryCode ??= 'RU'; } } const response = await api.post('api/v4/auth/email/login/code', null, { params: { email: email, code: code, country: country ?? '', countryCode: countryCode ?? '', majorVersion: '14', minorVersion: '0', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'x-mercy-ks': xMercyKs, 'x-mercy-k': xMercyK, header_appversion: '4.54.02', header_phonesystem: 'iOS', header_phonemodel: 'iPhone16,1', }, }); return this.authV4(email, response.data); } async getBasicHomeInfo() { if (!this.username || !this.authToken) { this.logger.error(`Cannot get home details: not authenticated, username is ${this.username} authToken is ${this.authToken}`); return undefined; } const api = await this.getAPIFor(this.username); const response = await api.get('api/v1/getHomeDetail'); const apiResponse = response.data; if (!apiResponse.data) { throw new Error('Failed to retrieve the home details'); } return apiResponse.data; } /** * Get cached country info from the last base URL lookup */ getCachedCountryInfo() { return { country: this.cachedCountry, countryCode: this.cachedCountryCode, }; } async getAPIFor(username) { const baseUrl = await this.getBaseUrl(username); return this.apiForUser(username, baseUrl); } async getBaseUrl(username) { if (this.cachedBaseUrl && this.username === username) { return this.cachedBaseUrl; } const api = await this.apiForUser(username); const response = await api.post('api/v1/getUrlByEmail', new URLSearchParams({ email: username, needtwostepauth: 'false', }).toString()); const apiResponse = response.data; if (!apiResponse.data) { throw new Error(`Failed to retrieve base URL: ${apiResponse.msg ?? ''}`); } this.cachedBaseUrl = apiResponse.data.url; this.cachedCountry = apiResponse.data.country; this.cachedCountryCode = apiResponse.data.countrycode; this.username = username; return apiResponse.data.url; } async apiForUser(username, baseUrl = this.baseUrl) { const instance = this.axiosFactory.create({ baseURL: baseUrl, headers: { header_clientid: crypto.createHash('md5').update(username).update(this.sessionId).digest('base64'), Authorization: this.authToken, header_clientlang: 'en', }, }); instance.interceptors.request.use((request) => { return AxiosLogger.requestLogger(request, { prefixText: 'Roborock Authenticate API', dateFormat: 'HH:MM:ss', headers: true, data: true, method: true, url: true, params: true, logger: this.logger.debug.bind(this.logger), }); }, AxiosLogger.errorLogger); instance.interceptors.response.use((response) => { AxiosLogger.responseLogger(response, { prefixText: 'Roborock Authenticate API', dateFormat: 'HH:MM:ss', headers: true, data: true, status: true, statusText: true, params: true, logger: this.logger.debug.bind(this.logger), }); return response; }, AxiosLogger.errorLogger); return instance; } auth(username, response) { const userdata = response.data; if (!userdata?.token) { throw new Error(`Authentication failed: ${response.msg ?? ''} code: ${response.code}`); } this.loginWithAuthToken(username, userdata.token); return userdata; } /** * Handle v4 authentication response with specific error code handling */ authV4(email, response) { if (response.code === AuthenticateResponseCode.InvalidCode) { throw new Error('Invalid verification code. Please check and try again.'); } if (response.code === AuthenticateResponseCode.RateLimited) { throw new Error('Rate limited. Please wait before trying again.'); } const userdata = response.data; if (!userdata?.token) { throw new Error(`Authentication failed: ${response.msg ?? ''} code: ${response.code}`); } this.loginWithAuthToken(email, userdata.token); return userdata; } loginWithAuthToken(username, token) { this.logger.notice(`Login using auth token for user: ${username} successfully`); this.username = username; this.authToken = token; } /** * Generate a random alphanumeric string of specified length */ generateRandomString(length) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; const randomBytes = crypto.randomBytes(length); for (let i = 0; i < length; i++) { result += chars[randomBytes[i] % chars.length]; } return result; } /** * Sign a key using the v3 API endpoint */ async signKeyV3(api, s) { const response = await api.post('api/v3/key/sign', null, { params: { s }, }); const apiResponse = response.data; if (!apiResponse.data?.k) { throw new Error(`Failed to sign key: ${apiResponse.msg ?? ''}`); } return apiResponse.data.k; } } //# sourceMappingURL=authClient.js.map