first commit
This commit is contained in:
+60
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](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).
|
||||||
@@ -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': ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7488
File diff suppressed because it is too large
Load Diff
@@ -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!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// }
|
||||||
@@ -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"},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user