UNPKG

@warriorteam/redai-zalo-sdk

Version:

Comprehensive TypeScript/JavaScript SDK for Zalo APIs - Official Account v3.0, ZNS with Full Type Safety, Consultation Service, Broadcast Service, Group Messaging with List APIs, Social APIs, Enhanced Article Management, Promotion Service v3.0 with Multip

1,082 lines 60.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ArticleService = void 0; const article_1 = require("../types/article"); const common_1 = require("../types/common"); /** * Service for handling Zalo Official Account Article Management APIs * * CONDITIONS FOR USING ZALO ARTICLE MANAGEMENT: * * 1. GENERAL CONDITIONS: * - OA must have permission to create articles * - Access token must have "manage_article" scope * - OA must have active status and be verified * * 2. ARTICLE CREATION: * - Title: required, max 150 characters * - Author: required for normal articles, max 50 characters * - Description: required, max 300 characters * - Image size: max 1MB per image * - Status: 'show' (publish immediately) or 'hide' (draft) * - Comment: 'show' (allow comments) or 'hide' (disable comments) * * 3. CONTENT VALIDATION: * - Normal articles: must have cover, body content, and author * - Video articles: must have video_id and avatar * - Body items: support text, image, video, and product types * - Tracking links: must be valid URLs * * 4. LIMITS AND CONSTRAINTS: * - Article list: max 10 items per request * - Processing time: use token to check progress * - Update frequency: reasonable intervals recommended */ class ArticleService { constructor(client) { this.client = client; // Zalo API endpoints - organized by functionality this.endpoints = { article: { create: "https://openapi.zalo.me/v2.0/article/create", update: "https://openapi.zalo.me/v2.0/article/update", remove: "https://openapi.zalo.me/v2.0/article/remove", verify: "https://openapi.zalo.me/v2.0/article/verify", detail: "https://openapi.zalo.me/v2.0/article/getdetail", list: "https://openapi.zalo.me/v2.0/article/getslice", }, }; } /** * Create normal article with rich content * @param accessToken OA access token * @param request Normal article information * @returns Token for tracking creation progress */ async createNormalArticle(accessToken, request) { try { // Validate input this.validateNormalArticleRequest(request); // Process tracking link - use default if not provided or invalid let processedTrackingLink = "https://v2.redai.vn/auth"; if (request.tracking_link && this.isValidTrackingLink(request.tracking_link)) { processedTrackingLink = request.tracking_link; } const data = { type: request.type, title: request.title, author: request.author, cover: request.cover, description: request.description, body: request.body, related_medias: request.related_medias || [], tracking_link: processedTrackingLink, status: request.status || article_1.ArticleStatus.HIDE, comment: request.comment || article_1.CommentStatus.SHOW, }; // Gọi API và nhận đúng cấu trúc bao bọc của Zalo const response = await this.client.apiPost(this.endpoints.article.create, accessToken, data); // Trả về đầy đủ cấu trúc response theo type chuẩn if (!response.data || !response.data.token) { throw new common_1.ZaloSDKError("No token received from article creation", -1, response); } return response; } catch (error) { throw this.handleArticleError(error, "Failed to create normal article"); } } /** * Create video article * @param accessToken OA access token * @param request Video article information * @returns Token for tracking creation progress */ async createVideoArticle(accessToken, request) { try { // Validate input this.validateVideoArticleRequest(request); const data = { type: request.type, title: request.title, description: request.description, video_id: request.video_id, avatar: request.avatar, status: request.status || article_1.ArticleStatus.HIDE, comment: request.comment || article_1.CommentStatus.SHOW, }; // Gọi API và nhận đúng cấu trúc bao bọc của Zalo const response = await this.client.apiPost(this.endpoints.article.create, accessToken, data); // Trả về đầy đủ cấu trúc response theo type chuẩn if (!response.data || !response.data.token) { throw new common_1.ZaloSDKError("No token received from article creation", -1, response); } return response; } catch (error) { throw this.handleArticleError(error, "Failed to create video article"); } } /** * Create article (automatically detect type) * @param accessToken OA access token * @param request Article information * @returns Token for tracking creation progress */ async createArticle(accessToken, request) { if (request.type === article_1.ArticleType.NORMAL) { return this.createNormalArticle(accessToken, request); } else if (request.type === article_1.ArticleType.VIDEO) { return this.createVideoArticle(accessToken, request); } else { throw new common_1.ZaloSDKError('Invalid article type. Only "normal" and "video" are supported', -1); } } /** * Check article creation progress * @param accessToken OA access token * @param token Token from article creation response * @returns Progress information */ async checkArticleProcess(accessToken, token) { try { if (!token || token.trim() === "") { throw new common_1.ZaloSDKError("Token cannot be empty", -1); } // Use verifyArticle method to check progress const response = await this.verifyArticle(accessToken, token); // Convert response to standard format const result = { article_id: response?.id, status: response?.id ? "success" : "processing", created_time: Date.now(), updated_time: Date.now(), }; return result; } catch (error) { // Handle token expiration - means article processing is completed if (error instanceof common_1.ZaloSDKError && error.message.includes("token was expired")) { return { status: "success", error_message: "Token expired - article processing completed", created_time: Date.now(), updated_time: Date.now(), }; } // Handle API errors - article may still be processing if (error instanceof common_1.ZaloSDKError && error.message.includes("API")) { return { status: "processing", created_time: Date.now(), updated_time: Date.now(), }; } // Handle response structure errors if (error instanceof TypeError) { return { status: "processing", error_message: "Article is being processed", created_time: Date.now(), updated_time: Date.now(), }; } throw this.handleArticleError(error, "Failed to check article process"); } } /** * Verify article and get article ID * @param accessToken OA access token * @param token Token from article creation or update response * @returns Article ID */ async verifyArticle(accessToken, token) { try { if (!token || token.trim() === "") { throw new common_1.ZaloSDKError("Token cannot be empty", -1); } const data = { token: token, }; const response = await this.client.apiPost(this.endpoints.article.verify, accessToken, data); return response.data; } catch (error) { throw this.handleArticleError(error, "Failed to verify article"); } } /** * Get article details * @param accessToken OA access token * @param articleId Article ID * @returns Article details */ async getArticleDetail(accessToken, articleId) { try { if (!articleId || articleId.trim() === "") { throw new common_1.ZaloSDKError("Article ID cannot be empty", -1); } const response = await this.client.apiGet(this.endpoints.article.detail, accessToken, { id: articleId }); return response.data; } catch (error) { throw this.handleArticleError(error, "Failed to get article detail"); } } /** * Get article list * @param accessToken OA access token * @param request List parameters * @returns Article list */ async getArticleList(accessToken, request) { try { // Validate input this.validateArticleListRequest(request); const response = await this.client.apiGet(this.endpoints.article.list, accessToken, { offset: request.offset, limit: request.limit, type: request.type, }); return response; } catch (error) { throw this.handleArticleError(error, "Failed to get article list"); } } /** * Get all articles by automatically fetching all pages * * @example * ```typescript * // Get all normal articles with progress tracking * const result = await articleService.getAllArticles(accessToken, "normal", { * batchSize: 50, * maxArticles: 1000, * onProgress: (progress) => { * console.log(`Batch ${progress.currentBatch}: ${progress.totalFetched} articles fetched`); * } * }); * * console.log(`Total: ${result.totalFetched} articles in ${result.totalBatches} batches`); * console.log(`Has more: ${result.hasMore}`); * ``` * * @param accessToken OA access token * @param type Article type ("normal" or "video") * @param options Configuration options for fetching * @returns All articles with pagination info */ async getAllArticles(accessToken, type = "normal", options = {}) { try { const { batchSize = 10, // Zalo API maximum limit is 10 maxArticles = 1000, onProgress } = options; // Validate parameters if (batchSize <= 0 || batchSize > article_1.ARTICLE_CONSTRAINTS.LIST_MAX_LIMIT) { throw new common_1.ZaloSDKError(`Batch size must be between 1 and ${article_1.ARTICLE_CONSTRAINTS.LIST_MAX_LIMIT}`, -1); } if (maxArticles < 0) { throw new common_1.ZaloSDKError("Max articles must be >= 0 (0 = no limit)", -1); } if (!["normal", "video"].includes(type)) { throw new common_1.ZaloSDKError('Type must be "normal" or "video"', -1); } const allArticles = []; let offset = 0; let currentBatch = 0; let hasMore = true; while (hasMore) { currentBatch++; // Calculate limit for this batch let currentLimit = batchSize; if (maxArticles > 0) { const remaining = maxArticles - allArticles.length; if (remaining <= 0) break; currentLimit = Math.min(batchSize, remaining); } // Fetch current batch const response = await this.getArticleList(accessToken, { offset, limit: currentLimit, type }); // Add articles to collection // Check if response is successful and has data if ('data' in response && response.data?.medias && response.data.medias.length > 0) { allArticles.push(...response.data.medias); offset += response.data.medias.length; // Check if we have more data hasMore = response.data.medias.length === currentLimit; // Stop if we've reached max articles if (maxArticles > 0 && allArticles.length >= maxArticles) { hasMore = false; } } else if ('medias' in response && response.medias && response.medias.length > 0) { // Handle direct response format (fallback) allArticles.push(...response.medias); offset += response.medias.length; // Check if we have more data hasMore = response.medias.length === currentLimit; // Stop if we've reached max articles if (maxArticles > 0 && allArticles.length >= maxArticles) { hasMore = false; } } else { hasMore = false; } // Call progress callback if provided if (onProgress) { onProgress({ currentBatch, totalFetched: allArticles.length, hasMore }); } // Safety check to prevent infinite loops if (currentBatch > 100) { console.warn("Reached maximum batch limit (100), stopping fetch"); break; } } return { articles: allArticles, totalFetched: allArticles.length, totalBatches: currentBatch, hasMore: hasMore && (maxArticles === 0 || allArticles.length < maxArticles) }; } catch (error) { throw this.handleArticleError(error, "Failed to get all articles"); } } /** * Get all articles of both types (normal and video) combined * * @example * ```typescript * // Get all articles (both normal and video) * const result = await articleService.getAllArticlesCombined(accessToken, { * batchSize: 50, * maxArticlesPerType: 500, * onProgress: (progress) => { * console.log(`${progress.type}: Batch ${progress.currentBatch}, Total: ${progress.totalFetched}`); * } * }); * * console.log(`Total articles: ${result.totalFetched}`); * console.log(`Normal articles: ${result.breakdown.normal.totalFetched}`); * console.log(`Video articles: ${result.breakdown.video.totalFetched}`); * ``` * * @param accessToken OA access token * @param options Configuration options for fetching * @returns All articles (normal + video) with combined pagination info */ async getAllArticlesCombined(accessToken, options = {}) { try { const { batchSize = 10, // Zalo API maximum limit is 10 maxArticlesPerType = 500, onProgress } = options; // Fetch normal articles const normalResult = await this.getAllArticles(accessToken, "normal", { batchSize, maxArticles: maxArticlesPerType, onProgress: onProgress ? (progress) => onProgress({ type: "normal", ...progress }) : undefined }); // Fetch video articles const videoResult = await this.getAllArticles(accessToken, "video", { batchSize, maxArticles: maxArticlesPerType, onProgress: onProgress ? (progress) => onProgress({ type: "video", ...progress }) : undefined }); // Combine results const allArticles = [...normalResult.articles, ...videoResult.articles]; return { articles: allArticles, breakdown: { normal: normalResult, video: videoResult }, totalFetched: allArticles.length, totalBatches: normalResult.totalBatches + videoResult.totalBatches }; } catch (error) { throw this.handleArticleError(error, "Failed to get all articles combined"); } } /** * Remove article * @param accessToken OA access token * @param articleId Article ID to remove * @returns Removal result */ async removeArticle(accessToken, articleId) { try { if (!articleId || articleId.trim() === "") { throw new common_1.ZaloSDKError("Article ID cannot be empty", -1); } const data = { id: articleId, }; const response = await this.client.apiPost(this.endpoints.article.remove, accessToken, data); return response; } catch (error) { // Handle case where article may already be deleted if (error instanceof common_1.ZaloSDKError && error.message.includes("No data received")) { return { message: "Article has been deleted or does not exist", }; } throw this.handleArticleError(error, "Failed to remove article"); } } /** * Remove multiple articles in bulk (advanced function with progress tracking) * @param accessToken OA access token * @param articleIds Array of article IDs to remove * @param options Options for bulk removal operation * @param progressCallback Optional callback to track progress * @returns Complete bulk removal result with detailed statistics * * This function automatically handles: * - Batch processing to avoid overwhelming the API * - Concurrent requests with configurable limits * - Error handling for individual articles * - Progress tracking and statistics * - Retry mechanism for failed requests */ async removeBulkArticles(accessToken, articleIds, options, progressCallback) { try { // Validate input if (!accessToken || accessToken.trim().length === 0) { throw new common_1.ZaloSDKError("Access token cannot be empty", -1); } if (!articleIds || articleIds.length === 0) { throw new common_1.ZaloSDKError("Article IDs array cannot be empty", -1); } // Filter out empty/invalid IDs const validArticleIds = articleIds.filter(id => id && id.trim().length > 0); if (validArticleIds.length === 0) { throw new common_1.ZaloSDKError("No valid article IDs found", -1); } // Set default options const opts = { batch_size: 10, max_concurrency: 3, continue_on_error: true, batch_delay: 100, request_timeout: 10000, ...options }; // Initialize tracking variables const results = []; const errorSummary = {}; const startTime = Date.now(); let successfulCount = 0; let failedCount = 0; // Progress tracking helper const updateProgress = (currentCount, isComplete = false) => { if (progressCallback) { const percentage = Math.round((currentCount / validArticleIds.length) * 100); const currentBatch = Math.ceil(currentCount / opts.batch_size); const totalBatches = Math.ceil(validArticleIds.length / opts.batch_size); progressCallback({ current_count: currentCount, total_count: validArticleIds.length, percentage, successful_count: successfulCount, failed_count: failedCount, is_complete: isComplete, current_batch: currentBatch, total_batches: totalBatches }); } }; // Initial progress updateProgress(0); // Process articles in batches for (let i = 0; i < validArticleIds.length; i += opts.batch_size) { const batch = validArticleIds.slice(i, i + opts.batch_size); // Create promises for current batch with concurrency limit const batchPromises = batch.map(async (articleId) => { const itemStartTime = Date.now(); try { // Create timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Request timeout')), opts.request_timeout); }); // Remove article with timeout const removePromise = this.removeArticle(accessToken, articleId); const response = await Promise.race([removePromise, timeoutPromise]); const processingTime = Date.now() - itemStartTime; successfulCount++; const result = { article_id: articleId, success: true, message: response.message, processing_time: processingTime }; results.push(result); return result; } catch (error) { const processingTime = Date.now() - itemStartTime; failedCount++; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorType = error instanceof common_1.ZaloSDKError ? 'ZaloSDKError' : 'UnknownError'; // Track error types errorSummary[errorType] = (errorSummary[errorType] || 0) + 1; const result = { article_id: articleId, success: false, error: errorMessage, processing_time: processingTime }; results.push(result); // Stop processing if continue_on_error is false if (!opts.continue_on_error) { throw new common_1.ZaloSDKError(`Bulk removal stopped due to error on article ${articleId}: ${errorMessage}`, -1); } return result; } }); // Process batch with concurrency limit const chunks = []; for (let j = 0; j < batchPromises.length; j += opts.max_concurrency) { chunks.push(batchPromises.slice(j, j + opts.max_concurrency)); } for (const chunk of chunks) { await Promise.all(chunk); } // Update progress after each batch updateProgress(Math.min(i + opts.batch_size, validArticleIds.length)); // Add delay between batches (except for the last batch) if (i + opts.batch_size < validArticleIds.length && opts.batch_delay > 0) { await new Promise(resolve => setTimeout(resolve, opts.batch_delay)); } } // Final progress update updateProgress(validArticleIds.length, true); // Calculate final statistics const totalTime = Date.now() - startTime; const successRate = validArticleIds.length > 0 ? Math.round((successfulCount / validArticleIds.length) * 100) : 0; // Return complete result return { total_processed: validArticleIds.length, successful_count: successfulCount, failed_count: failedCount, success_rate: successRate, total_time: totalTime, results: results.sort((a, b) => validArticleIds.indexOf(a.article_id) - validArticleIds.indexOf(b.article_id)), error_summary: Object.keys(errorSummary).length > 0 ? errorSummary : undefined }; } catch (error) { throw this.handleArticleError(error, "Failed to remove articles in bulk"); } } /** * Remove multiple articles in bulk with simple interface (no progress tracking) * @param accessToken OA access token * @param articleIds Array of article IDs to remove * @param options Optional settings for bulk removal * @returns Simple summary of bulk removal operation * * Simplified version of removeBulkArticles that returns just the summary statistics */ async removeBulkArticlesSimple(accessToken, articleIds, options) { const result = await this.removeBulkArticles(accessToken, articleIds, options); const failedArticles = result.results .filter(r => !r.success) .map(r => r.article_id); return { total_processed: result.total_processed, successful_count: result.successful_count, failed_count: result.failed_count, success_rate: result.success_rate, failed_articles: failedArticles.length > 0 ? failedArticles : undefined }; } /** * Get all articles with full details (advanced function with progress tracking) * @param accessToken OA access token * @param type Article type ("normal", "video", or "both") * @param options Options for bulk details fetch operation * @param progressCallback Optional callback to track progress * @returns Complete result with all articles and their full details * * This function automatically: * 1. Fetches all articles list using getAllArticles() or getAllArticlesCombined() * 2. Fetches detailed information for each article using getArticleDetail() * 3. Handles batch processing and concurrency control * 4. Provides detailed progress tracking and error handling * 5. Returns both successful and failed results with statistics */ async getAllArticlesWithDetails(accessToken, type = "both", options, progressCallback) { try { // Validate input if (!accessToken || accessToken.trim().length === 0) { throw new common_1.ZaloSDKError("Access token cannot be empty", -1); } // Set default options const opts = { detail_batch_size: 5, max_concurrency: 3, continue_on_error: true, batch_delay: 200, request_timeout: 15000, max_articles: 0, // 0 = unlimited list_options: { batch_size: 10, // Zalo API maximum limit is 10 max_articles: 0 // 0 = unlimited }, ...options }; // Initialize tracking variables const results = []; const articlesWithDetails = []; const errorSummary = {}; const startTime = Date.now(); let successfulCount = 0; let failedCount = 0; // Progress tracking helper const updateProgress = (currentCount, totalCount, phase, isComplete = false) => { if (progressCallback) { const percentage = totalCount > 0 ? Math.round((currentCount / totalCount) * 100) : 0; const currentBatch = phase === 'fetching_details' ? Math.ceil(currentCount / opts.detail_batch_size) : undefined; const totalBatches = phase === 'fetching_details' ? Math.ceil(totalCount / opts.detail_batch_size) : undefined; progressCallback({ current_count: currentCount, total_count: totalCount, percentage, successful_count: successfulCount, failed_count: failedCount, is_complete: isComplete, phase, current_batch: currentBatch, total_batches: totalBatches }); } }; // Phase 1: Fetch articles list updateProgress(0, 0, 'fetching_list'); const listStartTime = Date.now(); let articlesList = []; let listFetchStats = { total_pages_fetched: 0, list_fetch_time: 0 }; if (type === "both") { const combinedResult = await this.getAllArticlesCombined(accessToken, { batchSize: opts.list_options.batch_size, maxArticlesPerType: opts.list_options.max_articles || 500, onProgress: (progress) => { // Convert combined progress to simple progress for our callback updateProgress(progress.totalFetched, progress.totalFetched, 'fetching_list'); } }); articlesList = combinedResult.articles; listFetchStats.total_pages_fetched = combinedResult.totalBatches; } else { const singleResult = await this.getAllArticles(accessToken, type, { batchSize: opts.list_options.batch_size, maxArticles: opts.list_options.max_articles || 500, onProgress: (progress) => { updateProgress(progress.totalFetched, progress.totalFetched, 'fetching_list'); } }); articlesList = singleResult.articles; listFetchStats.total_pages_fetched = singleResult.totalBatches; } listFetchStats.list_fetch_time = Date.now() - listStartTime; // Apply max_articles limit if specified if (opts.max_articles > 0 && articlesList.length > opts.max_articles) { articlesList = articlesList.slice(0, opts.max_articles); } const totalArticles = articlesList.length; if (totalArticles === 0) { updateProgress(0, 0, 'complete', true); return { total_processed: 0, successful_count: 0, failed_count: 0, success_rate: 0, total_time: Date.now() - startTime, results: [], articles_with_details: [], list_fetch_stats: listFetchStats }; } // Phase 2: Fetch details for each article updateProgress(0, totalArticles, 'fetching_details'); // Process articles in batches for (let i = 0; i < totalArticles; i += opts.detail_batch_size) { const batch = articlesList.slice(i, i + opts.detail_batch_size); // Create promises for current batch with concurrency limit const batchPromises = batch.map(async (article) => { const itemStartTime = Date.now(); try { // Create timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Request timeout')), opts.request_timeout); }); // Fetch article detail with timeout const detailPromise = this.getArticleDetail(accessToken, article.id); const detail = await Promise.race([detailPromise, timeoutPromise]); const processingTime = Date.now() - itemStartTime; successfulCount++; const result = { article_id: article.id, success: true, detail: detail, processing_time: processingTime }; results.push(result); articlesWithDetails.push(detail); return result; } catch (error) { const processingTime = Date.now() - itemStartTime; failedCount++; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorType = error instanceof common_1.ZaloSDKError ? 'ZaloSDKError' : 'UnknownError'; // Track error types errorSummary[errorType] = (errorSummary[errorType] || 0) + 1; const result = { article_id: article.id, success: false, error: errorMessage, processing_time: processingTime }; results.push(result); // Stop processing if continue_on_error is false if (!opts.continue_on_error) { throw new common_1.ZaloSDKError(`Bulk details fetch stopped due to error on article ${article.id}: ${errorMessage}`, -1); } return result; } }); // Process batch with concurrency limit const chunks = []; for (let j = 0; j < batchPromises.length; j += opts.max_concurrency) { chunks.push(batchPromises.slice(j, j + opts.max_concurrency)); } for (const chunk of chunks) { await Promise.all(chunk); } // Update progress after each batch updateProgress(Math.min(i + opts.detail_batch_size, totalArticles), totalArticles, 'fetching_details'); // Add delay between batches (except for the last batch) if (i + opts.detail_batch_size < totalArticles && opts.batch_delay > 0) { await new Promise(resolve => setTimeout(resolve, opts.batch_delay)); } } // Final progress update updateProgress(totalArticles, totalArticles, 'complete', true); // Calculate final statistics const totalTime = Date.now() - startTime; const successRate = totalArticles > 0 ? Math.round((successfulCount / totalArticles) * 100) : 0; // Return complete result return { total_processed: totalArticles, successful_count: successfulCount, failed_count: failedCount, success_rate: successRate, total_time: totalTime, results: results.sort((a, b) => articlesList.findIndex(art => art.id === a.article_id) - articlesList.findIndex(art => art.id === b.article_id)), articles_with_details: articlesWithDetails, error_summary: Object.keys(errorSummary).length > 0 ? errorSummary : undefined, list_fetch_stats: listFetchStats }; } catch (error) { throw this.handleArticleError(error, "Failed to get all articles with details"); } } /** * Get all articles with full details - Simple interface (no progress tracking) * @param accessToken OA access token * @param type Article type ("normal", "video", or "both") * @param options Optional settings for details fetch * @returns Array of articles with full details * * Simplified version that returns just the articles with details array */ async getAllArticlesWithDetailsSimple(accessToken, type = "both", options) { const result = await this.getAllArticlesWithDetails(accessToken, type, options); return result.articles_with_details; } /** * Create article and wait for completion - All-in-one method * @param accessToken OA access token * @param request Article information * @param options Options for tracking and timeout * @param progressCallback Optional callback to track progress * @returns Article ID when creation is complete * * This method combines create + tracking + verification into one call: * 1. Creates the article and gets token * 2. Automatically tracks progress until completion * 3. Returns the final article ID * 4. Handles all errors and timeouts gracefully */ async createArticleAndWaitForCompletion(accessToken, request, options, progressCallback) { try { // Validate input if (!accessToken || accessToken.trim().length === 0) { throw new common_1.ZaloSDKError("Access token cannot be empty", -1); } if (!request) { throw new common_1.ZaloSDKError("Article request cannot be empty", -1); } // Set default options const opts = { timeout: 60000, // 1 minute checkInterval: 2000, // 2 seconds returnDetails: false, ...options }; const startTime = Date.now(); let token; let articleId; // Progress helper const updateProgress = (phase, message, additionalData) => { if (progressCallback) { progressCallback({ phase, message, token: additionalData?.token || token, article_id: additionalData?.article_id || articleId, elapsed_time: Date.now() - startTime }); } }; // Phase 1: Create article updateProgress('creating', 'Creating article...'); const createResponse = await this.createArticle(accessToken, request); token = createResponse.data?.token; if (!token) { throw new common_1.ZaloSDKError("No token received from article creation", -1); } updateProgress('processing', 'Article created, waiting for processing...', { token }); // Phase 2: Track progress until completion const maxAttempts = Math.ceil(opts.timeout / opts.checkInterval); let attempts = 0; let isComplete = false; while (attempts < maxAttempts && !isComplete) { attempts++; try { // Check if timeout exceeded const elapsedTime = Date.now() - startTime; if (elapsedTime >= opts.timeout) { throw new common_1.ZaloSDKError(`Article creation timeout after ${Math.round(elapsedTime / 1000)} seconds. Token: ${token}`, -1); } updateProgress('processing', `Checking progress... (attempt ${attempts}/${maxAttempts})`); // Try to verify article (this will throw if still processing) const verifyResult = await this.verifyArticle(accessToken, token); if (verifyResult && verifyResult.id) { articleId = verifyResult.id; isComplete = true; updateProgress('verifying', 'Article processing completed!', { article_id: articleId }); break; } } catch (error) { const elapsedTime = Date.now() - startTime; // Handle expected errors during processing if (error instanceof common_1.ZaloSDKError) { if (error.message.includes("token was expired")) { // Token expired usually means processing is done, but we need to find the article updateProgress('verifying', 'Token expired, attempting to find article...'); // Try to find the article by checking recent articles try { const recentArticles = await this.getArticleList(accessToken, { offset: 0, limit: 10, type: request.type }); // Look for article created around the same time (within last 5 minutes) // ArticleListResponse có thể là ZaloResponse<ArticleListData> hoặc error response if ('data' in recentArticles && recentArticles.data?.medias) { const recentArticle = recentArticles.data.medias.find((article) => { const articleTime = new Date(article.create_date).getTime(); const timeDiff = Math.abs(articleTime - startTime); return timeDiff < 5 * 60 * 1000 && article.title === request.title; }); if (recentArticle) { articleId = recentArticle.id; isComplete = true; updateProgress('complete', 'Article found after token expiration!', { article_id: articleId }); break; } } } catch (searchError) { // Continue with normal timeout handling } } // For other API errors, continue waiting if we haven't timed out if (elapsedTime < opts.timeout) { updateProgress('processing', `Still processing... (${Math.round(elapsedTime / 1000)}s elapsed)`); await new Promise(resolve => setTimeout(resolve, opts.checkInterval)); continue; } } // If we've reached here and timed out, throw the error if (elapsedTime >= opts.timeout) { throw new common_1.ZaloSDKError(`Article creation timeout after ${Math.round(elapsedTime / 1000)} seconds. Last error: ${error instanceof Error ? error.message : 'Unknown error'}. Token: ${token}`, -1); } // For unexpected errors, wait and retry updateProgress('processing', `Retrying after error... (${error instanceof Error ? error.message : 'Unknown error'})`); await new Promise(resolve => setTimeout(resolve, opts.checkInterval)); } } // Final check if we didn't get article ID if (!articleId) { throw new common_1.ZaloSDKError(`Failed to get article ID after ${attempts} attempts. Token: ${token}`, -1); } const totalTime = Date.now() - startTime; updateProgress('complete', `Article created successfully! ID: ${articleId}`, { article_id: articleId }); // Phase 3: Get article details if requested let articleDetails; if (opts.returnDetails) { try { updateProgress('complete', 'Fetching article details...'); articleDetails = await this.getArticleDetail(accessToken, articleId); } catch (error) { // Don't fail the whole operation if we can't get details console.warn('Failed to fetch article details:', error); } } return { article_id: articleId, token: token, total_time: totalTime, article_details: articleDetails }; } catch (error) { throw this.handleArticleError(error, "Failed to create article and wait for completion"); } } /** * Create article and wait for completion - Simple interface * @param accessToken OA access token * @param request Article information * @param timeoutMs Optional timeout in milliseconds (default: 60000) * @returns Article ID when creation is complete * * Simplified version that returns just the article ID */ async createArticleAndGetId(accessToken, request, timeoutMs = 60000) { const result = await this.createArticleAndWaitForCompletion(accessToken, request, { timeout: timeoutMs }); return result.article_id; } handleArticleError(error, defaultMessage) { if (error instanceof common_1.ZaloSDKError) { return error; } if (error.response?.data) { const errorData = error.response.data; return new common_1.ZaloSDKError(`${defaultMessage}: ${errorData.message || errorData.error || "Unknown error"}`, errorData.error || -1, errorData); } return new common_1.ZaloSDKError(`${defaultMessage}: ${error.message || "Unknown error"}`, -1, error); } /** * Update normal article * @param accessToken OA access token * @param request Update information * @returns Token for tracking update progress */ async updateNormalArticle(accessToken, request) { try { // Validate input this.validateUpdateNormalArticleRequest(request); // Process tracking link let processedTrackingLink = "https://v2.redai.vn/auth"; if (request.tracking_link && this.isValidTrackingLink(request.tracking_link)) { processedTrackingLink = request.tracking_link; } const data = { id: request.id, type: request.type, title: request.title, author: request.author, cover: request.cover, description: request.description, body: request.body, related_medias: request.related_medias || [], tracking_link: processedTrackingLink, status: request.status || article_1.ArticleStatus.HIDE, comment: request.comment || article_1.CommentStatus.SHOW, }; const response = await this.client.apiPost(this.endpoints.article.update, accessToken, data); return response; } catch (error) { throw this.handleArticleError(error, "Failed to update normal article"); } } /** * Update video article * @param accessToken OA access token * @param request Update information * @returns Token for tracking update progress */ async updateVideoArticle(accessToken, request) { try { // Validate input this.validateUpdateVideoArticleRequest(request); const data = { id: request.id, type: request.type, title: request.title, description: request.description, video_id: request.video_id, avatar: request.avatar, status: request.status || article_1.ArticleStatus.HIDE, comment: request.comment || article_1.CommentStatus.SHOW, }; const response = await this.client.apiPost(this.endpoints.article.update, accessToken, data); return response; } catch (error) { throw this.handleArticleError(error, "Failed to update video article"); } } /** * Update article (automatically detect type) * @param accessToken OA access token * @param request Update information * @returns Token for tracking update progress */ async updateArticle(accessToken, request) { if (request.type === article_1.ArticleType.NORMAL) { return this.updateNormalArticle(accessToken, request); } else if (request.type === article_1.ArticleType.VIDEO) { return this.updateVide