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/.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/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..e5609b6 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,170 @@ +name: Publish Image + +on: + push: + branches: ["main"] + tags: ["v*.*.*"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + 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 + + - 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: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.vars.outputs.image }} + # 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 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + 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: | + 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: 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: + 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: ${{ steps.vars.outputs.image }} + # 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="${{ steps.vars.outputs.image }}" + + 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"] 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/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/button_helpers.py b/handlers/button/button_helpers.py index 2f89a25..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 = [] @@ -404,7 +427,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/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/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/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/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/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 eeb1260..3759559 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) @@ -136,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') @@ -365,6 +367,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 = { @@ -392,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 @@ -481,4 +498,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/requirements.txt b/requirements.txt index a62e1c2..4666380 100644 Binary files a/requirements.txt and b/requirements.txt differ 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}') 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: