Skip to content

Instantly share code, notes, and snippets.

@RyadPasha
Last active July 11, 2023 12:57
Show Gist options
  • Save RyadPasha/8aa30994e08b9ed08183b091f596d7eb to your computer and use it in GitHub Desktop.
Save RyadPasha/8aa30994e08b9ed08183b091f596d7eb to your computer and use it in GitHub Desktop.
A service class representing the HuaweiAPI to send push notifications and check the risk of a device on Huawei devices.
/**
* A service class representing the HuaweiAPI to send push
* notifications and check the risk of a device on Huawei devices.
*
* @class HuaweiAPI
* @author Mohamed Riyad <m@ryad.me>
* @link https://RyadPasha.com
* @link https://gist.github.com/RyadPasha/8aa30994e08b9ed08183b091f596d7eb
* @copyright Copyright (C) 2023 RyadPasha. All rights reserved.
* @license MIT
* @version 1.0.1-2023.06.14
* @since 1.0.0-2023.06.02
*/
const TAG = 'HuaweiAPI'
const axios = require('axios')
class HuaweiAPI {
/**
* Create an instance of HuaweiAPI.
*
* @param {string} clientId - The client ID of the Huawei API credentials.
* @param {string} clientSecret - The client secret of the Huawei API credentials.
*/
constructor(clientId, clientSecret) {
// Huawei API credentials
this.clientId = clientId
this.clientSecret = clientSecret
// Access token and its expiration time
this.accessTokenExpirationBuffer = 60 // Buffer of 1 minute (60 seconds)
this.accessToken = null
this.tokenExpiration = null
// Base URLs for the Huawei APIs
this.oauthUrl = 'https://oauth-login.cloud.huawei.com/oauth2/v3/token'
this.verifyUrl = 'https://hirms.cloud.huawei.com/rms/v1/userRisks/verify'
this.pushBaseUrl = 'https://push-api.cloud.huawei.com/v1'
// Timeout for API requests, in milliseconds
this.timeout = 30000 // 30 seconds
// API limits
this.limit_of_push_tokens = 1000
// Whether to log to the console
this.log_to_console = true
}
/**
* Log the given message if logging is enabled.
*/
log(...params) {
this.log_to_console && console.log(`${TAG}:`, ...params)
}
/**
* Delay the execution for a specified amount of time.
*
* @param {number} ms - The duration to sleep, in milliseconds.
* @returns {Promise<void>} A Promise that resolves after the specified delay.
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Retry the execution of a given function until a specified condition is met or the maximum number of retries is reached.
*
* @param {Function} fn - The function to execute.
* @param {Function} condition - The condition to check. Should accept the result of `fn` and return a boolean value.
* @param {number} maxRetries - The maximum number of retries allowed.
* @returns {Promise<object|null>} A Promise that resolves once the retry process is completed.
*/
async retryWithLimit(fn, condition, maxRetries) {
let success = false
let result = null
let retryCount = 0
while (retryCount < maxRetries) {
try {
result = await fn()
if (condition(result)) {
success = true
break // Exit the loop if the condition is met
}
} catch (error) {
console.error(`Error occurred while executing the given function: ${error.message}`)
result = error
}
retryCount++
this.log(`Retrying (${retryCount})...`)
await this.sleep(1000) // Delay before retrying
}
if (success) {
this.log('Operation successful:', result)
} else {
this.log(`Operation unsuccessful after ${maxRetries} retries.`, result)
}
return result
}
/**
* Generate OAuth2 access token.
*
* @param {string} scope - The scope of the access token.
* @throws {Error} Failed to generate access token.
* @returns {Promise<object>} The response from the access token API.
*/
generateAccessToken(scope) {
return new Promise(async (resolve, reject) => {
axios
.post(this.oauthUrl, null, {
timeout: this.timeout,
params: {
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret
// scope: scope
}
})
.then((response) => {
if (!response.data || !response.data.access_token || !response.data.expires_in) {
reject({
success: false,
code: 2,
message: 'Failed to generate access token',
error: response.data ? response.data.error_description : 'No response data'
})
return
}
this.accessToken = response.data.access_token
this.tokenExpiration = Date.now() + (response.data.expires_in - this.accessTokenExpirationBuffer) * 1000
resolve({
success: true,
code: 1,
message: 'Access token generated',
data: response.data,
scope
})
})
.catch((error) => {
if (error.response) {
// Request made and server responded
reject({
success: false,
code: 3,
message: error.response && error.response.data && error.response.data.error_description ? error.response.data.error_description : 'No response data'
})
} else if (error.request) {
// The request was made but no response was received
reject({
success: false,
code: 4,
message: 'No response'
})
} else {
// Something happened in setting up the request that triggered an Error
reject({
success: false,
code: 5,
message: error.message || 'Unknown error'
})
}
})
})
}
/**
* Generate OAuth2 access token with retry.
*
* @param {string} scope - The scope of the access token.
* @param {number} maxRetries - The maximum number of retries allowed.
* @throws {Error} Failed to generate access token.
* @returns {Promise<object|null>} The response from the access token API.
*/
generateAccessTokenWithRetry(scope, maxRetries = 3) {
return this.retryWithLimit(
async () => this.generateAccessToken(scope),
(response) => response.success,
maxRetries
)
}
/**
* Check if the access token has expired.
*
* @returns {boolean} - True if access token has expired, false otherwise.
*/
isTokenExpired() {
return !this.tokenExpiration || this.tokenExpiration < Date.now()
}
/**
* Retrieve valid access token (regenerate if necessary).
*
* @param {string} scope - The scope of the access token.
* @returns {Promise<string>} The valid access token.
*/
async getValidAccessToken(scope) {
if (this.accessToken && !this.isTokenExpired()) {
return this.accessToken
}
try {
await this.generateAccessTokenWithRetry(scope)
} catch (error) {
this.log(`Failed to generate access token: ${error.message}`)
}
return this.accessToken
}
/**
* Handle push notification errors.
*
* @param {string} [error=0] - The error message.
* @param {number} [success=0] - The count of successful push notifications.
* @param {number} [failure=0] - The count of failed push notifications.
* @param {string[]} [invalidTokens=[]] - The list of invalid tokens.
* @returns {object} The response object.
*/
handleError(error, success = 0, failure = 0, invalidTokens = []) {
return {
success: success,
failure: failure,
illegal_tokens: invalidTokens,
errors: [error]
}
}
/**
* Send a push notification using Huawei Push Kit.
* This method is limited to sending notifications to 1000 devices at a time.
*
* @param {string[]} tokens - An array of device tokens to send the notification to.
* @param {Object} options - The options for configuring the push notification.
* @param {string} [options.title=null] - The title of the push notification.
* @param {string} [options.message=null] - The message of the push notification.
* @param {Object} [options.data=null] - Additional data to include in the data message.
* @param {string|null} [options.image=null] - The URL of the image to include in the notification. Default to null.
* @param {string} [options.msg_type='auto'] - Whether the payload is a notification message or a data message. Valid values are 'auto', 'message' and 'data'. Defaults to 'auto'.
* @param {number} [options.retry_limit=3] - The maximum number of retries allowed. Defaults to 3.
* @param {number} [options.ttl=null] - The time to live for the message in seconds. Defaults to null (where `86400` which is HMS default will be used).
* @throws {Error} Failed to send push notification.
* @returns {Promise<object>} The response from the push notification API.
*/
async sendPushNotificationLimited(tokens, options) {
const { title = null, message = null, data = null, image = null, msg_type = 'auto', retry_limit = 3, ttl = null } = options
const errors = []
try {
const payload = {
validate_only: false,
message: {
token: tokens
}
}
if (ttl) {
payload.message.android = {
ttl: `${ttl}s`
}
}
if (msg_type === 'message') {
// Message type
if (title && message) {
payload.message.android = {
notification: {
title: title,
body: message,
click_action: {
type: 3
}
}
}
if (image) {
payload.message.android.notification.image = image
}
} else {
return this.handleError('Title and message are required for message type')
}
} else if (msg_type === 'data') {
// Data type
if (data) {
if (typeof data === 'object') {
payload.message.data = JSON.stringify(data)
} else if (typeof data === 'string') {
payload.message.data = data
} else {
return this.handleError('Data must be a string or an object')
}
} else {
return this.handleError('Data is required for data type')
}
} else {
// Auto type
if (data) {
if (typeof data === 'object') {
payload.message.data = JSON.stringify(data)
} else if (typeof data === 'string') {
payload.message.data = data
} else {
return this.handleError('Data must be a string or an object')
}
} else if (title && message) {
payload.message.android = {
notification: {
title: title,
body: message,
click_action: {
type: 3
}
}
}
if (image) {
payload.message.android.notification.image = image
}
} else {
return this.handleError('Data or title and message are required for auto type')
}
}
// Retrieve valid access token
const accessToken = await this.getValidAccessToken('push.notification')
// Send push notification
const response = await axios.post(`${this.pushBaseUrl}/${this.clientId}/messages:send`, payload, {
timeout: this.timeout,
headers: {
Authorization: `Bearer ${accessToken}`
}
})
if (response.data) {
if (response.data.code === '80000000') {
return {
requestId: response.data.requestId || null,
success: tokens.length,
failure: 0,
illegal_tokens: []
}
} else if (response.data.code === '80100000') {
try {
return {
requestId: response.data.requestId || null,
...JSON.parse(response.data.msg)
}
} catch (error) {
this.log(`Error parsing response data: ${error.message}`)
}
} else if (response.data.code === '80200003') {
// Access token expired
// Check if we have reached the maximum number of retries
if (retry_limit <= 0) {
return this.handleError('Access token expired')
}
// Reset access token and token expiration
this.accessToken = null
this.tokenExpiration = null
this.log('Access token expired, retrying...')
// Retry
return await this.sendPushNotificationLimited(tokens, { ...options, retry_limit: retry_limit - 1 })
} else if (response.data.code === '80300002') {
// The current app does not have the permission to send messages.
this.log('The current app does not have the permission to send messages.')
errors.push('The current app does not have the permission to send messages.')
} else {
const error = response && data ? response.data.msg || response.data.code || 'No message' : 'Unknown error'
this.log('Unknown error:', error)
errors.push(error)
}
}
} catch (error) {
this.log('Error:', error)
}
return {
success: 0,
failure: tokens.length,
illegal_tokens: [],
errors
}
}
/**
* Send push notifications to a large number of device tokens.
* Splits tokens into chunks of 1000 tokens per request.
*
* @param {string[]} tokens - An array of device tokens to send the notification to.
* @param {Object} options - The options for configuring the push notification.
* @param {string} [options.title=null] - The title of the push notification.
* @param {string} [options.message=null] - The message of the push notification.
* @param {Object} [options.data=null] - Additional data to include in the data message.
* @param {string|null} [options.image=null] - The URL of the image to include in the notification.
* @param {string} [options.msg_type='auto'] - Whether the payload is a notification message or a data message. Valid values are 'auto', 'message' and 'data'. Defaults to 'auto'.
* @param {number} [options.ttl=null] - The time to live for the message. Defaults to null ('86400s' is HMS default).
* @throws {Error} Failed to send push notification.
* @returns {Promise<object>} The response from the push notification API.
*/
async sendPushNotification(tokens, options) {
const totalTokens = tokens.length
const invalidTokens = []
const errors = []
// Success and failure counts
let successCount = 0
let failureCount = 0
let requestIds = []
try {
for (let i = 0; i < totalTokens; i += this.limit_of_push_tokens) {
this.log(`Sending push notification to ${i} to ${i + this.limit_of_push_tokens} tokens`)
// Split tokens into chunks of 1000 tokens per request
const tokenChunk = tokens.slice(i, i + this.limit_of_push_tokens)
// Send push notification
const response = await this.sendPushNotificationLimited(tokenChunk, { ...options })
if (response) {
successCount += response.success ? parseInt(response.success) || 0 : 0
failureCount += response.failure ? parseInt(response.failure) || 0 : 0
if (response.illegal_tokens) {
invalidTokens.push(...response.illegal_tokens)
}
if (response.requestId) {
requestIds.push(response.requestId)
}
if (successCount === 0 && failureCount === 0) {
// No success or failure counts, add all tokens to failure
failureCount += tokenChunk.length
}
if (response.errors && response.errors.length > 0) {
// Add errors
errors.push(...response.errors)
}
} else {
failureCount += tokenChunk.length
errors.push(response)
}
}
return {
requestIds,
success: successCount,
failure: failureCount,
illegal_tokens: invalidTokens,
errors
}
} catch (error) {
errors.push(error.message)
this.log(`Error sending push notification: ${error.message}`, error)
return {
success: successCount,
failure: totalTokens - successCount,
illegal_tokens: invalidTokens,
errors: errors
}
}
}
/**
* Validate if a device is fake using the UserDetect API.
*
* @throws {Error} Failed to validate UserDetect.
* @returns {Promise<{success: (*|boolean), error: (string|*)}>} The response from the UserDetect API.
*/
async userDetect(token) {
// Retrieve valid access token
const accessToken = await this.getValidAccessToken('user.detect')
try {
// Send the verification request
const response = await axios.post(
`${this.verifyUrl}?appId=${this.clientId}`,
{
accessToken,
response: token
},
{
timeout: this.timeout
}
)
const success = response && response.data && response.data.success ? response.data.success : false
return {
success: success,
error: success ? '' : response && response.data && response.data['error-codes'] ? response.data['error-codes'] : 'N/A'
}
} catch (error) {
this.log('Error:', error)
}
return {
success: false,
error: 'N/A'
}
}
}
module.exports = HuaweiAPI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment