From 3bb4af632f5ff50ec9c7933b7aba5680cf727110 Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:02:07 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E5=9C=A8=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=BC=80=E5=90=AF=E4=BA=86`Topics`=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=9A=84=E6=83=85=E5=86=B5=E4=B8=8B=EF=BC=8C=E4=BD=BF?= =?UTF-8?q?=E7=94=A8`/bind`=E5=91=BD=E4=BB=A4=E5=B0=86=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=20Topic=20=E4=BF=A1=E6=81=AF=EF=BC=8C=E5=B9=B6=E4=B8=94?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E8=A7=84=E5=88=99=E7=9A=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=BC=9A=E8=A2=AB=E6=8A=95=E9=80=92=E5=88=B0=20Topic=20?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- filters/sender_filter.py | 73 ++++++++++++++++++++-- handlers/button/callback/other_callback.py | 2 +- handlers/button/settings_manager.py | 6 +- handlers/command_handlers.py | 30 +++++++-- models/models.py | 4 +- utils/common.py | 16 +++++ 6 files changed, 116 insertions(+), 15 deletions(-) diff --git a/filters/sender_filter.py b/filters/sender_filter.py index a22864f..be6c9b6 100644 --- a/filters/sender_filter.py +++ b/filters/sender_filter.py @@ -6,11 +6,66 @@ logger = logging.getLogger(__name__) +_TOPIC_REPLY_ERROR_TOKENS = ( + "MSG_ID_INVALID", + "MESSAGE_ID_INVALID", + "REPLY_MESSAGE_ID_INVALID", + "TOPIC_DELETED", + "TOPIC_CLOSED", + "THREAD_ID_INVALID", + "TOPIC_ID_INVALID", +) + +def _is_topic_reply_error(exc): + text = f"{type(exc).__name__} {exc}".upper() + normalized = text.replace("_", "") + for token in _TOPIC_REPLY_ERROR_TOKENS: + if token in text: + return True + if token.replace("_", "") in normalized: + return True + return False + + class SenderFilter(BaseFilter): """ 消息发送过滤器,用于发送处理后的消息 """ + async def _send_message_with_topic_retry(self, client, rule, target_chat_id, *args, **kwargs): + topic_id = getattr(rule, 'target_topic_id', None) + reply_to = topic_id + if reply_to is None: + return await client.send_message(target_chat_id, *args, **kwargs) + + try: + return await client.send_message(target_chat_id, *args, reply_to=reply_to, **kwargs) + except Exception as e: + if _is_topic_reply_error(e): + logger.warning( + f'发送消息到Topic失败,降级到主聊天重试 (rule_id={rule.id}, ' + f'target_chat_id={target_chat_id}, topic_id={topic_id}, error={str(e)})' + ) + return await client.send_message(target_chat_id, *args, **kwargs) + raise + + async def _send_file_with_topic_retry(self, client, rule, target_chat_id, *args, **kwargs): + topic_id = getattr(rule, 'target_topic_id', None) + reply_to = topic_id + if reply_to is None: + return await client.send_file(target_chat_id, *args, **kwargs) + + try: + return await client.send_file(target_chat_id, *args, reply_to=reply_to, **kwargs) + except Exception as e: + if _is_topic_reply_error(e): + logger.warning( + f'发送文件到Topic失败,降级到主聊天重试 (rule_id={rule.id}, ' + f'target_chat_id={target_chat_id}, topic_id={topic_id}, error={str(e)})' + ) + return await client.send_file(target_chat_id, *args, **kwargs) + raise + async def _process(self, context): """ 发送处理后的消息 @@ -159,7 +214,9 @@ async def _send_media_group(self, context, target_chat_id, parse_mode): caption_text += context.time_info + context.original_link # 作为一个组发送所有文件 - sent_messages = await client.send_file( + sent_messages = await self._send_file_with_topic_retry( + client, + rule, target_chat_id, files, caption=caption_text, @@ -214,7 +271,9 @@ async def _send_single_media(self, context, target_chat_id, parse_mode): text_to_send += original_link - await client.send_message( + await self._send_message_with_topic_retry( + client, + rule, target_chat_id, text_to_send, parse_mode=parse_mode, @@ -238,7 +297,9 @@ async def _send_single_media(self, context, target_chat_id, parse_mode): context.original_link ) - await client.send_file( + await self._send_file_with_topic_retry( + client, + rule, target_chat_id, file_path, caption=caption, @@ -284,11 +345,13 @@ async def _send_text_message(self, context, target_chat_id, parse_mode): # 组合消息文本 message_text = context.sender_info + context.message_text + context.time_info + context.original_link - await client.send_message( + await self._send_message_with_topic_retry( + client, + rule, target_chat_id, str(message_text), parse_mode=parse_mode, link_preview=link_preview, buttons=context.buttons ) - logger.info(f'{"带预览的" if link_preview else "无预览的"}文本消息已发送') \ No newline at end of file + logger.info(f'{"带预览的" if link_preview else "无预览的"}文本消息已发送') diff --git a/handlers/button/callback/other_callback.py b/handlers/button/callback/other_callback.py index 334ecdd..f6239cf 100644 --- a/handlers/button/callback/other_callback.py +++ b/handlers/button/callback/other_callback.py @@ -314,7 +314,7 @@ async def callback_perform_copy_rule(event, rule_id_data, session, message, data for column in inspector.columns: column_name = column.key if column_name not in ['id', 'source_chat_id', 'target_chat_id', 'source_chat', 'target_chat', - 'keywords', 'replace_rules', 'media_types']: + 'target_topic_id', 'keywords', 'replace_rules', 'media_types']: # 获取源规则的值并设置到目标规则 value = getattr(source_rule, column_name) setattr(target_rule, column_name, value) diff --git a/handlers/button/settings_manager.py b/handlers/button/settings_manager.py index 8bf56e4..5825552 100644 --- a/handlers/button/settings_manager.py +++ b/handlers/button/settings_manager.py @@ -445,10 +445,13 @@ async def create_settings_text(rule): """创建设置信息文本""" + topic_id = getattr(rule, 'target_topic_id', None) + topic_info = f" | Topic: `{topic_id}`\n" if topic_id is not None else "" + text = ( "📋 管理转发规则\n\n" f"规则ID: `{rule.id}`\n" - f"{rule.source_chat.name} --> {rule.target_chat.name}" + f"{rule.source_chat.name} --> {rule.target_chat.name}{topic_info}" ) return text @@ -652,4 +655,3 @@ async def create_buttons(rule): return buttons - diff --git a/handlers/command_handlers.py b/handlers/command_handlers.py index 0d68115..388c489 100644 --- a/handlers/command_handlers.py +++ b/handlers/command_handlers.py @@ -140,10 +140,20 @@ async def handle_bind_command(event, client, parts): if not target_chat_db.current_add_id: target_chat_db.current_add_id = str(source_chat_entity.id) + target_topic_id = None + # 只有在目标聊天是当前聊天时,才尝试提取 Topic ID + if not target_chat_input or str(current_chat.id) == str(target_chat_entity.id): + try: + target_topic_id = extract_topic_id(event.message) + except Exception as e: + logger.warning(f'提取目标Topic ID时出错: {str(e)}') + target_topic_id = None + # 创建转发规则 rule = ForwardRule( source_chat_id=source_chat_db.id, - target_chat_id=target_chat_db.id + target_chat_id=target_chat_db.id, + target_topic_id=target_topic_id ) # 如果是绑定自己,则默认使用白名单模式 @@ -155,10 +165,12 @@ async def handle_bind_command(event, client, parts): session.commit() await async_delete_user_message(event.client, event.message.chat_id, event.message.id, 0) + + topic_info = f" | Topic: `{target_topic_id}`\n" if target_topic_id is not None else "" await reply_and_delete(event, f'已设置转发规则:\n' f'源聊天: {source_chat_db.name} ({source_chat_db.telegram_chat_id})\n' - f'目标聊天: {target_chat_db.name} ({target_chat_db.telegram_chat_id})\n' + f'目标聊天: {target_chat_db.name} ({target_chat_db.telegram_chat_id}){topic_info}\n' f'请使用 /add 或 /add_regex 添加关键字', buttons=[Button.inline("⚙️ 打开设置", f"rule_settings:{rule.id}")] ) @@ -170,7 +182,8 @@ async def handle_bind_command(event, client, parts): f'已存在相同的转发规则:\n' f'源聊天: {source_chat_db.name}\n' f'目标聊天: {target_chat_db.name}\n' - f'如需修改请使用 /settings 命令' + f'如需修改请使用 /settings 命令\n' + f'如需绑定到某个 Topic,请先 /delete_rule 删除该规则,然后在目标群对应 Topic 内重新 /bind\n' ) return finally: @@ -835,7 +848,10 @@ async def handle_help_command(event, command): "• 括号内为命令的简写形式\n" "• 尖括号 <> 表示必填参数\n" "• 方括号 [] 表示可选参数\n" - "• 导入命令需要同时发送文件" + "• 导入命令需要同时发送文件\n\n" + "🧵 **Topics(线程)提示**\n" + "• 想把消息转发到某个 Topic:必须在目标群对应 Topic 内首次执行 /bind 创建规则\n" + "• 规则创建后暂不支持修改 Topic:请先删除规则,再在正确的 Topic 内重新 /bind" ) await async_delete_user_message(event.client, event.message.chat_id, event.message.id, 0) @@ -1670,7 +1686,7 @@ async def handle_copy_rule_command(event, command): for column in inspector.columns: column_name = column.key if column_name not in ['id', 'source_chat_id', 'target_chat_id', 'source_chat', 'target_chat', - 'keywords', 'replace_rules', 'media_types']: + 'target_topic_id', 'keywords', 'replace_rules', 'media_types']: # 获取源规则的值并设置到目标规则 value = getattr(source_rule, column_name) setattr(target_rule, column_name, value) @@ -2065,12 +2081,14 @@ async def handle_list_rule_command(event, command, parts): # 获取源聊天和目标聊天的名称 source_chat = rule.source_chat target_chat = rule.target_chat + topic_id = getattr(rule, 'target_topic_id', None) + topic_info = f" | Topic: `{topic_id}`\n" if topic_id is not None else "" # 构建规则描述 rule_desc = ( f'ID: {rule.id}\n' f'
来源: {source_chat.name} ({source_chat.telegram_chat_id})\n' - f'目标: {target_chat.name} ({target_chat.telegram_chat_id})\n' + f'目标: {target_chat.name} ({target_chat.telegram_chat_id}){topic_info}' '
' ) message_parts.append(rule_desc) diff --git a/models/models.py b/models/models.py index eeb1260..2efb05e 100644 --- a/models/models.py +++ b/models/models.py @@ -27,6 +27,7 @@ class ForwardRule(Base): id = Column(Integer, primary_key=True) source_chat_id = Column(Integer, ForeignKey('chats.id'), nullable=False) target_chat_id = Column(Integer, ForeignKey('chats.id'), nullable=False) + target_topic_id = Column(Integer, nullable=True) forward_mode = Column(Enum(ForwardMode), nullable=False, default=ForwardMode.BLACKLIST) use_bot = Column(Boolean, default=True) message_mode = Column(Enum(MessageMode), nullable=False, default=MessageMode.MARKDOWN) @@ -365,6 +366,7 @@ def migrate_db(engine): 'enable_only_push': 'ALTER TABLE forward_rules ADD COLUMN enable_only_push BOOLEAN DEFAULT FALSE', 'media_allow_text': 'ALTER TABLE forward_rules ADD COLUMN media_allow_text BOOLEAN DEFAULT FALSE', 'enable_ai_upload_image': 'ALTER TABLE forward_rules ADD COLUMN enable_ai_upload_image BOOLEAN DEFAULT FALSE', + 'target_topic_id': 'ALTER TABLE forward_rules ADD COLUMN target_topic_id INTEGER DEFAULT NULL', } keywords_new_columns = { @@ -481,4 +483,4 @@ def get_session(): logging.basicConfig(level=logging.INFO) engine = init_db() session = get_session() - logging.info("数据库初始化和迁移完成。") \ No newline at end of file + logging.info("数据库初始化和迁移完成。") diff --git a/utils/common.py b/utils/common.py index 71eb2f5..40450b1 100644 --- a/utils/common.py +++ b/utils/common.py @@ -15,6 +15,22 @@ logger = logging.getLogger(__name__) +def extract_topic_id(message): + rt = getattr(message, "reply_to", None) + if not rt: + return None + + if not getattr(rt, "forum_topic", False): + return None + + topic_id = getattr(rt, "reply_to_top_id", None) or getattr(rt, "reply_to_msg_id", None) + + # General 话题视作主聊天 + if topic_id in (None, 1): + return None + + return topic_id + async def get_main_module(): """获取 main 模块""" try: From 06b7e12ea31d0e38a20e9b8f4fa94c7e26f8c0cf Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:03:40 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=BD=91=E7=BB=9C=E4=BB=A3=E7=90=86=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E4=BE=BF=E5=9C=A8=E7=BD=91=E7=BB=9C=E5=8F=97=E9=99=90=E7=9A=84?= =?UTF-8?q?=E6=83=85=E5=86=B5=E4=B8=8B=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 +++++++++++++- main.py | 15 +++++++++++++-- requirements.txt | Bin 4158 -> 4198 bytes 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index fa1fef5..cd9ff7d 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,18 @@ CHAT_UPDATE_TIME=03:00 # 数据库配置 DATABASE_URL=sqlite:///./db/forward.db +######### 网络代理配置 ######### +# 代理类型 (http/socks5/socks4/none) +PROXY_TYPE=none +# 代理地址 +PROXY_HOST= +# 代理端口 +PROXY_PORT= +# 代理用户名 +PROXY_USERNAME= +# 代理密码 +PROXY_PASSWORD= + ######### UI 布局配置 ######### AI_MODELS_PER_PAGE=10 KEYWORDS_PER_PAGE=10 @@ -65,7 +77,7 @@ MEDIA_EXTENSIONS_COLS=6 # 每页显示的规则数量 RULES_PER_PAGE=20 - ######### AI设置 ######### +######### AI设置 ######### # 默认AI模型 DEFAULT_AI_MODEL=gemini-2.0-flash diff --git a/main.py b/main.py index b3bf10f..0d519e1 100644 --- a/main.py +++ b/main.py @@ -59,10 +59,21 @@ def clear_temp_dir(): for file in os.listdir('./temp'): os.remove(os.path.join('./temp', file)) +proxy = None +proxy_type = os.getenv('PROXY_TYPE') +if proxy_type and proxy_type.lower() != 'none': + proxy_host = os.getenv('PROXY_HOST') + proxy_port = os.getenv('PROXY_PORT') + if not proxy_host or not proxy_port: + logger.error("代理配置不完整,缺少 PROXY_HOST 或 PROXY_PORT") + raise ValueError("代理配置不完整,缺少 PROXY_HOST 或 PROXY_PORT") + proxy_username = os.getenv('PROXY_USERNAME', '') + proxy_password = os.getenv('PROXY_PASSWORD', '') + proxy = (proxy_type, proxy_host, int(proxy_port), True, proxy_username, proxy_password) # 创建客户端 -user_client = TelegramClient('./sessions/user', api_id, api_hash) -bot_client = TelegramClient('./sessions/bot', api_id, api_hash) +user_client = TelegramClient('./sessions/user', api_id, api_hash, proxy=proxy) +bot_client = TelegramClient('./sessions/bot', api_id, api_hash, proxy=proxy) # 初始化数据库 engine = init_db() diff --git a/requirements.txt b/requirements.txt index a62e1c29aaef7529d2824a649dd2a7aa1eddfb81..466638058074f2c83befe6b4a538dd068c1b7c74 100644 GIT binary patch delta 40 rcmdm|@JwOD8gA(fhJ1!R23>|?Af3#R4Ww;>(1<~g!D91#?)fYL=2Z#~ delta 12 UcmaE+uuoyb8t% Date: Mon, 9 Feb 2026 10:38:31 +0800 Subject: [PATCH 03/10] feat: add GitHub Actions workflow for publishing Docker images --- .github/workflows/publish-image.yml | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/publish-image.yml diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..d27d389 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,58 @@ +name: Publish Image + +on: + push: + # branches: [ "main" ] + tags: [ "v*.*.*" ] + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # latest 的默认行为是 auto;会在 semver/tag 等场景自动生成 latest + flavor: | + latest=auto + + # ✅ Git tag = v1.2.3 时,生成 1.2.3 / 1.2 / 1(注意:{{version}} 会去掉前缀 v) + # ✅ push 到 main 时也打 latest(推荐写法:用 is_default_branch 判断) + # ✅ 每次都生成提交 SHA tag(默认形如 sha-860c190) + tags: | + type=raw,value=latest,enable={{is_default_branch}} + + type=semver,pattern={{version}} # 1.2.3 + type=semver,pattern={{major}}.{{minor}} # 1.2 + type=semver,pattern={{major}} # 1 + + type=sha,format=short + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file From cf19c631b986f0616ec5d9fae8d0798fe376f2f7 Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:44:55 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=88=86=E6=94=AF=E4=B8=8D=E8=A7=A6=E5=8F=91action?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index d27d389..e697ae7 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -2,7 +2,7 @@ name: Publish Image on: push: - # branches: [ "main" ] + branches: [ "main", "ghcr" ] tags: [ "v*.*.*" ] jobs: From 02c75ae329ec10e72117e2f671bee74926f91ac2 Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:54:57 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=A8=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-image.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index e697ae7..9b1629e 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -5,6 +5,10 @@ on: branches: [ "main", "ghcr" ] tags: [ "v*.*.*" ] +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: docker: runs-on: ubuntu-latest From 499240ddc39de6483ea86e52144b7a875b073f36 Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:14:38 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20Github=20Actions=20=E5=8A=A0?= =?UTF-8?q?=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 + .github/workflows/publish-image.yml | 120 +++++++++++++++++++++++++--- Dockerfile | 33 ++++---- 3 files changed, 124 insertions(+), 30 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4bc78bf..7f849b4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,7 @@ db/forward.db1 **/venv/ **/env/ **/ENV/ +**/.venv/ # 忽略 Telethon 会话文件 *.session diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 9b1629e..eb2de60 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -2,19 +2,30 @@ name: Publish Image on: push: - branches: [ "main", "ghcr" ] - tags: [ "v*.*.*" ] + branches: ["main", "ghcr"] + tags: ["v*.*.*"] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: - docker: - runs-on: ubuntu-latest + build: + name: Build (${{ matrix.arch }}) + runs-on: ${{ matrix.runs_on }} permissions: contents: read packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + arch: amd64 + runs_on: ubuntu-latest + - platform: linux/arm64 + arch: arm64 + runs_on: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v5 @@ -34,7 +45,7 @@ jobs: # latest 的默认行为是 auto;会在 semver/tag 等场景自动生成 latest flavor: | latest=auto - + # ✅ Git tag = v1.2.3 时,生成 1.2.3 / 1.2 / 1(注意:{{version}} 会去掉前缀 v) # ✅ push 到 main 时也打 latest(推荐写法:用 is_default_branch 判断) # ✅ 每次都生成提交 SHA tag(默认形如 sha-860c190) @@ -47,16 +58,99 @@ jobs: type=sha,format=short - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build and push + - name: Build and push by digest + id: build uses: docker/build-push-action@v6 with: - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p digests + echo "${{ steps.build.outputs.digest }}" > "digests/${{ matrix.arch }}.digest" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.arch }} + path: digests/${{ matrix.arch }}.digest + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge manifest + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digests-* + path: digests + merge-multiple: true + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # latest 的默认行为是 auto;会在 semver/tag 等场景自动生成 latest + flavor: | + latest=auto + + # ✅ Git tag = v1.2.3 时,生成 1.2.3 / 1.2 / 1(注意:{{version}} 会去掉前缀 v) + # ✅ push 到 main 时也打 latest(推荐写法:用 is_default_branch 判断) + # ✅ 每次都生成提交 SHA tag(默认形如 sha-860c190) + tags: | + type=raw,value=latest,enable={{is_default_branch}} + + type=semver,pattern={{version}} # 1.2.3 + type=semver,pattern={{major}}.{{minor}} # 1.2 + type=semver,pattern={{major}} # 1 + + type=sha,format=short + + - name: Create multi-arch manifest and push tags + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + + refs=() + for f in digests/*.digest; do + digest="$(cat "$f")" + refs+=("${IMAGE}@${digest}") + done + + tag_args=() + while IFS= read -r tag; do + [[ -n "$tag" ]] || continue + tag_args+=("-t" "$tag") + done <<< "$TAGS" + + docker buildx imagetools create "${tag_args[@]}" "${refs[@]}" + + first_tag="$(printf '%s\n' "$TAGS" | sed -n '1p')" + docker buildx imagetools inspect "$first_tag" diff --git a/Dockerfile b/Dockerfile index 977292f..a4ecbb8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,33 @@ +FROM python:3.11-slim AS builder + +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + FROM python:3.11-slim -# 设置工作目录 WORKDIR /app -# 设置Docker日志配置 -ENV DOCKER_LOG_MAX_SIZE=10m -ENV DOCKER_LOG_MAX_FILE=3 +ENV DOCKER_LOG_MAX_SIZE=10m DOCKER_LOG_MAX_FILE=3 -# 安装系统依赖 -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ tzdata \ && ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && dpkg-reconfigure -f noninteractive tzdata \ - && apt-get install -y \ - gcc \ - python3-dev \ && rm -rf /var/lib/apt/lists/* -# 复制依赖文件并安装 -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY --from=builder /install /usr/local -# 创建临时文件目录 RUN mkdir -p /app/temp -# 复制应用代码 COPY . . -# 设置环境变量 ENV PYTHONUNBUFFERED=1 -# 启动命令 -CMD ["python", "main.py"] \ No newline at end of file +CMD ["python", "main.py"] From ed6fbd663ca05f6b2e0023e6f9e806267640512d Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:45:01 +0800 Subject: [PATCH 07/10] fix: update image name normalization in GitHub Actions workflow --- .github/workflows/publish-image.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index eb2de60..3d56b28 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -30,6 +30,12 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Normalize image name + id: vars + shell: bash + run: | + echo "image=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + - name: Login to GHCR uses: docker/login-action@v3 with: @@ -41,7 +47,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ steps.vars.outputs.image }} # latest 的默认行为是 auto;会在 semver/tag 等场景自动生成 latest flavor: | latest=auto @@ -67,7 +73,7 @@ jobs: with: platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + outputs: type=image,name=${{ steps.vars.outputs.image }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.arch }} @@ -99,6 +105,12 @@ jobs: path: digests merge-multiple: true + - name: Normalize image name + id: vars + shell: bash + run: | + echo "image=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + - name: Login to GHCR uses: docker/login-action@v3 with: @@ -113,7 +125,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ steps.vars.outputs.image }} # latest 的默认行为是 auto;会在 semver/tag 等场景自动生成 latest flavor: | latest=auto @@ -136,7 +148,7 @@ jobs: run: | set -euo pipefail - IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + IMAGE="${{ steps.vars.outputs.image }}" refs=() for f in digests/*.digest; do From 69809944d2680956225919b44125b980b137af5c Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:48:51 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=E7=94=9F?= =?UTF-8?q?=E6=88=90=20unknown/unknown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-image.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 3d56b28..a16b0ce 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -76,6 +76,8 @@ jobs: outputs: type=image,name=${{ steps.vars.outputs.image }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + provenance: false + sbom: false - name: Export digest run: | From 68ffc85d2036ad15c031f17e71f83c0f210bf805 Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:23:08 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=E5=AA=92=E4=BD=93=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E8=BF=87=E6=BB=A4=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?"=E8=B4=B4=E7=BA=B8"=E7=B1=BB=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- filters/media_filter.py | 15 +++++++++++++-- handlers/button/button_helpers.py | 3 ++- handlers/button/callback/media_callback.py | 2 +- models/db_operations.py | 7 ++++--- models/models.py | 15 +++++++++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/filters/media_filter.py b/filters/media_filter.py index 2dae0ba..201ea81 100644 --- a/filters/media_filter.py +++ b/filters/media_filter.py @@ -1,6 +1,7 @@ import logging import os import asyncio +from telethon.tl.types import DocumentAttributeSticker from utils.media import get_media_size from utils.constants import TEMP_DIR from filters.base_filter import BaseFilter @@ -252,7 +253,14 @@ async def _process_single_media(self, context): # 记录这是纯链接预览消息 context.is_pure_link_preview = True logger.info('这是一条纯链接预览消息') - + + def _is_sticker(self, media): + doc = getattr(media, 'document', None) + attributes = getattr(doc, 'attributes', None) if doc else None + if not attributes: + return False + return any(isinstance(attr, DocumentAttributeSticker) for attr in attributes) + async def _is_media_type_blocked(self, media, media_types): """ 检查媒体类型是否被屏蔽 @@ -268,6 +276,10 @@ async def _is_media_type_blocked(self, media, media_types): if getattr(media, 'photo', None) and media_types.photo: logger.info('媒体类型为图片,已被屏蔽') return True + + if getattr(media, 'document', None) and self._is_sticker(media) and getattr(media_types, 'sticker', False): + logger.info('媒体类型为贴纸,已被屏蔽') + return True if getattr(media, 'document', None) and media_types.document: logger.info('媒体类型为文档,已被屏蔽') @@ -360,4 +372,3 @@ async def _is_media_extension_allowed(self, rule, media): session.close() return allowed - diff --git a/handlers/button/button_helpers.py b/handlers/button/button_helpers.py index 2f89a25..ddb7deb 100644 --- a/handlers/button/button_helpers.py +++ b/handlers/button/button_helpers.py @@ -404,7 +404,8 @@ async def create_media_types_buttons(rule_id, media_types): 'document': '📄 文档', 'video': '🎬 视频', 'audio': '🎵 音频', - 'voice': '🎤 语音' + 'voice': '🎤 语音', + 'sticker': '🎭 贴纸' } for field, display_name in media_type_names.items(): diff --git a/handlers/button/callback/media_callback.py b/handlers/button/callback/media_callback.py index e3f638d..ab84e4c 100644 --- a/handlers/button/callback/media_callback.py +++ b/handlers/button/callback/media_callback.py @@ -141,7 +141,7 @@ async def callback_toggle_media_type(event, rule_id, session, message, data): rule_id = parts[1] media_type = parts[2] # 检查媒体类型是否有效 - if media_type not in ['photo', 'document', 'video', 'audio', 'voice']: + if media_type not in ['photo', 'document', 'video', 'audio', 'voice', 'sticker']: await event.answer(f"无效的媒体类型: {media_type}") return diff --git a/models/db_operations.py b/models/db_operations.py index 5ed62da..25be206 100644 --- a/models/db_operations.py +++ b/models/db_operations.py @@ -631,7 +631,8 @@ async def get_media_types(self, session, rule_id): document=False, video=False, audio=False, - voice=False + voice=False, + sticker=False ) session.add(media_types) session.commit() @@ -655,7 +656,7 @@ async def update_media_types(self, session, rule_id, media_types_dict): session.add(media_types) # 更新媒体类型设置 - for field in ['photo', 'document', 'video', 'audio', 'voice']: + for field in ['photo', 'document', 'video', 'audio', 'voice', 'sticker']: if field in media_types_dict: setattr(media_types, field, media_types_dict[field]) @@ -669,7 +670,7 @@ async def update_media_types(self, session, rule_id, media_types_dict): async def toggle_media_type(self, session, rule_id, media_type): """切换特定媒体类型的启用状态""" try: - if media_type not in ['photo', 'document', 'video', 'audio', 'voice']: + if media_type not in ['photo', 'document', 'video', 'audio', 'voice', 'sticker']: return False, f"无效的媒体类型: {media_type}" success, msg, media_types = await self.get_media_types(session, rule_id) diff --git a/models/models.py b/models/models.py index 2efb05e..3759559 100644 --- a/models/models.py +++ b/models/models.py @@ -137,6 +137,7 @@ class MediaTypes(Base): video = Column(Boolean, default=False) audio = Column(Boolean, default=False) voice = Column(Boolean, default=False) + sticker = Column(Boolean, default=False) # 关系 rule = relationship('ForwardRule', back_populates='media_types') @@ -394,6 +395,20 @@ def migrate_db(engine): except Exception as e: logging.error(f'添加列 {column} 时出错: {str(e)}') + # 添加media_types表的列(与 forward_rules/keywords 补列同一段) + if 'media_types' in existing_tables: + media_types_columns = {column['name'] for column in inspector.get_columns('media_types')} + media_types_new_columns = { + 'sticker': 'ALTER TABLE media_types ADD COLUMN sticker BOOLEAN DEFAULT FALSE', + } + for column, sql in media_types_new_columns.items(): + if column not in media_types_columns: + try: + connection.execute(text(sql)) + logging.info(f'已添加media_types.{column}列') + except Exception as e: + logging.error(f'添加media_types.{column}列时出错: {str(e)}') + #先检查forward_rules表的列的forward_mode是否存在 if 'forward_mode' not in forward_rules_columns: # 修改forward_rules表的列mode为forward_mode From f771bcda63c9a1d7cfb85b8ad08a4e8832a238fe Mon Sep 17 00:00:00 2001 From: RayShaw97 <152388631+RayShaw97@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:18:53 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E6=80=BB=E7=BB=93=E5=9B=9E=E6=BA=AF=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/button/button_helpers.py | 23 +++++ handlers/button/callback/ai_callback.py | 85 ++++++++++++++++--- handlers/button/callback/callback_handlers.py | 1 + scheduler/summary_scheduler.py | 23 ++--- 4 files changed, 110 insertions(+), 22 deletions(-) diff --git a/handlers/button/button_helpers.py b/handlers/button/button_helpers.py index ddb7deb..25a8f3e 100644 --- a/handlers/button/button_helpers.py +++ b/handlers/button/button_helpers.py @@ -12,6 +12,14 @@ DELAY_TIMES = load_delay_times() MEDIA_SIZE = load_max_media_size() MEDIA_EXTENSIONS = load_media_extensions() +SUMMARY_NOW_RANGES = [ + ("按总结时间", "schedule", None), + ("1小时", "1h", 1), + ("2小时", "2h", 2), + ("4小时", "4h", 4), + ("6小时", "6h", 6), + ("12小时", "12h", 12), +] async def create_ai_settings_buttons(rule=None,rule_id=None): """创建 AI 设置按钮""" buttons = [] @@ -50,6 +58,21 @@ async def create_ai_settings_buttons(rule=None,rule_id=None): return buttons + +async def create_summary_now_range_buttons(rule_id): + """创建立即总结回溯范围选择按钮""" + buttons = [] + for label, value, _ in SUMMARY_NOW_RANGES: + buttons.append([Button.inline(label, f"summary_now_range:{rule_id}:{value}")]) + + buttons.append([ + Button.inline('👈 返回', f"ai_settings:{rule_id}"), + Button.inline('❌ 关闭', "close_settings") + ]) + + return buttons + + async def create_media_settings_buttons(rule=None,rule_id=None): """创建媒体设置按钮""" buttons = [] diff --git a/handlers/button/callback/ai_callback.py b/handlers/button/callback/ai_callback.py index 04b73eb..6422498 100644 --- a/handlers/button/callback/ai_callback.py +++ b/handlers/button/callback/ai_callback.py @@ -4,7 +4,13 @@ import asyncio from telethon.tl import types -from handlers.button.button_helpers import create_ai_settings_buttons, create_model_buttons, create_summary_time_buttons +from handlers.button.button_helpers import ( + SUMMARY_NOW_RANGES, + create_ai_settings_buttons, + create_model_buttons, + create_summary_now_range_buttons, + create_summary_time_buttons, +) from models.models import ForwardRule, RuleSync from telethon import Button import logging @@ -16,6 +22,13 @@ logger = logging.getLogger(__name__) +def _get_summary_now_range(range_value): + for label, value, hours in SUMMARY_NOW_RANGES: + if value == range_value: + return label, hours + return None + + async def callback_ai_settings(event, rule_id, session, message, data): # 显示 AI 设置页面 try: @@ -334,38 +347,86 @@ async def callback_cancel_set_summary(event, rule_id, session, message, data): return async def callback_summary_now(event, rule_id, session, message, data): - # 处理立即执行总结的回调 - logger.info(f"处理立即执行总结回调 - rule_id: {rule_id}") - + # 处理立即执行总结的回溯范围选择 + logger.info(f"处理立即执行总结范围选择回调 - rule_id: {rule_id}") + try: rule = session.query(ForwardRule).get(int(rule_id)) if not rule: await event.answer("规则不存在") return - + + await event.edit( + "请选择立即总结的回溯范围:", + buttons=await create_summary_now_range_buttons(rule_id) + ) + except Exception as e: + logger.error(f"处理立即总结范围选择时出错: {str(e)}") + logger.error(traceback.format_exc()) + await event.answer(f"处理时出错: {str(e)}") + finally: + session.close() + + return + + +async def callback_summary_now_range(event, rule_id, session, message, data): + # 按选择的回溯范围立即执行总结 + logger.info(f"处理立即执行总结范围回调 - data: {data}") + + try: + parts = data.split(':', 2) + if len(parts) != 3: + await event.answer("回调数据格式错误") + return + + _, rule_id_part, range_value = parts + range_option = _get_summary_now_range(range_value) + if not range_option: + await event.answer("未知的总结范围") + return + + range_label, lookback_hours = range_option + rule = session.query(ForwardRule).get(int(rule_id_part)) + if not rule: + await event.answer("规则不存在") + return + main = await get_main_module() user_client = main.user_client bot_client = main.bot_client scheduler = SummaryScheduler(user_client, bot_client) await event.answer("开始执行总结,请稍候...") - + + range_text = ( + "按总结时间生成总结" + if lookback_hours is None + else f"生成最近 {lookback_hours} 小时总结" + ) + await message.edit( - f"正在为规则 {rule_id}({rule.source_chat.name} -> {rule.target_chat.name})生成总结...\n" + f"正在为规则 {rule_id_part}({rule.source_chat.name} -> {rule.target_chat.name}){range_text}...\n" f"处理需要一定时间,请耐心等待。", - buttons=[[Button.inline("返回", f"ai_settings:{rule_id}")]] + buttons=[[Button.inline("返回", f"ai_settings:{rule_id_part}")]] ) - + try: # 执行总结任务 - await asyncio.create_task(scheduler._execute_summary(rule.id,is_now=True)) - logger.info(f"已启动规则 {rule_id} 的立即总结任务") + await asyncio.create_task( + scheduler._execute_summary( + rule.id, + is_now=True, + lookback_hours=lookback_hours + ) + ) + logger.info(f"已启动规则 {rule_id_part} 的立即总结任务,范围: {range_label}") except Exception as e: logger.error(f"执行总结任务失败: {str(e)}") logger.error(traceback.format_exc()) await message.edit( f"总结生成失败: {str(e)}", - buttons=[[Button.inline("返回", f"ai_settings:{rule_id}")]] + buttons=[[Button.inline("返回", f"ai_settings:{rule_id_part}")]] ) except Exception as e: logger.error(f"处理总结时出错: {str(e)}") diff --git a/handlers/button/callback/callback_handlers.py b/handlers/button/callback/callback_handlers.py index 67db1bd..008c159 100644 --- a/handlers/button/callback/callback_handlers.py +++ b/handlers/button/callback/callback_handlers.py @@ -654,6 +654,7 @@ async def handle_callback(event): 'cancel_set_prompt': callback_cancel_set_prompt, 'cancel_set_summary': callback_cancel_set_summary, 'summary_now':callback_summary_now, + 'summary_now_range': callback_summary_now_range, # 媒体设置 'select_max_media_size': callback_select_max_media_size, 'set_max_media_size': callback_set_max_media_size, diff --git a/scheduler/summary_scheduler.py b/scheduler/summary_scheduler.py index 4b10edf..2c1066d 100644 --- a/scheduler/summary_scheduler.py +++ b/scheduler/summary_scheduler.py @@ -123,7 +123,7 @@ def _get_next_run_time(self, now, target_time): return next_time - async def _execute_summary(self, rule_id, is_now=False): + async def _execute_summary(self, rule_id, is_now=False, lookback_hours=None): """执行单个规则的总结任务""" session = get_session() try: @@ -140,18 +140,21 @@ async def _execute_summary(self, rule_id, is_now=False): # 计算时间范围 now = datetime.now(self.timezone) - summary_hour, summary_minute = map(int, rule.summary_time.split(':')) - # 设置结束时间为当前时间 end_time = now - # 设置开始时间为前一天的总结时间 - start_time = now.replace( - hour=summary_hour, - minute=summary_minute, - second=0, - microsecond=0 - ) - timedelta(days=1) + if lookback_hours is not None: + start_time = now - timedelta(hours=lookback_hours) + else: + summary_hour, summary_minute = map(int, rule.summary_time.split(':')) + + # 设置开始时间为前一天的总结时间 + start_time = now.replace( + hour=summary_hour, + minute=summary_minute, + second=0, + microsecond=0 + ) - timedelta(days=1) logger.info(f'规则 {rule_id} 获取消息时间范围: {start_time} 到 {end_time}')