feat(x-bot): 리트윗 옵션 및 고정 트윗 제외 기능 추가
- include_retweets 옵션으로 리트윗 포함 여부 설정 가능 - 고정된 트윗(pinned)은 기본적으로 파싱에서 제외 - XBotDialog에서 Twitter 아이콘을 X 아이콘으로 변경 - schedule_x의 username을 source_name으로 활용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9ceef6c656
commit
5d44434e36
7 changed files with 94 additions and 30 deletions
|
|
@ -69,6 +69,7 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
? JSON.parse(row.text_filters)
|
? JSON.parse(row.text_filters)
|
||||||
: row.text_filters)
|
: row.text_filters)
|
||||||
: [],
|
: [],
|
||||||
|
includeRetweets: row.include_retweets === 1,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const xBotResponse = {
|
||||||
display_name: { type: 'string' },
|
display_name: { type: 'string' },
|
||||||
avatar_url: { type: 'string' },
|
avatar_url: { type: 'string' },
|
||||||
text_filters: { type: 'array', items: { type: 'string' } },
|
text_filters: { type: 'array', items: { type: 'string' } },
|
||||||
|
include_retweets: { type: 'boolean' },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
|
@ -40,6 +41,7 @@ function formatBotResponse(row) {
|
||||||
? JSON.parse(row.text_filters)
|
? JSON.parse(row.text_filters)
|
||||||
: row.text_filters)
|
: row.text_filters)
|
||||||
: [],
|
: [],
|
||||||
|
include_retweets: row.include_retweets === 1,
|
||||||
cron_interval: row.cron_interval,
|
cron_interval: row.cron_interval,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
};
|
};
|
||||||
|
|
@ -157,6 +159,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
display_name: { type: ['string', 'null'] },
|
display_name: { type: ['string', 'null'] },
|
||||||
avatar_url: { type: ['string', 'null'] },
|
avatar_url: { type: ['string', 'null'] },
|
||||||
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
|
include_retweets: { type: 'boolean', default: false },
|
||||||
cron_interval: { type: 'integer', default: 1 },
|
cron_interval: { type: 'integer', default: 1 },
|
||||||
},
|
},
|
||||||
required: ['username'],
|
required: ['username'],
|
||||||
|
|
@ -173,6 +176,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
text_filters,
|
text_filters,
|
||||||
|
include_retweets = false,
|
||||||
cron_interval = 1,
|
cron_interval = 1,
|
||||||
} = request.body;
|
} = request.body;
|
||||||
|
|
||||||
|
|
@ -186,13 +190,14 @@ export default async function xBotsRoutes(fastify) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
`INSERT INTO bot_x (username, display_name, avatar_url, text_filters, cron_interval, enabled)
|
`INSERT INTO bot_x (username, display_name, avatar_url, text_filters, include_retweets, cron_interval, enabled)
|
||||||
VALUES (?, ?, ?, ?, ?, 1)`,
|
VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||||
[
|
[
|
||||||
username,
|
username,
|
||||||
display_name || null,
|
display_name || null,
|
||||||
avatar_url || null,
|
avatar_url || null,
|
||||||
text_filters && text_filters.length > 0 ? JSON.stringify(text_filters) : null,
|
text_filters && text_filters.length > 0 ? JSON.stringify(text_filters) : null,
|
||||||
|
include_retweets ? 1 : 0,
|
||||||
cron_interval,
|
cron_interval,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -209,6 +214,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
username,
|
username,
|
||||||
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
||||||
textFilters: text_filters || [],
|
textFilters: text_filters || [],
|
||||||
|
includeRetweets: include_retweets,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 전체 동기화 (async, 응답 대기하지 않음)
|
// 전체 동기화 (async, 응답 대기하지 않음)
|
||||||
|
|
@ -248,6 +254,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
display_name: { type: ['string', 'null'] },
|
display_name: { type: ['string', 'null'] },
|
||||||
avatar_url: { type: ['string', 'null'] },
|
avatar_url: { type: ['string', 'null'] },
|
||||||
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
|
include_retweets: { type: 'boolean' },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
|
@ -286,6 +293,10 @@ export default async function xBotsRoutes(fastify) {
|
||||||
? JSON.stringify(updates.text_filters)
|
? JSON.stringify(updates.text_filters)
|
||||||
: null);
|
: null);
|
||||||
}
|
}
|
||||||
|
if (updates.include_retweets !== undefined) {
|
||||||
|
fields.push('include_retweets = ?');
|
||||||
|
values.push(updates.include_retweets ? 1 : 0);
|
||||||
|
}
|
||||||
if (updates.cron_interval !== undefined) {
|
if (updates.cron_interval !== undefined) {
|
||||||
fields.push('cron_interval = ?');
|
fields.push('cron_interval = ?');
|
||||||
values.push(updates.cron_interval);
|
values.push(updates.cron_interval);
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ function formatScheduleResponse(hit) {
|
||||||
if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) {
|
if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) {
|
||||||
source = { name: hit.source_name, url: null };
|
source = { name: hit.source_name, url: null };
|
||||||
} else if (hit.category_id === CATEGORY_IDS.X) {
|
} else if (hit.category_id === CATEGORY_IDS.X) {
|
||||||
source = { name: '', url: null };
|
source = { name: hit.source_name || '', url: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -217,11 +217,12 @@ export async function syncScheduleById(meilisearch, db, scheduleId) {
|
||||||
s.category_id,
|
s.category_id,
|
||||||
c.name as category_name,
|
c.name as category_name,
|
||||||
c.color as category_color,
|
c.color as category_color,
|
||||||
sy.channel_name as source_name,
|
COALESCE(sy.channel_name, sx.username) as source_name,
|
||||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||||
FROM schedules s
|
FROM schedules s
|
||||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||||
|
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||||
WHERE s.id = ?
|
WHERE s.id = ?
|
||||||
|
|
@ -290,11 +291,12 @@ export async function syncAllSchedules(meilisearch, db) {
|
||||||
s.category_id,
|
s.category_id,
|
||||||
c.name as category_name,
|
c.name as category_name,
|
||||||
c.color as category_color,
|
c.color as category_color,
|
||||||
sy.channel_name as source_name,
|
COALESCE(sy.channel_name, sx.username) as source_name,
|
||||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||||
FROM schedules s
|
FROM schedules s
|
||||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||||
|
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||||
GROUP BY s.id
|
GROUP BY s.id
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function buildDatetime(date, time) {
|
||||||
* @returns {object|null} { name, url } 또는 null
|
* @returns {object|null} { name, url } 또는 null
|
||||||
*/
|
*/
|
||||||
export function buildSource(schedule) {
|
export function buildSource(schedule) {
|
||||||
const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule;
|
const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id, x_username } = schedule;
|
||||||
|
|
||||||
if (category_id === CATEGORY_IDS.YOUTUBE) {
|
if (category_id === CATEGORY_IDS.YOUTUBE) {
|
||||||
if (youtube_video_id) {
|
if (youtube_video_id) {
|
||||||
|
|
@ -58,9 +58,10 @@ export function buildSource(schedule) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category_id === CATEGORY_IDS.X && x_post_id) {
|
if (category_id === CATEGORY_IDS.X && x_post_id) {
|
||||||
|
const username = x_username || config.x.defaultUsername;
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: username,
|
||||||
url: `https://x.com/${config.x.defaultUsername}/status/${x_post_id}`,
|
url: `https://x.com/${username}/status/${x_post_id}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +194,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||||
sy.video_id as youtube_video_id,
|
sy.video_id as youtube_video_id,
|
||||||
sy.video_type as youtube_video_type,
|
sy.video_type as youtube_video_type,
|
||||||
sx.post_id as x_post_id,
|
sx.post_id as x_post_id,
|
||||||
|
sx.username as x_username,
|
||||||
sx.content as x_content,
|
sx.content as x_content,
|
||||||
sx.image_urls as x_image_urls
|
sx.image_urls as x_image_urls
|
||||||
FROM schedules s
|
FROM schedules s
|
||||||
|
|
@ -256,8 +258,9 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||||
}
|
}
|
||||||
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
|
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
|
||||||
const username = config.x.defaultUsername;
|
const username = s.x_username || config.x.defaultUsername;
|
||||||
result.postId = s.x_post_id;
|
result.postId = s.x_post_id;
|
||||||
|
result.username = username;
|
||||||
result.content = s.x_content || null;
|
result.content = s.x_content || null;
|
||||||
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
||||||
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
|
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
|
||||||
|
|
@ -292,7 +295,8 @@ const SCHEDULE_LIST_SQL = `
|
||||||
sy.channel_name as youtube_channel,
|
sy.channel_name as youtube_channel,
|
||||||
sy.video_id as youtube_video_id,
|
sy.video_id as youtube_video_id,
|
||||||
sy.video_type as youtube_video_type,
|
sy.video_type as youtube_video_type,
|
||||||
sx.post_id as x_post_id
|
sx.post_id as x_post_id,
|
||||||
|
sx.username as x_username
|
||||||
FROM schedules s
|
FROM schedules s
|
||||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
/**
|
/**
|
||||||
* 트윗을 DB에 저장
|
* 트윗을 DB에 저장
|
||||||
*/
|
*/
|
||||||
async function saveTweet(tweet) {
|
async function saveTweet(tweet, username) {
|
||||||
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
|
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
|
||||||
const [existing] = await fastify.db.query(
|
const [existing] = await fastify.db.query(
|
||||||
'SELECT id FROM schedule_x WHERE post_id = ?',
|
'SELECT id FROM schedule_x WHERE post_id = ?',
|
||||||
|
|
@ -79,10 +79,11 @@ async function xBotPlugin(fastify, opts) {
|
||||||
|
|
||||||
// schedule_x 테이블에 저장
|
// schedule_x 테이블에 저장
|
||||||
await connection.query(
|
await connection.query(
|
||||||
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls) VALUES (?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
scheduleId,
|
scheduleId,
|
||||||
tweet.id,
|
tweet.id,
|
||||||
|
username,
|
||||||
tweet.text,
|
tweet.text,
|
||||||
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
||||||
]
|
]
|
||||||
|
|
@ -169,7 +170,8 @@ async function xBotPlugin(fastify, opts) {
|
||||||
* 최근 트윗 동기화 (정기 실행)
|
* 최근 트윗 동기화 (정기 실행)
|
||||||
*/
|
*/
|
||||||
async function syncNewTweets(bot) {
|
async function syncNewTweets(bot) {
|
||||||
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
|
const options = { includeRetweets: bot.includeRetweets || false };
|
||||||
|
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username, options);
|
||||||
|
|
||||||
// 프로필 저장 (DB + 캐시)
|
// 프로필 저장 (DB + 캐시)
|
||||||
await saveProfile(bot.username, profile);
|
await saveProfile(bot.username, profile);
|
||||||
|
|
@ -183,7 +185,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleId = await saveTweet(tweet);
|
const scheduleId = await saveTweet(tweet, bot.username);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||||
|
|
@ -200,7 +202,8 @@ async function xBotPlugin(fastify, opts) {
|
||||||
* 전체 트윗 동기화 (초기화)
|
* 전체 트윗 동기화 (초기화)
|
||||||
*/
|
*/
|
||||||
async function syncAllTweets(bot) {
|
async function syncAllTweets(bot) {
|
||||||
const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log);
|
const options = { includeRetweets: bot.includeRetweets || false };
|
||||||
|
const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log, options);
|
||||||
|
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
let ytAddedCount = 0;
|
let ytAddedCount = 0;
|
||||||
|
|
@ -211,7 +214,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleId = await saveTweet(tweet);
|
const scheduleId = await saveTweet(tweet, bot.username);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
||||||
|
|
|
||||||
|
|
@ -125,18 +125,26 @@ function extractTextFromHtml(html) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTML에서 트윗 목록 파싱
|
* HTML에서 트윗 목록 파싱
|
||||||
|
* @param {string} html - HTML 문자열
|
||||||
|
* @param {string} username - 사용자명
|
||||||
|
* @param {object} options - 옵션
|
||||||
|
* @param {boolean} options.includeRetweets - 리트윗 포함 여부 (기본: false)
|
||||||
*/
|
*/
|
||||||
export function parseTweets(html, username) {
|
export function parseTweets(html, username, options = {}) {
|
||||||
|
const { includeRetweets = false } = options;
|
||||||
const tweets = [];
|
const tweets = [];
|
||||||
const containers = html.split('class="timeline-item ');
|
const containers = html.split('class="timeline-item ');
|
||||||
|
|
||||||
for (let i = 1; i < containers.length; i++) {
|
for (let i = 1; i < containers.length; i++) {
|
||||||
const container = containers[i];
|
const container = containers[i];
|
||||||
|
|
||||||
// 고정/리트윗 제외
|
// 고정 트윗 제외
|
||||||
const isPinned = container.includes('class="pinned"');
|
const isPinned = container.includes('class="pinned"');
|
||||||
|
if (isPinned) continue;
|
||||||
|
|
||||||
|
// 리트윗 필터링 (옵션에 따라)
|
||||||
const isRetweet = container.includes('class="retweet-header"');
|
const isRetweet = container.includes('class="retweet-header"');
|
||||||
if (isPinned || isRetweet) continue;
|
if (isRetweet && !includeRetweets) continue;
|
||||||
|
|
||||||
// 트윗 ID
|
// 트윗 ID
|
||||||
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
||||||
|
|
@ -246,8 +254,12 @@ export async function fetchProfile(nitterUrl, username) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nitter에서 트윗 수집 (첫 페이지만)
|
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||||
|
* @param {string} nitterUrl - Nitter URL
|
||||||
|
* @param {string} username - 사용자명
|
||||||
|
* @param {object} options - 옵션
|
||||||
|
* @param {boolean} options.includeRetweets - 리트윗 포함 여부
|
||||||
*/
|
*/
|
||||||
export async function fetchTweets(nitterUrl, username) {
|
export async function fetchTweets(nitterUrl, username, options = {}) {
|
||||||
const url = `${nitterUrl}/${username}`;
|
const url = `${nitterUrl}/${username}`;
|
||||||
const res = await fetchWithTimeout(url);
|
const res = await fetchWithTimeout(url);
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
|
|
@ -256,15 +268,20 @@ export async function fetchTweets(nitterUrl, username) {
|
||||||
const profile = extractProfile(html);
|
const profile = extractProfile(html);
|
||||||
|
|
||||||
// 트윗 파싱
|
// 트윗 파싱
|
||||||
const tweets = parseTweets(html, username);
|
const tweets = parseTweets(html, username, options);
|
||||||
|
|
||||||
return { tweets, profile };
|
return { tweets, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nitter에서 전체 트윗 수집 (페이지네이션)
|
* Nitter에서 전체 트윗 수집 (페이지네이션)
|
||||||
|
* @param {string} nitterUrl - Nitter URL
|
||||||
|
* @param {string} username - 사용자명
|
||||||
|
* @param {object} log - 로거
|
||||||
|
* @param {object} options - 옵션
|
||||||
|
* @param {boolean} options.includeRetweets - 리트윗 포함 여부
|
||||||
*/
|
*/
|
||||||
export async function fetchAllTweets(nitterUrl, username, log) {
|
export async function fetchAllTweets(nitterUrl, username, log, options = {}) {
|
||||||
const allTweets = [];
|
const allTweets = [];
|
||||||
let cursor = null;
|
let cursor = null;
|
||||||
let pageNum = 1;
|
let pageNum = 1;
|
||||||
|
|
@ -280,7 +297,7 @@ export async function fetchAllTweets(nitterUrl, username, log) {
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout(url);
|
const res = await fetchWithTimeout(url);
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
const tweets = parseTweets(html, username);
|
const tweets = parseTweets(html, username, options);
|
||||||
|
|
||||||
if (tweets.length === 0) {
|
if (tweets.length === 0) {
|
||||||
emptyCount++;
|
emptyCount++;
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import { useState, useEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Twitter, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
import { Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||||
import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots';
|
import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots';
|
||||||
|
import { XIcon } from './BotCard';
|
||||||
|
|
||||||
// 동기화 간격 옵션
|
// 동기화 간격 옵션
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
|
|
@ -131,6 +132,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [textFilters, setTextFilters] = useState([]);
|
const [textFilters, setTextFilters] = useState([]);
|
||||||
const [filterInput, setFilterInput] = useState('');
|
const [filterInput, setFilterInput] = useState('');
|
||||||
|
const [includeRetweets, setIncludeRetweets] = useState(false);
|
||||||
|
|
||||||
// X 봇 상세 조회 (수정 모드)
|
// X 봇 상세 조회 (수정 모드)
|
||||||
const { data: bot, isLoading: botLoading } = useQuery({
|
const { data: bot, isLoading: botLoading } = useQuery({
|
||||||
|
|
@ -154,7 +156,8 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
});
|
});
|
||||||
setInterval(bot.cron_interval || 1);
|
setInterval(bot.cron_interval || 1);
|
||||||
setTextFilters(bot.text_filters || []);
|
setTextFilters(bot.text_filters || []);
|
||||||
setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || false);
|
setIncludeRetweets(bot.include_retweets || false);
|
||||||
|
setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || bot.include_retweets || false);
|
||||||
} else if (!botId) {
|
} else if (!botId) {
|
||||||
// 추가 모드
|
// 추가 모드
|
||||||
setUsername('');
|
setUsername('');
|
||||||
|
|
@ -162,6 +165,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
setInterval(1);
|
setInterval(1);
|
||||||
setTextFilters([]);
|
setTextFilters([]);
|
||||||
setFilterInput('');
|
setFilterInput('');
|
||||||
|
setIncludeRetweets(false);
|
||||||
setShowAdvanced(false);
|
setShowAdvanced(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, bot, botId]);
|
}, [isOpen, bot, botId]);
|
||||||
|
|
@ -197,6 +201,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
display_name: profileInfo.displayName,
|
display_name: profileInfo.displayName,
|
||||||
avatar_url: profileInfo.avatarUrl,
|
avatar_url: profileInfo.avatarUrl,
|
||||||
text_filters: textFilters.length > 0 ? textFilters : null,
|
text_filters: textFilters.length > 0 ? textFilters : null,
|
||||||
|
include_retweets: includeRetweets,
|
||||||
cron_interval: interval,
|
cron_interval: interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -239,8 +244,8 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-sky-50 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||||
<Twitter size={20} className="text-sky-500" />
|
<XIcon size={20} fill="#000" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-bold text-gray-900">
|
<h2 className="text-lg font-bold text-gray-900">
|
||||||
{isEdit ? 'X 봇 수정' : 'X 봇 추가'}
|
{isEdit ? 'X 봇 수정' : 'X 봇 추가'}
|
||||||
|
|
@ -304,8 +309,8 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
className="w-12 h-12 rounded-full object-cover"
|
className="w-12 h-12 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 bg-sky-100 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
<Twitter size={24} className="text-sky-500" />
|
<XIcon size={24} fill="#374151" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
@ -347,7 +352,28 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div className="p-4 pt-0 space-y-4 border-t border-gray-100">
|
<div className="p-4 space-y-4 border-t border-gray-100">
|
||||||
|
{/* 리트윗 포함 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">리트윗 포함</label>
|
||||||
|
<p className="text-xs text-gray-400">리트윗도 일정에 추가합니다</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIncludeRetweets(!includeRetweets)}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||||
|
includeRetweets ? 'bg-sky-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||||
|
includeRetweets ? 'translate-x-5' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 텍스트 필터 */}
|
{/* 텍스트 필터 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-600 mb-1">텍스트 필터</label>
|
<label className="block text-sm text-gray-600 mb-1">텍스트 필터</label>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue