@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
JavaScript
"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