Creator Dave, on 16Mar. 2025
增加了 auth.py 代码 , 第一次使用这个项目程序,必须要用到的。写这文时,当时有忘记它,已经补上。
Updated on 18Mar.2025 转发问题,修改 process_message() ;统计部分增加翻页、调整行数功能;启动后自动运行“Start Forwarde” ,不需要再点击。添加SSL证书支持(证书放在/app/里)。
代码已经在文章中更新。
入口见:https:/jpt.daven.us:9018 (禁用了 "stop forwarder" button)
目的:
Telegram 后面使用 TG 作为简称。
在 TG 中,内容可以存在 组 Group,或 话题 Topic 里面。 组还有一个特殊功能是,在里面创建很多话题 Topics
TG 中人人都可以创建组或话题是(后面用 GT 作为简称),GT 就非常的多,每个 GT 下面有各自的内容,所以信息非常分散。平时要一一点开看,里面还充斥着广告,还有各种颜色视频、文字等 “妈妈禁止” 的坏内容要过滤掉。
但,我只对信息安全、国家经济信息感兴趣,别人已经过滤的信息发到自己的组里,即:把它们汇总到一个 G/T 中可以大大地减少阅读筛选。 如果要对公众展示,依据:原创新闻按照CC-BY-4.0协议发布 (只要标注新闻来源), 你可以自由地转发这些信息。
环境:
要有一个 Telegram 账号
要在TG 获得 API ID 和 API HASH ,申请用这个官网链接 https://my.telegram.org/
程序可以单机运行,我把它用 DOCKER 部署在 NAS 运行 (NAS 要能上外网)
项目: 38.Tg2Tg ver.0.4
更新历史:
0.1 基础功能实现:填写源,目标,条件词 转发信息,有统计功能
0.2 修复几个问题:配置不会保存,按钮事件不更新。
0.3 对组下的 topic 支持,填写格式: c/<group_name>/<topic_name>, 新功能:支持多组条件,可以开启或关闭以组为单位的条件
0.4 增加 :“排除”关键词功能:默认填写 None;全总内容匹配条件:通配符 ALL 或 *
1. 界面:
2. 主要功能介绍
- 消息监控和转发
- 监控多个源频道/群组的消息
- 根据关键词匹配自动转发消息到指定目标
- 支持将原始消息包括文本和媒体文件转发至目标
- 关键词过滤
- 基于关键词匹配转发消息
- 支持通配符("*"或"ALL")以转发所有消息
- 排除关键词功能:如果消息包含特定的排除关键词,即使匹配了普通关键词也不会被转发
- 灵活的转发配置
- 支持创建多个转发组,每组有独立的源、目标和关键词设置
- 每个转发组可以单独启用或禁用
- 支持将消息转发到 Telegram 话题(topics)中
- 统计数据和监控
- 跟踪消息监控和转发的详细统计信息
- 显示每个组、源和目标的消息处理情况
- 记录关键词匹配和排除情况的统计
- 计算转发率(被转发消息占总监控消息的百分比)
- 用户友好的 Web 界面
- 实时显示转发服务状态
- 直观地管理转发组配置
- 查看详细的统计数据
- 可视化操作界面,无需命令行操作
- 配置持久化
- 自动保存转发配置和统计数据
- 任务重启后自动恢复配置
- 定期(每10分钟)自动保存数据
(以上是 AI 读代码后总结,要能给格式就好了。)
3. 目录结构
/
├── app.py # 主应用程序
├── static/
│ ├── css/
│ │ └── styles.css
│ └── js/
│ └── app.js
├── templates/
│ └── index.html # 主页
├── forwarder_config.json # APP配置文件
├── message_forwarder.session # Telegram 会话文件 会自动创建
├── .env # API info
└── requirements.txt # Python 需要的安装依赖包清单
4. 完整代码 (AI 修改过)
主要是5个文件
1) 主程序 app.py
from telethon import TelegramClient, events
from telethon.tl.types import PeerChannel
import asyncio
import re
import logging
import os
from dotenv import load_dotenv
from flask import Flask, request, jsonify, render_template, redirect, url_for
import threading
import json
import nest_asyncio
import time
import ssl
# 配置文件路径
CONFIG_FILE = 'forwarder_config.json'
# 证书文件路径
CERT_PATH = '/app/fullchain.pem'
KEY_PATH = '/app/privkey.pem'
def save_config():
"""保存当前配置到文件"""
config = {
'forwarding_groups': FORWARDING_GROUPS,
'stats': stats
}
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
logger.info(f"Configuration saved to {CONFIG_FILE}")
except Exception as e:
logger.error(f"Failed to save configuration: {e}")
def load_config():
"""从文件加载配置"""
global FORWARDING_GROUPS, stats, DEFAULT_FORWARDING_GROUPS
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
config = json.load(f)
# 更新当前配置
FORWARDING_GROUPS = config.get('forwarding_groups', DEFAULT_FORWARDING_GROUPS)
# 更新统计信息
saved_stats = config.get('stats', {})
# 确保所有必要的统计字段都存在
if 'total_messages_seen' in saved_stats:
stats = saved_stats
logger.info(f"Configuration loaded from {CONFIG_FILE}")
return True
except Exception as e:
logger.error(f"Failed to load configuration: {e}")
return False
# Apply nest_asyncio to allow nested event loops
nest_asyncio.apply()
# Setup logging
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO)
logger = logging.getLogger(__name__)
# Load environment variables from .env file
load_dotenv()
# Telegram API credentials - you'll need to get these from https://my.telegram.org
API_ID = os.getenv('API_ID')
API_HASH = os.getenv('API_HASH')
SESSION_NAME = 'message_forwarder'
# 默认转发配置组
DEFAULT_FORWARDING_GROUPS = [
{
"id": "group1",
"name": "Finance Group",
"sources": ["FinanceNewsDaily"],
"destinations": ["c/cnbbs001/258522"],
"keywords": ["财经", "经济", "金融", "股市", "投资"],
"exclude_keywords": [], # 添加排除关键词列表
"enabled": True # 添加启用状态标志
}
]
# 全局状态跟踪变量
is_running = False
client = None
forwarder_thread = None
stats = {
"total_messages_seen": 0,
"messages_forwarded": 0,
"last_forwarded": None,
"keyword_matches": {},
"source_stats": {},
"destination_stats": {},
"group_stats": {} # 新增:按转发配置组统计
}
FORWARDING_GROUPS = DEFAULT_FORWARDING_GROUPS.copy()
last_save_time = 0
# Create Flask app
app = Flask(__name__, static_folder='static', template_folder='templates')
class MessageForwarder:
def __init__(self, forwarding_groups=DEFAULT_FORWARDING_GROUPS, loop=None):
# Use provided loop or create a new one
self.loop = loop or asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
# Initialize the client with the loop and additional parameters for better connection handling
self.client = TelegramClient(
SESSION_NAME,
API_ID,
API_HASH,
loop=self.loop,
connection_retries=None, # Infinite connection retries
auto_reconnect=True, # Automatically reconnect
sequential_updates=True # Process updates sequentially to avoid DB conflicts
)
self.forwarding_groups = forwarding_groups
# 从所有转发组中提取唯一的源列表
self.all_sources = self._get_all_sources()
self.running = False
def _get_all_sources(self):
"""从所有转发组中提取唯一源列表"""
all_sources = []
for group in self.forwarding_groups:
# 只添加启用的组中的源
if group.get("enabled", True):
for source in group["sources"]:
if source not in all_sources:
all_sources.append(source)
return all_sources
async def start(self):
"""Start the client and register handlers"""
global is_running
await self.client.start()
logger.info("Client started")
is_running = True
# 为每个唯一的源注册消息处理器
for source in self.all_sources:
# 创建一个闭包工厂函数
def make_handler(src):
async def source_handler(event):
logger.info(f"Received message from {src}: {event.message.text[:50] if event.message.text else 'No text'}")
await self.process_message(event, src)
return source_handler
# 为每个源注册独立的处理器
handler_func = make_handler(source)
self.client.add_event_handler(
handler_func,
events.NewMessage(chats=source)
)
# 打印监控信息
sources_str = ", ".join(self.all_sources)
logger.info(f"Monitoring messages from {sources_str}")
# 打印每个转发组的配置
for i, group in enumerate(self.forwarding_groups, 1):
enabled_status = "Enabled" if group.get("enabled", True) else "Disabled"
logger.info(f"Group {i} ({group['name']}) [{enabled_status}]: {', '.join(group['sources'])} -> {', '.join(group['destinations'])} | Keywords: {', '.join(group['keywords'])}")
# Keep the client running
await self.client.run_until_disconnected()
async def process_message(self, event, source_chat):
try:
"""Process incoming messages for all forwarding groups that include this source"""
global stats
message_text = event.message.text if event.message.text else ""
logger.info(f"Processing message from {source_chat} (channel ID: {event.chat_id})")
logger.info(f"Message content: {message_text[:100]}...")
stats["total_messages_seen"] += 1
# 调试日志
logger.info(f"Processing message: source={source_chat}, chat_id={event.chat_id}, text={message_text[:100]}...")
# Update source statistics
if source_chat not in stats["source_stats"]:
stats["source_stats"][source_chat] = {"messages_seen": 0, "messages_forwarded": 0}
stats["source_stats"][source_chat]["messages_seen"] += 1
# 查找包含此源的所有启用的转发组
relevant_groups = [
group for group in self.forwarding_groups
if source_chat in group["sources"] and group.get("enabled", True)
]
# 调试日志
logger.info(f"Found {len(relevant_groups)} relevant groups for source {source_chat}")
# 如果没有包含此源的启用的转发组,记录并返回
if not relevant_groups:
logger.warning(f"Received message from {source_chat} but it's not in any enabled forwarding group")
return
# 处理每个相关的转发组
for group in relevant_groups:
logger.info(f"Processing for group {group['name']} (ID: {group['id']})")
group_id = group["id"]
# 确保该组在统计信息中存在
if group_id not in stats["group_stats"]:
stats["group_stats"][group_id] = {
"name": group["name"],
"messages_seen": 0,
"messages_forwarded": 0,
"keyword_matches": {},
"keyword_exclusions":{}
}
# 更新组统计信息
stats["group_stats"][group_id]["messages_seen"] += 1
# 检查是否包含排除关键词
exclude_keywords = group.get("exclude_keywords", [])
excluded_keywords = [kw for kw in exclude_keywords if kw in message_text]
# 如果找到排除关键词,跳过此消息
if excluded_keywords:
logger.info(f"Message from {source_chat} contains excluded keywords in group '{group['name']}': {', '.join(excluded_keywords)}")
# 更新排除关键词统计
for kw in excluded_keywords:
# 组排除关键词统计
if "keyword_exclusions" not in stats["group_stats"][group_id]:
stats["group_stats"][group_id]["keyword_exclusions"] = {}
if kw in stats["group_stats"][group_id]["keyword_exclusions"]:
stats["group_stats"][group_id]["keyword_exclusions"][kw] += 1
else:
stats["group_stats"][group_id]["keyword_exclusions"][kw] = 1
continue # 跳过处理此消息
# 检查是否包含通配符或空关键词列表 (表示转发所有消息)
has_wildcard = "*" in group["keywords"] or "ALL" in group["keywords"] or not group["keywords"]
logger.info(f"Group '{group['name']}' wildcard check: has_wildcard={has_wildcard}, keywords={group['keywords']}")
logger.info(f"Group '{group['name']}' wild check results: has_wildcard={has_wildcard}, keywords={group['keywords']}")
if not has_wildcard:
matched = [kw for kw in group["keywords"] if kw in message_text]
logger.info(f"Regular keyword matches: {matched}")
# 处理消息匹配
if has_wildcard:
# 对于通配符组,直接匹配所有消息
matched_keywords = ["*"] # 标记为通配符匹配
logger.info(f"Message from {source_chat} matched wildcard in group '{group['name']}'")
else:
# 对于常规组,检查关键词匹配
matched_keywords = [kw for kw in group["keywords"] if kw in message_text]
if not matched_keywords:
# 如果没有匹配的关键词且不是通配符组,跳过此消息
continue
logger.info(f"Group '{group['name']}' wildcard check: {has_wildcard}, keywords: {group['keywords']}")
logger.info(f"Message from {source_chat} matched keywords in group '{group['name']}': {', '.join(matched_keywords)}")
# 更新关键词匹配统计
for kw in matched_keywords:
# 全局关键词统计
if kw in stats["keyword_matches"]:
stats["keyword_matches"][kw] += 1
else:
stats["keyword_matches"][kw] = 1
# 组关键词统计
if kw in stats["group_stats"][group_id]["keyword_matches"]:
stats["group_stats"][group_id]["keyword_matches"][kw] += 1
else:
stats["group_stats"][group_id]["keyword_matches"][kw] = 1
# 添加来源和组信息到消息
formatted_message = f"From: {source_chat} (Group: {group['name']})\n\n{message_text}"
# 转发到该组的所有目标
for destination in group["destinations"]:
try:
# 处理主题格式 (format: "c/group/topic_id")
if destination.startswith("c/") and '/' in destination[2:]:
# 解析目标字符串以获取组和主题
parts = destination.split('/')
if len(parts) >= 3:
group_name = parts[1]
topic_id = int(parts[2])
# 获取组实体
group_entity = await self.client.get_entity(group_name)
# 转发到具有主题ID的组
sent_message = await self.client.send_message(
entity=group_entity,
message=formatted_message, # 发送带有源信息的文本
reply_to=topic_id # 指定主题ID
)
# 如果原始消息包含媒体,单独转发
if event.message.media:
await self.client.send_file(
entity=group_entity,
file=event.message.media,
caption=f"Media from: {source_chat} (Group: {group['name']})",
reply_to=topic_id
)
else:
logger.error(f"Invalid topic format: {destination}")
continue
else:
# 对于常规转发,添加源信息
dest_entity = await self.client.get_entity(destination)
if event.message.media:
# 如果有媒体,以格式化消息作为标题发送
await self.client.send_file(
entity=dest_entity,
file=event.message.media,
caption=formatted_message
)
else:
# 否则只发送文本消息
await self.client.send_message(
entity=dest_entity,
message=formatted_message
)
# 更新目标统计信息
if destination not in stats["destination_stats"]:
stats["destination_stats"][destination] = {"messages_received": 0}
stats["destination_stats"][destination]["messages_received"] += 1
# 更新源统计信息
stats["source_stats"][source_chat]["messages_forwarded"] += 1
# 更新组统计信息
stats["group_stats"][group_id]["messages_forwarded"] += 1
# 更新全局统计信息
stats["messages_forwarded"] += 1
stats["last_forwarded"] = {
"group": group["name"],
"source": source_chat,
"destination": destination,
"text": message_text[:100] + "..." if len(message_text) > 100 else message_text,
"timestamp": event.message.date.isoformat(),
"matched_keywords": matched_keywords
}
logger.info(f"Message forwarded from {source_chat} to {destination} (Group: {group['name']})")
except Exception as e:
logger.error(f"Failed to forward message to {destination} (Group: {group['name']}): {e}")
except Exception as e:
logger.error(f"Error processing message from {source_chat}: {e}", exc_info=True)
async def stop(self):
"""Stop the client"""
if self.client:
await self.client.disconnect()
self.running = False
logger.info("Client stopped")
def start_forwarder(forwarding_groups=DEFAULT_FORWARDING_GROUPS):
"""Start the forwarder in a separate thread"""
global client, forwarder_thread
# Create event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Create forwarder with the loop
forwarder = MessageForwarder(forwarding_groups, loop=loop)
client = forwarder
# Run the client
loop.run_until_complete(forwarder.start())
def periodic_save():
"""定期保存配置"""
global last_save_time
current_time = time.time()
# 每10分钟保存一次配置
if current_time - last_save_time > 600: # 600秒 = 10分钟
save_config()
last_save_time = current_time
def generate_group_id():
"""生成唯一的组ID"""
import uuid
return f"group_{str(uuid.uuid4())[:8]}"
# Flask routes
@app.route('/')
def index():
"""Main page with status and controls"""
return render_template(
'index.html',
is_running=is_running,
stats=stats,
forwarding_groups=FORWARDING_GROUPS
)
@app.route('/start', methods=['POST'])
def start():
"""Start the forwarder"""
global forwarder_thread, is_running, stats, FORWARDING_GROUPS
if is_running:
return jsonify({"status": "already_running"})
# 从请求中获取转发组配置
forwarding_groups_data = request.json.get('forwarding_groups')
if forwarding_groups_data:
# 更新当前配置
FORWARDING_GROUPS = forwarding_groups_data
# 处理统计信息重置
reset_stats = request.json.get('reset_stats', False)
if reset_stats:
stats = {
"total_messages_seen": 0,
"messages_forwarded": 0,
"last_forwarded": None,
"keyword_matches": {},
"source_stats": {},
"destination_stats": {},
"group_stats": {}
}
# 保存当前配置
save_config()
# 在新线程中启动转发器
forwarder_thread = threading.Thread(
target=start_forwarder,
args=(FORWARDING_GROUPS,)
)
forwarder_thread.daemon = True
forwarder_thread.start()
return jsonify({
"status": "started",
"is_running": True,
"forwarding_groups": FORWARDING_GROUPS
})
@app.route('/stop', methods=['POST'])
def stop():
"""Stop the forwarder"""
global client, is_running
if not is_running:
return jsonify({"status": "not_running"})
# 保存配置
save_config()
# 创建事件循环并停止客户端
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if client:
loop.run_until_complete(client.stop())
is_running = False
return jsonify({
"status": "stopped",
"is_running": False
})
@app.route('/check_status')
def check_status():
"""返回当前服务运行状态"""
global is_running, FORWARDING_GROUPS
return jsonify({
"is_running": is_running,
"forwarding_groups": FORWARDING_GROUPS
})
@app.route('/stats')
def get_stats():
"""Get current statistics"""
# 定期保存配置
periodic_save()
return jsonify(stats)
@app.route('/save_config', methods=['POST'])
def save_config_route():
"""Save current configuration"""
# 从请求中获取新的转发组配置
global FORWARDING_GROUPS
forwarding_groups_data = request.json.get('forwarding_groups')
if forwarding_groups_data:
FORWARDING_GROUPS = forwarding_groups_data
save_config()
return jsonify({"status": "saved"})
@app.route('/add_group', methods=['POST'])
def add_group():
"""添加新的转发组"""
global FORWARDING_GROUPS
# 获取提交的表单数据
group_data = request.json
# 给新组生成唯一ID
if not group_data.get("id"):
group_data["id"] = generate_group_id()
# 确保启用状态存在
if "enabled" not in group_data:
group_data["enabled"] = True
# 添加到转发组列表
FORWARDING_GROUPS.append(group_data)
# 保存配置
save_config()
return jsonify({
"status": "added",
"group": group_data,
"forwarding_groups": FORWARDING_GROUPS
})
@app.route('/update_group', methods=['POST'])
def update_group():
"""更新现有转发组"""
global FORWARDING_GROUPS
# 获取提交的组数据
group_data = request.json
group_id = group_data.get("id")
if not group_id:
return jsonify({"status": "error", "message": "Group ID is required"}), 400
# 查找和更新组
for i, group in enumerate(FORWARDING_GROUPS):
if group["id"] == group_id:
# 确保启用状态存在
if "enabled" not in group_data:
group_data["enabled"] = group.get("enabled", True)
FORWARDING_GROUPS[i] = group_data
# 保存配置
save_config()
return jsonify({
"status": "updated",
"group": group_data,
"forwarding_groups": FORWARDING_GROUPS
})
return jsonify({"status": "error", "message": "Group not found"}), 404
@app.route('/delete_group', methods=['POST'])
def delete_group():
"""删除转发组"""
global FORWARDING_GROUPS
# 获取要删除的组ID
group_id = request.json.get("id")
if not group_id:
return jsonify({"status": "error", "message": "Group ID is required"}), 400
# 查找和删除组
for i, group in enumerate(FORWARDING_GROUPS):
if group["id"] == group_id:
del FORWARDING_GROUPS[i]
# 保存配置
save_config()
return jsonify({
"status": "deleted",
"group_id": group_id,
"forwarding_groups": FORWARDING_GROUPS
})
return jsonify({"status": "error", "message": "Group not found"}), 404
@app.route('/toggle_group', methods=['POST'])
def toggle_group():
"""Enable or disable a forwarding group"""
global FORWARDING_GROUPS
# Get the group ID and desired state
group_id = request.json.get("id")
enabled = request.json.get("enabled")
if group_id is None or enabled is None:
return jsonify({"status": "error", "message": "Group ID and enabled state are required"}), 400
# Find and update the group
for i, group in enumerate(FORWARDING_GROUPS):
if group["id"] == group_id:
FORWARDING_GROUPS[i]["enabled"] = enabled
# Save configuration
save_config()
logger.info(f"Group '{group['name']}' {('enabled' if enabled else 'disabled')}")
return jsonify({
"status": "updated",
"group_id": group_id,
"enabled": enabled,
"forwarding_groups": FORWARDING_GROUPS
})
return jsonify({"status": "error", "message": "Group not found"}), 404
# 启动时加载配置
load_config()
# 创建SSL上下文
def create_ssl_context():
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
try:
context.load_cert_chain(CERT_PATH, KEY_PATH)
return context
except Exception as e:
logger.error(f"Failed to load SSL certificates: {e}")
return None
# Start Flask app
if __name__ == '__main__':
# 在启动Flask应用前自动启动转发器
if not is_running:
logger.info("Auto-starting message forwarder...")
forwarder_thread = threading.Thread(
target=start_forwarder,
args=(FORWARDING_GROUPS,)
)
forwarder_thread.daemon = True
forwarder_thread.start()
# 获取SSL上下文
ssl_context = create_ssl_context()
if ssl_context:
# 使用HTTPS启动Flask
logger.info("Starting Flask with HTTPS support...")
app.run(host='0.0.0.0', port=9018, debug=False, ssl_context=ssl_context)
else:
# 回退到HTTP
logger.warning("Failed to load SSL certificates, starting with HTTP only...")
app.run(host='0.0.0.0', port=9018, debug=False)
2) templates\index.html
<!DOCTYPE html>
<html>
<head>
<title>Telegram Message Forwarder</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
</head>
<body>
<h1>Telegram Message Forwarder</h1>
<div class="status {{ 'running' if is_running else 'stopped' }}">
Status: <strong>{{ 'Running' if is_running else 'Stopped' }}</strong>
</div>
<!-- 转发组列表 -->
<div class="card">
<div class="header-with-button">
<h2>Forwarding Groups</h2>
<button id="addGroupBtn" class="add" {{ 'disabled' if is_running else '' }}>+ Add Group</button>
</div>
<div id="forwardingGroups">
{% for group in forwarding_groups %}
<div class="forwarding-group" data-group-id="{{ group.id }}">
<div class="group-header">
<h3>{{ group.name }}</h3>
<div class="group-actions">
<button class="toggle-group" data-enabled="{{ group.get('enabled', True) }}" {{ 'disabled' if is_running else '' }}>
{{ 'Enabled' if group.get('enabled', True) else 'Disabled' }}
</button>
<button class="edit-group" {{ 'disabled' if is_running else '' }}>Edit</button>
<button class="delete-group" {{ 'disabled' if is_running else '' }}>Delete</button>
</div>
</div>
<div class="group-details">
<div class="detail-item">
<span class="status-badge {{ 'enabled' if group.get('enabled', True) else 'disabled' }}">
{{ 'Enabled' if group.get('enabled', True) else 'Disabled' }}
</span>
</div>
<div class="detail-item">
<strong>Sources:</strong> {{ ", ".join(group.sources) }}
</div>
<div class="detail-item">
<strong>Destinations:</strong> {{ ", ".join(group.destinations) }}
</div>
<div class="detail-item">
<strong>Keywords:</strong> {{ ", ".join(group.keywords) }}
</div>
<div class="detail-item">
<strong>Exclude Keywords:</strong> {{ ", ".join(group.get('exclude_keywords', [])) or 'None' }}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="button-group">
<button id="startForwarderBtn" class="start" {{ 'disabled' if is_running else '' }}>Start Forwarder</button>
<button id="stopForwarderBtn" class="stop" {{ 'disabled' if not is_running else '' }}>Stop Forwarder</button>
<button id="saveConfigBtn" class="save">Save Configuration</button>
</div>
</div>
<!-- 添加/编辑组对话框 -->
<div id="groupModal" class="modal">
<div class="modal-content">
<span class="close">×</span>
<h2 id="modalTitle">Add Forwarding Group</h2>
<form id="groupForm">
<input type="hidden" id="groupId" name="groupId">
<div class="form-group">
<label for="groupName">Group Name:</label>
<input type="text" id="groupName" name="groupName" required>
</div>
<div class="form-group">
<label for="groupSources">Source Channels (comma separated):</label>
<input type="text" id="groupSources" name="groupSources" required>
<small>Multiple sources separated by commas (e.g., FinanceNewsDaily, CryptoNews, StockAlerts)</small>
</div>
<div class="form-group">
<label for="groupDestinations">Destinations (comma separated):</label>
<input type="text" id="groupDestinations" name="groupDestinations" required>
<small>Multiple destinations separated by commas (e.g., group1, channel2, user3)</small>
</div>
<div class="form-group">
<label for="groupKeywords">Keywords (comma separated):</label>
<input type="text" id="groupKeywords" name="groupKeywords" required>
<small>Messages containing any of these keywords will be forwarded</small>
</div>
<div class="form-group">
<label for="groupExcludeKeywords">Exclude Keywords (comma separated):</label>
<input type="text" id="groupExcludeKeywords" name="groupExcludeKeywords">
<small>Messages containing any of these keywords will NOT be forwarded, even if they contain matching keywords</small>
</div>
<div class="form-group">
<div class="checkbox-container">
<input type="checkbox" id="groupEnabled" name="groupEnabled" checked>
<label for="groupEnabled">Group Enabled</label>
</div>
</div>
<div class="form-group">
<button type="submit" class="save">Save Group</button>
</div>
</form>
</div>
</div>
<div class="card">
<h2>Global Statistics</h2>
<table>
<tr>
<td>Total Messages Seen:</td>
<td id="totalMessages">{{ stats.total_messages_seen }}</td>
</tr>
<tr>
<td>Messages Forwarded:</td>
<td id="forwardedMessages">{{ stats.messages_forwarded }}</td>
</tr>
</table>
<h3>Keyword Matches</h3>
<!-- Pagination controls will be inserted here by JavaScript -->
<table>
<thead>
<tr>
<th>Keyword</th>
<th>Matches</th>
</tr>
</thead>
<tbody id="keywordStats">
{% for keyword, count in stats.keyword_matches.items() %}
<tr>
<td>{{ keyword }}</td>
<td>{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>Last Forwarded Message</h3>
<pre id="lastMessage">{{ stats.last_forwarded | tojson(indent=2) if stats.last_forwarded else 'No messages forwarded yet' }}</pre>
</div>
<div class="card">
<h2>Group Statistics</h2>
<div id="groupStats">
{% for group_id, group_data in stats.group_stats.items() %}
<div class="group-stat-card">
<h3>{{ group_data.name }}</h3>
<table>
<tr>
<td>Messages Seen:</td>
<td>{{ group_data.messages_seen }}</td>
</tr>
<tr>
<td>Messages Forwarded:</td>
<td>{{ group_data.messages_forwarded }}</td>
</tr>
<tr>
<td>Forward Rate:</td>
<td>
{% if group_data.messages_seen > 0 %}
{{ (group_data.messages_forwarded / group_data.messages_seen * 100) | round(1) }}%
{% else %}
0%
{% endif %}
</td>
</tr>
</table>
{% if group_data.keyword_matches %}
<h4>Keyword Matches</h4>
<table>
<thead>
<tr>
<th>Keyword</th>
<th>Matches</th>
</tr>
</thead>
<tbody>
{% for keyword, count in group_data.keyword_matches.items() %}
<tr>
<td>{{ keyword }}</td>
<td>{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if group_data.keyword_exclusions %}
<h4>Keyword Exclusions</h4>
<table>
<thead>
<tr>
<th>Excluded Keyword</th>
<th>Occurrences</th>
</tr>
</thead>
<tbody>
{% for keyword, count in group_data.keyword_exclusions.items() %}
<tr>
<td>{{ keyword }}</td>
<td>{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2>Source Statistics</h2>
<!-- Pagination controls will be inserted here by JavaScript -->
<table>
<thead>
<tr>
<th>Source</th>
<th>Messages Seen</th>
<th>Messages Forwarded</th>
<th>Forward Rate</th>
</tr>
</thead>
<tbody id="sourceStats">
{% for source, data in stats.source_stats.items() %}
<tr>
<td>{{ source }}</td>
<td>{{ data.messages_seen }}</td>
<td>{{ data.messages_forwarded }}</td>
<td>
{% if data.messages_seen > 0 %}
{{ (data.messages_forwarded / data.messages_seen * 100) | round(1) }}%
{% else %}
0%
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Destination Statistics</h2>
<!-- Pagination controls will be inserted here by JavaScript -->
<table>
<thead>
<tr>
<th>Destination</th>
<th>Messages Received</th>
</tr>
</thead>
<tbody id="destinationStats">
{% for dest, data in stats.destination_stats.items() %}
<tr>
<td>{{ dest }}</td>
<td>{{ data.messages_received }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</body>
</html>
3) static\css\styles.css
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1, h2, h3, h4 {
color: #333;
}
.status {
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
font-weight: bold;
}
.running {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.stopped {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.card {
border: 1px solid #ddd;
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
button {
padding: 10px 15px;
border: none;
border-radius: 5px;
cursor: pointer;
margin-right: 10px;
font-weight: bold;
transition: background-color 0.3s ease;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.start {
background-color: #28a745;
color: white;
}
.start:hover:not([disabled]) {
background-color: #218838;
}
.stop {
background-color: #dc3545;
color: white;
}
.stop:hover:not([disabled]) {
background-color: #c82333;
}
.save {
background-color: #17a2b8;
color: white;
}
.save:hover:not([disabled]) {
background-color: #138496;
}
.add {
background-color: #007bff;
color: white;
}
.add:hover:not([disabled]) {
background-color: #0069d9;
}
.edit-group {
background-color: #ffc107;
color: #212529;
padding: 5px 10px;
font-size: 0.9em;
}
.edit-group:hover:not([disabled]) {
background-color: #e0a800;
}
.delete-group {
background-color: #dc3545;
color: white;
padding: 5px 10px;
font-size: 0.9em;
}
.delete-group:hover:not([disabled]) {
background-color: #c82333;
}
/* Toggle group button */
.toggle-group {
background-color: #6c757d;
color: white;
padding: 5px 10px;
font-size: 0.9em;
}
.toggle-group[data-enabled="true"] {
background-color: #28a745;
}
.toggle-group[data-enabled="false"] {
background-color: #dc3545;
}
.toggle-group:hover:not([disabled]) {
opacity: 0.9;
}
/* Status badge for enabled/disabled groups */
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 10px;
font-size: 0.8em;
font-weight: bold;
margin-bottom: 8px;
}
.status-badge.enabled {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-badge.disabled {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
}
th, td {
text-align: left;
padding: 8px;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
}
pre {
background-color: #f8f9fa;
padding: 10px;
border-radius: 5px;
overflow: auto;
border: 1px solid #eee;
font-family: monospace;
font-size: 14px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.checkbox-container {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.checkbox-container input[type="checkbox"] {
width: auto;
margin-right: 10px;
}
.form-group small {
display: block;
color: #6c757d;
font-size: 0.85em;
margin-top: 4px;
}
.button-group {
margin: 15px 0;
display: flex;
gap: 10px;
}
/* 转发组样式 */
.forwarding-group {
border: 1px solid #ddd;
border-radius: 5px;
margin-bottom: 15px;
overflow: hidden;
}
.group-header {
background-color: #f8f9fa;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #ddd;
}
.group-header h3 {
margin: 0;
}
.group-actions {
display: flex;
gap: 5px;
}
.group-details {
padding: 10px 15px;
}
.detail-item {
margin-bottom: 5px;
}
.header-with-button {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.header-with-button h2 {
margin: 0;
}
/* 组统计卡片 */
#groupStats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 15px;
}
.group-stat-card {
border: 1px solid #ddd;
border-radius: 5px;
padding: 15px;
}
/* 模态对话框 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.modal-content {
background-color: #fefefe;
margin: 10% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 5px;
width: 80%;
max-width: 600px;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
}
@media (max-width: 700px) {
#groupStats {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
button {
width: 100%;
margin-right: 0;
margin-bottom: 10px;
}
}
/* 表格设置控件样式 */
.table-settings {
margin: 10px 0;
padding: 8px;
background-color: #f8f9fa;
border-radius: 5px;
border: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-settings label {
margin-right: 5px;
font-weight: bold;
}
.row-selector {
padding: 5px;
border: 1px solid #ced4da;
border-radius: 4px;
background-color: white;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 10px;
}
.pagination-controls button {
padding: 4px 10px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.pagination-controls button:hover:not([disabled]) {
background-color: #5a6268;
}
.pagination-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-size: 0.9em;
color: #495057;
min-width: 100px;
text-align: center;
}
/* 表格工具栏 */
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
/* 搜索框 */
.table-search {
padding: 5px 10px;
border: 1px solid #ced4da;
border-radius: 4px;
width: 200px;
}
4) static\js\app.js
document.addEventListener('DOMContentLoaded', function() {
// 初始化和启动自动刷新
refreshStats();
// 同时也刷新服务状态
refreshServiceStatus();
// 每5秒刷新一次统计信息和服务状态
setInterval(function() {
refreshStats();
refreshServiceStatus();
}, 5000);
// 初始化模态框
const modal = document.getElementById('groupModal');
const closeBtn = modal.querySelector('.close');
// 添加组按钮
const addGroupBtn = document.getElementById('addGroupBtn');
if (addGroupBtn) {
addGroupBtn.addEventListener('click', function() {
// 重置表单
document.getElementById('groupForm').reset();
document.getElementById('groupId').value = '';
document.getElementById('modalTitle').textContent = 'Add Forwarding Group';
// 默认启用新组
document.getElementById('groupEnabled').checked = true;
// 显示模态框
modal.style.display = 'block';
});
}
// 关闭模态框
if (closeBtn) {
closeBtn.addEventListener('click', function() {
modal.style.display = 'none';
});
}
// 点击模态框外部关闭
window.addEventListener('click', function(event) {
if (event.target === modal) {
modal.style.display = 'none';
}
});
// 组表单提交
const groupForm = document.getElementById('groupForm');
if (groupForm) {
groupForm.addEventListener('submit', function(e) {
e.preventDefault();
// 收集表单数据
const groupId = document.getElementById('groupId').value;
const groupName = document.getElementById('groupName').value;
const sourcesStr = document.getElementById('groupSources').value;
const destinationsStr = document.getElementById('groupDestinations').value;
const keywordsStr = document.getElementById('groupKeywords').value;
const excludeKeywordsStr = document.getElementById('groupExcludeKeywords').value;
const enabled = document.getElementById('groupEnabled').checked;
// 解析列表
const sources = sourcesStr.split(',').map(s => s.trim()).filter(s => s);
const destinations = destinationsStr.split(',').map(d => d.trim()).filter(d => d);
const keywords = keywordsStr.split(',').map(k => k.trim()).filter(k => k);
const exclude_keywords = excludeKeywordsStr.split(',').map(k => k.trim()).filter(k => k);
// 创建组数据对象
const groupData = {
name: groupName,
sources: sources,
destinations: destinations,
keywords: keywords,
exclude_keywords: exclude_keywords,
enabled: enabled
};
// 确定是添加还是更新
let url, method;
if (groupId) {
// 更新现有组
url = '/update_group';
groupData.id = groupId;
} else {
// 添加新组
url = '/add_group';
}
// 发送请求
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(groupData)
})
.then(response => response.json())
.then(data => {
if (data.status === 'added' || data.status === 'updated') {
// 关闭模态框
modal.style.display = 'none';
// 刷新组列表
refreshForwardingGroups(data.forwarding_groups);
} else {
alert('Failed to save group: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error saving group:', error);
alert('Failed to save group due to an error');
});
});
}
// 添加编辑组事件处理
document.addEventListener('click', function(e) {
if (e.target.classList.contains('edit-group')) {
const groupElement = e.target.closest('.forwarding-group');
const groupId = groupElement.dataset.groupId;
// 获取组数据
fetch('/check_status')
.then(response => response.json())
.then(data => {
const group = data.forwarding_groups.find(g => g.id === groupId);
if (group) {
// 填充表单
document.getElementById('groupId').value = group.id;
document.getElementById('groupName').value = group.name;
document.getElementById('groupSources').value = group.sources.join(', ');
document.getElementById('groupDestinations').value = group.destinations.join(', ');
document.getElementById('groupKeywords').value = group.keywords.join(', ');
document.getElementById('groupExcludeKeywords').value = group.exclude_keywords ? group.exclude_keywords.join(', ') : '';
document.getElementById('groupEnabled').checked = group.hasOwnProperty('enabled') ? group.enabled : true;
// 更新模态框标题
document.getElementById('modalTitle').textContent = 'Edit Forwarding Group';
// 显示模态框
modal.style.display = 'block';
}
})
.catch(error => {
console.error('Error fetching group data:', error);
alert('Failed to load group data');
});
}
});
// 添加删除组事件处理
document.addEventListener('click', function(e) {
if (e.target.classList.contains('delete-group')) {
if (!confirm('Are you sure you want to delete this forwarding group?')) {
return;
}
const groupElement = e.target.closest('.forwarding-group');
const groupId = groupElement.dataset.groupId;
// 发送删除请求
fetch('/delete_group', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: groupId })
})
.then(response => response.json())
.then(data => {
if (data.status === 'deleted') {
// 刷新组列表
refreshForwardingGroups(data.forwarding_groups);
} else {
alert('Failed to delete group: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error deleting group:', error);
alert('Failed to delete group due to an error');
});
}
});
// 添加组启用/禁用切换处理
document.addEventListener('click', function(e) {
if (e.target.classList.contains('toggle-group')) {
const groupElement = e.target.closest('.forwarding-group');
const groupId = groupElement.dataset.groupId;
const currentlyEnabled = e.target.getAttribute('data-enabled') === 'true';
const newState = !currentlyEnabled;
// 发送切换请求
fetch('/toggle_group', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: groupId,
enabled: newState
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'updated') {
// 刷新组列表
refreshForwardingGroups(data.forwarding_groups);
} else {
alert('Failed to update group status: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error updating group status:', error);
alert('Failed to update group status due to an error');
});
}
});
// 启动转发器
const startBtn = document.getElementById('startForwarderBtn');
if (startBtn) {
startBtn.addEventListener('click', function() {
// 获取当前配置
fetch('/check_status')
.then(response => response.json())
.then(data => {
// 发送启动请求
fetch('/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
forwarding_groups: data.forwarding_groups,
reset_stats: false
})
})
.then(response => response.json())
.then(startData => {
if (startData.status === 'started') {
refreshServiceStatus();
refreshStats();
} else {
alert('Failed to start forwarder: ' + (startData.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error starting forwarder:', error);
alert('Failed to start forwarder due to an error');
});
})
.catch(error => {
console.error('Error fetching current config:', error);
alert('Failed to fetch current configuration');
});
});
}
// 停止转发器
const stopBtn = document.getElementById('stopForwarderBtn');
if (stopBtn) {
stopBtn.addEventListener('click', function() {
fetch('/stop', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.status === 'stopped') {
refreshServiceStatus();
} else {
alert('Failed to stop forwarder: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error stopping forwarder:', error);
alert('Failed to stop forwarder due to an error');
});
});
}
// 保存配置
const saveConfigBtn = document.getElementById('saveConfigBtn');
if (saveConfigBtn) {
saveConfigBtn.addEventListener('click', function() {
// 获取当前配置
fetch('/check_status')
.then(response => response.json())
.then(data => {
// 发送保存请求
fetch('/save_config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
forwarding_groups: data.forwarding_groups
})
})
.then(response => response.json())
.then(saveData => {
if (saveData.status === 'saved') {
alert('Configuration saved successfully');
} else {
alert('Failed to save configuration: ' + (saveData.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error saving configuration:', error);
alert('Failed to save configuration due to an error');
});
})
.catch(error => {
console.error('Error fetching current config:', error);
alert('Failed to fetch current configuration');
});
});
}
// 添加表格设置控件
setTimeout(createTableSettings, 500); // 延迟一点以确保其他元素已加载
});
/**
* 刷新服务状态
*/
function refreshServiceStatus() {
fetch('/check_status')
.then(response => response.json())
.then(data => {
// 获取状态元素
const statusElement = document.querySelector('.status');
if (data.is_running) {
// 更新为运行状态
statusElement.classList.remove('stopped');
statusElement.classList.add('running');
statusElement.innerHTML = 'Status: <strong>Running</strong>';
// 更新按钮状态
document.querySelectorAll('button.start').forEach(btn => btn.disabled = true);
document.querySelectorAll('button.stop').forEach(btn => btn.disabled = false);
document.querySelectorAll('button.add, button.edit-group, button.delete-group, button.toggle-group').forEach(btn => btn.disabled = true);
} else {
// 更新为停止状态
statusElement.classList.remove('running');
statusElement.classList.add('stopped');
statusElement.innerHTML = 'Status: <strong>Stopped</strong>';
// 更新按钮状态
document.querySelectorAll('button.start').forEach(btn => btn.disabled = false);
document.querySelectorAll('button.stop').forEach(btn => btn.disabled = true);
document.querySelectorAll('button.add, button.edit-group, button.delete-group, button.toggle-group').forEach(btn => btn.disabled = false);
}
// 刷新转发组显示
refreshForwardingGroups(data.forwarding_groups);
})
.catch(error => {
console.error('Error fetching service status:', error);
});
}
/**
* 刷新转发组列表
*/
function refreshForwardingGroups(groups) {
const container = document.getElementById('forwardingGroups');
if (!container) return;
// 清空容器
container.innerHTML = '';
// 添加每个组
groups.forEach(group => {
const isEnabled = group.hasOwnProperty('enabled') ? group.enabled : true;
const groupElement = document.createElement('div');
groupElement.className = 'forwarding-group';
groupElement.dataset.groupId = group.id;
// 获取排除关键词
const excludeKeywords = group.exclude_keywords || [];
groupElement.innerHTML = `
<div class="group-header">
<h3>${group.name}</h3>
<div class="group-actions">
<button class="toggle-group" data-enabled="${isEnabled}" ${document.querySelector('.status').classList.contains('running') ? 'disabled' : ''}>
${isEnabled ? 'Enabled' : 'Disabled'}
</button>
<button class="edit-group" ${document.querySelector('.status').classList.contains('running') ? 'disabled' : ''}>Edit</button>
<button class="delete-group" ${document.querySelector('.status').classList.contains('running') ? 'disabled' : ''}>Delete</button>
</div>
</div>
<div class="group-details">
<div class="detail-item">
<span class="status-badge ${isEnabled ? 'enabled' : 'disabled'}">
${isEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="detail-item">
<strong>Sources:</strong> ${group.sources.join(', ')}
</div>
<div class="detail-item">
<strong>Destinations:</strong> ${group.destinations.join(', ')}
</div>
<div class="detail-item">
<strong>Keywords:</strong> ${group.keywords.join(', ')}
</div>
<div class="detail-item">
<strong>Exclude Keywords:</strong> ${excludeKeywords.join(', ') || 'None'}
</div>
</div>
`;
container.appendChild(groupElement);
});
}
/**
* 获取并更新统计数据
*/
function refreshStats() {
fetch('/stats')
.then(response => response.json())
.then(data => {
updateStatistics(data);
})
.catch(error => {
console.error('Error fetching statistics:', error);
});
}
/**
* 更新UI显示的统计数据
*/
function updateStatistics(data) {
// 更新基本计数器
document.getElementById('totalMessages').textContent = data.total_messages_seen;
document.getElementById('forwardedMessages').textContent = data.messages_forwarded;
// 更新关键词统计
const keywordStats = document.getElementById('keywordStats');
keywordStats.innerHTML = '';
// 按匹配次数降序排列关键词
const sortedKeywords = Object.entries(data.keyword_matches || {})
.sort((a, b) => b[1] - a[1]);
for (const [keyword, count] of sortedKeywords) {
const row = document.createElement('tr');
row.innerHTML = `<td>${keyword}</td><td>${count}</td>`;
keywordStats.appendChild(row);
}
// 更新组统计信息
const groupStats = document.getElementById('groupStats');
if (groupStats) {
groupStats.innerHTML = '';
// 将组统计数据转换为数组
const groupsData = Object.entries(data.group_stats || {});
if (groupsData.length === 0) {
groupStats.innerHTML = '<p>No group statistics available</p>';
} else {
// 遍历每个组
for (const [groupId, group] of groupsData) {
const groupElement = document.createElement('div');
groupElement.className = 'group-stat-card';
// 计算转发率
const forwardRate = group.messages_seen > 0
? ((group.messages_forwarded / group.messages_seen) * 100).toFixed(1)
: 0;
let keywordsHtml = '';
if (group.keyword_matches && Object.keys(group.keyword_matches).length > 0) {
// 按匹配次数排序关键词
const sortedGroupKeywords = Object.entries(group.keyword_matches)
.sort((a, b) => b[1] - a[1]);
keywordsHtml = `
<h4>Keyword Matches</h4>
<table>
<thead>
<tr>
<th>Keyword</th>
<th>Matches</th>
</tr>
</thead>
<tbody>
${sortedGroupKeywords.map(([kw, count]) =>
`<tr><td>${kw}</td><td>${count}</td></tr>`
).join('')}
</tbody>
</table>
`;
}
// 添加排除关键词统计
let exclusionsHtml = '';
if (group.keyword_exclusions && Object.keys(group.keyword_exclusions).length > 0) {
// 按匹配次数排序排除关键词
const sortedExcludeKeywords = Object.entries(group.keyword_exclusions)
.sort((a, b) => b[1] - a[1]);
exclusionsHtml = `
<h4>Keyword Exclusions</h4>
<table>
<thead>
<tr>
<th>Excluded Keyword</th>
<th>Occurrences</th>
</tr>
</thead>
<tbody>
${sortedExcludeKeywords.map(([kw, count]) =>
`<tr><td>${kw}</td><td>${count}</td></tr>`
).join('')}
</tbody>
</table>
`;
}
groupElement.innerHTML = `
<h3>${group.name}</h3>
<table>
<tr>
<td>Messages Seen:</td>
<td>${group.messages_seen}</td>
</tr>
<tr>
<td>Messages Forwarded:</td>
<td>${group.messages_forwarded}</td>
</tr>
<tr>
<td>Forward Rate:</td>
<td>${forwardRate}%</td>
</tr>
</table>
${keywordsHtml}
${exclusionsHtml}
`;
groupStats.appendChild(groupElement);
}
}
}
// 更新源统计信息
const sourceStats = document.getElementById('sourceStats');
if (sourceStats) {
sourceStats.innerHTML = '';
const sources = Object.entries(data.source_stats || {});
if (sources.length === 0) {
sourceStats.innerHTML = '<tr><td colspan="4">No source statistics available</td></tr>';
} else {
for (const [source, stats] of sources) {
const row = document.createElement('tr');
const forwardRate = stats.messages_seen > 0
? ((stats.messages_forwarded / stats.messages_seen) * 100).toFixed(1) + '%'
: '0%';
row.innerHTML = `
<td>${source}</td>
<td>${stats.messages_seen}</td>
<td>${stats.messages_forwarded}</td>
<td>${forwardRate}</td>
`;
sourceStats.appendChild(row);
}
}
}
// 更新目标统计信息
const destinationStats = document.getElementById('destinationStats');
if (destinationStats) {
destinationStats.innerHTML = '';
const destinations = Object.entries(data.destination_stats || {});
if (destinations.length === 0) {
destinationStats.innerHTML = '<tr><td colspan="2">No destination statistics available</td></tr>';
} else {
for (const [destination, stats] of destinations) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${destination}</td>
<td>${stats.messages_received}</td>
`;
destinationStats.appendChild(row);
}
}
}
// 更新最近转发消息信息
const lastMessage = document.getElementById('lastMessage');
if (lastMessage) {
if (data.last_forwarded) {
lastMessage.textContent = JSON.stringify(data.last_forwarded, null, 2);
} else {
lastMessage.textContent = 'No messages forwarded yet';
}
}
// 更新表格分页
updateTableDisplay('keywordStats');
updateTableDisplay('sourceStats');
updateTableDisplay('destinationStats');
}
// 表格设置
const tableSettings = {
keywordStats: {
maxRows: 10,
currentPage: 1
},
sourceStats: {
maxRows: 10,
currentPage: 1
},
destinationStats: {
maxRows: 10,
currentPage: 1
}
};
// 创建表格设置控制面板
function createTableSettings() {
// 获取所有统计卡片
const cards = document.querySelectorAll('.card');
// 为每个卡片添加设置控件
cards.forEach(card => {
// 获取卡片标题
const title = card.querySelector('h2');
if (!title) return;
// 检查是否是我们要添加设置的卡片
const titleText = title.textContent.trim();
let tableId;
if (titleText === 'Global Statistics') {
tableId = 'keywordStats';
} else if (titleText === 'Source Statistics') {
tableId = 'sourceStats';
} else if (titleText === 'Destination Statistics') {
tableId = 'destinationStats';
} else {
return; // 不处理其他卡片
}
// 创建设置容器
const settingsContainer = document.createElement('div');
settingsContainer.className = 'table-settings';
// 添加行数选择器
const rowSelector = document.createElement('div');
rowSelector.innerHTML = `
<label for="${tableId}-rows">每页显示行数:</label>
<select id="${tableId}-rows" class="row-selector">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="1000">全部</option>
</select>
`;
// 添加分页控件
const paginationControls = document.createElement('div');
paginationControls.className = 'pagination-controls';
paginationControls.innerHTML = `
<button class="page-prev" data-table="${tableId}">上一页</button>
<span class="page-info" id="${tableId}-page-info">第1页</span>
<button class="page-next" data-table="${tableId}">下一页</button>
`;
// 添加控件到容器
settingsContainer.appendChild(rowSelector);
settingsContainer.appendChild(paginationControls);
// 插入设置容器
const targetTable = card.querySelector(`#${tableId}`);
if (targetTable) {
targetTable.parentNode.insertBefore(settingsContainer, targetTable);
// 设置选择器事件
const selector = settingsContainer.querySelector(`#${tableId}-rows`);
selector.addEventListener('change', function() {
tableSettings[tableId].maxRows = parseInt(this.value);
tableSettings[tableId].currentPage = 1; // 重置到第一页
updateTableDisplay(tableId);
});
// 分页按钮事件
const prevBtn = settingsContainer.querySelector('.page-prev');
const nextBtn = settingsContainer.querySelector('.page-next');
prevBtn.addEventListener('click', function() {
if (tableSettings[tableId].currentPage > 1) {
tableSettings[tableId].currentPage--;
updateTableDisplay(tableId);
}
});
nextBtn.addEventListener('click', function() {
const table = document.getElementById(tableId);
const totalRows = table.querySelectorAll('tr').length;
const totalPages = Math.ceil(totalRows / tableSettings[tableId].maxRows);
if (tableSettings[tableId].currentPage < totalPages) {
tableSettings[tableId].currentPage++;
updateTableDisplay(tableId);
}
});
}
});
}
// 更新表格显示
function updateTableDisplay(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const settings = tableSettings[tableId];
const rows = table.querySelectorAll('tr');
const totalRows = rows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / settings.maxRows));
// 约束页码在有效范围内
if (settings.currentPage > totalPages) {
settings.currentPage = totalPages;
}
// 计算要显示的行范围
const startIndex = (settings.currentPage - 1) * settings.maxRows;
const endIndex = Math.min(startIndex + settings.maxRows, totalRows);
// 更新页码信息
const pageInfo = document.getElementById(`${tableId}-page-info`);
if (pageInfo) {
pageInfo.textContent = `第${settings.currentPage}页 / 共${totalPages}页`;
}
// 更新行的可见性
rows.forEach((row, index) => {
if (index >= startIndex && index < endIndex) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
// 更新分页按钮状态
const prevBtn = document.querySelector(`.page-prev[data-table="${tableId}"]`);
const nextBtn = document.querySelector(`.page-next[data-table="${tableId}"]`);
if (prevBtn) {
prevBtn.disabled = settings.currentPage === 1;
}
if (nextBtn) {
nextBtn.disabled = settings.currentPage === totalPages;
}
}
5) .env 文件第一个字符是点
# Telegram API credentials (get from https://my.telegram.org)
API_ID=666666666
API_HASH=999999999999999999
使用自己的 API KEY
5. Docker 部署
1) Dockerfiles
FROM python:3.11-slim
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create directory structure
RUN mkdir -p /app/static/css /app/static/js /app/templates /app/sessions
# Copy application files
COPY app.py .
COPY templates/ ./templates/
COPY static/ ./static/
COPY .env .
# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV SESSION_NAME=/app/sessions/message_forwarder
# Expose the port
EXPOSE 9018
# Run the application
CMD ["python", "app.py"]
如果不在 DOCKER 运行,python 最好使用 3.11.x ,不然在安装 PyPi 会有错误 (telethon aiohttp)可以解决,但无法批处理运行。
2)requirements.txt
telethon==1.28.5
flask==2.3.3
Werkzeug==2.3.7
python-dotenv==1.0.0
aiohttp==3.8.5
async-timeout==4.0.3
nest-asyncio==1.5.8
pytz==2023.3
3) 部署命令:
将占用服务器端口 9018
6. 第一次必须 运行验证 (Telegram认证问题)
文件 .env 虽然有 api key 但第一次使用必须做:Telegram认证问题
错误描述:“telethon.errors.rpcerrorlist.AuthKeyUnregisteredError: The key is not registered in the system (caused by GetUsersRequest)”
我的办法是:使用单独的 auth.py 来验证身份
auth.py 代码:(如果不是在 Container 中,要在Python 环境中运行)
from telethon import TelegramClient
import asyncio
# 使用与主应用程序相同的API凭据和会话名称
api_id = '666666'
api_hash = '999999999999999999999'
session_name = 'message_forwarder'
async def main():
# 连接并认证
client = TelegramClient(session_name, api_id, api_hash)
await client.start()
# 确认登录成功
me = await client.get_me()
print(f"认证成功!已登录为 {me.first_name}")
print("您可以关闭此窗口并启动主应用程序了")
# 等待一段时间再断开连接(允许用户看到结果)
await asyncio.sleep(10)
await client.disconnect()
# 运行认证
asyncio.run(main())
因为是字符串要使用引号把 api_id api_hash 的值包住
在 Container 中运行:
python auth.py
按提示,输入手机号后, 在 TG 中找到:login code
输入后,如果成功会返回:
终端程序,从容器中退出,即可使用。
总结:
希望能看到
上一个绿豆芽项目:从左到右 第二批,第三批,第四批已经泡2天。 拍照很烦恼,前天刚把 PicTool < Project-37 PicsAdjTool> 照片白平衡批处理调整 可以批量裁剪工具 Ver.0.3 -- Last updated on 15Mar.2025 添加剪切全部应用功能-优快云博客补好,慢慢来。
豆芽生长现状,这个学来的方法,种豆芽,真方便,就是取出很难。