first commit

This commit is contained in:
NAME
2026-05-11 03:18:19 +00:00
commit c72a38201a
37 changed files with 10182 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
dist
.idea
dist.zip
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ pnpm install
```
## Compile and run the project
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Run tests
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ pnpm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+33
View File
@@ -0,0 +1,33 @@
module.exports = {
apps: [
{
name: 'nestjs-xposter-worker',
script: 'dist/main.js',
// Cấu hình quan trọng
instances: 1, // Chỉ chạy duy nhất 1 bản ghi
exec_mode: 'fork', // Chế độ fork (mặc định cho 1 instance)
cron_restart: '0 * * * *', // Restart mỗi giờ một lần
// Các cấu hình bổ sung nên có
watch: false, // Không watch file ở môi trường prod
autorestart: true, // Tự động restart nếu app bị crash đột ngột
max_memory_restart: '1G', // Restart nếu app ngốn quá 1GB RAM (phòng leak memory)
env: {
NODE_ENV: 'production',
},
},
],
deploy : {
production : {
user : 'SSH_USERNAME',
host : 'SSH_HOSTMACHINE',
ref : 'origin/master',
repo : 'GIT_REPOSITORY',
path : 'DESTINATION_PATH',
'pre-deploy-local': '',
'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production',
'pre-setup': ''
}
}
};
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+84
View File
@@ -0,0 +1,84 @@
{
"name": "x_poster",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch --no-clear",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.1042.0",
"@keyv/redis": "^5.1.6",
"@nestjs/cache-manager": "^3.1.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@twitter-api-v2/plugin-token-refresher": "^1.0.0",
"axios": "^1.16.0",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
"https-proxy-agent": "^9.0.0",
"lodash": "^4.18.1",
"playwright": "^1.59.1",
"playwright-extra": "^4.3.6",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"twitter-api-v2": "^1.29.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "latest",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+7488
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import {Controller, Get} from '@nestjs/common';
import {AppService} from './app.service';
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
) {
}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+33
View File
@@ -0,0 +1,33 @@
import {Module} from '@nestjs/common';
import {AppController} from './app.controller';
import {AppService} from './app.service';
import {SqsModule} from "./sqs-module/sqs.module";
import {XPosterModule} from "./x-poster/x-poster.module";
import {ConfigModule} from "@nestjs/config";
import {CacheModule} from "@nestjs/cache-manager";
import KeyvRedis from "@keyv/redis";
@Module({
imports: [
ConfigModule.forRoot(
{
isGlobal: true,
// load: [configuration],
},
),
CacheModule.registerAsync({
isGlobal: true,
useFactory: () => ({
stores: [
new KeyvRedis(`redis://127.0.0.1:6379/1`)
],
}),
}),
SqsModule,
XPosterModule
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {
}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+10
View File
@@ -0,0 +1,10 @@
export function rand(min, max) {
return Math.floor(Math.random() * (max - min) + min);
}
export function getTweetIdFromUrl(xUrl: string) {
xUrl = xUrl.replace('twitter.com', 'x.com').split('?')[0];
const match = xUrl.match(/status\/(\d+)/);
if (!match) throw new Error('URL X không hợp lệ');
return match[1];
}
+15
View File
@@ -0,0 +1,15 @@
import {NestFactory} from '@nestjs/core';
import {AppModule} from './app.module';
import {SqsPosterWorker} from "./sqs-module/sqs.poster.worker";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3000;
await app.listen(port, () => console.log(`Listening on port ${port}`));
await app.get(SqsPosterWorker).start();
}
bootstrap();
+37
View File
@@ -0,0 +1,37 @@
import {Injectable} from "@nestjs/common";
import axios from "axios";
@Injectable()
export class NotifyService {
async sendMessageToTele(message: string): Promise<any> {
const axios = require('axios');
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const CHAT_ID = process.env.TELEGRAM_CHAT_ID!;
await axios.post(
`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`,
{
chat_id: CHAT_ID,
text: message,
parse_mode: 'HTML'
}
);
}
async sendMessageToTeleByChatId(chatId:number,message: string): Promise<any> {
const axios = require('axios');
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
await axios.post(
`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`,
{
chat_id: chatId,
text: message,
parse_mode: 'HTML'
}
);
}
}
+23
View File
@@ -0,0 +1,23 @@
// sqs.module.ts
import { Module, Global } from '@nestjs/common';
import { SqsService } from './sqs.service';
import {SqsPostService} from "./sqs.post.service";
import {SqsPosterWorker} from "./sqs.poster.worker";
import {XPosterRouterService} from "../x-poster/x-poster.router.service";
import {XPosterModule} from "../x-poster/x-poster.module";
import {FacebookApi} from "../x-poster/facebook.api";
import {NotifyService} from "../notify.service";
@Global()
@Module({
imports:[XPosterModule],
providers: [
SqsService,
SqsPostService,
SqsPosterWorker,
FacebookApi,
NotifyService,
],
exports: [SqsService],
})
export class SqsModule {}
+36
View File
@@ -0,0 +1,36 @@
// post.service.ts
import {Injectable, Logger, OnModuleInit} from '@nestjs/common';
import {SqsService} from "./sqs.service";
@Injectable()
export class SqsPostService implements OnModuleInit {
private logger = new Logger(SqsPostService.name);
private queueName: string = process.env.SQS_POSTER_QUEUE_NAME!;
constructor(private readonly sqs: SqsService) {
if (this.queueName === undefined) {
throw new Error('Chưa set queueName')
}
}
async onModuleInit() {
await this.sqs.ensureQueue(this.queueName)
// throw new Error("Method not implemented.");
}
async getQueueName(): Promise<string> {
return this.queueName;
}
async getMessage() {
return this.sqs.receive(process.env.SQS_POSTER_QUEUE_NAME!);
}
async deleteMessage(receiptHandle: string) {
this.logger.debug(`delete message: ${JSON.stringify(receiptHandle)}`);
return this.sqs.ack(this.queueName, receiptHandle);
}
}
+172
View File
@@ -0,0 +1,172 @@
// worker.ts
import {Injectable, Logger} from "@nestjs/common";
import {SqsPostService} from "./sqs.post.service";
import {SUPPORT_SOCIAL_PROVIDERS, XPosterRouterService, XStrategy} from "../x-poster/x-poster.router.service";
import {rand} from "../helper";
import {FacebookApi} from "../x-poster/facebook.api";
import {NotifyService} from "../notify.service";
@Injectable()
export class SqsPosterWorker {
private readonly logger = new Logger(SqsPosterWorker.name);
constructor(
private readonly sqs: SqsPostService,
private readonly xRouterService: XPosterRouterService,
private readonly facebookApi: FacebookApi,
private readonly notifyService: NotifyService,
) {
}
async start() {
console.log(`🚀 Worker started for ${await this.sqs.getQueueName()}`);
await this.notifyService.sendMessageToTele(`🚀 Worker started for ${await this.sqs.getQueueName()}`)
let ReceiptHandle = '';
while (true) {
try {
console.log('worker get message ...');
const msg = await this.sqs.getMessage();
if (!msg) {
console.log('no message , sleeping...');
await this.sleep(10000); //sleep 10s
continue;
}
const {raw, body} = msg;
ReceiptHandle = raw.ReceiptHandle!;
// 👉 ack (xóa message)
await this.sqs.deleteMessage(ReceiptHandle);
//ReceiptHandle = '';
// 👉 xử lý job
await this.process(body);
} catch (err) {
console.error('❌ Worker error:', err);
if (ReceiptHandle) {
await this.sqs.deleteMessage(ReceiptHandle!);
}
await this.sleep(60000); // tránh spam CPU khi lỗi
} finally {
ReceiptHandle = '';
}
}
}
private async process(data: any) {
console.log('📩 Got job:', data);
const {type, content, xSubmitProvider, tweetUrl, publishTo, tweetId, telegramChatId} = data;
switch (type) {
case 'X_POSTER_TWEET': {
await this.doPostTweet(
content,
publishTo,
xSubmitProvider);
break;
}
case 'X_POSTER_REPLY': {
await this.doReplyTweet(
content,
tweetUrl,
tweetId,
xSubmitProvider
);
break;
}
case 'X_POSTER_QUOTE': {
await this.doQuoteTweet(
content,
tweetUrl,
tweetId,
xSubmitProvider
);
break;
}
}
// TODO: gọi puppeteer / API X
// ví dụ:
// await postToX(data.content);
// giả lập delay
await this.sleep(rand(7, 10) * 1000); //nghỉ 10s
}
private sleep(ms: number) {
return new Promise(res => setTimeout(res, ms));
}
private async doPostTweet(
text: string,
publishTo: Array<string> = ['fb'],
strategy: string = XStrategy.API_ONLY,
) {
try {
console.log(`==> doPostTweet`, publishTo);
let sendSuccess = false;
if (publishTo.includes(SUPPORT_SOCIAL_PROVIDERS.FB)) {
console.log(`==> doPostTweet publish to fb`);
await this.facebookApi.postToPage(text);
await this.notifyService.sendMessageToTele(`Post to FB success`);
sendSuccess = true;
}
if (publishTo.includes(SUPPORT_SOCIAL_PROVIDERS.X)) {
console.log(`==> doPostTweet publish to X`);
// @ts-ignore
await this.xRouterService.postTweet({text, strategy});
// await this.notifyService.sendMessageToTele(`Post to X success!`);
sendSuccess = true;
}
return sendSuccess
} catch (e) {
this.logger.error(`doPostTweet error:${e.message}`);
await this.notifyService.sendMessageToTele(`==> doPostTweet error:${e.message}`)
this.logger.error(e);
}
}
private async doReplyTweet(
text: string,
tweetUrl: string,
tweetId: string,
strategy: string = XStrategy.BROWSER_COOKIE
) {
try {
console.log('doReplyTweet');
// @ts-ignore
const r = await this.xRouterService.postReply({text, tweetUrl, tweetId, strategy});
await this.notifyService.sendMessageToTele(`Worker->reply message success`)
return r
} catch (e) {
this.logger.error(e);
await this.notifyService.sendMessageToTele(`Worker==> doReplyTweet error:${e.message}`)
}
}
private async doQuoteTweet(
text: string,
tweetUrl: string,
tweetId: string,
strategy: string = XStrategy.BROWSER_COOKIE
) {
try {
console.log('doQuoteTweet');
// @ts-ignore
const r = await this.xRouterService.postQuote({text, tweetUrl, tweetId, strategy});
await this.notifyService.sendMessageToTele(`✅ Quote message success`)
return r
} catch (e) {
this.logger.error(e);
await this.notifyService.sendMessageToTele(`==> doQuoteTweet error:${e.message}`)
}
}
}
+141
View File
@@ -0,0 +1,141 @@
// sqs.service.ts
import {
SQSClient,
SendMessageCommand,
ReceiveMessageCommand,
DeleteMessageCommand,
CreateQueueCommand,
GetQueueUrlCommand,
} from '@aws-sdk/client-sqs';
import {Injectable, Logger, OnModuleInit} from '@nestjs/common';
@Injectable()
export class SqsService {
private readonly logger = new Logger(SqsService.name);
private client: SQSClient;
private baseUrl = process.env.SQS_ENDPOINT + '000000000000';
// // 👉 define tất cả queue ở đây
// private queues = [
// 'post-acc1',
// 'post-acc2',
// ];
constructor() {
this.client = new SQSClient({
region: 'elasticmq',
endpoint: process.env.SQS_ENDPOINT,
credentials: {
accessKeyId: 'x',
secretAccessKey: 'x',
},
});
}
getQueueUrl(name: string) {
return `${this.baseUrl}/${name}`;
}
public async ensureQueue(name: string) {
try {
await this.client.send(new CreateQueueCommand({
QueueName: name,
}));
this.logger.log(`Queue ensured: ${name}`);
} catch (err) {
this.logger.error(`Failed to ensure queue ${name}`, err);
}
}
// private async ensureQueue(name: string) {
// try {
// await this.client.send(new GetQueueUrlCommand({
// QueueName: name,
// }));
//
// this.logger.log(`Queue exists: ${name}`);
// } catch (err) {
// this.logger.warn(`Queue missing → creating: ${name}`);
//
// await this.client.send(new CreateQueueCommand({
// QueueName: name,
// Attributes: {
// VisibilityTimeout: '60',
// MessageRetentionPeriod: '86400', // 1 ngày
// },
// }));
//
// this.logger.log(`Queue created: ${name}`);
// }
// }
// =====================
// Core APIs
// =====================
async enqueue(name: string, data: any, opts?: {
delaySeconds?: number;
jobId?: string;
}) {
const queueUrl = this.getQueueUrl(name);
// console.log(`QueueUrl: ${queueUrl}`);
const body = {
...data,
_jobId: opts?.jobId,
_ts: Date.now(),
};
try {
const data = await this.client.send(new SendMessageCommand({
QueueUrl: queueUrl,
MessageBody: JSON.stringify(body),
DelaySeconds: opts?.delaySeconds || 0,
}))
console.log("Gửi thành công! MessageId:", data.MessageId);
} catch (err) {
console.error("Lỗi khi gửi tin nhắn:", err.message);
// throw err;
}
// return this.client.send(new SendMessageCommand({
// QueueUrl: queueUrl,
// MessageBody: JSON.stringify(body),
// DelaySeconds: opts?.delaySeconds || 0,
// })).then(()=> {
// this.logger.log(`Queue enqueued: ${name}`);
// });
}
async receive(queueName: string) {
const queueUrl = this.getQueueUrl(queueName);
const res = await this.client.send(new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 1,
WaitTimeSeconds: 10,
VisibilityTimeout: 180,
}));
if (!res.Messages?.length) return null;
const msg = res.Messages[0];
return {
raw: msg,
body: JSON.parse(msg.Body!),
};
}
async ack(name: string, receiptHandle: string) {
const queueUrl = this.getQueueUrl(name);
await this.client.send(new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: receiptHandle,
}));
}
}
+10
View File
@@ -0,0 +1,10 @@
import {Global, Module} from '@nestjs/common';
import {XCacheService} from "./x-cache.service";
@Global()
@Module({
providers: [XCacheService],
exports: [XCacheService],
})
export class XCacheModule {
}
+60
View File
@@ -0,0 +1,60 @@
import {Inject, Injectable} from "@nestjs/common";
import {Cache, CACHE_MANAGER} from "@nestjs/cache-manager";
@Injectable()
export class XCacheService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {
}
async delCachedKey(key: string) {
return await this.cacheManager.del(key); // Retrieve data
}
async getCachedData<T>(key: string) {
return await this.cacheManager.get<T>(key); // Retrieve data
}
async setCachedKey(key: string, data, ttl_by_sec = 1000) {
await this.cacheManager.set(key, data, ttl_by_sec * 1000); // Save to Redis
return data;
}
async setCacheTwRefreshToken(refreshToken: string) {
await this.cacheManager.set('tw_app_refresh_token', refreshToken, 30 * 24 * 3600);
}
async getCacheTwRefreshToken() {
return this.cacheManager.get('tw_app_refresh_token');
}
async setCacheTwAccessToken(token: string) {
await this.setCachedKey('tw_app_access_token', token, 3 * 3600);
}
async getCacheTwAccessToken() {
return this.cacheManager.get('tw_app_access_token');
}
async setCacheTwAuthorize(data) {
await this.setCachedKey('tw_app_authorize', data, 3 * 3600);
}
async getCacheTwAuthorize() {
return this.cacheManager.get('tw_app_authorize');
}
async setCacheTweetUrlById(tweetId, tweetUrl) {
await this.setCachedKey(`x_tweetId:${tweetId}`, tweetUrl, 24 * 3600);
return {tweetId, tweetUrl};
}
async getCacheTweetUrlById(tweetId) {
return await this.getCachedData(`x_tweetId:${tweetId}`) as string;
}
async incrCountCollectNewsapi() {
//this.cacheManager.c
}
}
+24
View File
@@ -0,0 +1,24 @@
// dto/create-tweet.dto.ts
import { IsString, IsOptional, IsArray, MaxLength } from 'class-validator';
export class CreateTweetDto {
@IsString()
// @MaxLength(280)
text: string;
@IsOptional()
@IsArray()
mediaIds?: string[];
@IsOptional()
@IsString()
replyToTweetId?: string;
}
export class ReplyTweetDto {
@IsString()
text: string;
@IsOptional()
tweetUrl?: string;
}
+13
View File
@@ -0,0 +1,13 @@
import {IsOptional, IsString} from "class-validator";
export class XCookieAccountDto {
@IsString()
authToken: string; // auth_token cookie
@IsString()
ct0: string; // ct0 cookie (CSRF token)
@IsString()
@IsOptional()
proxy?: string;
}
+38
View File
@@ -0,0 +1,38 @@
// src/modules/social/facebook-api.service.ts
import {HttpException, HttpStatus, Injectable} from '@nestjs/common';
import axios from 'axios';
@Injectable()
export class FacebookApi {
private readonly fbBaseUrl = 'https://graph.facebook.com/v19.0';
private readonly pageAccessToken = process.env.FB_PAGE_ACCESS_TOKEN;
private readonly pageId = process.env.FB_PAGE_ID;
async postToPage(content: string, imageUrl?: string) {
// console.log('postToPage==>', content, imageUrl);
try {
let url = `${this.fbBaseUrl}/${this.pageId}/feed`;
let params: any = {
message: content + `\n Disclaimer: For reference only. AI content may have errors. Not liable for inaccuracies or damages. Verify from official sources.`,
access_token: this.pageAccessToken,
};
// Nếu có ảnh, chúng ta dùng endpoint /photos
if (imageUrl) {
url = `${this.fbBaseUrl}/${this.pageId}/photos`;
params.url = imageUrl;
}
const response = await axios.post(url, params);
//response.data= { id: '1010286162176053_122107818902775551' }
return response.data; // Trả về ID bài viết nếu thành công
} catch (error) {
console.log('Lỗi khi đăng bài lên FB');
console.log(error.message);
throw new HttpException(
error.response?.data || 'Lỗi khi đăng bài lên FB',
HttpStatus.BAD_REQUEST,
);
}
}
}
@@ -0,0 +1,12 @@
// interfaces/x-cookie.interface.ts
export interface XCookieAccount {
authToken: string; // auth_token cookie
ct0: string; // ct0 cookie (CSRF token)
proxy?: string; // optional proxy per account
}
export interface TweetResult {
success: boolean;
tweetId?: string;
error?: string;
}
+165
View File
@@ -0,0 +1,165 @@
// import {HttpException, Injectable} from "@nestjs/common";
// import {PgPostService} from "../../shared/pg.post.service";
// import {FacebookApi} from "./facebook.api";
// import {XApiService} from "./x-api.service";
// import {normalizeTagsSingleCashtag} from "../../shared/helper";
// import {XBrowserService} from "./x-browser.service";
// import {XStrategy} from "./x-router.service";
// import {buildXCookies} from "./utils/x-headers.util";
// import {isEmpty} from "lodash";
// import {XCacheService} from "../x-cache/x-cache.service";
//
// @Injectable()
// export class PublishPageService {
// constructor(
// private readonly pgPostService: PgPostService,
// private readonly facebookApiService: FacebookApi,
// private readonly twitterClient: XApiService,
// private readonly xBrowserService: XBrowserService,
// private readonly cacheService: XCacheService,
// ) {
// }
//
// async relyX(content: string, tweetId: string) {
// //return this.twitterClient.postReply(content, tweetId);
// // return this.twitterClient.qoute(content, tweetId);
// const tweetUrl = await this.cacheService.getCacheTweetUrlById(tweetId);
// if(isEmpty(tweetUrl)) {
// throw new HttpException(`Không tìm thấy tweet url từ Id : ${tweetId}`, 500);
// }
// return this.xBrowserService.postReply(
// tweetUrl,
// content
// );
// }
//
// async quoteX(content: string, tweetId: string) {
// // return this.twitterClient.qoute(content, tweetId);
// const tweetUrl = await this.cacheService.getCacheTweetUrlById(tweetId);
// if(isEmpty(tweetUrl)) {
// throw new HttpException(`Không tìm thấy tweet url từ Id : ${tweetId}`, 500);
// }
// return this.xBrowserService.quoteTweet(
// {
// accountId: 'realflashkaze',
// cookies: buildXCookies()
// },
// tweetUrl,
// content
// );
// }
//
// async publishToFacebook(pgPostId: number): Promise<any> {
// const post = await this.pgPostService.post({id: 1 * pgPostId})
// if (!post) {
// console.log('publishToFacebook:no post was found:', pgPostId);
// return 'no post';
// }
//
// if (!post.content) {
// console.log('publishToFacebook:Post with no content:', pgPostId);
// return 'no post';
// }
// // if (post.style) {
// // console.log('Post with no content:', pgPostId);
// // return 'no post';
// // }
// if (post.status != 'pending') {
// console.log('publishToFacebook:This post already change status', pgPostId);
// return 'This post already change status: ' + pgPostId;
// }
// if (post.isFbPostState > 0) {
// console.log('publishToFacebook:Already posted', pgPostId);
// return 'Already posted on FB: ' + pgPostId;
// }
// console.log('publishToFacebook=>posting')
//
// const resultPost = await this.facebookApiService.postToPage(post.content);
//
// await this.pgPostService.updatePost(pgPostId, {
// isFbPostState: 1
// })
// console.log({resultPost});
// console.log('publishToFacebook=>done');
// return resultPost
// }
//
// async publishTwitter(pgPostIdNum: number, XPostProvider = XStrategy.API_ONLY): Promise<any> {
// const pgPostId = 1 * pgPostIdNum;
// const post = await this.pgPostService.post({id: 1 * pgPostId})
// if (!post) {
// console.log('no post was found:', pgPostId);
// return 'no post';
// }
//
// if (!post.content) {
// console.log('Post with no content:', pgPostId);
// return 'no post';
// }
// if (post.status != 'pending') {
// console.log('This post already change status', pgPostId);
// return 'This post already change status: ' + pgPostId;
// }
// if (post.isTwitterPostState > 0) {
// console.log('Already posted', pgPostId);
// return 'Already posted on X: ' + pgPostId;
// }
// console.log('publishTwitter=>posting');
// const _normalizeTagsSingleCashtagContent = normalizeTagsSingleCashtag(post.content);
//
// if (XPostProvider === XStrategy.BROWSER_ONLY) {
// const resp = await this.xBrowserService.postTweet(
// {
// accountId: 'realflashkaze',
// cookies: buildXCookies()
// },
// _normalizeTagsSingleCashtagContent
// )
// if (resp.success) {
// await this.pgPostService.updatePost(pgPostId, {
// isTwitterPostState: 1
// })
// } else {
// console.log('publishTwitter=>posting_error', resp.error);
// throw new HttpException(resp.error || 'exception', 500);
// }
// return;
// }
//
// if (XPostProvider === XStrategy.API_ONLY) {
// const {data: createdTweet} = await this.twitterClient
// .postSimpleTwitte(_normalizeTagsSingleCashtagContent)
// .catch(err => {
// console.error(err);
// const errStatus = err.data.status;
// // if (errStatus == '401') {
// // this.twitterClient.refreshAccessToken();
// // }
// const errText = err.data.title;
// const errDetail = err.data.detail;
// console.log(`publishTwitter=>posting_err_ ${errText} - ${errStatus} -${errDetail}`)
// throw new HttpException(errText, errStatus);
// });
// await this.pgPostService.updatePost(pgPostId, {
// isTwitterPostState: 1
// })
//
// console.log('Tweet', createdTweet.id);
// return {
// ...createdTweet,
// url: `https://x.com/${process.env.TWITTER_USERNAME}/status/${createdTweet.id}`
// }
// }
// }
//
// async rejectedPost(pgPostIdNum: number): Promise<any> {
// const pgPostId = 1 * pgPostIdNum;
//
// await this.pgPostService.updatePost(pgPostId, {
// status: 'rejected',
// })
//
// return pgPostId;
// }
//
// }
+34
View File
@@ -0,0 +1,34 @@
// utils/x-headers.util.ts
export function buildXHeaders(authToken: string, ct0: string) {
return {
authority: 'x.com',
accept: '*/*',
'accept-language': 'en-US,en;q=0.9',
authorization: `Bearer ${process.env.X_BEARER_TOKEN}`,
'content-type': 'application/json',
cookie: `auth_token=${authToken}; ct0=${ct0};`,
origin: 'https://x.com',
referer: 'https://x.com/home',
host: 'api.x.com',
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15',
'x-csrf-token': ct0,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-client-language': 'en',
};
}
export function buildXCookies() {
return [
{
name: "ct0",
value: process.env.X_COOKIE_CT0,
},
{
name: "auth_token",
value: process.env.X_COOKIE_AUTH_TOKEN,
},
{name: "lang", value: "en"},
];
}
+130
View File
@@ -0,0 +1,130 @@
import {Inject, Injectable} from "@nestjs/common";
import {TwitterApi} from 'twitter-api-v2';
import {TwitterApiAutoTokenRefresher} from '@twitter-api-v2/plugin-token-refresher'
import {EUploadMimeType} from "twitter-api-v2/dist/esm/types";
import {MediaV2MediaCategory} from "twitter-api-v2/dist/esm/types/v2/media.v2.types";
import {XCacheService} from "../x-cache/x-cache.service";
@Injectable()
export class XApiService {
private clientId = process.env.TWITTER_CLIENT_ID + '';
private clientSecret = process.env.TWITTER_CLIENT_SECRET;
private readonly userClient: any;
constructor(
@Inject() private cacheService: XCacheService,
) {
}
async getTwitterClientV2() {
return new TwitterApi(
{
clientId: this.clientId,
clientSecret: this.clientSecret,
},
)
}
async getTwitterClientV2ViaAccessToken() {
const accessToken = await this.getCacheAccessToken() as string;
const refreshToken = await this.getCacheRefreshToken() as string;
// console.log({refreshToken});
// @ts-ignore
// @ts-ignore
return new TwitterApi(accessToken,
{
plugins: [
new TwitterApiAutoTokenRefresher({
refreshCredentials: {
clientId: '' + this.clientId,
clientSecret: this.clientSecret,
},
refreshToken,
// Hàm này được gọi tự động khi token được refresh thành công
onTokenUpdate: async (newTokens) => {
console.log('===> Token đã được làm mới:', newTokens);
await this.setCacheRefreshToken('' + newTokens.refreshToken);
await this.setCacheAccessToken(newTokens.accessToken);
},
// Hàm xử lý khi refresh thất bại (ví dụ: refresh token cũng hết hạn)
onTokenRefreshError: async (error) => {
console.error('Không thể refresh token:', error);
throw error;
},
}),
],
});
}
async uploadImageV1(media: Buffer, options: {
media_type: `${EUploadMimeType}` | EUploadMimeType;
media_category?: MediaV2MediaCategory;
additional_owners?: string[];
}, chunkSize?: number): Promise<string> {
const client = await this.getTwitterClientV2ViaAccessToken();
// return client.v2.uploadMedia(media, options, chunkSize);
return client.v2.uploadMedia(media, options, chunkSize);
}
async postSimpleTweet(content) {
const client = await this.getTwitterClientV2ViaAccessToken();
return client.v2.tweet(content)
}
async postReply(content: string, tweetId: string) {
const client = await this.getTwitterClientV2ViaAccessToken();
return client.v2.reply(content, tweetId)
}
async qoute(content: string, tweetId: string) {
const client = await this.getTwitterClientV2ViaAccessToken();
// @ts-ignore
if (content.indexOf(`https://x.com/${process.env.TWITTER_USERNAME}/status`) === -1) {
//hacking, use tweet instead of quote, because limit x
return client.v2.tweet(content)
}
return client.v2.quote(content, tweetId)
}
async setCacheAccessToken(accessToken: string) {
return this.cacheService.setCachedKey('tw_accesstoken', '' + accessToken, 24 * 3600);
}
async delCacheAccessToken() {
return this.cacheService.delCachedKey('tw_accesstoken');
}
async getCacheAccessToken() {
return this.cacheService.getCachedData('tw_accesstoken')
}
async getCacheRefreshToken() {
return this.cacheService.getCachedData('tw_refreshtoken')
}
async setCacheRefreshToken(refreshToken: string) {
//30day
return this.cacheService.setCachedKey('tw_refreshtoken', refreshToken, 30 * 24 * 3600);
}
async refreshAccessToken() {
const client = new TwitterApi({clientId: this.clientId, clientSecret: this.clientSecret});
const refreshToken = await this.getCacheRefreshToken();
const {
client: refreshedClient,
accessToken,
refreshToken: newRefreshToken
} = await client.refreshOAuth2Token('' + refreshToken);
await this.cacheService.setCachedKey('tw_accesstoken_time_add', Date.now(), 3 * 24 * 3600);
await this.setCacheAccessToken(accessToken);
await this.setCacheRefreshToken('' + refreshToken);
// Example request
await refreshedClient.v2.me();
}
}
+467
View File
@@ -0,0 +1,467 @@
// src/modules/x-browser/x-browser.service.ts
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy, HttpException,
} from '@nestjs/common';
import {chromium, Browser, BrowserContext, Page} from 'playwright';
import {buildXCookies} from "./utils/x-headers.util";
import {rand} from "../helper";
export interface BrowserAccount {
accountId: string;
cookies: Array<{
name: string;
value: string;
domain?: string;
path?: string;
}>;
proxy?: string;
userAgent?: string;
}
export interface BrowserTweetResult {
success: boolean;
tweetId?: string;
error?: string;
needsRelogin?: boolean;
}
@Injectable()
export class XBrowserService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(XBrowserService.name);
private browser: Browser | null = null;
private contextPool = new Map<
string,
{ ctx: BrowserContext; lastUsed: number }
>();
private readonly MAX_CONTEXTS = 5;
private readonly CONTEXT_TTL_MS = 15 * 60 * 1000; // 15 phút
async onModuleInit() {
// Lazy launch chỉ mở khi cần
setInterval(() => this.cleanupStaleContexts(), 60_000);
}
private async ensureBrowser(): Promise<Browser> {
if (this.browser && this.browser.isConnected()) return this.browser;
this.logger.log('Launching Chromium...');
this.browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage',
],
});
return this.browser;
}
private async getOrCreateContext(
account: BrowserAccount,
useCache = true
): Promise<BrowserContext> {
console.log('getOrCreateContext:1')
// console.log({account});
const cached = this.contextPool.get(account.accountId);
if (useCache && cached) {
console.log('getOrCreateContext:cached');
cached.lastUsed = Date.now();
return cached.ctx;
}
console.log('getOrCreateContext:2')
// LRU eviction
if (this.contextPool.size >= this.MAX_CONTEXTS) {
const oldest = [...this.contextPool.entries()].sort(
(a, b) => a[1].lastUsed - b[1].lastUsed,
)[0];
await oldest[1].ctx.close().catch(() => null);
this.contextPool.delete(oldest[0]);
}
console.log('getOrCreateContext:3')
const browser = await this.ensureBrowser();
console.log('getOrCreateContext:4')
const ctx = await browser.newContext({
userAgent:
account.userAgent ??
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
viewport: {width: 1366, height: 768},
locale: 'en-US',
proxy: account.proxy ? {server: account.proxy} : undefined,
});
console.log('getOrCreateContext:5')
// Anti-detection: ẩn webdriver flag
await ctx.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
});
console.log('getOrCreateContext:6')
console.log(account.cookies);
await ctx.addCookies(
account.cookies.map((c) => ({
...c,
domain: c.domain || '.x.com',
path: c.path || '/',
})),
);
this.contextPool.set(account.accountId, {ctx, lastUsed: Date.now()});
console.log('getOrCreateContext:7')
// console.log({
// ctx
// })
return ctx;
}
private async getPage(account: BrowserAccount): Promise<Page> {
let ctx = await this.getOrCreateContext(account);
console.log('Đã khởi tạo ctx')
if (ctx.isClosed()) {
console.log('browser is closeed, reopen');
ctx = await this.getOrCreateContext(account, false);
}
const cookies = account.cookies.map((c) => ({
...c,
domain: c.domain || '.x.com',
path: c.path || '/',
}));
console.log('cookies:', cookies);
await ctx.addCookies(cookies);
return ctx.newPage();
}
async postTweet(
account: BrowserAccount,
text: string,
): Promise<BrowserTweetResult> {
let page: Page | null = null;
try {
const ctx = await this.getOrCreateContext(account);
const cookies = account.cookies.map((c) => ({
...c,
domain: c.domain || '.x.com',
path: c.path || '/',
}));
await ctx.addCookies(cookies);
page = await ctx.newPage();
// Intercept để lấy tweet id từ response
let capturedTweetId: string | undefined;
page.on('response', async (resp) => {
if (resp.url().includes('/CreateTweet')) {
try {
const json = await resp.json();
capturedTweetId =
json?.data?.create_tweet?.tweet_results?.result?.rest_id;
} catch {
}
}
});
// await page.keyboard.press('Mở trang ...');
await page.goto('https://x.com/home', {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
await page.mouse.wheel(0, rand(300, 500));
await page.waitForTimeout(rand(1000, 4000));
// Detect login/challenge screen
if (page.url().includes('/login') || page.url().includes('/flow')) {
return {
success: false,
error: 'Redirected to login',
needsRelogin: true,
};
}
await page.mouse.wheel(200, rand(300, 800));
await page.waitForTimeout(rand(2000, 5000));
await page.evaluate(() => window.scrollTo({top: 0, behavior: 'smooth'}));
await page.waitForTimeout(rand(1000, 2000));
// page.mouse.move()
// Mở composer
// const composer = page.locator('a[href="/compose/post"]').first();
// await page.waitForTimeout(2000 + (Math.random()+Math.random()) * 3000);
// await composer.click();
const textarea = page.locator('div[data-testid="tweetTextarea_0"]');
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
await textarea.fill(text);
console.log(' Nhập tweet xong ...');
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
await page.waitForTimeout(5000);
// Chờ nút enable
// const postBtn = page.locator('button[data-testid="tweetButtonInline"]');
// await postBtn.waitFor({state: 'visible', timeout: 5_000});
// await postBtn.click();
// await page.locator('button[data-testid="tweetButtonInline"]').click({ force: true });
const btn = page.locator('button[data-testid="tweetButtonInline"]');
const btnBox = await btn.boundingBox();
console.log(btnBox);
console.log('Nhấn Control+Enter ...');
// @ts-ignore
// await page.mouse.click(btnBox?.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
await page.keyboard.press('Control+Enter');
console.log('Nhấn Control+Enter done ...');
await page.waitForTimeout(5000);
// Chờ request CreateTweet hoàn tất
// await page.waitForResponse(
// (r) => r.url().includes('/CreateTweet') && r.status() === 200,
// {timeout: 15_000},
// );
return {success: true, tweetId: capturedTweetId};
} catch (err: any) {
this.logger.error(`Browser post failed: ${err.message}`);
// console.error(err);
return {success: false, error: err.message};
} finally {
await page?.close().catch(() => null);
}
}
async postQuote(
account: BrowserAccount,
tweetUrl: string,
quoteText: string,
) {
const page = await this.getPage(account);
try {
// ===== SAFE GOTO =====
try {
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
} catch (e) {
console.log('❌ Load fail');
throw e;
}
await page.waitForTimeout(rand(2000, 4000));
// ===== CHECK LOGIN =====
if (await page.locator('input[name="text"]').count()) {
console.log('❌ Cookie die → bị redirect login');
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
}
// ===== SCROLL (giống người thật) =====
await page.mouse.wheel(0, rand(300, 800));
await page.waitForTimeout(rand(1000, 5000));
await page.mouse.wheel(0, rand(300, 800));
await page.waitForTimeout(rand(4000, 8000));
await page.evaluate(() => window.scrollTo({top: 0, behavior: 'smooth'}));
await page.waitForTimeout(rand(1000, 2000));
// ===== CLICK RETWEET =====
let retweetBtn = page.locator('[data-testid="retweet"]');
if (!(await retweetBtn.count())) {
console.log('❌ Không thấy nút retweet (tweet private?)');
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
}
await retweetBtn.first().click();
await page.waitForTimeout(rand(1000, 2000));
try {
await page.locator('a[href="/compose/post"]').click({timeout: 2000});
} catch {
console.log('fallback → click by text');
await page.locator('a[role="menuitem"]')
.filter({hasText: 'Quote'})
.click();
}
// // ===== CLICK QUOTE =====
// let quoteBtn = page.locator('[data-testid="retweetWithComment"]');
//
// if (!(await quoteBtn.count())) {
// console.log('❌ Không thấy nút quote');
// return;
// }
//
// await quoteBtn.first().click();
await page.waitForTimeout(rand(2000, 3000));
// ===== TYPE LIKE HUMAN =====
// const content = pick(quoteText);
const content = quoteText;
const box = page.locator('div[role="textbox"]').first();
// chọn đúng textbox đang visible
// const box = page.locator('div[role="textbox"]:visible').first();
if (!(await box.count())) {
console.log('❌ Không thấy textbox');
throw new HttpException('❌ Không thấy textbox', 500);
}
// đợi nó xuất hiện thật sự
await box.waitFor({state: 'visible', timeout: 7000});
// scroll nhẹ vào view (tránh bị offscreen)
// await box.scrollIntoViewIfNeeded();
// focus trước khi gõ
console.log('focus trước khi gõ')
await box.click({delay: rand(50, 150)});
for (let char of content) {
await box.type(char, {delay: rand(50, 120)});
}
console.log('gõ quote xong ...')
await page.waitForTimeout(rand(1000, 2000));
// ===== POST =====
let postBtn = page.locator('[data-testid="tweetButton"]');
console.log('count ...')
if ((await postBtn.count())) {
console.log('click nút quote ...')
await postBtn.click({timeout: 7000}).catch(async (e) => {
console.log('❌ Nut click khong duoc, thử dùng bàn phím Control+Enter');
await page.keyboard.press('Control+Enter');
});
await page.waitForTimeout(rand(4000, 6000));
console.log('✅ Quoted thành công');
} else {
console.log('❌ Không thấy nút post, gọi Ctr + Enter');
await page.keyboard.press('Control+Enter');
await page.waitForTimeout(rand(4000, 6000));
console.log('✅ Quoted thành công');
}
return {success: true, error: ''};
} catch (err: any) {
this.logger.error(`Browser post quote failed: ${err.message}`);
// console.error(err);
return {success: false, error: `Browser post quote failed: ${err.message}`};
} finally {
await page?.close().catch(() => null);
}
}
async postReply(account: BrowserAccount, tweetUrl, content) {
if (!content) {
console.log(`Nội dung trả lời không có`);
throw new Error('Nội dung trả lời không có');
}
// let ctx = await this.getOrCreateContext(account);
//
// console.log('ctx', ctx);
// if (ctx.isClosed()) {
// console.log('browser is closeed, reopen');
// ctx = await this.getOrCreateContext(account, false);
// }
// const cookies = account.cookies.map((c) => ({
// ...c,
// domain: '.x.com',
// path: '/',
// }));
// await ctx.addCookies(cookies);
const page = await this.getPage(account);
try {
// limit X
// content = content.slice(0, 280);
// vào tweet
// ===== SAFE GOTO =====
try {
console.log(`Mo trang web tweetUrl`);
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
} catch (e) {
console.log('❌ Load fail');
throw e;
}
// đợi UI ổn
console.log(`đợi UI ổn...`)
await page.waitForSelector('article', {timeout: 7000});
// scroll nhẹ
console.log(`scroll nhẹ ...`)
await page.mouse.wheel(0, 300);
await page.waitForTimeout(1000 + Math.random() * 2000);
// lấy textbox visible
const box = page.locator('div[role="textbox"]:visible').first();
await box.waitFor({state: 'visible', timeout: 7000});
// focus
console.log(`box focus ...`)
await box.click();
// nhập content (fallback nếu type fail)
try {
await box.fill(''); // clear
await box.type(content, {delay: 30 + Math.random() * 150});
} catch {
await box.fill(content);
}
console.log(`nhập nội dung xong ...`)
await page.waitForTimeout(800 + Math.random() * 1200);
// nút reply
const btn = page.locator('[data-testid="tweetButtonInline"]:visible');
if (!(await btn.count())) {
console.log('❌ Không thấy nút reply');
throw new Error('Không thấy nút reply');
// return false;
}
await btn.click();
console.log(`nhấn nút gửi ...`)
await page.waitForTimeout(3000);
console.log('✅ Reply OK');
return {success: true, error: ''};
} catch (err) {
this.logger.error(`Browser reply failed: ${err.message}`);
// console.error(err);
return {success: false, error: `Browser reply failed: ${err.message}`};
} finally {
await page?.close().catch(() => null);
}
}
private async cleanupStaleContexts() {
const now = Date.now();
for (const [id, entry] of this.contextPool.entries()) {
if (now - entry.lastUsed > this.CONTEXT_TTL_MS) {
await entry.ctx.close().catch(() => null);
this.contextPool.delete(id);
this.logger.log(`Closed stale context ${id}`);
}
}
}
async onModuleDestroy() {
for (const {ctx} of this.contextPool.values()) {
await ctx.close().catch(() => null);
}
this.contextPool.clear();
await this.browser?.close().catch(() => null);
}
}
+243
View File
@@ -0,0 +1,243 @@
// x-cookie.service.ts
import {Injectable, Logger} from '@nestjs/common';
import axios, {AxiosInstance} from 'axios';
import {HttpsProxyAgent} from 'https-proxy-agent';
import {XCookieAccount} from './interfaces/x-cookie.interface';
import {buildXHeaders} from './utils/x-headers.util';
import {ConfigService} from "@nestjs/config";
interface GraphQLVariables {
[key: string]: any;
}
@Injectable()
export class XCookieService {
private readonly logger = new Logger(XCookieService.name);
// private readonly client: AxiosInstance;
private userId: string;
private queryID: string;
private screenName: string | null = null;
private TWEET_URL = '';
constructor(private readonly config: ConfigService) {
// ── Lấy cookie từ env ──
// const authToken = X_COOKIE_NAME.auth_token;
// const ct0 = X_COOKIE_NAME.cto;
// const kdt = X_COOKIE_NAME.kdt;
// const authToken = this.config.get<string>('TWITTER_AUTH_TOKEN');
// const ct0 = this.config.get<string>('TWITTER_CT0')!;
this.queryID = this.config.get<string>('TWITTER_QUERY_ID') || '';
const createQid = this.config.get<string>('TWITTER_CREATE_TWEET_QUERY_ID') || 'Qkq4oPdZYuNB_Qw3TDuFqQ';
this.TWEET_URL = `https://x.com/i/api/graphql/${createQid}/CreateTweet`;
// Bearer token này gần như cố định cho Web App, có thể để default
const bearer = 'AAAAAAAAAAAAAAAAAAAAAF7OAAAAAAAPS6nVJjCEf6gW6rLVnQujDGAh8%3DkQS5VtDfPBTSO89WMK4HvSpSUYshWVio9dNNWQEvwfkGmL7nPF';
console.log(`userId=${this.userId}`);
}
private buildClient(account: XCookieAccount): AxiosInstance {
console.log('buildClient');
const config: any = {
// baseURL: 'https://x.com/i/api',
headers: buildXHeaders(account.authToken, account.ct0),
timeout: 20000,
'referer': 'https://x.com/compose/tweet',
'origin': 'https://x.com',
};
if (account.proxy) {
const agent = new HttpsProxyAgent(account.proxy);
config.httpsAgent = agent;
config.proxy = false;
}
const client = axios.create(config);
// Interceptor: log lỗi chi tiết nếu bị 403/404/400
client.interceptors.response.use(
(res) => res,
(err) => {
const status = err.response?.status;
const errors = err.response?.data?.errors;
this.logger.log(`Call Url: ${err.config?.url}`)
this.logger.error(`Twitter HTTP ${status}:`, errors || err.message);
return Promise.reject(err);
},
);
return client;
}
/**
* ĐĂNG TWEET đơn
*/
async createTweet(account: XCookieAccount, text: string): Promise<any> {
const payload = {
variables: {
tweet_text: text,
dark_request: false,
media: {media_entities: [], possibly_sensitive: false},
semantic_annotation_id: [],
},
features: {
tweets_nudges_moments: true,
tweet_with_visibility_results_prefetch_gql_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
interactive_text_enabled: true,
responsive_web_text_conversations_enabled: false,
longform_notetweets_richtext_consumption_enabled: false,
responsive_web_enhance_cards_enabled: false,
},
};
const client = this.buildClient(account);
const res = await client.post(this.TWEET_URL, payload);
const result = res.data?.data?.create_tweet?.tweet_results?.result;
if (!result) {
throw new Error(`CreateTweet failed: ${JSON.stringify(res.data)}`);
}
const tid = result.rest_id;
const screenName = result.core?.user_results?.result?.legacy?.screen_name || 'i';
return {
id: tid,
url: `https://x.com/${screenName}/status/${tid}`,
success: true
};
}
/** ============================================
* 2. REPLY TWEET
* ============================================ */
async createReplyTweet(account: XCookieAccount, text: string, replyToTweetId: string): Promise<{
id: string;
url: string
}> {
const payload = {
variables: {
tweet_text: text,
reply: {
in_reply_to_tweet_id: replyToTweetId,
exclude_reply_user_ids: [],
},
dark_request: false,
media: {media_entities: [], possibly_sensitive: false},
},
features: {
tweets_nudges_moments: true,
tweet_with_visibility_results_prefetch_gql_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
interactive_text_enabled: true,
responsive_web_text_conversations_enabled: false,
longform_notetweets_richtext_consumption_enabled: false,
responsive_web_enhance_cards_enabled: false,
},
};
const client = this.buildClient(account);
const res = await client.post(this.TWEET_URL, payload);
const result = res.data?.data?.create_tweet?.tweet_results?.result;
if (!result) throw new Error('Reply tweet failed');
return {id: result.rest_id, url: `https://x.com/i/web/status/${result.rest_id}`};
}
/** ============================================
* 3. QUOTE TWEET (Retweet kèm cmt)
* ============================================ */
async createQuoteTweet(account: XCookieAccount, content: string, quoteTweetId: string): Promise<{
id: string;
url: string
}> {
// Cách 1: Dùng attachment_url (X Web thường dùng cách này)
const payload = {
variables: {
tweet_text: content,
dark_request: false,
media: {media_entities: [], possibly_sensitive: false},
attachment_url: `https://x.com/i/web/status/${quoteTweetId}`,
semantic_annotation_id: [],
},
features: this.getCommonFeatures(),
};
const client = this.buildClient(account);
const res = await client.post(this.TWEET_URL, payload);
return this.extractTweetResult(res.data);
}
private getCommonFeatures() {
return {
tweets_nudges_moments: true,
tweet_with_visibility_results_prefetch_gql_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
interactive_text_enabled: true,
responsive_web_text_conversations_enabled: false,
longform_notetweets_richtext_consumption_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
}
private extractTweetResult(data: any): { id: string; url: string } {
const result = data?.data?.create_tweet?.tweet_results?.result;
if (!result) throw new Error(`CreateTweet failed: ${JSON.stringify(data)}`);
const tid = result.rest_id;
const screenName = result.core?.user_results?.result?.legacy?.screen_name || 'i';
return {id: tid, url: `https://x.com/${screenName}/status/${tid}`};
}
/**
* VERIFY COOKIE (FIX lỗi 34): Dùng GraphQL UserByRestId (hash ổn định hơn v1.1)
* @param account
* @param userHandle Optional: Nếu biết @username
*/
async verifyCookie(account: XCookieAccount, userHandle?: string): Promise<{ screen_name: string; name: string }> {
try {
let variables: GraphQLVariables;
let queryId: string;
// if (userHandle) {
// UserByScreenName (hash hiện tại 2026: extract nếu lỗi)
queryId = 'IGgvgiOx4QZndDHuD3x9TQ'; // PLACEHOLDER → Extract (xem dưới)
variables = {
"screen_name": userHandle,
"withSafetyModeUserFields": true,
"withSuperFollowsUserFields": true
};
// }
// else {
// // UserByRestId (dùng userId từ twid)
// queryId = 'IGgvgiOx4QZndDHuD3x9TQ'; // Placeholder → Extract
// variables = { "userId": this.userId, "withSafetyModeUserFields": true, "withSuperFollowsUserFields": true };
// }
// const account = {
// authToken: X_COOKIE_NAME.auth_token,
// ct0: X_COOKIE_NAME.cto,
// }
const client = this.buildClient(account);
const res = await client.get(
`https://x.com/i/api/graphql/IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName?variables=%7B%22screen_name%22%3A%22elonmusk%22%2C%22withSafetyModeUserFields%22%3Atrue%7D&features=%7B%7D`,)
.catch((e)=>{
console.error(e.message);
});
// return res.data.data.user;
const user = res?.data.data.user; // Hoặc res.data.data.result
this.screenName = user.rest_id ? user.legacy.screen_name : user.screen_name;
this.logger.log(`Verified: @${this.screenName}`);
return {screen_name: this.screenName!, name: ''};
} catch (error: any) {
this.logger.error(`Verify failed: ${error.response?.data?.errors?.[0]?.message || error.message}`);
throw error;
}
}
}
+158
View File
@@ -0,0 +1,158 @@
// x-poster.controller.ts
import {Body, Controller, Get, HttpException, Post, Query, Req} from '@nestjs/common';
import {CreateTweetDto, ReplyTweetDto} from './dto/create-tweet.dto';
import {XPosterRouterService, XStrategy} from "./x-poster.router.service";
import {XCookieService} from "./x-cookie.service";
import {XApiService} from "./x-api.service";
import {XCacheService} from "../x-cache/x-cache.service";
import {XBrowserService} from "./x-browser.service";
@Controller('')
export class XPosterController {
constructor(
private readonly service: XPosterRouterService,
private readonly xCookieService: XCookieService,
private readonly xBrowserService: XBrowserService,
private readonly xCacheService: XCacheService,
private readonly xApiService: XApiService
) {
}
@Get('tw_callback')
async twitterAuthCallback(
@Query('code') code: string,
@Query('state') state: string,
@Req() request: Request) {
console.log('twitterAuthCallback==>')
const cacheAuthData = await this.xCacheService.getCacheTwAuthorize();
console.log({cacheAuthData});
//@ts-ignore
const {codeVerifier, state: sessionState} = cacheAuthData || {}
if (!codeVerifier || !state || !sessionState || !code) {
throw new HttpException('You denied the app or your session expired!', 400)
}
if (state !== sessionState) {
throw new HttpException('Stored tokens didnt match!!', 400)
}
const client = await this.xApiService.getTwitterClientV2();
const {client: loggedClient, accessToken, refreshToken} = await client.loginWithOAuth2({
code,
codeVerifier: codeVerifier,
redirectUri: `http://localhost:${process.env.PORT}/tw_callback`
});
await this.xCacheService.setCacheTwAccessToken(accessToken);
await this.xCacheService.setCacheTwRefreshToken('' + refreshToken);
console.log({loggedClient, accessToken, refreshToken});
// @ts-ignore
const {data: userObject} = loggedClient.v2.me();
return userObject;
}
@Get('tw_authorize')
async twitterAuthorize() {
console.log('twitterAuthorize==>', await this.xCacheService.getCacheTwAuthorize());
const client = await this.xApiService.getTwitterClientV2();
const {
url,
codeVerifier,
state
} = client.generateOAuth2AuthLink(`http://localhost:${process.env.PORT}/tw_callback`,
{
scope:
[
'tweet.read',
'tweet.write',
'users.read',
'offline.access',
'media.write',
]
});
// Redirect your client to {url}
console.log('Please go to', url);
console.log({codeVerifier, state});
await this.xCacheService.setCacheTwAuthorize({codeVerifier, state});
return {url, codeVerifier, state};
}
@Get('/tw/me')
async testTwitterMe() {
const client = await this.xApiService.getTwitterClientV2ViaAccessToken();
return client.v2.me();
}
@Post('xreply')
async xreply(
@Body() tweet: ReplyTweetDto,
) {
const account = {
accountId: process.env.X_USERNAME!,
cookies: [
{
name: 'auth_token',
value: process.env.X_COOKIE_AUTH_TOKEN!,
},
{
name: 'ct0',
value: process.env.X_COOKIE_CT0!,
},
],
proxy: '',
userAgent: ''
};
return this.xBrowserService.postReply(account, tweet.tweetUrl, tweet.text)
}
@Post('tweet')
async tweet(
@Body() tweet: CreateTweetDto,
) {
const authToken = 'f5574950a0d98cf49ca2e574cc6a4139cc8a2d81';
const ct0 = '75db96b40f4814925670722e3335840467b87c7eb403aab14fbe719297bf465347810f92dc2116c3d30f15153a332976c50d856983dfe19046d4f10413b3d1cd8bac6f7a99e5b03c6949bafdad0f01c0';
// return this.service.postTweet(
// {
// account: {
// id: 'realflashkaze',
// browser: {
// accountId: 'realflashkaze',
// cookies: [
// {
// name: "ct0",
// value: '75db96b40f4814925670722e3335840467b87c7eb403aab14fbe719297bf465347810f92dc2116c3d30f15153a332976c50d856983dfe19046d4f10413b3d1cd8bac6f7a99e5b03c6949bafdad0f01c0'
// },
// {
// name: "auth_token",
// value: 'f5574950a0d98cf49ca2e574cc6a4139cc8a2d81'
// },
// {name: "dnt", value: "1"},
// {name: "lang", value: "en"},
// {name: "twid", value: "u%3D2043937828644536320"},
// ]
// }
// },
// text: tweet.text,
// strategy: XStrategy.BROWSER_ONLY
// });
}
@Get('verify')
verify() {
const account = {
authToken: process.env.X_COOKIE_AUTH_TOKEN!, // auth_token cookie
ct0: process.env.X_COOKIE_CT0!,
}
return this.xCookieService.verifyCookie(account);
}
}
+26
View File
@@ -0,0 +1,26 @@
// src/modules/manager/manager.module.ts
import {Global, Module} from '@nestjs/common';
import {XApiService} from "./x-api.service";
import {XBrowserService} from "./x-browser.service";
import {XPosterController} from "./x-poster.controller";
import {XPosterRouterService} from "./x-poster.router.service";
import {XCookieService} from "./x-cookie.service";
import {XCacheService} from "../x-cache/x-cache.service";
import {NotifyService} from "../notify.service";
@Global()
@Module({
imports: [],
providers: [
XApiService,
XBrowserService,
XCookieService,
XCacheService,
XPosterRouterService,
NotifyService,
],
controllers: [XPosterController],
exports: [XApiService, XBrowserService, XPosterRouterService],
})
export class XPosterModule {
}
+412
View File
@@ -0,0 +1,412 @@
// src/modules/x-router/x-router.service.ts
import {Injectable, Logger} from '@nestjs/common';
import {XCookieAccount} from "./interfaces/x-cookie.interface";
import {BrowserAccount, XBrowserService} from "./x-browser.service";
import {XApiService} from "./x-api.service";
import {XCookieService} from "./x-cookie.service";
import {NotifyService} from "../notify.service";
export enum SUPPORT_SOCIAL_PROVIDERS {
FB = 'fb',
X = 'x'
}
export enum XStrategy {
COOKIE_FIRST = 'cookie_first', // rẻ nhất → fallback browser → api
API_FIRST = 'api_first', // ổn định nhất → fallback cookie → browser
BROWSER_FIRST = 'browser_first', // khi cần chống bot nặng
COOKIE_ONLY = 'cookie_only',
API_ONLY = 'api_only',
BROWSER_ONLY = 'browser_only',
AUTO = 'auto', // dựa vào health account
BROWSER_API = 'browser_api',
BROWSER_COOKIE = 'browser_cookie'// khi cần chống bot nặng
}
export interface UnifiedAccount {
id: string;
api?: { accessToken: string; accessSecret: string; appKey: string; appSecret: string };
cookie?: XCookieAccount;
browser?: BrowserAccount;
}
export interface RouterResult {
success: boolean;
tweetId?: string;
via: 'api' | 'cookie' | 'browser';
attempts: Array<{ method: string; error?: string }>;
error?: string;
}
@Injectable()
export class XPosterRouterService {
private readonly logger = new Logger(XPosterRouterService.name);
private readonly X_UNIFIED_ACCOUNT: UnifiedAccount;
constructor(
private readonly apiSvc: XApiService,
private readonly cookieSvc: XCookieService,
private readonly browserSvc: XBrowserService,
private readonly notifyService: NotifyService,
) {
this.X_UNIFIED_ACCOUNT = {
id: process.env.X_USERNAME!,
api: {
accessToken: '',
accessSecret: '',
appKey: process.env.TWITTER_CLIENT_ID + '',
appSecret: process.env.TWITTER_CLIENT_SECRET!,
},
cookie: {
authToken: process.env.X_COOKIE_AUTH_TOKEN!, // auth_token cookie
ct0: process.env.X_COOKIE_CT0!, // ct0 cookie (CSRF token)
proxy: '',
},
browser: {
accountId: process.env.X_USERNAME!,
cookies: [
{
name: 'auth_token',
value: process.env.X_COOKIE_AUTH_TOKEN!,
},
{
name: 'ct0',
value: process.env.X_COOKIE_CT0!,
},
],
proxy: '',
userAgent: ''
},
};
console.error(this.X_UNIFIED_ACCOUNT);
}
async verifyCookie(account: XCookieAccount): Promise<any> {
return this.cookieSvc.verifyCookie(account, 'UserByScreenName')
}
async postTweet(params: {
text: string;
strategy?: XStrategy;
}): Promise<RouterResult> {
const account = this.X_UNIFIED_ACCOUNT;
const strategy = params.strategy ?? XStrategy.BROWSER_FIRST;
const chain = this.buildChain(strategy, account);
const attempts: RouterResult['attempts'] = [];
for (const method of chain) {
this.logger.log(`[${account.id}] Trying via ${method}`);
const result = await this.executeTweet(method, account, params.text);
attempts.push({method, error: result.error});
if (result.success) {
this.logger.log(`Đã đăng bài thành công`);
await this.notifyService.sendMessageToTele(`Đã đăng bài X thành công`);
return {
success: true,
tweetId: result.tweetId,
via: method,
attempts,
};
} else {
}
// Nếu lỗi không fallback được (vd: text quá dài) → dừng
if (this.isFatalError(result.error)) {
this.logger.log(`Dăng bài thất bại isFatalError`);
return {
success: false,
via: method,
attempts,
error: result.error,
};
}
this.logger.log(`Dăng bài thất bại, thử phương pháp khác`);
}
return {
success: false,
via: chain[chain.length - 1],
attempts,
error: 'All methods failed',
};
}
async postReply(params: {
text: string;
tweetUrl: string;
tweetId: string;
strategy?: XStrategy;
}): Promise<RouterResult> {
const account = this.X_UNIFIED_ACCOUNT;
const strategy = params.strategy ?? XStrategy.BROWSER_FIRST;
const chain = this.buildChain(strategy, account);
const attempts: RouterResult['attempts'] = [];
// await this.browserSvc.postReply(
// account.browser!,
// params.tweetUrl, params.text
// );
// return {
// success: true,
// tweetId: params.tweetUrl,
// via: 'browser',
// attempts,
// };
for (const method of chain) {
this.logger.log(`[${account.id}] Trying reply via ${method}`);
const result = await this.executeReply(method, account, {
text: params.text,
tweetUrl: params.tweetUrl,
tweetId: params.tweetId
});
attempts.push({method, error: result.error});
if (result.success) {
return {
success: true,
tweetId: result.tweetId,
via: method,
attempts,
};
}
// Nếu lỗi không fallback được (vd: text quá dài) → dừng
if (this.isFatalError(result.error)) {
return {
success: false,
via: method,
attempts,
error: result.error,
};
}
}
return {
success: false,
via: chain[chain.length - 1],
attempts,
error: 'All methods failed',
};
}
async postQuote(params: {
text: string;
tweetUrl: string;
tweetId: string;
strategy?: XStrategy;
}): Promise<RouterResult> {
const account = this.X_UNIFIED_ACCOUNT;
const strategy = params.strategy ?? XStrategy.BROWSER_FIRST;
const chain = this.buildChain(strategy, account);
const attempts: RouterResult['attempts'] = [];
// await this.browserSvc.postReply(
// account.browser!,
// params.tweetUrl, params.text
// );
// return {
// success: true,
// tweetId: params.tweetUrl,
// via: 'browser',
// attempts,
// };
for (const method of chain) {
this.logger.log(`[${account.id}] Trying quote via ${method}`);
const result = await this.executeQuote(method, account, {
text: params.text,
tweetUrl: params.tweetUrl,
tweetId: params.tweetId,
});
attempts.push({method, error: result.error});
if (result.success) {
return {
success: true,
tweetId: result.tweetId,
via: method,
attempts,
};
}
// Nếu lỗi không fallback được (vd: text quá dài) → dừng
if (this.isFatalError(result.error)) {
return {
success: false,
via: method,
attempts,
error: result.error,
};
}
}
return {
success: false,
via: chain[chain.length - 1],
attempts,
error: 'All methods failed',
};
}
/** Xây chain dựa trên strategy + account capabilities */
private buildChain(
strategy: XStrategy,
account: UnifiedAccount,
): Array<'api' | 'cookie' | 'browser'> {
const has = {
api: !!account.api,
cookie: !!account.cookie,
browser: !!account.browser,
};
const chains: Record<XStrategy, Array<'api' | 'cookie' | 'browser'>> = {
[XStrategy.BROWSER_API]: ['browser', 'api'],
[XStrategy.COOKIE_FIRST]: ['cookie', 'browser', 'api'],
[XStrategy.API_FIRST]: ['api', 'cookie', 'browser'],
[XStrategy.BROWSER_FIRST]: ['browser', 'cookie', 'api'],
[XStrategy.COOKIE_ONLY]: ['cookie'],
[XStrategy.API_ONLY]: ['api'],
[XStrategy.BROWSER_ONLY]: ['browser'],
[XStrategy.BROWSER_COOKIE]: ['cookie', 'browser',],
[XStrategy.AUTO]: ['cookie', 'browser', 'api'], // có thể dựa health store
};
return chains[strategy].filter((m) => has[m]);
}
private async executeTweet(
method: 'api' | 'cookie' | 'browser',
account: UnifiedAccount,
text: string,
): Promise<{ success: boolean; tweetId?: string; error?: string }> {
try {
if (method === 'api' && account.api) {
const {data: r} = await this.apiSvc.postSimpleTweet(text);
return {
tweetId: r.id,
success: true,
}
}
if (method === 'cookie' && account.cookie) {
return await this.cookieSvc.createTweet(account.cookie, text);
}
if (method === 'browser' && account.browser) {
return await this.browserSvc.postTweet(account.browser, text);
}
return {success: false, error: `Method ${method} not configured`};
} catch (e: any) {
return {success: false, error: e.message};
}
}
private async executeReply(
method: 'api' | 'cookie' | 'browser',
account: UnifiedAccount,
params: {
text: string,
tweetUrl: string,
tweetId: string
}
): Promise<{ success: boolean; tweetId?: string; error?: string }> {
try {
if (method === 'api' && account.api) {
this.logger.error(`api not supported`);
return {
error: 'Api not supported',
success: false,
}
}
if (method === 'cookie' && account.cookie) {
// const r = await this.cookieSvc.createReplyTweet(
// account.cookie!,
// params.text,
// params.tweetId!
// );
//
// // this.logger.error(`quote api not supported`);
// return {
// error: '',
// success: true,
// }
this.logger.error(`cookie not supported`);
// return {
// success: false,
// error: 'Cookie not supported',
// }
}
if (method === 'browser' && account.browser) {
return await this.browserSvc.postReply(
account.browser,
params.tweetUrl,
params.text
);
}
return {success: false, error: `Method ${method} not configured`};
} catch (e: any) {
return {success: false, error: e.message};
}
}
private async executeQuote(
method: 'api' | 'cookie' | 'browser',
account: UnifiedAccount,
params: {
text: string,
tweetUrl: string,
tweetId: string,
}
): Promise<{ success: boolean; tweetId?: string; error?: string }> {
try {
if (method === 'api' && account.api) {
this.logger.error(`quote api not supported`);
return {
success: false,
error: 'quote api not supported',
}
}
if (method === 'cookie' && account.cookie) {
// return await this.cookieSvc.verifyCookie(account.cookie, params.text);
// const r = await this.cookieSvc.createQuoteTweet(
// account.cookie!,
// params.text,
// params.tweetId!
// );
this.logger.error(`quote api not supported`);
return {
error: '',
success: true,
}
}
if (method === 'browser' && account.browser) {
return await this.browserSvc.postQuote(
account.browser,
params.tweetUrl!,
params.text
);
}
return {success: false, error: `Method ${method} not configured`};
} catch (e: any) {
return {success: false, error: e.message};
}
}
private isFatalError(error?: string): boolean {
if (!error) return false;
const fatalPatterns = [
/duplicate/i,
/too long/i,
/forbidden content/i,
/suspended/i,
];
return fatalPatterns.some((p) => p.test(error));
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false,
"preserveWatchOutput": true
}
}