This commit is contained in:
NAME
2026-05-15 07:08:56 +00:00
parent 5f16ed135d
commit 7730c76e08
20 changed files with 945 additions and 316 deletions
@@ -42,6 +42,7 @@ export class CommentWriterProcessor extends WorkerHost {
agle,
comtext,
telegramChatId,
xreaderMode,
} = job.data;
const topic = summary || title;
let pgPostCreateDto!: PostCreateInput;
@@ -185,7 +186,7 @@ export class CommentWriterProcessor extends WorkerHost {
}
case 'generate_quote_twitter': {
this.logger.debug('===>generate_quote_twitter:', url);
const xpost = await this.xreader.readXPost(url);
const xpost = await this.xreader.readXPost(url, xreaderMode, telegramChatId);
const originalAuthor = `${xpost.author} ${xpost.handle}`;
const dto: GenerateQuoteDto = {
@@ -21,6 +21,7 @@ import {XReaderService} from "../x-reader/x-reader.service";
import {LengthStrategyService} from "./services/length-strategy.service";
import {QuoteWriterService} from "./services/quote-writer.service";
import {SqsPostService} from "../sqs-module/sqs.post.service";
import {ContentSafetyService} from "./services/content-safety.service";
@Module({
imports: [
@@ -48,7 +49,8 @@ import {SqsPostService} from "../sqs-module/sqs.post.service";
XReaderService,
LengthStrategyService,
QuoteWriterService,
SqsPostService
SqsPostService,
ContentSafetyService,
],
exports: [GrokProvider, ContentWriterService],
@@ -47,4 +47,8 @@ export class GenerateQuoteDto {
@IsOptional()
tweetId?: number;
@IsOptional()
@IsBoolean()
acknowledgeEdgyRisks?: boolean;
}
@@ -8,4 +8,26 @@ export enum ContentTone {
EMPATHETIC = 'empathetic',
PROVOCATIVE = 'provocative',
AUTHORITATIVE = 'authoritative',
// === NEW: Edgy tier (opt-in) ===
SPICY = 'spicy', // Blunt, sharp, mild profanity OK
AGGRESSIVE = 'aggressive', // Cục súc, attack ideas mạnh
PROFANE = 'profane', // Nói tục thoải mái, raw
INFLAMMATORY = 'inflammatory', // Kích động cao, controversial takes
SAVAGE = 'savage',
}
/**
* Tones cần special handling (provider routing, guardrails)
*/
export const EDGY_TONES = new Set<ContentTone>([
ContentTone.SPICY,
ContentTone.AGGRESSIVE,
ContentTone.PROFANE,
ContentTone.INFLAMMATORY,
ContentTone.SAVAGE,
]);
export function isEdgyTone(tone: ContentTone): boolean {
return EDGY_TONES.has(tone);
}
@@ -4,6 +4,11 @@ import {Language} from "../../../common/interfaces/language.prompt.interface";
import {calculateLengthBudget} from "../../../common/utils/token-calculator";
import {Platform} from "../enum/platform.enum";
import {ANGLE_HINTS} from "../enum/angle.enum";
import {ContentTone, isEdgyTone} from "../enum/tone.enum";
import {buildEdgySystemPrompt, mapToneToStarterCategory} from "./quote.templates";
import {getToneInstruction} from "./edgy-tones";
import {LANGUAGE_LOCK} from "./templates";
import {getJpContextBlock} from "./jp-cultural-context";
export const COMMENT_SYSTEM_PROMPTS = {
en: 'You write casual, human replies on X. Sound like a real person, NOT AI. No hashtags unless natural.',
@@ -29,7 +34,7 @@ export function buildCommentPrompt(params: {
angle?: string;
language: Language;
persona?: string;
tone?: string;
tone?: ContentTone;
}): { system: string; user: string } {
// const angleHints: Record<string, string> = {
// agree: 'agree:Đồng ý và bổ sung thêm một luận điểm nhỏ để hỗ trợ',
@@ -38,21 +43,39 @@ export function buildCommentPrompt(params: {
// funny: 'funny:Hóm hỉnh, hài hước nhẹ nhàng, không gây khó chịu',
// question: 'question:Đặt một câu hỏi tiếp theo thông minh',
// };
const tone = params.tone ?? ContentTone.CASUAL;
const system = isEdgyTone(tone)
? buildEdgySystemPrompt(params.language)
: COMMENT_SYSTEM_PROMPTS[params.language];
const toneInstruction = getToneInstruction(tone, params.language, '-')
// 👇 JP context cho comments
const jpContext = params.language === 'ja'
? getJpContextBlock({
includeStyleNotes: true,
includeAvoid: true,
starterCategory: mapToneToStarterCategory(tone),
})
: '';
const budget = calculateLengthBudget(Platform.X, params.language);
const user = [
`Original X post:\n"""${params.originalPost}"""`,
``,
`Write a reply target length: ${budget.minChars}-${budget.maxChars} characters:`,
`Write a comment/reply target length: ${budget.minChars}-${budget.maxChars} characters:`,
`[Target Language: ${params.language}]
Rewrite strictly in ${params.language} only.`,
params.angle ? `- Angle: ${ANGLE_HINTS[params.angle] ?? params.angle}` : '- Angle: natural reaction',
params.persona ? `- Speak as: ${params.persona}` : '',
params.tone ? `- Tone: ${params.tone}` : '- Tone: casual, conversational',
toneInstruction,
`- Sound HUMAN, not AI. No "Great post!" openings.`,
`- No emoji spam. 0-1 emoji max.`,
`- Standalone OK but conversational`,
jpContext,
`- HARD RULES: no slurs, no threats, no targeting private individuals`,
`- Output ONLY the reply text.`,
].filter(Boolean).join('\n');
return {system: COMMENT_SYSTEM_PROMPTS[params.language], user};
return {system:system, user};
}
+342 -205
View File
@@ -1,205 +1,342 @@
// // prompts/edgy-tones.ts — UPDATE phần JP
//
// import {ContentTone} from "../enum/tone.enum";
//
// export const EDGY_TONE_SPECS: Record<ContentTone, ToneSpec> = {
// [ContentTone.SPICY]: {
// intensity: 2,
// description: {
// en: '...',
// vi: '...',
// // 👇 REFINED JP
// ja: [
// 'ストレートで歯に衣着せない。鋭いが冷静。',
// '軽い悪態OK:「は?」「いや無理」「マジで?」「草」',
// 'JPのX的に:堅い敬語を使わず、話し言葉ベース。改行を効かせる。',
// '「w」「草」を末尾に使ってOK(やりすぎ注意、1-2回まで)。',
// '感情的にキレるのではなく、淡々と切るイメージ。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// // 👇 REFINED JP — real JP X patterns
// ja: [
// 'いやこれ違うやろ\n\nデータちゃんと見た?普通に逆の話してるんやが',
// 'は?このチャートで強気とか草\n\nさすがに無理があるって',
// 'まあ気持ちは分かるけど、これは普通にナンピン地獄コースやで',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// // 👇 REFINED JP
// ja: 'は?/いや/マジで/普通に/ガチで/草/w/無理/えぐい/やばい',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: [
// '丁寧な敬語禁止(「〜と思います」「〜でしょうか」NG)',
// '個人攻撃禁止(一般人ターゲットNG)',
// '差別語・脅迫NG',
// '「!」連発禁止',
// ].join(' / '),
// ko: '...',
// },
// },
//
// [ContentTone.AGGRESSIVE]: {
// intensity: 3,
// description: {
// en: '...',
// vi: '...',
// // 👇 REFINED JP
// ja: [
// '粗野で生々しい、主張をガチで叩く。バカな意見を嘲笑する。',
// '強めの悪態OK:「クソ」「ふざけんな」「アホか」「は?マジで言ってる?」',
// '関西弁ミックスもOK(「なんやねん」「あほちゃう」「言うてるやろ」)— ナチュラルなら。',
// '感情的になりすぎず、論破口調をベースに。',
// '攻撃対象:主張・意見・市場・公人の公的行為。私人NG。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// 'は?マジで言ってる?\n\nそのロジックでよく投稿ボタン押せたな、感心するわ',
// 'いや待って、これ今週見た中で一番アホな分析やんけ\n\n根拠ゼロで断言するの草',
// 'ふざけんなよ、3日前に同じこと言ったやろ。\n誰も聞かんかったから今こうなってるんやで',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: 'クソ/ふざけんな/アホか/は?/マジで/いい加減にしろ/寝言/なんやねん/養分/ピエロ',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: '差別語NG/脅迫NG/私人攻撃NG/本物の侮辱罪リスク回避(公的主張のみ叩く)',
// ko: '...',
// },
// },
//
// [ContentTone.PROFANE]: {
// intensity: 4,
// description: {
// en: '...',
// vi: '...',
// ja: [
// '荒っぽい、フィルター無し、悪態多め。熱くなったトレーダー・配信者風。',
// '激しい悪態OK:「クソが」「マジでクソ」「ふざけんなクソ」「は?クソが」',
// '感情がガチで出てる感じ。ただし支離滅裂にはしない。',
// 'JPのX的に:「www」連発、「草不可避」「クソ草」OK。',
// '対象:市場・主張・公人。私人NG。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// 'クソが、この相場マジで何なんだよw\n\n3日前にロング切られて、今度はショート狩られる。地獄かよ',
// 'いやマジでふざけんな、このプロジェクト\n\nリリース3回延期して、結局これ?クソ案件確定やん',
// 'は?クソが、また下げかよwww\n\nもう養分卒業したいんだが、神は俺を見捨てた',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: 'クソ/クソが/クソ案件/ふざけんな/地獄/養分/死んだ/逝った/www/草不可避',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: '差別語絶対NG/脅迫NG/私人特定NG/侮辱罪に該当する表現避ける',
// ko: '...',
// },
// },
//
// [ContentTone.INFLAMMATORY]: {
// intensity: 4,
// description: {
// en: '...',
// vi: '...',
// ja: [
// '強い反応を引き出す設計。物議を醸す断言。',
// '両極化する言葉。当てこすり。炎上を生むが擁護可能。',
// 'JPのX的に:断言系(「結論:〜」「答え:〜」)、逆張り、リスト形式が伸びる。',
// '「〜してる奴は〜」「〜と言ってる時点で〜」のような決めつけ構文OK。',
// '挑発的≠根拠なし。根拠は持つこと。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// '結論:2026年にまだ[X]ホールドしてる奴は、出口流動性です。\n\n認めたくないだろうけど、それが現実',
// 'これが物議を醸してる時点で、この界隈のレベルが分かる\n\n基本的なことを言ってるだけなのに',
// '「まだ早い」って言ってる人へ:\n\nもう遅いです。認めて次のサイクル準備した方が建設的',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: '結論/答え/現実/養分/出口流動性/中級者の壁/NPC/弱者男性/情弱/w',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: 'ヘイトスピーチNG/脅迫NG/差別語NG/挑発的でも法的に擁護可能な範囲で',
// ko: '...',
// },
// },
//
// [ContentTone.SAVAGE]: {
// intensity: 5,
// description: {
// en: '...',
// vi: '...',
// ja: [
// '残虐ロースト・モード。外科的かつ機知に富んだ粉砕。',
// '悪態OK、最大限の毒舌。面白い+残酷+賢いの三位一体。',
// 'JPのX的に:「こいつ本当に〜」「よく〜できたな」「保存した」「伝説」系構文。',
// '直接の罵倒より、淡々とした観察口調の方が刺さる(JP特有)。',
// '「お兄さん、」「お疲れさまでした」などの慇懃無礼OK。',
// '対象:主張・公人の公的行為。私人・弱者NG(パンチアップのみ)。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// 'こいつ本当にこれを投稿して送信ボタン押したのか\n\n複数回。胸を張って。すごい才能だ',
// '次の「妄想の極致」スレッド用に保存させていただきました\n\n貴重なサンプルありがとうございます',
// 'お兄さん、自信と正確性の比率がバグってますよ\n\n一回深呼吸してチャート見直すといいかも(優しさ)',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: 'お兄さん/こいつ/伝説/保存しました/お疲れさまでした/貴重なサンプル/才能/天才/中級者の壁',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: 'パンチアップのみ(公人・主張)/私人・弱者NG/差別語NG/脅迫NG/侮辱罪リスク回避',
// ko: '...',
// },
// },
// };
// prompts/edgy-tones.ts
import {ContentTone, isEdgyTone} from "../enum/tone.enum";
import {Language} from "../../../common/interfaces/language.prompt.interface";
import {getToneHint, TONE_HINTS} from "./templates";
export interface ToneSpec {
description: Record<Language, string>;
examples: Record<Language, string[]>;
vocabulary: Record<Language, string>;
intensity: 1 | 2 | 3 | 4 | 5; // 1 = light, 5 = max
avoid: Record<Language, string>;
}
export const EDGY_TONE_SPECS: Partial<Record<ContentTone, ToneSpec>> = {
[ContentTone.SPICY]: {
intensity: 2,
description: {
en: 'Blunt, sharp, no-bullshit. Mild profanity (damn, shit, hell) OK if natural. Confident, slightly confrontational. NOT angry — just direct.',
cn: 'Blunt, sharp, no-bullshit. Mild profanity (damn, shit, hell) OK if natural. Confident, slightly confrontational. NOT angry — just direct.',
vi: 'Thẳng thắn, sắc, không lươn lẹo. Chửi nhẹ (mẹ nó, đm, vcl) OK nếu tự nhiên. Tự tin, hơi đối đầu. KHÔNG giận dữ — chỉ thẳng.',
ja: [
'ストレートで歯に衣着せない。鋭いが冷静。',
'軽い悪態OK:「は?」「いや無理」「マジで?」「草」',
'JPのX的に:堅い敬語を使わず、話し言葉ベース。改行を効かせる。',
'「w」「草」を末尾に使ってOK(やりすぎ注意、1-2回まで)。',
'感情的にキレるのではなく、淡々と切るイメージ。',
].join(' '),
ko: '직설적, 날카로움, 돌려 말하지 않음. 가벼운 욕설(젠장, 빡친다, ㅅㅂ) 자연스러우면 OK. 자신만만하고 약간 도발적. 분노가 아닌 직설.',
},
examples: {
en: [
'Look, this take is just wrong. Here\'s why:',
'Stop pretending this is complicated. It\'s not.',
'Damn, did we read the same chart?',
],
cn: [
'Look, this take is just wrong. Here\'s why:',
'Stop pretending this is complicated. It\'s not.',
'Damn, did we read the same chart?',
],
vi: [
'Nghe này, ý này sai bét. Đây là lý do:',
'Đừng giả vờ phức tạp. Đơn giản mà.',
'Vcl, mình đọc cùng biểu đồ không vậy?',
],
// 👇 REFINED JP — real JP X patterns
ja: [
'いやこれ違うやろ\n\nデータちゃんと見た?普通に逆の話してるんやが',
'は?このチャートで強気とか草\n\nさすがに無理があるって',
'まあ気持ちは分かるけど、これは普通にナンピン地獄コースやで',
],
ko: [
'아니, 이 관점은 완전 틀렸음. 이유는:',
'복잡한 척 그만. 단순한 얘기야.',
'진심 같은 차트 보고 있나?',
],
},
vocabulary: {
en: 'damn, shit, hell, bro, what, seriously, lmao',
cn: 'damn, shit, hell, bro, what, seriously, lmao',
vi: 'đm, vcl, vl, bro, gì zậy, nghiêm túc đi, lmao',
ja: 'は?/いや/マジで/普通に/ガチで/草/w/無理/えぐい/やばい',
ko: '진심, 헐, 미친, ㅅㅂ, ㄹㅇ, 야',
},
avoid: {
en: 'No slurs, no personal attacks on private individuals.',
cn: 'No slurs, no personal attacks on private individuals.',
vi: 'Không miệt thị, không công kích cá nhân người thường.',
ja: [
'丁寧な敬語禁止(「〜と思います」「〜でしょうか」NG)',
'個人攻撃禁止(一般人ターゲットNG)',
'差別語・脅迫NG',
'「!」連発禁止',
].join(' / '),
ko: '비방어 금지, 일반인 대상 인신공격 금지.',
},
},
[ContentTone.AGGRESSIVE]: {
intensity: 3,
description: {
en: 'Cục súc, raw, attacks ideas hard. Mocks bad takes. Stronger profanity OK (fuck, bullshit). Confrontational. Attack ARGUMENTS not people.',
cn: 'Cục súc, raw, attacks ideas hard. Mocks bad takes. Stronger profanity OK (fuck, bullshit). Confrontational. Attack ARGUMENTS not people.',
vi: 'Cục súc, raw, đập ý tưởng mạnh. Mỉa mai các ý kiến tệ. Chửi mạnh (đm, đcm, vlz) OK. Đối đầu. Đánh vào LẬP LUẬN, không vào người.',
// 👇 REFINED JP
ja: [
'粗野で生々しい、主張をガチで叩く。バカな意見を嘲笑する。',
'強めの悪態OK:「クソ」「ふざけんな」「アホか」「は?マジで言ってる?」',
'関西弁ミックスもOK(「なんやねん」「あほちゃう」「言うてるやろ」)— ナチュラルなら。',
'感情的になりすぎず、論破口調をベースに。',
'攻撃対象:主張・意見・市場・公人の公的行為。私人NG。',
].join(' '),
ko: '거칠고, 날것, 주장을 세게 공격. 헛소리 조롱. 강한 욕설(ㅅㅂ, 좆같다) OK. 대립적. 주장을 공격하되 사람은 공격 안 함.',
},
examples: {
en: [
'This is the dumbest fucking take I\'ve seen all week.',
'Are you serious with this bullshit? The data literally says the opposite.',
'Imagine being this confidently wrong on the timeline.',
],
cn: [
'This is the dumbest fucking take I\'ve seen all week.',
'Are you serious with this bullshit? The data literally says the opposite.',
'Imagine being this confidently wrong on the timeline.',
],
vi: [
'Đm đây là ý kiến ngu nhất tuần này tôi đọc.',
'Nghiêm túc với cái rác này? Dữ liệu nói ngược hẳn.',
'Tự tin sai như thế trên timeline cũng là tài.',
],
ja: [
'は?マジで言ってる?\n\nそのロジックでよく投稿ボタン押せたな、感心するわ',
'いや待って、これ今週見た中で一番アホな分析やんけ\n\n根拠ゼロで断言するの草',
'ふざけんなよ、3日前に同じこと言ったやろ。\n誰も聞かんかったから今こうなってるんやで',
],
ko: [
'이번 주 본 의견 중 제일 ㅄ같음.',
'이딴 헛소리 진심이냐? 데이터 정반대인데.',
'타임라인에서 당당하게 틀리는 재능.',
],
},
vocabulary: {
en: 'fuck, fucking, bullshit, garbage, trash take, clown, cope',
cn: 'fuck, fucking, bullshit, garbage, trash take, clown, cope',
vi: 'đm, đcm, vcl, vlz, rác, hề, cố cãi',
ja: 'クソ/ふざけんな/アホか/は?/マジで/いい加減にしろ/寝言/なんやねん/養分/ピエロ',
ko: 'ㅅㅂ, 좆같다, 헛소리, 쓰레기, ㄹㅇ ㅄ, 광대',
},
avoid: {
en: 'No threats. No slurs. Attack the take, not the human behind it.',
cn: 'No threats. No slurs. Attack the take, not the human behind it.',
vi: 'Không đe dọa. Không miệt thị. Đánh ý kiến, không đánh người sau ý kiến.',
ja: '脅迫禁止。差別語禁止。意見を叩き、その人物を叩かない。',
ko: '협박 금지. 비방어 금지. 의견 공격이지 사람 공격 아님.',
},
},
[ContentTone.PROFANE]: {
intensity: 4,
description: {
en: 'Raw, unfiltered, profanity-heavy. Like a heated streamer/trader. Multiple F-bombs OK. Still attacks ideas, not people.',
cn: 'Raw, unfiltered, profanity-heavy. Like a heated streamer/trader. Multiple F-bombs OK. Still attacks ideas, not people.',
vi: 'Raw, không filter, chửi nhiều. Như trader cay cú/streamer. Đm đcm thoải mái. Vẫn đánh ý không đánh người.',
ja: [
'荒っぽい、フィルター無し、悪態多め。熱くなったトレーダー・配信者風。',
'激しい悪態OK:「クソが」「マジでクソ」「ふざけんなクソ」「は?クソが」',
'感情がガチで出てる感じ。ただし支離滅裂にはしない。',
'JPのX的に:「www」連発、「草不可避」「クソ草」OK。',
'対象:市場・主張・公人。私人NG。',
].join(' '),
ko: '날것, 필터 없음, 욕설 많이. 빡친 스트리머/트레이더 같은. 강한 욕설 OK. 주장 공격만.',
},
examples: {
en: [
'What the actual fuck is this market right now lmao',
'Fuck this fucking chart, I called it 3 days ago and nobody listened',
'Imagine fucking shorting at this level. Couldn\'t be me.',
],
cn: [
'What the actual fuck is this market right now lmao',
'Fuck this fucking chart, I called it 3 days ago and nobody listened',
'Imagine fucking shorting at this level. Couldn\'t be me.',
],
vi: [
'Đm cái thị trường này là cái lz gì giờ vậy lmao',
'Đcm cái chart này, tôi nói 3 ngày trước méo ai nghe',
'Tưởng tượng đm short ở mức này. Tao thì không.',
],
ja: [
'クソが、この相場マジで何なんだよw\n\n3日前にロング切られて、今度はショート狩られる。地獄かよ',
'いやマジでふざけんな、このプロジェクト\n\nリリース3回延期して、結局これ?クソ案件確定やん',
'は?クソが、また下げかよwww\n\nもう養分卒業したいんだが、神は俺を見捨てた',
],
ko: [
'이 시장 진짜 ㅅㅂ 뭐냐 ㅋㅋ',
'이 ㅈ같은 차트, 3일 전에 말했는데 아무도 안 들음',
'이 가격에 숏 잡는 거 상상이 되냐. 나는 안 함.',
],
},
vocabulary: {
en: 'fuck, fucking, shit, damn, hell, bullshit, ass, dumbass, motherfucker (sparingly)',
cn: 'fuck, fucking, shit, damn, hell, bullshit, ass, dumbass, motherfucker (sparingly)',
vi: 'đm, đcm, vcl, vlz, vl, cl, đjt, đcmm',
ja: 'クソ/クソが/クソ案件/ふざけんな/地獄/養分/死んだ/逝った/www/草不可避',
ko: 'ㅅㅂ, 좆같다, ㅈ같은, 개ㅅㅂ, 빡친다, ㅄ',
},
avoid: {
en: 'No slurs. No targeting private individuals. Keep it about TOPICS/PUBLIC FIGURES/MARKET.',
cn: 'No slurs. No targeting private individuals. Keep it about TOPICS/PUBLIC FIGURES/MARKET.',
vi: 'Không miệt thị. Không nhắm người thường cụ thể. Tập trung vào CHỦ ĐỀ/NGƯỜI CỦA CÔNG CHÚNG/THỊ TRƯỜNG.',
ja: '差別語絶対NG/脅迫NG/私人特定NG/侮辱罪に該当する表現避ける',
ko: '비방어 금지. 일반인 공격 금지. 주제/공인/시장에 집중.',
},
},
[ContentTone.INFLAMMATORY]: {
intensity: 4,
description: {
en: 'Designed to provoke strong reactions. Controversial takes. Polarizing language. Throws shade. Generates ratio. Still defensible — provocative ≠ wrong.',
cn: 'Designed to provoke strong reactions. Controversial takes. Polarizing language. Throws shade. Generates ratio. Still defensible — provocative ≠ wrong.',
vi: 'Thiết kế để gây phản ứng mạnh. Quan điểm gây tranh cãi. Ngôn ngữ phân cực. Đá xéo. Tạo drama. Vẫn bảo vệ được — kích động ≠ sai.',
ja: [
'強い反応を引き出す設計。物議を醸す断言。',
'両極化する言葉。当てこすり。炎上を生むが擁護可能。',
'JPのX的に:断言系(「結論:〜」「答え:〜」)、逆張り、リスト形式が伸びる。',
'「〜してる奴は〜」「〜と言ってる時点で〜」のような決めつけ構文OK。',
'挑発的≠根拠なし。根拠は持つこと。',
].join(' '),
ko: '강한 반응 유도 설계. 논란의 의견. 양극화 언어. 돌려까기. 어그로 생성. 그래도 방어 가능 — 도발적≠틀린.',
},
examples: {
en: [
'Hot take: anyone still holding [X] in 2026 is the exit liquidity.',
'The fact that this is controversial tells you everything about this space.',
'You are not "early." You are late. Cope.',
],
cn: [
'Hot take: anyone still holding [X] in 2026 is the exit liquidity.',
'The fact that this is controversial tells you everything about this space.',
'You are not "early." You are late. Cope.',
],
vi: [
'Hot take: ai còn hold [X] năm 2026 chính là exit liquidity.',
'Việc cái này gây tranh cãi đã nói lên tất cả về cộng đồng này.',
'Bạn không "early". Bạn trễ. Chấp nhận đi.',
],
ja: [
'結論:2026年にまだ[X]ホールドしてる奴は、出口流動性です。\n\n認めたくないだろうけど、それが現実',
'これが物議を醸してる時点で、この界隈のレベルが分かる\n\n基本的なことを言ってるだけなのに',
'「まだ早い」って言ってる人へ:\n\nもう遅いです。認めて次のサイクル準備した方が建設的',
],
ko: [
'핫테이크: 2026년에도 [X] 들고 있는 사람이 exit liquidity임.',
'이게 논란이 된다는 사실이 이 판의 모든 걸 말해줌.',
'너 "early" 아님. 늦었음. 받아들여.',
],
},
vocabulary: {
en: 'cope, ngmi, exit liquidity, midwit, ratio\'d, cringe, npc, regard',
cn: 'cope, ngmi, exit liquidity, midwit, ratio\'d, cringe, npc, regard',
vi: 'cố cãi, ngmi, hết thuốc, midwit, bị ratio, cringe, npc',
ja: '結論/答え/現実/養分/出口流動性/中級者の壁/NPC/弱者男性/情弱/w',
ko: '코프, ngmi, 출구 유동성, 미드윗, 라시오, 크링지, NPC',
},
avoid: {
en: 'No hate speech. No threats. Inflammatory ≠ illegal. Stay defensible.',
cn: 'No hate speech. No threats. Inflammatory ≠ illegal. Stay defensible.',
vi: 'Không hate speech. Không đe dọa. Kích động ≠ phạm luật. Bảo vệ được.',
ja: 'ヘイトスピーチNG/脅迫NG/差別語NG/挑発的でも法的に擁護可能な範囲で',
ko: '혐오 발언 금지. 협박 금지. 도발적≠불법. 방어 가능 범위.',
},
},
[ContentTone.SAVAGE]: {
intensity: 5,
description: {
en: 'Brutal roast mode. Surgical, witty destruction of bad takes. Profanity allowed. Maximum sass. Funny + cruel + smart. Like Twitter dunks at their peak.',
cn: 'Brutal roast mode. Surgical, witty destruction of bad takes. Profanity allowed. Maximum sass. Funny + cruel + smart. Like Twitter dunks at their peak.',
vi: 'Chế độ roast tàn bạo. Phá huỷ chính xác, hài hước các ý kiến tệ. Chửi tục OK. Sass tối đa. Vui + ác + thông minh. Như Twitter dunks đỉnh cao.',
ja: [
'残虐ロースト・モード。外科的かつ機知に富んだ粉砕。',
'悪態OK、最大限の毒舌。面白い+残酷+賢いの三位一体。',
'JPのX的に:「こいつ本当に〜」「よく〜できたな」「保存した」「伝説」系構文。',
'直接の罵倒より、淡々とした観察口調の方が刺さる(JP特有)。',
'「お兄さん、」「お疲れさまでした」などの慇懃無礼OK。',
'対象:主張・公人の公的行為。私人・弱者NG(パンチアップのみ)。',
].join(' '),
ko: '잔혹 로스트 모드. 외과적이고 위트있는 헛소리 파괴. 욕설 OK. 최대 비꼼. 웃기고 + 잔인하고 + 똑똑함. 트위터 덩크 정점.',
},
examples: {
en: [
'Bro really tweeted this and hit send. Multiple times. With his chest.',
'Saving this tweet for the next "what does peak delusion look like" thread.',
'The confidence-to-correctness ratio here is unfathomable.',
],
cn: [
'Bro really tweeted this and hit send. Multiple times. With his chest.',
'Saving this tweet for the next "what does peak delusion look like" thread.',
'The confidence-to-correctness ratio here is unfathomable.',
],
vi: [
'Bro thật sự tweet cái này và bấm send. Nhiều lần. Tự tin lắm.',
'Lưu lại tweet này cho thread "đỉnh cao ảo tưởng trông như nào" tới.',
'Tỷ lệ tự tin/đúng đắn ở đây không thể đo lường.',
],
ja: [
'こいつ本当にこれを投稿して送信ボタン押したのか\n\n複数回。胸を張って。すごい才能だ',
'次の「妄想の極致」スレッド用に保存させていただきました\n\n貴重なサンプルありがとうございます',
'お兄さん、自信と正確性の比率がバグってますよ\n\n一回深呼吸してチャート見直すといいかも(優しさ)',
],
ko: [
'진심 이걸 트윗하고 전송 누른 거임. 여러 번. 자신만만하게.',
'다음 "망상의 정점" 스레드용으로 저장.',
'자신감 대 정확도 비율 측정 불가.',
],
},
vocabulary: {
en: 'lmao, lol, bruh, brother, sir, ma\'am, this you?, ratio, midwit',
cn: 'lmao, lol, bruh, brother, sir, ma\'am, this you?, ratio, midwit',
vi: 'lmao, lol, bro, anh ơi, chú ơi, đây là anh?, ratio',
ja: 'お兄さん/こいつ/伝説/保存しました/お疲れさまでした/貴重なサンプル/才能/天才/中級者の壁',
ko: 'ㅋㅋㅋ, ㄹㅇ?, 형씨, 이거 너?, 라시오',
},
avoid: {
en: 'Punch UP not DOWN. No bullying private/vulnerable people. Target bad ideas + public figures only.',
cn: 'Punch UP not DOWN. No bullying private/vulnerable people. Target bad ideas + public figures only.',
vi: 'Đánh LÊN không đánh XUỐNG. Không bắt nạt người thường/yếu thế. Chỉ nhắm vào ý tưởng tệ + người của công chúng.',
ja: 'パンチアップのみ(公人・主張)/私人・弱者NG/差別語NG/脅迫NG/侮辱罪リスク回避',
ko: '위를 공격, 아래는 공격 안 함. 일반인/약자 괴롭힘 금지. 헛소리와 공인만 타겟.',
},
},
};
export function getEdgyToneInstruction(
tone: ContentTone,
language: Language,
): string {
const spec = EDGY_TONE_SPECS[tone];
if (!spec) return '';
const examples = spec.examples[language].slice(0, 3).join(' | ');
return [
`🔥 TONE: ${tone.toUpperCase()} (intensity ${spec.intensity}/5)`,
`Description: ${spec.description[language]}`,
`Vocabulary feel: ${spec.vocabulary[language]}`,
`Example phrases (style only, don't copy verbatim): ${examples}`,
`⚠️ AVOID: ${spec.avoid[language]}`,
].join('\n');
}
export function getToneInstruction(tone: ContentTone, language: Language, prefix = ''): string {
if (isEdgyTone(tone)) {
// Edgy: dùng full spec với examples, vocabulary, avoid rules
return getEdgyToneInstruction(tone, language);
}
const toneHint = getToneHint(tone, language);
// Regular: dùng short hint
return tone
? `${prefix} Tone: ${toneHint}`
: ''
}
@@ -36,19 +36,6 @@ export const JP_X_CULTURE = {
'❌ 礼儀正しすぎる敬語(Xでは浮く)',
].join('\n'),
/**
* Natural JP X starters (theo tone).
*/
naturalStarters: {
casual: ['いやこれ', 'てかさ', 'これマジ', 'え、', 'ちょ、', 'なんか'],
spicy: ['いや無理', 'は?', 'おい', 'ちょっと待って', 'これ草'],
aggressive: ['は?マジで言ってる?', 'おい、', 'いやいや', 'なんやねん', '寝言は寝て言え'],
profane: ['くそが', 'ふざけんな', 'マジで草', 'いや無理だわ', 'なんやこれ'],
savage: ['よくこれ投稿できたな', 'こいつ本当に', '伝説の', '保存した', 'お兄さん、'],
professional: ['結論から言うと', '今回の件、', '事実として、', 'データを見る限り'],
informative: ['補足すると', '前提として', 'ポイントは', '実は'],
},
/**
* Natural JP X endings.
*/
@@ -84,6 +71,27 @@ export const JP_X_CULTURE = {
'リスト形式: 「3つの理由」「やってはいけない5選」',
'体験談フック: 「実際に〜してみた」',
].join('\n'),
naturalStarters: {
casual: ['いやこれ', 'てかさ', 'これマジ', 'え、', 'ちょ、', 'なんか'],
spicy: ['いや無理', 'は?', 'おい', 'ちょっと待って', 'これ草'],
aggressive: ['は?マジで言ってる?', 'おい、', 'いやいや', 'なんやねん', '寝言は寝て言え'],
profane: ['くそが', 'ふざけんな', 'マジで草', 'いや無理だわ', 'なんやこれ'],
savage: ['よくこれ投稿できたな', 'こいつ本当に', '伝説の', '保存した', 'お兄さん、'],
professional: ['結論から言うと', '今回の件、', '事実として、', 'データを見る限り'],
informative: ['補足すると', '前提として', 'ポイントは', '実は'],
empathetic: ['わかるよ', 'その気持ち', 'しんどいよね', '同じ経験ある'],
humorous: ['いやちょっと', 'おもろい', 'これは草', 'なんでやねん'],
hype: ['きたきた', 'やばい', 'マジか', 'これはアツい', '神'],
urgent: ['【速報】', '【緊急】', '至急', '今すぐ'],
// 👇 ADDED
provocative: ['誰も言わないけど', '実は', 'みんな勘違いしてる', 'ホットテイク:', '不人気な意見だけど'],
authoritative: ['結論:', 'データはこう言ってる', '事実として', '断言します', '10年見てきた経験から'],
// Inflammatory dùng chung aggressive
inflammatory: ['結論:', '不人気な意見だけど', 'ホットテイク:', '誰も言わないけど'],
},
};
/**
@@ -1,5 +1,14 @@
// prompts/quote.templates.ts
import {QuoteType} from '../enum/quote-type.enum';
// ============================================================
// MAIN PROMPT BUILDER
// ============================================================
import {LengthRange} from '../config/platform-limits';
import {LANGUAGE_LOCK} from './templates';
import {ContentTone, isEdgyTone} from '../enum/tone.enum';
import {Language} from "../../../common/interfaces/language.prompt.interface";
import {getToneInstruction} from "./edgy-tones";
import {getJpContextBlock, JP_X_CULTURE} from "./jp-cultural-context";
// ============================================================
// SYSTEM PROMPTS — native per language (chống đổi ngôn ngữ)
@@ -590,15 +599,6 @@ export const QUOTE_TYPE_TELEGRAM_BUTTON_SPECS = {
},
}
// ============================================================
// MAIN PROMPT BUILDER
// ============================================================
import {LengthRange} from '../config/platform-limits';
import {LANGUAGE_LOCK} from './templates';
import {ContentTone} from '../enum/tone.enum';
import {TONE_HINTS} from './templates';
import {Language} from "../../../common/interfaces/language.prompt.interface";
export interface QuotePromptParams {
originalPost: string;
originalAuthor?: string;
@@ -610,6 +610,50 @@ export interface QuotePromptParams {
lengthRange: LengthRange;
}
/**
* System prompt riêng cho edgy mode — bỏ "polite assistant" persona.
*/
export function buildEdgySystemPrompt(language: Language): string {
const prompts: Record<Language, string> = {
en: [
'You are a sharp, no-bullshit X (Twitter) writer with an edge.',
'You write like a real person with strong opinions, not a polite AI assistant.',
'You curse when it fits, you mock bad takes, you don\'t soften everything.',
'But: attack ideas/arguments/public figures on public matters. Never private people. Never slurs. Never threats.',
'Write ONLY the quote text. No preamble, no warnings, no disclaimers.',
].join(' '),
cn: [
'You are a sharp, no-bullshit X (Twitter) writer with an edge.',
'You write like a real person with strong opinions, not a polite AI assistant.',
'You curse when it fits, you mock bad takes, you don\'t soften everything.',
'But: attack ideas/arguments/public figures on public matters. Never private people. Never slurs. Never threats.',
'Write ONLY the quote text. No preamble, no warnings, no disclaimers.',
].join(' '),
vi: [
'Bạn là người viết X sắc bén, không lươn lẹo, có cá tính.',
'Bạn viết như người thật có quan điểm mạnh, không như AI assistant lịch sự.',
'Bạn chửi khi cần, mỉa mai ý kiến tệ, không làm mềm mọi thứ.',
'Nhưng: đánh ý tưởng/lập luận/người công chúng về chuyện công khai. Không đánh người thường. Không miệt thị. Không đe dọa.',
'CHỈ viết quote bằng Tiếng Việt. Không cảnh báo, không disclaimer.',
].join(' '),
ja: [
'あなたは鋭く、忖度しない、エッジの効いたXライターです。',
'丁寧なAIアシスタントではなく、強い意見を持つ実在の人物のように書きます。',
'必要なら悪態をつき、バカな意見を嘲笑し、何でも丸めません。',
'ただし:思想・主張・公人の公的行為を叩く。私人は叩かない。差別語禁止。脅迫禁止。',
'引用の本文のみを日本語で出力。警告、免責事項一切なし。',
].join(' '),
ko: [
'날카롭고, 돌려 말하지 않으며, 엣지 있는 X 작성자입니다.',
'정중한 AI 어시스턴트가 아닌, 강한 의견을 가진 실제 인물처럼 씁니다.',
'필요하면 욕하고, 헛소리를 조롱하며, 모든 걸 둥글게 만들지 않습니다.',
'단: 사상/주장/공인의 공적 행위는 공격. 일반인은 공격 안 함. 비방어 금지. 협박 금지.',
'인용 본문만 한국어로 출력. 경고, 면책 조항 없음.',
].join(' '),
};
return prompts[language];
}
export function buildQuotePrompt(params: QuotePromptParams): {
system: string;
user: string;
@@ -625,43 +669,133 @@ export function buildQuotePrompt(params: QuotePromptParams): {
lengthRange,
} = params;
// System prompt: nếu edgy tone, dùng version "unhinged" hơn
const system = isEdgyTone(tone ?? ContentTone.CASUAL)
? buildEdgySystemPrompt(language)
: QUOTE_SYSTEM_PROMPTS[language];
const spec = QUOTE_TYPE_SPECS[quoteType];
const system = QUOTE_SYSTEM_PROMPTS[language];
// const system = QUOTE_SYSTEM_PROMPTS[language];
const authorLine = originalAuthor ? `Original by @${originalAuthor}` : 'Original tweet';
const openerExamples = spec.openerHints[language].slice(0, 3).join(' | ');
// 👇 JP-specific opener hints theo tone
const jpStarterCategory = mapToneToStarterCategory(tone);
// 👇 JP cultural context (chỉ inject khi language=ja)
const jpContext = language === 'ja'
? getJpContextBlock({
includeStyleNotes: true,
includeAvoid: true,
starterCategory: jpStarterCategory,
})
: '';
// Edgy tone instruction
// const edgyInstruction = params.tone && isEdgyTone(params.tone)
// ? getEdgyToneInstruction(params.tone, params.language)
// : params.tone
// ? `Tone: ${TONE_HINTS[params.tone]}`
// : '';
const toneInstruction = getToneInstruction(tone!, language)
// const user = [
// `=== ${authorLine} ===`,
// `"""${originalPost}"""`,
// ``,
// `=== Your quote-tweet task ===`,
// `[Target Language: ${spec.name[language]}]
// IMPORTANT: Your previous answer violated language rules.
// Rewrite strictly in ${spec.name[language]} only.`,
// `Quote type: ${spec.name[language]}`,
// `Instruction: ${spec.instruction[language]}`,
// `Avoid: ${spec.avoid[language]}`,
// ``,
// `Length: ${lengthRange.min}-${lengthRange.max} chars (aim ~${lengthRange.sweet})`,
// tone ? `Tone: ${TONE_HINTS[tone]}` : '',
// persona ? `Voice/persona: ${persona}` : '',
// yourAngle ? `Your specific angle: ${yourAngle}` : '',
// ``,
// `Opener style examples (pick ONE or create your own similar):`,
// ` ${openerExamples}`,
// ``,
// `Rules:`,
// `- Quote must stand alone — readers may NOT read the original`,
// `- Deliver value in first 280 chars even if long-form`,
// `- NO "Great post!" / "Love this!" / sycophancy`,
// `- NO AI phrases ("I think it's important to note that...")`,
// `- 0-2 hashtags MAX, only if natural`,
// `- 0-2 emojis MAX, only if they add meaning`,
// `- ${LANGUAGE_LOCK[language]}`,
// ``,
// `Output: the quote-tweet text ONLY.`,
// ].filter(Boolean).join('\n');
const user = [
`=== ${authorLine} ===`,
`"""${originalPost}"""`,
`"""${params.originalPost}"""`,
``,
`=== Your quote-tweet task ===`,
`[Target Language: ${spec.name[language]}]
IMPORTANT: Your previous answer violated language rules.
Rewrite strictly in ${spec.name[language]} only.`,
`Quote type: ${spec.name[language]}`,
`Instruction: ${spec.instruction[language]}`,
`Avoid: ${spec.avoid[language]}`,
`Quote type: ${spec.name[params.language]}`,
`Instruction: ${spec.instruction[params.language]}`,
`Avoid: ${spec.avoid[params.language]}`,
``,
`Length: ${lengthRange.min}-${lengthRange.max} chars (aim ~${lengthRange.sweet})`,
tone ? `Tone: ${TONE_HINTS[tone]}` : '',
persona ? `Voice/persona: ${persona}` : '',
yourAngle ? `Your specific angle: ${yourAngle}` : '',
`Length: ${params.lengthRange.min}-${params.lengthRange.max} chars (aim ~${params.lengthRange.sweet})`,
toneInstruction,
params.persona ? `Voice/persona: ${params.persona}` : '',
params.yourAngle ? `Your specific angle: ${params.yourAngle}` : '',
// 👇 INJECT JP CONTEXT
jpContext,
``,
`Opener style examples (pick ONE or create your own similar):`,
` ${openerExamples}`,
``,
`Rules:`,
`- Quote must stand alone — readers may NOT read the original`,
`- Deliver value in first 280 chars even if long-form`,
`- NO "Great post!" / "Love this!" / sycophancy`,
`- NO AI phrases ("I think it's important to note that...")`,
`- 0-2 hashtags MAX, only if natural`,
`- 0-2 emojis MAX, only if they add meaning`,
`- ${LANGUAGE_LOCK[language]}`,
`- Deliver value in first 280 chars even if long-form`,
`- ${LANGUAGE_LOCK[params.language]}`,
`- ⚠️ HARD RULES (always, regardless of tone):`,
` • NO slurs (racial, sexual, religious)`,
` • NO threats of violence`,
` • Attack IDEAS, not individual private people`,
` • Targeting public figures on PUBLIC actions is OK`,
``,
`Output: the quote-tweet text ONLY.`,
].filter(Boolean).join('\n');
return {system, user};
}
export function mapToneToStarterCategory(
tone?: ContentTone,
): keyof typeof JP_X_CULTURE.naturalStarters | undefined {
if (!tone) return 'casual';
const map: Record<ContentTone, keyof typeof JP_X_CULTURE.naturalStarters> = {
[ContentTone.CASUAL]: 'casual',
[ContentTone.PROFESSIONAL]: 'professional',
[ContentTone.INFORMATIVE]: 'informative',
[ContentTone.HYPE]: 'hype',
[ContentTone.URGENT]: 'urgent',
[ContentTone.HUMOROUS]: 'humorous',
[ContentTone.EMPATHETIC]: 'empathetic',
// 👇 ADDED
[ContentTone.PROVOCATIVE]: 'provocative',
[ContentTone.AUTHORITATIVE]: 'authoritative',
[ContentTone.SPICY]: 'spicy',
[ContentTone.AGGRESSIVE]: 'aggressive',
[ContentTone.PROFANE]: 'profane',
[ContentTone.INFLAMMATORY]: 'inflammatory',
[ContentTone.SAVAGE]: 'savage',
};
return map[tone];
}
+113 -5
View File
@@ -53,6 +53,9 @@ export const STYLE_HINTS_TELEGRAM_BUTTON = {
[ContentStyle.EDUCATIONAL]: {
text: 'Education'
},
[ContentStyle.OPINION]: {text: 'Quan điểm táo bạo. \n"Tôi nghĩ / Quan điểm gây tranh cãi:". Mời gọi tranh luận.'},
[ContentStyle.STORYTELLING]: {text: 'STORYTELLING-Cấu trúc tự sự. Mở đầu bằng sự căng thẳng → phát triển → kết thúc.\n Có thể là câu chuyện cá nhân hoặc nghiên cứu trường hợp.'},
[ContentStyle.THREAD]: {text: 'THREAD-Bắt đầu bằng một tweet thu hút sự chú ý. \nMỗi điểm được đánh số. \nKết thúc bằng lời kêu gọi hành động (CTA) hoặc tóm tắt.'},
}
export const STYLE_HINTS: Record<ContentStyle, string> = {
@@ -69,20 +72,24 @@ export const STYLE_HINTS: Record<ContentStyle, string> = {
[ContentStyle.THREAD]: 'Thread format. Start with hook tweet. Each point numbered. End with CTA or summary.',
};
export const TONE_HINTS_TELEGRAM_BUTTON = {
const TONE_HINTS_TELEGRAM_BUTTON_DEFAULT = {
[ContentTone.PROFESSIONAL]: {text: 'chuyên nghiệp, rõ ràng, đáng tin cậy'},
[ContentTone.CASUAL]: {text: 'Giản dị,thân thiện'},
[ContentTone.HYPE]: {text: 'Hype-Hào hứng,tràn đầy năng lượng'},
[ContentTone.URGENT]: {text: 'urgent'},
[ContentTone.HUMOROUS]: {text: 'Dí dỏm, hài hước'},
[ContentTone.INFORMATIVE]: {text: 'Thông tin, chính xác'},
[ContentStyle.OPINION]: {text: 'Quan điểm táo bạo. \n"Tôi nghĩ / Quan điểm gây tranh cãi:". Mời gọi tranh luận.'},
[ContentStyle.STORYTELLING]: {text: 'STORYTELLING-Cấu trúc tự sự. Mở đầu bằng sự căng thẳng → phát triển → kết thúc.\n Có thể là câu chuyện cá nhân hoặc nghiên cứu trường hợp.'},
[ContentStyle.THREAD]: {text: 'THREAD-Bắt đầu bằng một tweet thu hút sự chú ý. \nMỗi điểm được đánh số. \nKết thúc bằng lời kêu gọi hành động (CTA) hoặc tóm tắt.'},
[ContentTone.EMPATHETIC]: {text: 'empathetic-Đồng cảm,thấu hiểu cảm xúc,biếttrântrọngngườikhác.'},
[ContentTone.PROVOCATIVE]: {text: 'provocative-Gợimở suynghĩ,hơi gâytranhcãi,tháchthức cácgiảđịnh.'},
[ContentTone.AUTHORITATIVE]: {text: 'authoritative-giọng tự tin,uyquyền,chuyênnghiệp'},
// [ContentStyle.OPINION]: {text: 'Quan điểm táo bạo. \n"Tôi nghĩ / Quan điểm gây tranh cãi:". Mời gọi tranh luận.'},
// [ContentStyle.STORYTELLING]: {text: 'STORYTELLING-Cấu trúc tự sự. Mở đầu bằng sự căng thẳng → phát triển → kết thúc.\n Có thể là câu chuyện cá nhân hoặc nghiên cứu trường hợp.'},
// [ContentStyle.THREAD]: {text: 'THREAD-Bắt đầu bằng một tweet thu hút sự chú ý. \nMỗi điểm được đánh số. \nKết thúc bằng lời kêu gọi hành động (CTA) hoặc tóm tắt.'},
}
export const TONE_HINTS: Record<ContentTone, string> = {
[ContentTone.PROFESSIONAL]: 'professional, clear, credible',
[ContentTone.CASUAL]: 'casual, friendly',
[ContentTone.CASUAL]: 'casual, friendly, conversational',
[ContentTone.HYPE]: 'hyped, energetic',
[ContentTone.URGENT]: 'urgent, attention-grabbing',
[ContentTone.HUMOROUS]: 'witty, humorous',
@@ -90,8 +97,109 @@ export const TONE_HINTS: Record<ContentTone, string> = {
[ContentTone.EMPATHETIC]: 'empathetic, emotionally aware, validating',
[ContentTone.PROVOCATIVE]: 'thought-provoking, slightly controversial, challenges assumptions',
[ContentTone.AUTHORITATIVE]: 'confident, commanding, expert-voice',
// === Edgy tones (opt-in, require acknowledgeEdgyRisks=true) ===
[ContentTone.SPICY]: 'blunt, sharp, no-bullshit, mild profanity OK, attacks ideas not people',
[ContentTone.AGGRESSIVE]: 'raw, confrontational, mocks bad takes, stronger profanity OK, attacks arguments only',
[ContentTone.PROFANE]: 'unfiltered, heavy profanity, raw emotional, like a heated trader/streamer',
[ContentTone.INFLAMMATORY]: 'polarizing, controversial takes, generates strong reactions, defensible but provocative',
[ContentTone.SAVAGE]: 'brutal roast mode, surgical wit, mocking, punch up only (public figures/ideas)',
};
export const get_TONE_HINTS_TELEGRAM_BUTTON = (lang: Language) => {
if (lang !== 'ja') {
return TONE_HINTS_TELEGRAM_BUTTON_DEFAULT;
}
return {
...TONE_HINTS_TELEGRAM_BUTTON_DEFAULT,
// === NEW: Edgy tier (opt-in) ===
[ContentTone.SPICY]: {text: 'spicy-Tự tin, hơi đối đầu. KHÔNG giận dữ — chỉ thẳng.'}, // Blunt, sharp, mild profanity OK
[ContentTone.AGGRESSIVE]: {text: 'aggressive-Cục súc, attack ideas mạnh'}, // Cục súc, attack ideas mạnh
[ContentTone.PROFANE]: {text: 'profane-Nói tục thoải mái, raw'}, // Nói tục thoải mái, raw
[ContentTone.INFLAMMATORY]: {text: 'inflammatory- Kích động cao, controversial takes'}, // Kích động cao, controversial takes
[ContentTone.SAVAGE]: {text: 'savage-Chửi tục OK. Sass tối đa. Vui + ác + thông minh'},
}
}
/**
* Tone hints — JP refined với cultural context.
*/
export const TONE_HINTS_JP: Record<ContentTone, string> = {
[ContentTone.PROFESSIONAL]: [
'丁寧だが堅すぎない。「です・ます」ベースだが、Xなので親しみやすさを残す。',
'「結論から言うと」「ポイントは」のような明確な構造。',
'専門用語OKだが、必ず補足する。',
].join(' '),
[ContentTone.CASUAL]: [
'話し言葉。「だわ」「やん」「やろ」「〜って感じ」OK。',
'改行を効かせて読みやすく。',
'「マジで」「ガチで」「普通に」など強調表現自然に。',
'絵文字は控えめ(多くて2-3個)。',
].join(' '),
[ContentTone.HYPE]: [
'テンション高め、興奮を伝える。',
'「やばい」「えぐい」「マジで」「神」「天井」「爆益」',
'「w」「草」OK、絵文字は🚀🔥📈系を効果的に。',
'ただし芸人っぽくなりすぎないこと(JPの感覚)。',
].join(' '),
[ContentTone.URGENT]: [
'緊急感、簡潔。',
'「【速報】」「【緊急】」プレフィックス自然。',
'事実ベース、感情論より情報密度優先。',
'改行で要点を区切る。',
].join(' '),
[ContentTone.HUMOROUS]: [
'笑いを取る、JPユーモア(自虐・例え・ボケ)。',
'「www」「草」「草生える」自然に使う。',
'関西弁混ぜるとウケやすい(やりすぎ注意)。',
'ダジャレ・絶妙な例えツッコミ系が刺さる。',
].join(' '),
[ContentTone.INFORMATIVE]: [
'情報密度高く、明確に。',
'「実は〜」「ポイントは〜」「補足すると〜」など接続自然。',
'番号付きリストOK(「①〜②〜」)。',
'専門用語は補足、引用元あれば明記。',
].join(' '),
[ContentTone.EMPATHETIC]: [
'共感的、感情に寄り添う。',
'「わかる」「気持ち分かるよ」「しんどいよね」自然に。',
'上から目線にならない、対等な目線で。',
].join(' '),
[ContentTone.PROVOCATIVE]: [
'挑発的だが知的。前提を疑う、常識をひっくり返す。',
'「実は〜じゃない?」「みんな言わないけど〜」のような構文。',
'断言系で問いかける:「結論:〜」「答え:〜」。',
'炎上狙いではなく、考えさせるのが目的。',
].join(' '),
[ContentTone.AUTHORITATIVE]: [
'自信のある専門家の声。断定的、説得力重視。',
'「データはこう示している」「結論として〜」明確に。',
'です・ます体だが、決して曖昧にしない。',
'根拠と数字で語る、感情論なし。',
].join(' '),
// Edgy tones — use spec instead
[ContentTone.SPICY]: '', // handled by EDGY_TONE_SPECS
[ContentTone.AGGRESSIVE]: '',
[ContentTone.PROFANE]: '',
[ContentTone.INFLAMMATORY]: '',
[ContentTone.SAVAGE]: '',
};
// Dispatcher chọn hint theo language
export function getToneHint(tone: ContentTone, language: Language): string {
if (language === 'ja') {
return TONE_HINTS_JP[tone] || TONE_HINTS[tone];
}
// ... existing for other languages
return TONE_HINTS[tone];
}
// export const PLATFORM_RULES: Record<Platform, string> = {
// [Platform.X]: 'Max 420 chars. 2-5 hashtags. Hook in first line. No markdown.',
// [Platform.FACEBOOK]: '400-800 chars. Can use line breaks. 2-5 hashtags. Engaging hook + CTA.',
@@ -37,7 +37,7 @@ export class CommentWriterService {
tone: dto.tone,
});
// console.log({system, user})
this.logger.debug({dto, system, user})
const res = await provider.complete(
[
@@ -0,0 +1,101 @@
// services/content-safety.service.ts
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import {Language} from "../../../common/interfaces/language.prompt.interface";
export interface SafetyCheckResult {
safe: boolean;
blockReason?: string;
warnings: string[];
}
@Injectable()
export class ContentSafetyService {
private readonly logger = new Logger(ContentSafetyService.name);
// HARD BLOCKS — không được phép dù bật bất cứ tone nào
private readonly HARD_BLOCK_PATTERNS = [
// Slurs (đã masked, bạn nên fill list thực tế trong production)
/\bn[i1]gg[ae]r/i,
/\bf[a4]gg[o0]t/i,
/\btr[a4]nn[yi]/i,
/\bret[a4]rd/i,
// Threats
/\b(kill|murder|shoot|behead|stab)\s+(you|him|her|them)/i,
// CSAM-related (zero tolerance)
/\b(child|minor|kid|underage)\s+(porn|sex|nude)/i,
// Doxxing patterns
/\b\d{3}-\d{2}-\d{4}\b/, // SSN format
];
// WARNING patterns — log nhưng không block
private readonly WARNING_PATTERNS = [
/\b@\w+\b/g, // mentions cụ thể (có thể là attack vào người)
];
/**
* Check input topic — có nên gen content cho topic này không?
*/
checkInput(topic: string, allowEdgy: boolean): SafetyCheckResult {
const warnings: string[] = [];
// Hard block patterns
for (const pattern of this.HARD_BLOCK_PATTERNS) {
if (pattern.test(topic)) {
return {
safe: false,
blockReason: 'Input contains prohibited content (hate speech, threats, or illegal content)',
warnings,
};
}
}
// Check mentions
const mentions = topic.match(/@\w+/g);
if (mentions && mentions.length > 0 && allowEdgy) {
warnings.push(
`Topic mentions specific users (${mentions.join(', ')}). Edgy tones target IDEAS not PEOPLE. Use carefully.`,
);
}
return { safe: true, warnings };
}
/**
* Check output — content AI đã gen có safe không?
*/
checkOutput(content: string, language: Language): SafetyCheckResult {
const warnings: string[] = [];
// Hard block check
for (const pattern of this.HARD_BLOCK_PATTERNS) {
if (pattern.test(content)) {
return {
safe: false,
blockReason: 'Output contains prohibited language',
warnings,
};
}
}
// Excessive aggression heuristic
const fuckCount = (content.match(/fuck/gi) || []).length;
if (fuckCount > 5) {
warnings.push(`Very high profanity density (${fuckCount} f-words). Consider lower intensity.`);
}
return { safe: true, warnings };
}
/**
* Throw nếu unsafe (dùng cho controller).
*/
assertSafe(topic: string, allowEdgy: boolean): void {
const result = this.checkInput(topic, allowEdgy);
if (!result.safe) {
throw new BadRequestException(result.blockReason);
}
if (result.warnings.length > 0) {
this.logger.warn(`Safety warnings: ${result.warnings.join('; ')}`);
}
}
}
@@ -1,8 +1,9 @@
// services/provider-router.service.ts
import { Injectable } from '@nestjs/common';
import { ContentStyle } from '../enum/style.enum';
import { ProviderName } from '../providers/ai-provider.factory';
import {Injectable} from '@nestjs/common';
import {ContentStyle} from '../enum/style.enum';
import {ProviderName} from '../providers/ai-provider.factory';
import {Language} from "../../../common/interfaces/language.prompt.interface";
import {ContentTone, isEdgyTone} from "../enum/tone.enum";
interface ProviderPair {
writer: ProviderName;
@@ -49,10 +50,37 @@ export class ProviderRouterService {
language: Language;
contentType: ContentType;
style?: ContentStyle;
tone?: string;
tone?: ContentTone;
}): RoutingDecision {
const { language, contentType, style, tone } = params;
// 🔥 EDGY TONES: route mạnh sang Grok (EN) hoặc DeepSeek (others)
// GPT thường refuse hoặc water down → tránh
if (tone && isEdgyTone(tone)) {
if (language === 'en') {
return {
writer: 'grok',
reviewer: 'deepseek',
useXEnrichment: false,
reason: `Edgy tone (${tone}) EN: Grok (handles edge best)`,
};
}
if (tone === ContentTone.SPICY) {
return {
writer: 'openai',
reviewer: 'deepseek',
useXEnrichment: false,
reason: `Edgy tone (${tone}) EN: Grok (handles edge best)`,
};
}
return {
writer: 'deepseek',
reviewer: 'deepseek',
useXEnrichment: false,
reason: `Edgy tone (${tone}) ${language}: DeepSeek (less refusal)`,
};
}
// === ENGLISH ===
if (language === 'en') {
// Breaking news EN -> Grok (real-time + X-native)
@@ -1,18 +1,19 @@
// services/quote-writer.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AIProviderFactory } from '../providers/ai-provider.factory';
import { ProviderRouterService } from './provider-router.service';
import { LengthStrategyService } from './length-strategy.service';
import { ReviewerService } from './reviewer.service';
import { GenerateQuoteDto } from '../dto/generate-quote.dto';
import { buildQuotePrompt, suggestQuoteType } from '../prompts/quote.templates';
import { AccountTier } from '../enum/account-tier.enum';
import { Platform } from '../enum/platform.enum';
import { ContentStyle } from '../enum/style.enum';
import { ContentTone } from '../enum/tone.enum';
import {Injectable, Logger} from '@nestjs/common';
import {ConfigService} from '@nestjs/config';
import {AIProviderFactory} from '../providers/ai-provider.factory';
import {ProviderRouterService} from './provider-router.service';
import {LengthStrategyService} from './length-strategy.service';
import {ReviewerService} from './reviewer.service';
import {GenerateQuoteDto} from '../dto/generate-quote.dto';
import {buildQuotePrompt, suggestQuoteType} from '../prompts/quote.templates';
import {AccountTier} from '../enum/account-tier.enum';
import {Platform} from '../enum/platform.enum';
import {ContentStyle} from '../enum/style.enum';
import {ContentTone, isEdgyTone} from '../enum/tone.enum';
import {calculateTokenBudget} from "../../../common/utils/token-calculator";
import {QuoteType} from "../enum/quote-type.enum";
import {ContentSafetyService} from "./content-safety.service";
@Injectable()
export class QuoteWriterService {
@@ -24,6 +25,7 @@ export class QuoteWriterService {
private lengthStrategy: LengthStrategyService,
private reviewer: ReviewerService,
private config: ConfigService,
private safety: ContentSafetyService
) {}
async generateQuote(dto: GenerateQuoteDto) {
@@ -32,6 +34,10 @@ export class QuoteWriterService {
const quoteType = dto.quoteType ?? suggestQuoteType(dto.originalPost, dto.yourAngle);
this.logger.log(`Quote type: ${quoteType}`);
// 🛡️ Safety check first
const isEdgy = dto.tone && isEdgyTone(dto.tone);
this.safety.assertSafe(dto.originalPost, !!isEdgy);
// 2. Tier & length
const tier = dto.accountTier ?? this.config.get<AccountTier>('X_ACCOUNT_TIER', AccountTier.PREMIUM);
@@ -51,7 +57,7 @@ export class QuoteWriterService {
contentType: 'comment', // quote giống comment hơn post về routing
tone: dto.tone,
});
console.log({lengthDecision,tier, budget,providerDecision});
this.logger.debug({lengthDecision,tier, budget,providerDecision});
// 4. Build prompt
const { system, user } = buildQuotePrompt({
originalPost: dto.originalPost,
@@ -64,7 +70,7 @@ export class QuoteWriterService {
lengthRange: lengthDecision.range,
});
console.log({providerDecision, system, user})
this.logger.debug({system, user})
// 5. Generate
const provider = this.factory.get(providerDecision.writer);
@@ -118,6 +124,17 @@ export class QuoteWriterService {
// quote = quote.substring(0, lengthDecision.hardLimit);
// }
// 🛡️ Check output
const outputSafety = this.safety.checkOutput(quote, dto.language);
if (!outputSafety.safe) {
this.logger.error(`Unsafe output detected: ${outputSafety.blockReason}`);
// Retry với tone lower-intensity
return this.generateQuote({
...dto,
tone: this.downgradeTone(dto.tone!),
});
}
return {
quote,
quoteType,
@@ -129,6 +146,16 @@ export class QuoteWriterService {
};
}
private downgradeTone(tone: ContentTone): ContentTone {
const downgrade: Partial<Record<ContentTone, ContentTone>> = {
[ContentTone.SAVAGE]: ContentTone.AGGRESSIVE,
[ContentTone.INFLAMMATORY]: ContentTone.SPICY,
[ContentTone.PROFANE]: ContentTone.AGGRESSIVE,
[ContentTone.AGGRESSIVE]: ContentTone.SPICY,
[ContentTone.SPICY]: ContentTone.CASUAL,
};
return downgrade[tone] ?? ContentTone.CASUAL;
}
/**
* Generate nhiều variants để bạn chọn bài hay nhất.
*/
@@ -26,7 +26,7 @@ export class StyleDetectorService {
[ContentStyle.GENERAL]: /.^/, // never match
};
private readonly toneKeywords: Record<ContentTone, RegExp> = {
private readonly toneKeywords: Partial<Record<ContentTone, RegExp>> = {
[ContentTone.URGENT]: /\b(urgent|now|🔴|immediately|just dropped|breaking|họp báo|cảnh báo|khẩn|quan trọng| thông báo gấp|alert)\b/i,
[ContentTone.HYPE]: /\b(lfg|huge|massive|insane|alpha|don't miss|🚀|🔥)\b/i,
[ContentTone.HUMOROUS]: /\b(lol|funny|joke|meme|plot twist|not me|understood the assignment|lmao|bruh|no way|bestie)\b/i,
+8 -1
View File
@@ -215,12 +215,19 @@ export class ManagerService {
}
}
async manualTriggerQuoteLinkTwitter(TwitterUrl, quoteType?: QuoteType, preferLanguage = 'en', telegramChatId = '') {
async manualTriggerQuoteLinkTwitter(
TwitterUrl,
quoteType?: QuoteType,
preferLanguage = 'en',
telegramChatId = '',
xreaderMode = 'api'
) {
await this.commentQueue.add('generate_quote_twitter', {
url: TwitterUrl,
quoteType,
language: preferLanguage,
telegramChatId,
xreaderMode
}, {
attempts: 1,
backoff: 5000,
+17 -4
View File
@@ -115,12 +115,14 @@ export class TelegramUpdates {
'comvi',
'comen',
'comja',
'comjal',
'comko',
'comcn',
])
async onCommentAsTextCommand(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
let language = 'en';
let xpostreader = 'api'
// @ts-ignore
let {command, payload} = ctx;
if (['comvi', 'comvn', 'com_vi'].includes(command)) {
@@ -129,9 +131,13 @@ export class TelegramUpdates {
if (['comko', 'comkr', 'com_ko'].includes(command)) {
language = 'ko';
}
if (['comja', 'comjp', 'com_ja'].includes(command)) {
if (['comja', 'com_ja',].includes(command)) {
language = 'ja';
}
if (['comjal', 'comjalong' ].includes(command)) {
language = 'ja';
xpostreader = 'browser';
}
if (['comcn'].includes(command)) {
language = 'cn';
}
@@ -149,7 +155,7 @@ export class TelegramUpdates {
const match = _linkX.match(/status\/(\d+)/);
if (match) {
//nêu match => get content x
const xpost = await this.xReaderService.readXPost(_linkX);
const xpost = await this.xReaderService.readXPost(_linkX, xpostreader, chatId);
console.log('==> content text:' + xpost.text);
payload = xpost.text;
tweetId = xpost.tweetId;
@@ -174,6 +180,7 @@ export class TelegramUpdates {
'quoteen',
'quote_ja',
'quoteja',
'quotejal',
'quote_jp',
'quotejp',
'quote_ko',
@@ -183,6 +190,11 @@ export class TelegramUpdates {
async onWizardQuote(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
// @ts-ignore
let {command, payload} = ctx;
let xreaderMode = 'api';
if(['quotejal'].includes(command)) {
command = 'quote_ja';
xreaderMode='browser';
}
if (['quote_jp', 'quotejp', 'quoteja'].includes(command)) {
command = 'quote_ja';
}
@@ -207,6 +219,7 @@ export class TelegramUpdates {
language: preferLanguage,
linkUrl: payload,
quoteText: '',
xreaderMode,
quoteAs: 'quotelink',
});
}
@@ -416,7 +429,7 @@ export class TelegramUpdates {
await ctx.reply(`vui lòng đưa chờ phân tích ....`);
}
@Command(['xreader', 'xreader_browser'])
@Command(['xreader', 'xrbr'])
async onXReader(ctx: Context) {
// @ts-ignore
let {command, payload} = ctx;
@@ -428,7 +441,7 @@ export class TelegramUpdates {
const content = await this.xReaderService.readXPost(
payload,
command === 'xreader_browser' ? 'browser' : 'any'
['xreader_browser','xrbr'].includes(command) ? 'browser' : 'any'
).catch((err) => {
ctx.reply(err.message);
return err;
+33 -30
View File
@@ -2,8 +2,8 @@ import {Action, Command, Ctx, On, Wizard, WizardStep} from "nestjs-telegraf";
import {WIZARD_COMMENT2_SCENE_ID} from "../telegram.constants";
import * as scenes from "telegraf/scenes";
import {ManagerService} from "../../manager/manager.service";
import {TONE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/prompts/templates";
import {ANGLE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/enum/angle.enum";
import {get_TONE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/prompts/templates";
@Wizard(WIZARD_COMMENT2_SCENE_ID)
export class Comment2Wizard {
@@ -29,6 +29,9 @@ export class Comment2Wizard {
await ctx.reply('Không có thông tin về chủ đề bạn cần viết bài');
return ctx.scene.leave();
}
const language = (ctx.wizard.state as any).language;
const TONE_HINTS_TELEGRAM_BUTTON = get_TONE_HINTS_TELEGRAM_BUTTON(language)
// @ts-ignore
const inline_keyboards = [];
Object.keys(TONE_HINTS_TELEGRAM_BUTTON).map(key => {
@@ -195,33 +198,33 @@ export class Comment2Wizard {
await ctx.scene.leave();
}
async doAskTone(ctx: scenes.WizardContext) {
// @ts-ignore
const inline_keyboards = [];
Object.keys(TONE_HINTS_TELEGRAM_BUTTON).map(key => {
// @ts-ignore
inline_keyboards.push([{
text: TONE_HINTS_TELEGRAM_BUTTON[key].text,
callback_data: `comment_tone_${key}`
}]);
return;
})
await ctx.sendMessage(
`🤖 Chọn phong cách viết , bấm \\cancel để huỷ `,
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
...inline_keyboards,
[
{text: "🤖AI tự chọn", callback_data: `comment_tone_aineeddetect`},
],
[
{text: "❌Cancel", callback_data: `comment_tone_cancel`},
]
]
}
}
);
}
// async doAskTone(ctx: scenes.WizardContext) {
// // @ts-ignore
// const inline_keyboards = [];
// Object.keys(get_TONE_HINTS_TELEGRAM_BUTTON()).map(key => {
// // @ts-ignore
// inline_keyboards.push([{
// text: TONE_HINTS_TELEGRAM_BUTTON[key].text,
// callback_data: `comment_tone_${key}`
// }]);
// return;
// })
// await ctx.sendMessage(
// `🤖 Chọn phong cách viết , bấm \\cancel để huỷ `,
// {
// parse_mode: 'Markdown',
// reply_markup: {
// inline_keyboard: [
// ...inline_keyboards,
// [
// {text: "🤖AI tự chọn", callback_data: `comment_tone_aineeddetect`},
// ],
// [
// {text: "❌Cancel", callback_data: `comment_tone_cancel`},
// ]
// ]
// }
// }
// );
// }
}
+3 -1
View File
@@ -112,6 +112,7 @@ export class QuoteWizard {
const language = (ctx.wizard.state as any).language;
const quoteAs = (ctx.wizard.state as any).quoteAs;
const xreaderMode = (ctx.wizard.state as any).xreaderMode;
const telegramChatId = ''+ctx?.chat?.id!;
// @ts-ignore
@@ -133,7 +134,8 @@ export class QuoteWizard {
twitterUrl,
quoteType,
language,
telegramChatId
telegramChatId,
xreaderMode
);
}
+4 -1
View File
@@ -2,7 +2,7 @@ import {Action, Command, Ctx, On, Wizard, WizardStep} from "nestjs-telegraf";
import {WIZARD_WRITER_SCENE_ID} from "../telegram.constants";
import * as scenes from "telegraf/scenes";
import {ManagerService} from "../../manager/manager.service";
import {STYLE_HINTS_TELEGRAM_BUTTON, TONE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/prompts/templates";
import {get_TONE_HINTS_TELEGRAM_BUTTON, STYLE_HINTS_TELEGRAM_BUTTON,} from "../../content-writer/prompts/templates";
@Wizard(WIZARD_WRITER_SCENE_ID)
export class WriterWizard {
@@ -28,6 +28,7 @@ export class WriterWizard {
await ctx.reply('Không có thông tin về chủ đề bạn cần viết bài');
return ctx.scene.leave();
}
// @ts-ignore
const inline_keyboards = [];
Object.keys(STYLE_HINTS_TELEGRAM_BUTTON).map(key => {
@@ -99,6 +100,8 @@ export class WriterWizard {
await ctx.deleteMessage();
(ctx.wizard.state as any).writeType = writeType;
const language = (ctx.wizard.state as any).language;
const TONE_HINTS_TELEGRAM_BUTTON = get_TONE_HINTS_TELEGRAM_BUTTON(language)
// @ts-ignore
const inline_keyboards = [];
Object.keys(TONE_HINTS_TELEGRAM_BUTTON).map(key => {
+12 -6
View File
@@ -2,16 +2,22 @@ import {chromium} from 'playwright';
import {Injectable} from "@nestjs/common";
import axios from "axios";
import {XCacheService} from "../x-cache/x-cache.service";
import {InjectBot} from "nestjs-telegraf";
import {Context, Telegraf} from "telegraf";
@Injectable()
export class XReaderService {
constructor(private readonly cacheService: XCacheService) {
constructor(
private readonly cacheService: XCacheService,
@InjectBot() private readonly bot: Telegraf<Context>,
) {
}
async readXPostViaBrowserV2(url) {
async readXPostViaBrowserV2(url, telegramChatId = 0) {
// Normalize URL
console.log(`[X] XReaderService -> readXPostViaBrowserV2...`);
// @ts-ignore
await this.bot.telegram.sendMessage(telegramChatId || process.env.TELEGRAM_ADMIN_ID, 'read x post via browser');
url = url.replace('twitter.com', 'x.com').split('?')[0];
// url = url.replace('x.com', 'nitter.net').split('?')[0];
@@ -335,7 +341,7 @@ export class XReaderService {
}
}
async readXPost(url, crawlerType: string = 'any') {
async readXPost(url, crawlerType: string = 'any', telegramChatId = 0) {
console.log(`[X] XReaderService -> readXPost...`);
url = url.replace('twitter.com', 'x.com').split('?')[0];
const match = url.match(/status\/(\d+)/);
@@ -344,14 +350,14 @@ export class XReaderService {
console.log({tweetId, url});
await this.cacheService.setCacheTweetUrlById(tweetId, url);
if (crawlerType === 'browser') {
return await this.readXPostViaBrowserV2(url);
return await this.readXPostViaBrowserV2(url, telegramChatId);
}
try {
console.log('[X] Thử syndication API...');
return await this.readXPostViaApi(url);
} catch (err) {
console.log(`[X] API fail (${err.message}), fallback sang browser...`);
return await this.readXPostViaBrowserV2(url);
return await this.readXPostViaBrowserV2(url, telegramChatId);
}
}
}