feat(x-bot): 관리 YouTube 채널 영상 제외 옵션 추가
- bot_x에 exclude_managed_channels 컬럼 추가 (기본값 1, 기존 동작 유지) - X 봇이 트윗에서 YouTube 링크를 추출할 때 이미 등록된 YouTube 봇 채널의 영상을 중복 추가할지 옵션으로 제어 - XBotDialog에 토글 추가 (extract_youtube 활성 시만 노출, 왼쪽 border로 하위 옵션 시각화) - services/x/index.js processYoutubeLinks 시그니처에 옵션 파라미터 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e87549ca3
commit
678e228bc5
6 changed files with 51 additions and 8 deletions
|
|
@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS bot_x (
|
||||||
text_filters LONGTEXT,
|
text_filters LONGTEXT,
|
||||||
include_retweets TINYINT(1) DEFAULT 0,
|
include_retweets TINYINT(1) DEFAULT 0,
|
||||||
extract_youtube TINYINT(1) NOT NULL DEFAULT 0,
|
extract_youtube TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
-- extract_youtube가 켜졌을 때, YouTube 봇으로 등록된 채널 영상이면 추가에서 제외
|
||||||
|
exclude_managed_channels TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
cron_interval INT DEFAULT 1,
|
cron_interval INT DEFAULT 1,
|
||||||
enabled TINYINT(1) DEFAULT 1,
|
enabled TINYINT(1) DEFAULT 1,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
: [],
|
: [],
|
||||||
includeRetweets: row.include_retweets === 1,
|
includeRetweets: row.include_retweets === 1,
|
||||||
extractYoutube: row.extract_youtube === 1,
|
extractYoutube: row.extract_youtube === 1,
|
||||||
|
excludeManagedChannels: row.exclude_managed_channels === 1,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const xBotResponse = {
|
||||||
text_filters: { type: 'array', items: { type: 'string' } },
|
text_filters: { type: 'array', items: { type: 'string' } },
|
||||||
include_retweets: { type: 'boolean' },
|
include_retweets: { type: 'boolean' },
|
||||||
extract_youtube: { type: 'boolean' },
|
extract_youtube: { type: 'boolean' },
|
||||||
|
exclude_managed_channels: { type: 'boolean' },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
|
@ -45,6 +46,7 @@ function formatBotResponse(row) {
|
||||||
: [],
|
: [],
|
||||||
include_retweets: row.include_retweets === 1,
|
include_retweets: row.include_retweets === 1,
|
||||||
extract_youtube: row.extract_youtube === 1,
|
extract_youtube: row.extract_youtube === 1,
|
||||||
|
exclude_managed_channels: row.exclude_managed_channels === 1,
|
||||||
cron_interval: row.cron_interval,
|
cron_interval: row.cron_interval,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
};
|
};
|
||||||
|
|
@ -195,8 +197,8 @@ 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, include_retweets, extract_youtube, cron_interval, enabled)
|
`INSERT INTO bot_x (username, display_name, avatar_url, text_filters, include_retweets, extract_youtube, exclude_managed_channels, cron_interval, enabled)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||||
[
|
[
|
||||||
username,
|
username,
|
||||||
display_name || null,
|
display_name || null,
|
||||||
|
|
@ -204,6 +206,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
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,
|
include_retweets ? 1 : 0,
|
||||||
extract_youtube ? 1 : 0,
|
extract_youtube ? 1 : 0,
|
||||||
|
exclude_managed_channels ? 1 : 0,
|
||||||
cron_interval,
|
cron_interval,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -222,6 +225,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
textFilters: text_filters || [],
|
textFilters: text_filters || [],
|
||||||
includeRetweets: include_retweets,
|
includeRetweets: include_retweets,
|
||||||
extractYoutube: extract_youtube,
|
extractYoutube: extract_youtube,
|
||||||
|
excludeManagedChannels: exclude_managed_channels,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 전체 동기화 (async, 응답 대기하지 않음)
|
// 전체 동기화 (async, 응답 대기하지 않음)
|
||||||
|
|
@ -264,6 +268,7 @@ export default async function xBotsRoutes(fastify) {
|
||||||
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
include_retweets: { type: 'boolean' },
|
include_retweets: { type: 'boolean' },
|
||||||
extract_youtube: { type: 'boolean' },
|
extract_youtube: { type: 'boolean' },
|
||||||
|
exclude_managed_channels: { type: 'boolean' },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
|
@ -310,6 +315,10 @@ export default async function xBotsRoutes(fastify) {
|
||||||
fields.push('extract_youtube = ?');
|
fields.push('extract_youtube = ?');
|
||||||
values.push(updates.extract_youtube ? 1 : 0);
|
values.push(updates.extract_youtube ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
if (updates.exclude_managed_channels !== undefined) {
|
||||||
|
fields.push('exclude_managed_channels = ?');
|
||||||
|
values.push(updates.exclude_managed_channels ? 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);
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,11 @@ async function xBotPlugin(fastify, opts) {
|
||||||
/**
|
/**
|
||||||
* 트윗에서 YouTube 링크 처리
|
* 트윗에서 YouTube 링크 처리
|
||||||
*/
|
*/
|
||||||
async function processYoutubeLinks(tweet) {
|
async function processYoutubeLinks(tweet, { excludeManagedChannels = true } = {}) {
|
||||||
const videoIds = extractYoutubeVideoIds(tweet.text);
|
const videoIds = extractYoutubeVideoIds(tweet.text);
|
||||||
if (videoIds.length === 0) return 0;
|
if (videoIds.length === 0) return 0;
|
||||||
|
|
||||||
const managedChannels = await getManagedChannelIds();
|
const managedChannels = excludeManagedChannels ? await getManagedChannelIds() : [];
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
for (const videoId of videoIds) {
|
for (const videoId of videoIds) {
|
||||||
|
|
@ -146,8 +146,8 @@ async function xBotPlugin(fastify, opts) {
|
||||||
const video = await fetchVideoInfo(videoId);
|
const video = await fetchVideoInfo(videoId);
|
||||||
if (!video) continue;
|
if (!video) continue;
|
||||||
|
|
||||||
// 관리 중인 채널이면 스킵
|
// 옵션에 따라 관리 중인 채널 영상은 스킵
|
||||||
if (managedChannels.includes(video.channelId)) continue;
|
if (excludeManagedChannels && managedChannels.includes(video.channelId)) continue;
|
||||||
|
|
||||||
const scheduleId = await saveYoutubeFromTweet(video);
|
const scheduleId = await saveYoutubeFromTweet(video);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
|
|
@ -207,7 +207,9 @@ async function xBotPlugin(fastify, opts) {
|
||||||
addedCount++;
|
addedCount++;
|
||||||
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
||||||
if (bot.extractYoutube === true) {
|
if (bot.extractYoutube === true) {
|
||||||
ytAddedCount += await processYoutubeLinks(tweet);
|
ytAddedCount += await processYoutubeLinks(tweet, {
|
||||||
|
excludeManagedChannels: bot.excludeManagedChannels !== false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +240,9 @@ async function xBotPlugin(fastify, opts) {
|
||||||
addedCount++;
|
addedCount++;
|
||||||
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
||||||
if (bot.extractYoutube === true) {
|
if (bot.extractYoutube === true) {
|
||||||
ytAddedCount += await processYoutubeLinks(tweet);
|
ytAddedCount += await processYoutubeLinks(tweet, {
|
||||||
|
excludeManagedChannels: bot.excludeManagedChannels !== false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,7 @@ X 봇 추가
|
||||||
| `text_filters` | string[]\|null | null | 텍스트 필터 (하나라도 포함 시 추가, 비어있으면 모든 트윗) |
|
| `text_filters` | string[]\|null | null | 텍스트 필터 (하나라도 포함 시 추가, 비어있으면 모든 트윗) |
|
||||||
| `include_retweets` | boolean | false | 리트윗 포함 여부 |
|
| `include_retweets` | boolean | false | 리트윗 포함 여부 |
|
||||||
| `extract_youtube` | boolean | false | 트윗 내 YouTube 링크 자동 추출하여 유튜브 일정 추가 |
|
| `extract_youtube` | boolean | false | 트윗 내 YouTube 링크 자동 추출하여 유튜브 일정 추가 |
|
||||||
|
| `exclude_managed_channels` | boolean | true | `extract_youtube`가 true일 때, 등록된 YouTube 봇 채널의 영상은 중복 추가에서 제외 |
|
||||||
| `cron_interval` | integer | 1 | 동기화 간격 (분) |
|
| `cron_interval` | integer | 1 | 동기화 간격 (분) |
|
||||||
|
|
||||||
### PUT /admin/x-bots/:id
|
### PUT /admin/x-bots/:id
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const [filterInput, setFilterInput] = useState('');
|
const [filterInput, setFilterInput] = useState('');
|
||||||
const [includeRetweets, setIncludeRetweets] = useState(false);
|
const [includeRetweets, setIncludeRetweets] = useState(false);
|
||||||
const [extractYoutube, setExtractYoutube] = useState(false);
|
const [extractYoutube, setExtractYoutube] = useState(false);
|
||||||
|
const [excludeManagedChannels, setExcludeManagedChannels] = useState(true);
|
||||||
|
|
||||||
// X 봇 상세 조회 (수정 모드)
|
// X 봇 상세 조회 (수정 모드)
|
||||||
const { data: bot, isLoading: botLoading } = useQuery({
|
const { data: bot, isLoading: botLoading } = useQuery({
|
||||||
|
|
@ -159,6 +160,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
setTextFilters(bot.text_filters || []);
|
setTextFilters(bot.text_filters || []);
|
||||||
setIncludeRetweets(bot.include_retweets || false);
|
setIncludeRetweets(bot.include_retweets || false);
|
||||||
setExtractYoutube(bot.extract_youtube || false);
|
setExtractYoutube(bot.extract_youtube || false);
|
||||||
|
setExcludeManagedChannels(bot.exclude_managed_channels ?? true);
|
||||||
setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || bot.include_retweets || bot.extract_youtube || false);
|
setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || bot.include_retweets || bot.extract_youtube || false);
|
||||||
} else if (!botId) {
|
} else if (!botId) {
|
||||||
// 추가 모드
|
// 추가 모드
|
||||||
|
|
@ -206,6 +208,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
text_filters: textFilters.length > 0 ? textFilters : null,
|
text_filters: textFilters.length > 0 ? textFilters : null,
|
||||||
include_retweets: includeRetweets,
|
include_retweets: includeRetweets,
|
||||||
extract_youtube: extractYoutube,
|
extract_youtube: extractYoutube,
|
||||||
|
exclude_managed_channels: excludeManagedChannels,
|
||||||
cron_interval: interval,
|
cron_interval: interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -399,6 +402,29 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 관리 중인 채널 제외 (extractYoutube 활성 시만) */}
|
||||||
|
{extractYoutube && (
|
||||||
|
<div className="flex items-center justify-between pl-4 border-l-2 border-gray-100">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">관리 채널 영상 제외</label>
|
||||||
|
<p className="text-xs text-gray-400">등록된 YouTube 봇 채널의 영상은 트윗에서 중복 추가하지 않습니다</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExcludeManagedChannels(!excludeManagedChannels)}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||||
|
excludeManagedChannels ? '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 ${
|
||||||
|
excludeManagedChannels ? '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