AI Update: شروع به پیاده سازی کن

This commit is contained in:
Antigravity Bot
2026-08-30 11:54:50 +03:30
parent c2a3cdc085
commit a5342b0850
4 changed files with 648 additions and 23 deletions
+18
View File
@@ -152,17 +152,35 @@ CREATE INDEX IF NOT EXISTS idx_source_websites_active ON source_websites(is_acti
CREATE INDEX IF NOT EXISTS idx_ai_providers_active ON ai_providers(is_active); CREATE INDEX IF NOT EXISTS idx_ai_providers_active ON ai_providers(is_active);
CREATE TABLE IF NOT EXISTS admin_channels (
id SERIAL PRIMARY KEY,
channel_id BIGINT UNIQUE NOT NULL,
title VARCHAR(255),
username VARCHAR(255),
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_admin_channels_active ON admin_channels(is_active);
-- Migration safety for existing tables -- Migration safety for existing tables
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS fallback_provider_id BIGINT REFERENCES ai_providers(id) ON DELETE SET NULL; ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS fallback_provider_id BIGINT REFERENCES ai_providers(id) ON DELETE SET NULL;
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS supports_vision BOOLEAN DEFAULT FALSE; ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS supports_vision BOOLEAN DEFAULT FALSE;
ALTER TABLE sources ADD COLUMN IF NOT EXISTS category_id INT REFERENCES channel_categories(id) ON DELETE SET NULL; ALTER TABLE sources ADD COLUMN IF NOT EXISTS category_id INT REFERENCES channel_categories(id) ON DELETE SET NULL;
ALTER TABLE targets ADD COLUMN IF NOT EXISTS category_id INT REFERENCES channel_categories(id) ON DELETE SET NULL; ALTER TABLE targets ADD COLUMN IF NOT EXISTS category_id INT REFERENCES channel_categories(id) ON DELETE SET NULL;
ALTER TABLE sources ADD COLUMN IF NOT EXISTS admin_channel_id BIGINT;
ALTER TABLE source_websites ADD COLUMN IF NOT EXISTS admin_channel_id BIGINT;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS review_channel_id BIGINT;
ALTER TABLE sources ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE; ALTER TABLE sources ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE;
ALTER TABLE targets ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE; ALTER TABLE targets ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE;
ALTER TABLE sources ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP; ALTER TABLE sources ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE targets ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP; ALTER TABLE targets ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_sources_category ON sources(category_id); CREATE INDEX IF NOT EXISTS idx_sources_category ON sources(category_id);
CREATE INDEX IF NOT EXISTS idx_targets_category ON targets(category_id); CREATE INDEX IF NOT EXISTS idx_targets_category ON targets(category_id);
CREATE INDEX IF NOT EXISTS idx_sources_admin_channel ON sources(admin_channel_id);
CREATE INDEX IF NOT EXISTS idx_source_websites_admin_channel ON source_websites(admin_channel_id);
CREATE INDEX IF NOT EXISTS idx_posts_review_channel ON posts(review_channel_id);
ALTER TABLE targets ADD COLUMN IF NOT EXISTS personality TEXT DEFAULT ''; ALTER TABLE targets ADD COLUMN IF NOT EXISTS personality TEXT DEFAULT '';
ALTER TABLE targets ADD COLUMN IF NOT EXISTS custom_footer TEXT DEFAULT ''; ALTER TABLE targets ADD COLUMN IF NOT EXISTS custom_footer TEXT DEFAULT '';
ALTER TABLE targets ADD COLUMN IF NOT EXISTS sleep_start_hour INT DEFAULT 0; ALTER TABLE targets ADD COLUMN IF NOT EXISTS sleep_start_hour INT DEFAULT 0;
+13
View File
@@ -1,6 +1,16 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@dataclass
class AdminReviewChannel:
id: Optional[int]
channel_id: int
title: Optional[str]
username: Optional[str] = None
is_default: bool = False
is_active: bool = True
created_at: Optional[str] = None
@dataclass @dataclass
class ChannelCategory: class ChannelCategory:
id: Optional[int] id: Optional[int]
@@ -16,6 +26,7 @@ class SourceChannel:
username: Optional[str] username: Optional[str]
title: Optional[str] title: Optional[str]
category_id: Optional[int] = None category_id: Optional[int] = None
admin_channel_id: Optional[int] = None
context_message_count: int = 0 context_message_count: int = 0
is_active: bool = True is_active: bool = True
created_at: Optional[str] = None created_at: Optional[str] = None
@@ -26,6 +37,7 @@ class SourceWebsite:
name: str name: str
url: str url: str
category_id: Optional[int] = None category_id: Optional[int] = None
admin_channel_id: Optional[int] = None
check_interval_min: int = 30 check_interval_min: int = 30
auto_reanalyze_hours: int = 24 auto_reanalyze_hours: int = 24
last_reanalyzed_at: Optional[str] = None last_reanalyzed_at: Optional[str] = None
@@ -82,6 +94,7 @@ class Post:
rejection_reason: Optional[str] = None rejection_reason: Optional[str] = None
published_to: List[Dict[str, Any]] = field(default_factory=list) published_to: List[Dict[str, Any]] = field(default_factory=list)
is_deleted: bool = False is_deleted: bool = False
review_channel_id: Optional[int] = None
review_message_id: Optional[int] = None review_message_id: Optional[int] = None
scheduled_at: Optional[str] = None scheduled_at: Optional[str] = None
published_at: Optional[str] = None published_at: Optional[str] = None
+197 -7
View File
@@ -1,11 +1,17 @@
import json import json
import asyncpg import asyncpg
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Dict, Any, Tuple from db.models import (
from db.models import SourceChannel, SourceWebsite, TargetChannel, Post, Setting, AILog, AIProviderProfile, ChannelCategory SourceChannel, SourceWebsite, TargetChannel, Post, Setting,
AILog, AIProviderProfile, ChannelCategory, AdminReviewChannel
)
from db.database import get_db_pool from db.database import get_db_pool
def _parse_admin_channel_row(row: asyncpg.Record) -> AdminReviewChannel:
return AdminReviewChannel(**dict(row))
def _parse_post_row(row: asyncpg.Record) -> Post: def _parse_post_row(row: asyncpg.Record) -> Post:
data = dict(row) data = dict(row)
if isinstance(data.get("published_to"), str): if isinstance(data.get("published_to"), str):
@@ -566,13 +572,19 @@ class Repository:
) )
return [_parse_post_row(r) for r in rows] return [_parse_post_row(r) for r in rows]
async def update_review_message_id(self, post_id: int, review_message_id: int) -> None: async def update_review_message_id(self, post_id: int, review_message_id: Optional[int], review_channel_id: Optional[int] = None) -> None:
pool = await self._get_pool() pool = await self._get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute( if review_channel_id is not None:
"UPDATE posts SET review_message_id = $1 WHERE id = $2;", await conn.execute(
review_message_id, post_id, "UPDATE posts SET review_message_id = $1, review_channel_id = $2 WHERE id = $3;",
) review_message_id, review_channel_id, post_id,
)
else:
await conn.execute(
"UPDATE posts SET review_message_id = $1 WHERE id = $2;",
review_message_id, post_id,
)
async def _upsert_published_entry( async def _upsert_published_entry(
self, self,
@@ -1044,6 +1056,184 @@ class Repository:
description="Global emergency operational pause" description="Global emergency operational pause"
) )
# --- Admin Review Channels ---
async def add_admin_channel(
self,
channel_id: int,
title: Optional[str] = None,
username: Optional[str] = None,
is_default: bool = False
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
if is_default:
await conn.execute("UPDATE admin_channels SET is_default = FALSE;")
row = await conn.fetchrow(
"""
INSERT INTO admin_channels (channel_id, title, username, is_default, is_active)
VALUES ($1, $2, $3, $4, TRUE)
ON CONFLICT(channel_id) DO UPDATE SET
title = COALESCE(EXCLUDED.title, admin_channels.title),
username = COALESCE(EXCLUDED.username, admin_channels.username),
is_default = EXCLUDED.is_default,
is_active = TRUE
RETURNING id;
""",
channel_id, title, username, is_default
)
return row["id"]
async def get_admin_channels(self) -> List[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM admin_channels WHERE is_active = TRUE ORDER BY is_default DESC, id ASC;")
return [_parse_admin_channel_row(r) for r in rows]
async def get_admin_channel_by_id(self, admin_id: int) -> Optional[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE id = $1;", admin_id)
return _parse_admin_channel_row(row) if row else None
async def get_admin_channel_by_telegram_id(self, channel_id: int) -> Optional[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE channel_id = $1;", channel_id)
return _parse_admin_channel_row(row) if row else None
async def get_default_admin_channel(self) -> Optional[AdminReviewChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE is_active = TRUE AND is_default = TRUE LIMIT 1;")
if not row:
row = await conn.fetchrow("SELECT * FROM admin_channels WHERE is_active = TRUE ORDER BY id ASC LIMIT 1;")
return _parse_admin_channel_row(row) if row else None
async def set_default_admin_channel(self, admin_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("UPDATE admin_channels SET is_default = FALSE;")
await conn.execute("UPDATE admin_channels SET is_default = TRUE WHERE id = $1;", admin_id)
async def delete_admin_channel(self, admin_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT channel_id FROM admin_channels WHERE id = $1;", admin_id)
if row:
ch_id = row["channel_id"]
await conn.execute("UPDATE sources SET admin_channel_id = NULL WHERE admin_channel_id = $1;", ch_id)
await conn.execute("UPDATE source_websites SET admin_channel_id = NULL WHERE admin_channel_id = $1;", ch_id)
await conn.execute("DELETE FROM admin_channels WHERE id = $1;", admin_id)
# --- Source & Website Admin Channel Setters ---
async def set_source_admin_channel(self, source_id: int, admin_channel_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET admin_channel_id = $1 WHERE id = $2;", admin_channel_id, source_id)
async def set_website_admin_channel(self, site_id: int, admin_channel_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE source_websites SET admin_channel_id = $1 WHERE id = $2;", admin_channel_id, site_id)
# --- Review Purge Helpers ---
async def get_review_posts_by_admin_channel(self, admin_channel_id: int) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE
ORDER BY id ASC;
""",
admin_channel_id
)
return [_parse_post_row(r) for r in rows]
async def count_review_posts_by_admin_channel(self, admin_channel_id: int) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
val = await conn.fetchval(
"""
SELECT COUNT(*) FROM posts
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE;
""",
admin_channel_id
)
return val or 0
async def clear_review_messages_for_channel(self, admin_channel_id: int) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE;
""",
admin_channel_id
)
posts = [_parse_post_row(r) for r in rows]
await conn.execute(
"""
UPDATE posts
SET review_message_id = NULL,
is_deleted = TRUE,
status = 'deleted'
WHERE review_channel_id = $1
AND review_message_id IS NOT NULL
AND is_deleted = FALSE;
""",
admin_channel_id
)
return posts
async def get_all_review_posts_with_message_id(self) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_message_id IS NOT NULL
AND is_deleted = FALSE
ORDER BY id ASC;
"""
)
return [_parse_post_row(r) for r in rows]
async def clear_all_review_messages(self) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE review_message_id IS NOT NULL
AND is_deleted = FALSE;
"""
)
posts = [_parse_post_row(r) for r in rows]
await conn.execute(
"""
UPDATE posts
SET review_message_id = NULL,
is_deleted = TRUE,
status = 'deleted'
WHERE review_message_id IS NOT NULL
AND is_deleted = FALSE;
"""
)
return posts
+420 -16
View File
@@ -69,7 +69,8 @@ def get_source_fetch_buttons(channel_id: int, source_id: Optional[int] = None):
] ]
if source_id is not None: if source_id is not None:
rows.append([ rows.append([
Button.inline("📜 تنظیم پیام‌های زمینه (Context)", data=f"src_ctx_menu:{source_id}"), Button.inline("📜 پیام‌های زمینه (Context)", data=f"src_ctx_menu:{source_id}"),
Button.inline("📋 کانال نظارت ادمین", data=f"src_adm:{source_id}"),
]) ])
rows.append([ rows.append([
Button.inline("📁 تعیین دسته‌بندی", data=f"src_cat:{source_id}"), Button.inline("📁 تعیین دسته‌بندی", data=f"src_cat:{source_id}"),
@@ -663,9 +664,31 @@ class AdminBotService:
) )
return caption return caption
async def get_effective_review_channel_id(self, post: Post) -> Optional[int]:
"""Determine which Admin Review Channel this post should be sent to."""
if post.source_channel_id <= -900000000:
site_id = abs(post.source_channel_id + 900000000)
site = await self.repo.get_source_website_by_id(site_id)
if site and site.admin_channel_id:
return site.admin_channel_id
else:
src = await self.repo.get_source_by_channel_id(post.source_channel_id)
if src and src.admin_channel_id:
return src.admin_channel_id
default_adm = await self.repo.get_default_admin_channel()
if default_adm and default_adm.channel_id:
return default_adm.channel_id
return self.review_channel_id or None
async def send_raw_review_post(self, post_id: int, refresh_only: bool = False): async def send_raw_review_post(self, post_id: int, refresh_only: bool = False):
post = await self.repo.get_post_by_id(post_id) post = await self.repo.get_post_by_id(post_id)
if not post or not self.review_channel_id or post.is_deleted: if not post or post.is_deleted:
return
target_review_channel_id = post.review_channel_id or await self.get_effective_review_channel_id(post)
if not target_review_channel_id:
return return
targets = await self.repo.get_active_targets() targets = await self.repo.get_active_targets()
@@ -678,7 +701,7 @@ class AdminBotService:
return return
try: try:
await self.client.edit_message( await self.client.edit_message(
self.review_channel_id, post.review_message_id, target_review_channel_id, post.review_message_id,
clamp_for_telegram(caption, bool(post.media_path)), clamp_for_telegram(caption, bool(post.media_path)),
parse_mode="html", buttons=keyboard, parse_mode="html", buttons=keyboard,
) )
@@ -692,7 +715,7 @@ class AdminBotService:
try: try:
if has_media: if has_media:
msg = await self.client.send_file( msg = await self.client.send_file(
self.review_channel_id, target_review_channel_id,
file=post.media_path, file=post.media_path,
caption=caption, caption=caption,
parse_mode="html", parse_mode="html",
@@ -700,14 +723,14 @@ class AdminBotService:
) )
else: else:
msg = await self.client.send_message( msg = await self.client.send_message(
self.review_channel_id, target_review_channel_id,
caption, caption,
parse_mode="html", parse_mode="html",
buttons=keyboard buttons=keyboard
) )
await self.repo.update_review_message_id(post.id, msg.id) await self.repo.update_review_message_id(post.id, msg.id, review_channel_id=target_review_channel_id)
except Exception as e: except Exception as e:
await log_exception("admin_bot.send_review_post", e, {"post_id": post.id, "review_channel_id": self.review_channel_id}) await log_exception("admin_bot.send_review_post", e, {"post_id": post.id, "review_channel_id": target_review_channel_id})
async def refresh_error_metrics(self) -> int: async def refresh_error_metrics(self) -> int:
"""Republish the open-error gauges from the database. Returns the open count.""" """Republish the open-error gauges from the database. Returns the open count."""
@@ -954,6 +977,14 @@ class AdminBotService:
if cat: if cat:
cat_name = cat.name cat_name = cat.name
adm_name = "پیش‌فرض سیستم"
if getattr(source, "admin_channel_id", None):
adm = await self.repo.get_admin_channel_by_telegram_id(source.admin_channel_id)
if adm:
adm_name = f"<b>{adm.title or adm.channel_id}</b>"
else:
adm_name = f"<code>{source.admin_channel_id}</code>"
auto_targets = await self.repo.get_targets_auto_routed_from(source.channel_id) auto_targets = await self.repo.get_targets_auto_routed_from(source.channel_id)
auto_line = ( auto_line = (
"🤖 <b>ارسال خودکار به:</b> " + "، ".join(f"<b>{t.title}</b>" for t in auto_targets) "🤖 <b>ارسال خودکار به:</b> " + "، ".join(f"<b>{t.title}</b>" for t in auto_targets)
@@ -968,6 +999,7 @@ class AdminBotService:
f"• 🆔 <b>شناسه کانال:</b> <code>{source.channel_id}</code>\n" f"• 🆔 <b>شناسه کانال:</b> <code>{source.channel_id}</code>\n"
f"• 🔗 <b>یوزرنیم:</b> @{source.username or 'ندارد'}\n" f"• 🔗 <b>یوزرنیم:</b> @{source.username or 'ندارد'}\n"
f"• 📁 <b>دسته‌بندی:</b> <b>{cat_name}</b>\n" f"• 📁 <b>دسته‌بندی:</b> <b>{cat_name}</b>\n"
f"• 📋 <b>کانال نظارت ادمین:</b> {adm_name}\n"
f"• 📜 <b>پیام‌های زمینه (Context):</b> <b>{ctx_label}</b>\n" f"• 📜 <b>پیام‌های زمینه (Context):</b> <b>{ctx_label}</b>\n"
f"• 📥 <b>پست‌های دریافت‌شده:</b> <b>{collected}</b>\n" f"• 📥 <b>پست‌های دریافت‌شده:</b> <b>{collected}</b>\n"
f"{auto_line}\n\n" f"{auto_line}\n\n"
@@ -1053,12 +1085,21 @@ class AdminBotService:
last_re = site.last_reanalyzed_at.strftime("%Y-%m-%d %H:%M") if site.last_reanalyzed_at else "انجام نشده" last_re = site.last_reanalyzed_at.strftime("%Y-%m-%d %H:%M") if site.last_reanalyzed_at else "انجام نشده"
needs = getattr(site, "custom_instructions", "") or "" needs = getattr(site, "custom_instructions", "") or ""
adm_name = "پیش‌فرض سیستم"
if getattr(site, "admin_channel_id", None):
adm = await self.repo.get_admin_channel_by_telegram_id(site.admin_channel_id)
if adm:
adm_name = f"<b>{adm.title or adm.channel_id}</b>"
else:
adm_name = f"<code>{site.admin_channel_id}</code>"
text = ( text = (
f"🌐 <b>وبسایت مبدا:</b> <b>{site.name}</b>\n\n" f"🌐 <b>وبسایت مبدا:</b> <b>{site.name}</b>\n\n"
f"• 🔗 <b>آدرس وبسایت:</b> {site.url}\n" f"• 🔗 <b>آدرس وبسایت:</b> {site.url}\n"
f"• 🔌 <b>اندپوینت دریافت:</b> <code>{endpoint}</code>\n" f"• 🔌 <b>اندپوینت دریافت:</b> <code>{endpoint}</code>\n"
f"• 🏷 <b>نوع ساختار:</b> <code>{parser_type}</code>\n" f"• 🏷 <b>نوع ساختار:</b> <code>{parser_type}</code>\n"
f"• 📁 <b>دسته‌بندی:</b> <b>{cat_name}</b>\n" f"• 📁 <b>دسته‌بندی:</b> <b>{cat_name}</b>\n"
f"• 📋 <b>کانال نظارت ادمین:</b> {adm_name}\n"
f"• 📝 <b>نیازمندی‌ها و فیلترهای استخراج:</b>\n" f"• 📝 <b>نیازمندی‌ها و فیلترهای استخراج:</b>\n"
f"<i>{needs or 'ثبت نشده (استخراج تمام اخبار و پست‌ها)'}</i>\n\n" f"<i>{needs or 'ثبت نشده (استخراج تمام اخبار و پست‌ها)'}</i>\n\n"
f"• ⏱ <b>فاصله بررسی:</b> هر <b>{site.check_interval_min} دقیقه</b>\n" f"• ⏱ <b>فاصله بررسی:</b> هر <b>{site.check_interval_min} دقیقه</b>\n"
@@ -1078,11 +1119,14 @@ class AdminBotService:
Button.inline("⏱ تغییر فاصله بررسی", data=f"web_intv:{site.id}") Button.inline("⏱ تغییر فاصله بررسی", data=f"web_intv:{site.id}")
], ],
[ [
Button.inline("🔄 دوره تحلیل مجدد AI", data=f"web_reintv:{site.id}"), Button.inline("📋 کانال نظارت ادمین", data=f"web_adm:{site.id}"),
Button.inline("📁 تعیین دسته‌بندی", data=f"web_cat:{site.id}") Button.inline("📁 تعیین دسته‌بندی", data=f"web_cat:{site.id}")
], ],
[ [
Button.inline("🗑 حذف این وبسایت", data=f"del_web:{site.id}"), Button.inline("🔄 دوره تحلیل مجدد AI", data=f"web_reintv:{site.id}"),
Button.inline("🗑 حذف این وبسایت", data=f"del_web:{site.id}")
],
[
Button.inline("🔙 بازگشت به لیست وبسایت‌ها", data="list_web") Button.inline("🔙 بازگشت به لیست وبسایت‌ها", data="list_web")
] ]
] ]
@@ -1144,7 +1188,10 @@ class AdminBotService:
Button.inline("➕ افزودن مبدا / مقصد جدید", data="hub_add"), Button.inline("➕ افزودن مبدا / مقصد جدید", data="hub_add"),
Button.inline("📂 دسته‌بندی موضوعی کانال‌ها", data="hub_cats") Button.inline("📂 دسته‌بندی موضوعی کانال‌ها", data="hub_cats")
], ],
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")] [
Button.inline("🗑 پاکسازی پیام‌های کانال‌های ادمین", data="purge_adm_menu"),
Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")
]
] ]
return text, buttons return text, buttons
@@ -1152,11 +1199,13 @@ class AdminBotService:
sources = await self.repo.get_active_sources() sources = await self.repo.get_active_sources()
targets = await self.repo.get_active_targets() targets = await self.repo.get_active_targets()
websites = await self.repo.get_active_source_websites() websites = await self.repo.get_active_source_websites()
adm_channels = await self.repo.get_admin_channels()
text = ( text = (
f"🤖 <b>بخش کانال‌ها، وبسایت‌ها و ربات‌ها (Bots Hub):</b>\n\n" f"🤖 <b>بخش کانال‌ها، وبسایت‌ها و ربات‌ها (Bots Hub):</b>\n\n"
f"• 📡 <b>کانال‌های مبدا:</b> {len(sources)} کانال\n" f"• 📡 <b>کانال‌های مبدا:</b> {len(sources)} کانال\n"
f"• 🎯 <b>کانال‌های مقصد:</b> {len(targets)} کانال\n" f"• 🎯 <b>کانال‌های مقصد:</b> {len(targets)} کانال\n"
f"• 🌐 <b>وبسایت‌های مبدا:</b> {len(websites)} وبسایت\n\n" f"• 🌐 <b>وبسایت‌های مبدا:</b> {len(websites)} وبسایت\n"
f"• 📋 <b>کانال‌های نظارت ادمین:</b> {len(adm_channels)} کانال ثبت‌شده\n\n"
f"<i>بخش مورد نظر را برای مشاهده و مدیریت انتخاب کنید:</i>" f"<i>بخش مورد نظر را برای مشاهده و مدیریت انتخاب کنید:</i>"
) )
buttons = [ buttons = [
@@ -1173,10 +1222,13 @@ class AdminBotService:
Button.inline(" افزودن وبسایت", data="add_web") Button.inline(" افزودن وبسایت", data="add_web")
], ],
[ [
Button.inline("🔑 احراز هویت یوزربات", data="hub_req_code"), Button.inline("📋 کانال‌های نظارت ادمین", data="list_adm_channels"),
Button.inline("📂 مدیریت دسته‌بندی‌ها", data="list_cat") Button.inline("📂 مدیریت دسته‌بندی‌ها", data="list_cat")
], ],
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")] [
Button.inline("🔑 احراز هویت یوزربات", data="hub_req_code"),
Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")
]
] ]
return text, buttons return text, buttons
@@ -1194,13 +1246,198 @@ class AdminBotService:
buttons = [ buttons = [
[Button.inline(pause_btn_text, data=pause_cb)], [Button.inline(pause_btn_text, data=pause_cb)],
[ [
Button.inline("📝 گزارش تغییرات (Release Notes)", data="sys_changes"), Button.inline("🗑 پاکسازی پیام‌های کانال‌های ادمین", data="purge_adm_menu"),
Button.inline("❓ راهنمای سیستم", data="sys_help") Button.inline("📝 گزارش تغییرات", data="sys_changes")
], ],
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")] [
Button.inline("❓ راهنمای سیستم", data="sys_help"),
Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")
]
] ]
return text, buttons return text, buttons
async def _render_source_admin_channel_menu(self, source_id: int) -> Tuple[str, List[List[Button]]]:
source = await self.repo.get_source_by_id(source_id)
if not source:
return "❌ کانال مبدا یافت نشد.", []
adm_channels = await self.repo.get_admin_channels()
text = (
f"📋 <b>تعیین کانال نظارت ادمین برای کانال مبدا:</b>\n"
f"📢 <b>{source.title or source.channel_id}</b>\n\n"
f"پست‌های جدید این کانال مبدا برای بررسی به کدام کانال مدیر/نظارت ارسال شوند؟\n\n"
f"<i>یکی از کانال‌های زیر را انتخاب کنید یا گزینه پیش‌فرض را بزنید:</i>"
)
buttons = []
for a in adm_channels:
mark = "" if source.admin_channel_id == a.channel_id else ""
def_tag = " (پیش‌فرض)" if a.is_default else ""
buttons.append([Button.inline(f"{mark}📋 {a.title or a.channel_id}{def_tag}", data=f"src_set_adm:{source.id}:{a.channel_id}")])
is_def = (source.admin_channel_id is None)
def_mark = "" if is_def else ""
buttons.append([Button.inline(f"{def_mark}⚙️ پیش‌فرض سیستم ({self.review_channel_id})", data=f"src_set_adm:{source.id}:none")])
buttons.append([Button.inline("➕ افزودن کانال ادمین جدید", data=f"src_add_adm:{source.id}")])
buttons.append([Button.inline("🔙 بازگشت به تنظیمات کانال مبدا", data=f"src_view:{source.id}")])
return text, buttons
async def _render_website_admin_channel_menu(self, site_id: int) -> Tuple[str, List[List[Button]]]:
site = await self.repo.get_source_website_by_id(site_id)
if not site:
return "❌ وبسایت یافت نشد.", []
adm_channels = await self.repo.get_admin_channels()
text = (
f"📋 <b>تعیین کانال نظارت ادمین برای وبسایت مبدا:</b>\n"
f"🌐 <b>{site.name}</b>\n\n"
f"پست‌های استخراج‌شده از این وبسایت برای بررسی به کدام کانال مدیر/نظارت ارسال شوند؟\n\n"
f"<i>یکی از کانال‌های زیر را انتخاب کنید یا گزینه پیش‌فرض را بزنید:</i>"
)
buttons = []
for a in adm_channels:
mark = "" if site.admin_channel_id == a.channel_id else ""
def_tag = " (پیش‌فرض)" if a.is_default else ""
buttons.append([Button.inline(f"{mark}📋 {a.title or a.channel_id}{def_tag}", data=f"web_set_adm:{site.id}:{a.channel_id}")])
is_def = (site.admin_channel_id is None)
def_mark = "" if is_def else ""
buttons.append([Button.inline(f"{def_mark}⚙️ پیش‌فرض سیستم ({self.review_channel_id})", data=f"web_set_adm:{site.id}:none")])
buttons.append([Button.inline("➕ افزودن کانال ادمین جدید", data=f"web_add_adm:{site.id}")])
buttons.append([Button.inline("🔙 بازگشت به تنظیمات وبسایت", data=f"web_view:{site.id}")])
return text, buttons
async def _render_admin_channels_menu(self) -> Tuple[str, List[List[Button]]]:
channels = await self.repo.get_admin_channels()
lines = [
"📋 <b>مدیریت کانال‌های نظارت و بازبینی ادمین (Admin Review Channels)</b>\n",
"شما می‌توانید چندین کانال ادمین مجزا تعریف کنید تا پست‌های منابع مختلف به کانال‌های مدیر متفاوتی هدایت شوند:\n"
]
buttons = []
if channels:
for ch in channels:
def_star = " ⭐️ (پیش‌فرض)" if ch.is_default else ""
lines.append(f"• <b>{ch.title or 'کانال ادمین'}</b> (<code>{ch.channel_id}</code>){def_star}")
buttons.append([
Button.inline(f"📋 {ch.title or ch.channel_id}{def_star}", data=f"adm_view:{ch.id}"),
])
else:
lines.append(f"<i>تنها کانال پیش‌فرض محیطی فعال است: <code>{self.review_channel_id}</code></i>")
buttons.append([Button.inline("➕ افزودن کانال ادمین جدید", data="add_adm_ch")])
buttons.append([Button.inline("🔙 بازگشت به منوی ربات‌ها", data="hub_bots")])
return "\n".join(lines), buttons
async def _render_admin_channel_detail(self, admin_id: int) -> Tuple[str, List[List[Button]]]:
adm = await self.repo.get_admin_channel_by_id(admin_id)
if not adm:
return "❌ کانال ادمین یافت نشد.", [[Button.inline("🔙 بازگشت", data="list_adm_channels")]]
pending_count = await self.repo.count_review_posts_by_admin_channel(adm.channel_id)
def_line = "⭐️ <b>وضعیت:</b> کانال پیش‌فرض سیستم" if adm.is_default else "⚪️ <b>وضعیت:</b> کانال فرعی"
text = (
f"📋 <b>مشخصات کانال نظارت ادمین:</b> <b>{adm.title or adm.channel_id}</b>\n\n"
f"• 🆔 <b>شناسه عددی:</b> <code>{adm.channel_id}</code>\n"
f"• 🔗 <b>یوزرنیم:</b> @{adm.username or 'ندارد'}\n"
f"{def_line}\n"
f"• 📨 <b>پیام‌های بررسی‌نشده در این کانال:</b> <b>{pending_count}</b> پیام\n\n"
f"<i>عملیات مورد نظر را انتخاب کنید:</i>"
)
buttons = []
if not adm.is_default:
buttons.append([Button.inline("⭐️ تعیین به‌عنوان پیش‌فرض سیستم", data=f"adm_set_def:{adm.id}")])
buttons.append([Button.inline(f"🗑 پاکسازی پیام‌های این کانال ({pending_count})", data=f"purge_adm_ask:{adm.channel_id}")])
buttons.append([Button.inline("❌ حذف این کانال ادمین", data=f"adm_del:{adm.id}")])
buttons.append([Button.inline("🔙 بازگشت به لیست کانال‌های ادمین", data="list_adm_channels")])
return text, buttons
async def _render_purge_admin_channels_menu(self) -> Tuple[str, List[List[Button]]]:
adm_channels = await self.repo.get_admin_channels()
all_channels_map: Dict[int, str] = {}
if self.review_channel_id:
all_channels_map[self.review_channel_id] = "کانال پیش‌فرض محیطی"
for a in adm_channels:
all_channels_map[a.channel_id] = a.title or str(a.channel_id)
lines = [
"🗑 <b>پاکسازی پیام‌های کانال‌های ادمین (Purge Review Messages)</b>\n",
"با استفاده از این بخش می‌توانید با یک دکمه، تمام کارت‌ها و پیام‌های ارسال‌شده به کانال‌های مدیران را به‌صورت تفکیک‌شده یا یکجا پاکسازی کنید:\n"
]
buttons = []
total_pending = 0
for ch_id, title in all_channels_map.items():
cnt = await self.repo.count_review_posts_by_admin_channel(ch_id)
total_pending += cnt
lines.append(f"• 📋 <b>{title}</b> (<code>{ch_id}</code>): <b>{cnt}</b> پیام بررسی‌نشده")
buttons.append([
Button.inline(f"🗑 پاکسازی پیام‌های {title[:20]} ({cnt})", data=f"purge_adm_ask:{ch_id}")
])
if total_pending > 0 or len(all_channels_map) > 1:
buttons.append([Button.inline(f"🔥 پاکسازی همه کانال‌های ادمین (مجموع {total_pending})", data="purge_adm_ask:all")])
buttons.append([Button.inline("🔙 بازگشت به منوی سیستم", data="hub_system")])
return "\n".join(lines), buttons
async def purge_review_messages(self, channel_id_or_all: str) -> Tuple[int, int, str]:
"""
Deletes review messages from the specified admin channel (or all admin channels).
Returns: (deleted_tg_count, cleared_db_count, summary_message)
"""
deleted_tg_count = 0
cleared_db_count = 0
if channel_id_or_all == "all":
posts = await self.repo.clear_all_review_messages()
cleared_db_count = len(posts)
by_channel: Dict[int, List[int]] = {}
for p in posts:
if p.media_path and os.path.exists(p.media_path):
try:
os.remove(p.media_path)
except Exception:
pass
ch_id = p.review_channel_id or self.review_channel_id
if ch_id and p.review_message_id:
by_channel.setdefault(ch_id, []).append(p.review_message_id)
for ch_id, msg_ids in by_channel.items():
for chunk_idx in range(0, len(msg_ids), 100):
chunk = msg_ids[chunk_idx:chunk_idx+100]
try:
await self.client.delete_messages(ch_id, chunk)
deleted_tg_count += len(chunk)
except Exception as e:
logger.warning(f"Error deleting chunk from channel {ch_id}: {e}")
summary = f"🔥 <b>تمام پیام‌های کانال‌های نظارت پاکسازی شدند:</b>\n\n• 🗑 پیام‌های حذف‌شده از تلگرام: <b>{deleted_tg_count}</b>\n• 🗂 پست‌های بایگانی‌شده در سیستم: <b>{cleared_db_count}</b>"
else:
ch_id = int(channel_id_or_all)
posts = await self.repo.clear_review_messages_for_channel(ch_id)
cleared_db_count = len(posts)
msg_ids = []
for p in posts:
if p.media_path and os.path.exists(p.media_path):
try:
os.remove(p.media_path)
except Exception:
pass
if p.review_message_id:
msg_ids.append(p.review_message_id)
for chunk_idx in range(0, len(msg_ids), 100):
chunk = msg_ids[chunk_idx:chunk_idx+100]
try:
await self.client.delete_messages(ch_id, chunk)
deleted_tg_count += len(chunk)
except Exception as e:
logger.warning(f"Error deleting chunk from channel {ch_id}: {e}")
summary = f"🗑 <b>پیام‌های کانال نظارت <code>{ch_id}</code> پاکسازی شدند:</b>\n\n• 🗑 پیام‌های حذف‌شده از تلگرام: <b>{deleted_tg_count}</b>\n• 🗂 پست‌های بایگانی‌شده در سیستم: <b>{cleared_db_count}</b>"
return deleted_tg_count, cleared_db_count, summary
async def _render_monitor_hub(self) -> Tuple[str, List[List[Button]]]: async def _render_monitor_hub(self) -> Tuple[str, List[List[Button]]]:
rep = await get_instant_metrics_report() rep = await get_instant_metrics_report()
buttons = [ buttons = [
@@ -1597,6 +1834,22 @@ class AdminBotService:
text, buttons = await self._render_categories_menu() text, buttons = await self._render_categories_menu()
await event.reply(text, parse_mode="html", buttons=buttons or None) await event.reply(text, parse_mode="html", buttons=buttons or None)
# --- Admin Review Channels Management ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/admin_channels|/review_channels|📋 کانال‌های نظارت|📋 کانال‌های نظارت ادمین)$"))
async def cmd_admin_channels(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
text, buttons = await self._render_admin_channels_menu()
await event.reply(text, parse_mode="html", buttons=buttons or None)
# --- Purge Review Channels ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/purge|/clear_reviews|🗑 پاکسازی کانال‌های ادمین|🗑 پاکسازی کانال‌های نظارت)$"))
async def cmd_purge_admin_channels(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
text, buttons = await self._render_purge_admin_channels_menu()
await event.reply(text, parse_mode="html", buttons=buttons or None)
# --- Unified Add Hub --- # --- Unified Add Hub ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/add|➕ افزودن مبدا / مقصد)$")) @self.client.on(events.NewMessage(pattern=r"(?i)^(/add|➕ افزودن مبدا / مقصد)$"))
async def cmd_add_hub(event: events.NewMessage.Event): async def cmd_add_hub(event: events.NewMessage.Event):
@@ -1834,6 +2087,33 @@ class AdminBotService:
) )
await event.reply(card, parse_mode="html", buttons=buttons) await event.reply(card, parse_mode="html", buttons=buttons)
# 2.1. Waiting for Admin Review Channel Forward or Text
elif action == "wait_admin_channel_fwd":
ch_id, title, username, err = await self._resolve_channel(event)
if err or not ch_id:
await event.reply(err or "خطا در دریافت اطلاعات کانال نظارت. لطفا مجدد ارسال کنید:", buttons=get_cancel_button())
return
src_id = state.get("src_id")
site_id = state.get("site_id")
self.user_states.pop(event.sender_id, None)
adm_id = await self.repo.add_admin_channel(channel_id=ch_id, title=title, username=username)
if src_id:
await self.repo.set_source_admin_channel(src_id, ch_id)
card, buttons = await self._render_source_config(src_id)
await event.reply(f"✅ کانال نظارت <b>{title}</b> با شناسه <code>{ch_id}</code> افزوده و به کانال مبدا اختصاص داده شد!", parse_mode="html")
await event.reply(card, parse_mode="html", buttons=buttons)
elif site_id:
await self.repo.set_website_admin_channel(site_id, ch_id)
card, buttons = await self._render_website_config(site_id)
await event.reply(f"✅ کانال نظارت <b>{title}</b> با شناسه <code>{ch_id}</code> افزوده و به وبسایت مبدا اختصاص داده شد!", parse_mode="html")
await event.reply(card, parse_mode="html", buttons=buttons)
else:
card, buttons = await self._render_admin_channel_detail(adm_id)
await event.reply(f"✅ کانال نظارت ادمین <b>{title}</b> با شناسه <code>{ch_id}</code> با موفقیت افزوده شد!", parse_mode="html")
await event.reply(card, parse_mode="html", buttons=buttons)
# 3. Waiting for Target Personality # 3. Waiting for Target Personality
elif action == "wait_personality": elif action == "wait_personality":
target_id = state.get("target_id") target_id = state.get("target_id")
@@ -3128,6 +3408,130 @@ class AdminBotService:
await event.edit(card, parse_mode="html", buttons=buttons) await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer("✅ دسته‌بندی کانال با موفقیت به روز شد!") await event.answer("✅ دسته‌بندی کانال با موفقیت به روز شد!")
# --- Admin Review Channels Management Callbacks ---
elif data == "list_adm_channels":
text, buttons = await self._render_admin_channels_menu()
await event.edit(text, parse_mode="html", buttons=buttons)
await event.answer()
elif data == "add_adm_ch":
self.user_states[event.sender_id] = {"action": "wait_admin_channel_fwd"}
guide = (
"📋 <b>افزودن کانال نظارت و بازبینی ادمین:</b>\n\n"
"ساده‌ترین روش: <b>یک پیام از کانال نظارت مورد نظر به این ربات فوروارد (Forward) کنید!</b>\n\n"
"<i>(یا می‌توانید آیدی، یوزرنیم یا لینک کانال مثل @admin_review_channel را ارسال نمایید. مطمئن شوید ربات در آن کانال ادمین با دسترسی ارسال و حذف پیام است)</i>"
)
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
await event.answer()
elif data.startswith("adm_view:"):
adm_id = int(data.split(":")[1])
text, buttons = await self._render_admin_channel_detail(adm_id)
await event.edit(text, parse_mode="html", buttons=buttons)
await event.answer()
elif data.startswith("adm_set_def:"):
adm_id = int(data.split(":")[1])
await self.repo.set_default_admin_channel(adm_id)
text, buttons = await self._render_admin_channel_detail(adm_id)
await event.edit(text, parse_mode="html", buttons=buttons)
await event.answer("⭐️ کانال به‌عنوان پیش‌فرض سیستم تنظیم شد!")
elif data.startswith("adm_del:"):
adm_id = int(data.split(":")[1])
await self.repo.delete_admin_channel(adm_id)
text, buttons = await self._render_admin_channels_menu()
await event.edit(f"🗑 <b>کانال نظارت ادمین حذف شد.</b>\n\n{text}", parse_mode="html", buttons=buttons)
await event.answer("کانال نظارت حذف شد.")
# --- Source / Website Admin Channel Assignment Callbacks ---
elif data.startswith("src_adm:"):
src_id = int(data.split(":")[1])
text, buttons = await self._render_source_admin_channel_menu(src_id)
await event.edit(text, parse_mode="html", buttons=buttons)
await event.answer()
elif data.startswith("src_set_adm:"):
_, src_id_str, adm_ch_str = data.split(":")
src_id = int(src_id_str)
adm_ch = None if adm_ch_str == "none" else int(adm_ch_str)
await self.repo.set_source_admin_channel(src_id, adm_ch)
card, buttons = await self._render_source_config(src_id)
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer("✅ کانال نظارت برای این مبدا تنظیم شد!")
elif data.startswith("src_add_adm:"):
src_id = int(data.split(":")[1])
self.user_states[event.sender_id] = {"action": "wait_admin_channel_fwd", "src_id": src_id}
guide = (
"📋 <b>افزودن کانال نظارت جدید برای این مبدا:</b>\n\n"
"یک پیام از کانال نظارت مورد نظر به ربات فوروارد کنید یا یوزرنیم/آیدی آن را بفرستید:"
)
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
await event.answer()
elif data.startswith("web_adm:"):
site_id = int(data.split(":")[1])
text, buttons = await self._render_website_admin_channel_menu(site_id)
await event.edit(text, parse_mode="html", buttons=buttons)
await event.answer()
elif data.startswith("web_set_adm:"):
_, site_id_str, adm_ch_str = data.split(":")
site_id = int(site_id_str)
adm_ch = None if adm_ch_str == "none" else int(adm_ch_str)
await self.repo.set_website_admin_channel(site_id, adm_ch)
card, buttons = await self._render_website_config(site_id)
await event.edit(card, parse_mode="html", buttons=buttons)
await event.answer("✅ کانال نظارت برای این وبسایت تنظیم شد!")
elif data.startswith("web_add_adm:"):
site_id = int(data.split(":")[1])
self.user_states[event.sender_id] = {"action": "wait_admin_channel_fwd", "site_id": site_id}
guide = (
"📋 <b>افزودن کانال نظارت جدید برای این وبسایت:</b>\n\n"
"یک پیام از کانال نظارت مورد نظر به ربات فوروارد کنید یا یوزرنیم/آیدی آن را بفرستید:"
)
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
await event.answer()
# --- Purge Review Messages Callbacks ---
elif data == "purge_adm_menu":
text, buttons = await self._render_purge_admin_channels_menu()
await event.edit(text, parse_mode="html", buttons=buttons)
await event.answer()
elif data.startswith("purge_adm_ask:"):
target_ch = data.split(":", 1)[1]
if target_ch == "all":
prompt_text = (
"⚠️ <b>هشدار مهم: پاکسازی تمام کانال‌های نظارت ادمین!</b>\n\n"
"آیا از حذف کامل تمام کارت‌های بازبینی و پیام‌های ارسال‌شده در <b>تمام کانال‌های نظارت</b> مطمئن هستید؟\n\n"
"<i>این عملیات پیام‌ها را از کانال‌های تلگرام حذف کرده و وضعیت پست‌ها را بایگانی می‌کند.</i>"
)
else:
prompt_text = (
f"⚠️ <b>هشدار: پاکسازی پیام‌های کانال نظارت <code>{target_ch}</code></b>\n\n"
f"آیا از حذف تمام کارت‌های بازبینی ارسال‌شده به این کانال مطمئن هستید؟"
)
confirm_buttons = [
[Button.inline("🗑 بله، پیام‌ها پاکسازی شوند", data=f"purge_adm_do:{target_ch}")],
[Button.inline("❌ انصراف و بازگشت", data="purge_adm_menu")]
]
await event.edit(prompt_text, parse_mode="html", buttons=confirm_buttons)
await event.answer()
elif data.startswith("purge_adm_do:"):
target_ch = data.split(":", 1)[1]
await event.edit("⏳ <b>در حال پاکسازی پیام‌ها از تلگرام و پایگاه‌داده...</b>", parse_mode="html", buttons=None)
_, _, summary = await self.purge_review_messages(target_ch)
back_buttons = [
[Button.inline("🗑 بازگشت به منوی پاکسازی", data="purge_adm_menu")],
[Button.inline("🔙 منوی سیستم", data="hub_system")]
]
await event.edit(summary, parse_mode="html", buttons=back_buttons)
await event.answer("✅ پاکسازی با موفقیت انجام شد!")
# --- Review Channel Buttons --- # --- Review Channel Buttons ---
elif data.startswith("hist:"): elif data.startswith("hist:"):