Compare commits

...

44 Commits

Author SHA1 Message Date
OpenClaw Bot
8708c931c8 feat: 添加每周总结功能
- 新增 get_weekly_stats_for_discord() 获取过去7天统计数据
- 新增 generate_weekly_summary_for_discord() 生成周报
- 新增 generate_weekly_summary_image() 生成周报图片
- 新增 weekly_summary.html 模板
- 添加每周日21:00定时推送
- 添加 /weekly_summary 命令手动触发
2026-02-07 03:44:09 +00:00
Ching L
7f81574192 feat(discord): add win/loss status to match messages
All checks were successful
continuous-integration/drone Build is passing
- Added win field to serialize_match_for_discord return data
  - Display victory/defeat emoji and text at start of match messages
  - Extract win status directly from data instead of inferring from embed color
2025-10-24 14:39:41 +08:00
Ching L
bb4ee378d9 feat: add method to recalculate streak from recent matches
All checks were successful
continuous-integration/drone/push Build is passing
- Add recalculate_streak_from_recent_matches to Friend class
- Fetches last 20 matches and recalculates win/loss streaks
- Sorts matches chronologically to ensure accurate calculation
2025-09-11 15:26:18 +08:00
Ching L
7498f413bf fix: resolve streak notification issues
- Initialize global streak_updates variable to prevent NameError
- Fix streak notifications only showing for first match
- Ensure notifications appear once per batch instead of being lost
2025-09-11 15:17:23 +08:00
Ching L
c85eeb9d74 feat(discord): show player names at the beginning of match messages
All checks were successful
continuous-integration/drone/push Build is passing
- Extract friend names from embed fields
- Display player names prominently before match details
- Maintain streak notifications ordering when present
2025-09-11 14:30:47 +08:00
Ching L
21c7b95653 feat: Update match result messaging to include streak notifications in the content
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-24 11:49:55 +08:00
Ching L
e381dce261 feat: Enhance friend match processing to avoid duplicate API calls and improve streak updates
All checks were successful
continuous-integration/drone Build is passing
continuous-integration/drone/push Build is passing
2025-07-24 10:35:32 +08:00
Ching L
895737927a feat: Optimize match processing by updating streaks before creating database records 2025-07-24 09:54:09 +08:00
Ching L
b5c58f842a chore: Remove draw_result.py as it is no longer needed for match result visualization
All checks were successful
continuous-integration/drone Build is passing
continuous-integration/drone/push Build is passing
2025-03-17 13:49:45 +08:00
Ching L
44336e5ff0 refactor: Move on_ready event definition to a more appropriate location and restore logging functionality 2025-03-13 16:35:44 +08:00
Ching L
e103e8706c feat: Add streak tracking and notifications for friends' match results 2025-03-13 16:30:40 +08:00
Ching L
3b138437d1 feat: Adjust message sending interval based on game time 2025-03-10 09:12:43 +08:00
Ching L
23309d3976 feat: Implement delayed start for daily rank changes check 2025-03-07 11:45:09 +08:00
Ching L
27128c3557 refactor: Remove async from rank changes check function 2025-03-07 11:35:23 +08:00
Ching L
f087aa9ba2 fix: Ensure ranking conversion handles string input 2025-03-07 11:24:59 +08:00
Ching L
a697a6e636 feat: Add daily rank tracking for Discord friends TUN-143 2025-03-07 11:22:40 +08:00
Ching L
f9ce233099 fix: Adjust match limit and embed image placement logic 2025-03-07 10:13:56 +08:00
Ching L
f055bd7027 fix: Remove unnecessary screenshot quality parameter 2025-03-07 10:00:47 +08:00
Ching L
bbc23217f9 feat: Improve screenshot quality by increasing device pixel ratio 2025-03-07 09:46:51 +08:00
Ching L
33c160b16b style: Enhance recent matches template layout and responsiveness 2025-03-06 10:10:36 +08:00
Ching L
9f5be12b2e fix: Improve file extension handling in image upload utility 2025-03-06 09:30:46 +08:00
Ching L
9e32de9922 fix: Update image upload method with explicit file name parameter 2025-03-06 09:26:45 +08:00
Ching L
5a6b5db082 fix: Update config file path for image upload utility 2025-03-06 09:22:33 +08:00
Ching L
47f4df7803 refactor: Convert match serialization methods to async 2025-03-06 09:10:42 +08:00
Ching L
f18f03a7f7 refactor: Migrate image generation to async Playwright 2025-03-05 23:01:00 +08:00
Ching L
9c8b360553 feat: Add image generation for recent matches report 2025-03-05 21:54:29 +08:00
Ching L
2bae19643a feat: Add hero name translation for match report embeds 2025-03-05 18:30:50 +08:00
Ching L
e2c6c9ea5b feat: Add image generation and Cloudflare R2 upload support for match reports 2025-03-05 17:50:52 +08:00
Ching
f24106bef1 feat: Add hero name translation utility function 2025-02-20 22:45:46 +08:00
Ching
a86dcb1bbc ci: 2024-05-13 11:05:06 +08:00
Ching
fd24fc01f7 fix: 修复获取不到开黑数据时报错的问题 2024-05-12 11:57:21 +08:00
Ching
3087dd3085 fix: 修复战报中开黑数据返回格式有误的问题 2024-05-12 11:23:34 +08:00
Ching
eddfcbf7f7 fix: 修复 openapi 接口报错时,战绩消息为空的问题 2024-05-12 01:10:47 +08:00
Ching
f4e17c5126 fix: 修复战报中没有开黑信息的问题 TUN-66 2024-05-11 16:37:02 +08:00
Ching
8b97724b08 fix: 修复获取不到战报的问题 TUN-51 catch 发送消息时的 error 2024-05-10 11:07:53 +08:00
Ching
520409f735 feat: Change radiant_indicator position in serialize_match_for_discord TUN-48 2024-04-05 13:24:22 +08:00
Ching
2185d955ad refactor: Refactor serialize_player function to handle empty player.personaname 2024-03-28 16:03:24 +08:00
Ching
54a32028e2 feat: change nickname display in serialize_player function TUN-38 2024-03-28 10:24:45 +08:00
Ching
9e9732d406 feat: Add Sentry error tracking TUN-39 2024-03-28 09:37:36 +08:00
Ching
57f7faf4fb feat: 修改比赛信息格式 #86enwkvn5 2024-03-20 11:47:30 +08:00
Ching
47ceabdd7a feat: 修改比赛信息中的开黑队友名字格式 2024-03-19 18:09:16 +08:00
Ching
6e64f38d64 ci: 增加部署脚本 2024-03-06 11:13:00 +08:00
Ching
ab5f6695e1 feat: Add end time to Match class 2024-03-06 11:01:04 +08:00
Ching
e1e5a63fe7 feat: 增加创建比赛结果图片逻辑 2024-03-06 10:51:49 +08:00
13 changed files with 7203 additions and 79 deletions

52
.drone.yml Normal file
View File

@ -0,0 +1,52 @@
kind: pipeline
type: docker
name: default
steps:
- name: deploy
image: appleboy/drone-ssh
settings:
host:
- 148.135.109.242
username: root
key:
from_secret: ssh_key
passphrase:
from_secret: ssh_passphrase
port: 22
command_timeout: 2m
script:
- echo "Go to the project directory"
- cd /root/develop/discord-dota-bot
- echo "Pull the latest code"
- git pull
- echo "Restart service"
- supervisorctl restart dotabot
script_stop: true
when:
event:
- push
- name: discord notification
image: appleboy/drone-discord
settings:
webhook_id:
from_secret: discord_webhook_id
webhook_token:
from_secret: discord_webhook_token
# message: |
# Drone Build #${DRONE_BUILD_NUMBER} ${DRONE_BUILD_STATUS}
# Project: ${DRONE_REPO_NAME}
# Branch: ${DRONE_BRANCH}
# Commit: ${DRONE_COMMIT_SHA:0:8}
# [Build Log](${DRONE_BUILD_LINK})
when:
status: [success, failure]
event:
- push
volumes:
- name: dockersock
host:
path: /var/run/docker.sock

View File

@ -7,6 +7,7 @@ attrs==23.1.0
backcall==0.2.0 backcall==0.2.0
beautifulsoup4==4.12.2 beautifulsoup4==4.12.2
blurhash==1.1.4 blurhash==1.1.4
boto3==1.34.143
bs4==0.0.1 bs4==0.0.1
certifi==2022.12.7 certifi==2022.12.7
charset-normalizer==3.0.1 charset-normalizer==3.0.1

View File

@ -5,6 +5,15 @@ from loguru import logger
import dota import dota
import utils import utils
import sentry_sdk
import datetime
import asyncio
sentry_sdk.init(
dsn="https://272f1e4ecb3847ac8d24be796515e558@o4506942768021504.ingest.us.sentry.io/4506986058743808",
)
# formatter = logging.Formatter('%(levelname)s %(name)s %(asctime)s %(message)s', '%Y-%m-%d %H:%M:%S') # formatter = logging.Formatter('%(levelname)s %(name)s %(asctime)s %(message)s', '%Y-%m-%d %H:%M:%S')
# log_handler = logging.FileHandler(utils.logger_file) # log_handler = logging.FileHandler(utils.logger_file)
@ -18,30 +27,59 @@ logger.info('start bot')
# bot = discord.Bot(proxy='http://127.0.0.1:1235') # bot = discord.Bot(proxy='http://127.0.0.1:1235')
bot = discord.Bot() bot = discord.Bot()
@bot.event
async def on_ready():
logger.info(f"We have logged in as {bot.user}")
channel_id = 1152167937852055552 channel_id = 1152167937852055552
@tasks.loop(minutes=1) @tasks.loop(minutes=1)
async def send_message(channel): async def send_message(channel):
if utils.is_game_time(): if utils.is_game_time():
send_message.change_interval(minutes=3) send_message.change_interval(minutes=2)
else: else:
send_message.change_interval(minutes=15) send_message.change_interval(minutes=15)
try: try:
matches = dota.get_friends_recent_matches() matches = dota.get_friends_recent_matches()
except: # 获取连胜连败消息
streak_notifications = dota.check_streaks()
except Exception as e:
logger.error(f"Error in send_message task: {e}")
return return
# 用于标记是否是第一场比赛
first_match = True
for match_ in matches: for match_ in matches:
data = dota.serialize_match_for_discord(match_) data = await dota.serialize_match_for_discord(match_)
logger.info(f"sending match {match_['match_id']}, {data}") logger.info(f"sending match {match_['match_id']}, {data}")
await channel.send(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']]) try:
# 从embed中提取朋友信息并添加胜负状态
friends_info = ""
win_status = "✅ **胜利** " if data.get('win') else "❌ **失败** "
@bot.command(description="获取最近战绩", name='recent_matches') # this decorator makes a slash command if data['embeds'] and len(data['embeds']) > 0:
async def get_friends_recent_matches(ctx, name, match_count=5): # a slash command will be created with the name "ping" embed = data['embeds'][0]
if 'fields' in embed and len(embed['fields']) > 2:
# 第三个field包含了朋友信息
field = embed['fields'][2]
if 'value' in field and field['value']:
friends_info = f"{win_status}**{field['value']}** 的比赛:\n\n"
# 将朋友信息放在内容开头,连胜连败消息只在第一场比赛时添加
content = data['content']
if first_match and streak_notifications:
streak_msg = '\n'.join(streak_notifications) + '\n\n'
content = friends_info + streak_msg + content
first_match = False # 标记已经处理过第一场比赛
else:
content = friends_info + content
# 发送比赛结果
await channel.send(content=content, embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
except Exception as e:
logger.error(f"send match error {e}")
@bot.command(description="获取最近战绩", name='recent_matches')
async def get_friends_recent_matches(ctx, name, match_count=5):
await ctx.defer() await ctx.defer()
logger.info(f"get_friends_recent_matches {name} {match_count}") logger.info(f"get_friends_recent_matches {name} {match_count}")
friends = dota.Friend.filter(name=name) friends = dota.Friend.filter(name=name)
@ -49,8 +87,14 @@ async def get_friends_recent_matches(ctx, name, match_count=5): # a slash comman
if friends.count() == 0: if friends.count() == 0:
await ctx.respond(content=f'找不到 {name} 的信息') await ctx.respond(content=f'找不到 {name} 的信息')
return return
data = dota.Friend.serialize_recent_matches_for_discord(friends, match_count) data = await dota.Friend.serialize_recent_matches_for_discord(friends, match_count)
await ctx.respond(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']]) if not data:
await ctx.respond(content=f'找不到 {name} 的战绩')
return
try:
await ctx.respond(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
except Exception as e:
logger.error(f"send recent_matches error {e}")
@bot.command(description='获取朋友', name='list_friends') @bot.command(description='获取朋友', name='list_friends')
async def get_friends(ctx): async def get_friends(ctx):
@ -96,6 +140,22 @@ async def deactivate_friend(ctx, steam_id):
else: else:
await ctx.respond(content=f'找不到 {steam_id}') await ctx.respond(content=f'找不到 {steam_id}')
@bot.command(description='获取本周统计总结', name='weekly_summary')
async def get_weekly_summary(ctx):
"""手动触发每周总结"""
await ctx.defer()
logger.info("Manual weekly summary requested")
try:
data = await dota.generate_weekly_summary_for_discord()
if data:
await ctx.respond(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
else:
await ctx.respond(content='本周暂无数据')
except Exception as e:
logger.error(f"Error generating weekly summary: {e}")
await ctx.respond(content=f'生成周报失败: {str(e)}')
@bot.command(description='启用朋友', name='activate_friend') @bot.command(description='启用朋友', name='activate_friend')
async def activate_friend(ctx, steam_id): async def activate_friend(ctx, steam_id):
logger.info(f'activate_friend {steam_id}') logger.info(f'activate_friend {steam_id}')
@ -111,10 +171,70 @@ async def activate_friend(ctx, steam_id):
async def heartbeat(): async def heartbeat():
utils.heartbeat() utils.heartbeat()
@tasks.loop(hours=24)
async def check_rank_changes(channel):
logger.info("Checking for rank changes")
try:
data = await dota.check_rank_changes_for_discord()
if data:
logger.info(f"Sending rank changes: {data}")
await channel.send(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
except Exception as e:
logger.error(f"Error checking rank changes: {e}")
sentry_sdk.capture_exception(e)
@check_rank_changes.before_loop
async def before_check_rank_changes():
# 等待到晚上9点再开始第一次运行
now = datetime.datetime.now()
target_time = now.replace(hour=21, minute=0, second=0, microsecond=0)
if now >= target_time:
target_time = target_time + datetime.timedelta(days=1)
seconds_until_target = (target_time - now).total_seconds()
await asyncio.sleep(seconds_until_target)
@tasks.loop(hours=168) # 每7天运行一次
async def weekly_summary(channel):
"""每周总结任务"""
logger.info("Generating weekly summary")
try:
data = await dota.generate_weekly_summary_for_discord()
if data:
logger.info(f"Sending weekly summary")
await channel.send(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
except Exception as e:
logger.error(f"Error generating weekly summary: {e}")
sentry_sdk.capture_exception(e)
@weekly_summary.before_loop
async def before_weekly_summary():
# 等待到周日晚上9点再开始第一次运行
now = datetime.datetime.now()
# 计算下一个周日的21:00
days_until_sunday = (6 - now.weekday()) % 7
if days_until_sunday == 0 and now.hour >= 21:
days_until_sunday = 7
target_time = (now + datetime.timedelta(days=days_until_sunday)).replace(hour=21, minute=0, second=0, microsecond=0)
seconds_until_target = (target_time - now).total_seconds()
logger.info(f"Weekly summary will start in {seconds_until_target} seconds")
await asyncio.sleep(seconds_until_target)
@bot.event @bot.event
async def on_ready(): async def on_ready():
logger.info(f"We have logged in as {bot.user}")
channel = bot.get_channel(channel_id) channel = bot.get_channel(channel_id)
send_message.start(channel) send_message.start(channel)
heartbeat.start() heartbeat.start()
# 启动天梯检查任务
check_rank_changes.start(channel)
# 启动每周总结任务
weekly_summary.start(channel)
bot.run('MTE1MjE2NTc3NDMwNDIyMzI2Mg.GEi-17.VvuIkRy_cFD9XF6wtTagY95LKEbTxKaxy-FxGw') # 这里替换成你自己的 token bot.run('MTE1MjE2NTc3NDMwNDIyMzI2Mg.GEi-17.VvuIkRy_cFD9XF6wtTagY95LKEbTxKaxy-FxGw') # 这里替换成你自己的 token

528
dota.py
View File

@ -2,15 +2,20 @@ import peewee
import opendota import opendota
import datetime import datetime
from loguru import logger from loguru import logger
import json
import players import players
import utils import utils
from image_generator import ImageGenerator
import asyncio
db = peewee.SqliteDatabase('dota.db') db = peewee.SqliteDatabase('dota.db')
hero_client = opendota.HeroesApi() hero_client = opendota.HeroesApi()
player_client = opendota.PlayersApi() player_client = opendota.PlayersApi()
match_client = opendota.MatchesApi() match_client = opendota.MatchesApi()
image_generator = ImageGenerator()
# 初始化全局变量,用于存储连胜连败更新
streak_updates = []
class BaseModel(peewee.Model): class BaseModel(peewee.Model):
@ -48,26 +53,38 @@ class Match(BaseModel):
duration = peewee.IntegerField() duration = peewee.IntegerField()
radiant_win = peewee.BooleanField() radiant_win = peewee.BooleanField()
party_size = peewee.IntegerField(null=True) party_size = peewee.IntegerField(null=True)
opendota_response = peewee.TextField(null=True)
def serialize_match(self): def serialize_match(self):
try: if not self.opendota_response:
match_ = match_client.get_matches_by_match_id(self.match_id) try:
except Exception as e: match_ = match_client.get_matches_by_match_id(self.match_id)
logger.error('fail to get match %s' % self.match_id) m_dict = match_.to_dict()
raise e for player in m_dict['players']:
if player['last_login']:
# datatime obj to timestamp
player['last_login'] = int(player['last_login'].timestamp())
self.opendota_response = json.dumps(m_dict)
self.save()
except Exception as e:
logger.error('fail to get match %s' % self.match_id)
raise e
md = json.loads(self.opendota_response)
match_data = { match_data = {
'players': [players.serialize_player(player) for player in match_.players], 'players': [players.serialize_player(player) for player in md['players']],
'dire_score': match_.dire_score, 'dire_score': md['dire_score'],
'radiant_score': match_.radiant_score, 'radiant_score': md['radiant_score'],
# isoformat utc+8 # isoformat utc+8
'start_time': datetime.datetime.fromtimestamp(match_.start_time).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'), 'start_time': datetime.datetime.fromtimestamp(md['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_.duration), 'end_time': datetime.datetime.fromtimestamp(md['start_time'] + md['duration']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
'radiant_win': match_.radiant_win, 'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(md['duration']),
'radiant_win': md['radiant_win'],
'party_size': self.party_size, 'party_size': self.party_size,
'match_id': self.match_id, 'match_id': self.match_id,
} }
if not self.party_size: if not self.party_size:
player_account_ids = [player.account_id for player in match_.players if player.account_id] player_account_ids = [player['account_id'] for player in md['players'] if player['account_id']]
match_data['party_size'] = Friend.select().where(Friend.steam_id.in_(player_account_ids)).count() match_data['party_size'] = Friend.select().where(Friend.steam_id.in_(player_account_ids)).count()
return match_data return match_data
@ -76,6 +93,10 @@ class Friend(BaseModel):
steam_id = peewee.IntegerField(primary_key=True) steam_id = peewee.IntegerField(primary_key=True)
name = peewee.CharField() name = peewee.CharField()
active = peewee.BooleanField(default=True) active = peewee.BooleanField(default=True)
rank_tier = peewee.IntegerField(null=True)
win_streak = peewee.IntegerField(default=0) # 连胜计数
loss_streak = peewee.IntegerField(default=0) # 连败计数
last_match_id = peewee.IntegerField(null=True) # 上一场比赛ID用于避免重复计算
def get_recent_matches(self, limit=1): def get_recent_matches(self, limit=1):
try: try:
@ -84,6 +105,20 @@ class Friend(BaseModel):
logger.error('fail to get player %s recent matches. error: %s' % (self.steam_id, e)) logger.error('fail to get player %s recent matches. error: %s' % (self.steam_id, e))
return [] return []
def update_rank_tier(self):
"""Update player's rank tier from OpenDota API"""
try:
player_info = player_client.get_players_by_account_id(self.steam_id)
if player_info and hasattr(player_info, 'rank_tier') and player_info.rank_tier:
old_rank_tier = self.rank_tier
self.rank_tier = player_info.rank_tier
self.save()
return old_rank_tier != self.rank_tier, old_rank_tier
return False, None
except Exception as e:
logger.error(f'Failed to update rank tier for player {self.steam_id}. Error: {e}')
return False, None
def serialize_recent_matches(self, limit=1): def serialize_recent_matches(self, limit=1):
matches = self.get_recent_matches(limit=limit) matches = self.get_recent_matches(limit=limit)
data = [] data = []
@ -97,6 +132,7 @@ class Friend(BaseModel):
'assists': match_.assists, 'assists': match_.assists,
'party_size': match_.party_size, 'party_size': match_.party_size,
'start_time': match_.start_time, 'start_time': match_.start_time,
'end_time': match_.start_time + match_.duration,
'duration': match_.duration, 'duration': match_.duration,
'average_rank': utils.get_ranking(match_.average_rank), 'average_rank': utils.get_ranking(match_.average_rank),
'hero_id': match_.hero_id, 'hero_id': match_.hero_id,
@ -104,7 +140,7 @@ class Friend(BaseModel):
return data return data
@classmethod @classmethod
def serialize_recent_matches_for_discord(cls, friends, limit=5): async def serialize_recent_matches_for_discord(cls, friends, limit=5):
# { # {
# "content": "## 水哥的战报\n", # "content": "## 水哥的战报\n",
# "embeds": [ # "embeds": [
@ -119,10 +155,15 @@ class Friend(BaseModel):
# ], # ],
# } # }
matches = [] matches = []
if limit > 10: # if limit > 10:
limit = 10 # limit = 10
for friend in friends: for friend in friends:
matches.extend(friend.serialize_recent_matches(limit=limit)) matches_ = friend.serialize_recent_matches(limit=limit)
if not matches_:
continue
matches.extend(matches_)
if not matches:
return None
# sort matches by start_time from latest to oldest # sort matches by start_time from latest to oldest
matches.sort(key=lambda x: x['start_time'], reverse=True) matches.sort(key=lambda x: x['start_time'], reverse=True)
name = friends[0].name name = friends[0].name
@ -130,49 +171,222 @@ class Friend(BaseModel):
'content': f'## {name}的战报', 'content': f'## {name}的战报',
'embeds': [], 'embeds': [],
} }
for match_ in matches[:limit]:
for match_ in matches[:min(limit, 9)]:
duration = '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_['duration']) duration = '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_['duration'])
summary = f"{duration}"
if match_['party_size'] == None:
if Match.filter(match_id=match_['match_id']).exists():
match_['party_size'] = Match.get(match_id=match_['match_id']).party_size
if match_['party_size']:
match_['party_size'] = 0
if match_['party_size'] and match_['party_size'] > 1: if match_['party_size'] and match_['party_size'] > 1:
summary = f"{match_['party_size']}{duration}" summary = f"{match_['party_size']}{duration}"
elif match_['party_size'] == None: elif match_['party_size'] and match_['party_size'] == 1:
summary = f"??黑 {duration}"
else:
summary = f"单排 {duration}" summary = f"单排 {duration}"
if match_['average_rank']: if match_['average_rank']:
summary += '\n' + match_['average_rank'] summary += '\n' + match_['average_rank']
start_time = datetime.datetime.fromtimestamp(match_['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00') # start_time = datetime.datetime.fromtimestamp(match_['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00')
hero_name = Hero.get(hero_id=match_['hero_id']).localized_name end_time = datetime.datetime.fromtimestamp(match_['end_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00')
hero_name = utils.get_hero_chinese_name(Hero.get(hero_id=match_['hero_id']).localized_name)
data['embeds'].append({ data['embeds'].append({
'title': f"{hero_name} {match_['kills']}{match_['deaths']}{match_['assists']}", 'title': f"{hero_name} {match_['kills']}{match_['deaths']}{match_['assists']}",
'description': summary, 'description': summary,
'color': 6732650 if match_['win'] else 16724787, # 66bb6a or FF3333 'color': 6732650 if match_['win'] else 16724787, # 66bb6a or FF3333
'fields': [], 'fields': [],
'timestamp': start_time, 'timestamp': end_time,
'url': f"https://www.opendota.com/matches/{match_['match_id']}", 'url': f"https://www.opendota.com/matches/{match_['match_id']}",
}) })
# 生成图片报告
image_url = None
try:
# 直接等待异步函数而不是使用asyncio.run()
image_generator = ImageGenerator()
image_url = await image_generator.generate_recent_matches_image(name, matches[:limit])
except Exception as e:
logger.error(f"生成最近比赛报告图片失败: {str(e)}")
# 如果成功生成了图片添加到最后一个embed中
if image_url:
data['embeds'].append({
'image': {
'url': image_url
},
'color': 3447003 # 蓝色
})
return data return data
def recalculate_streak_from_recent_matches(self):
"""获取近20场比赛并重新计算连胜连败记录"""
try:
# 获取近20场比赛
recent_matches = self.get_recent_matches(limit=20)
if not recent_matches:
logger.warning(f"No recent matches found for {self.name}")
return False
# 按时间从旧到新排序start_time升序
recent_matches.sort(key=lambda x: x.start_time)
# 重置连胜连败计数
self.win_streak = 0
self.loss_streak = 0
# 从最旧的比赛开始计算连胜连败
for match in recent_matches:
# 判断是否获胜
player_won = match.radiant_win == (match.player_slot < 128)
if player_won:
self.win_streak += 1
self.loss_streak = 0
else:
self.loss_streak += 1
self.win_streak = 0
# 更新最后一场比赛的ID避免重复计算
if recent_matches:
self.last_match_id = recent_matches[-1].match_id
# 保存更新后的数据
self.save()
logger.info(f"Updated streak for {self.name}: {self.win_streak} wins, {self.loss_streak} losses")
return True
except Exception as e:
logger.error(f"Failed to recalculate streak for {self.name}: {e}")
return False
def update_streak(self, match_id, win):
"""更新连胜连败计数"""
# 避免重复计算同一场比赛
if self.last_match_id == match_id:
return None
old_win_streak = self.win_streak
old_loss_streak = self.loss_streak
if win:
self.win_streak += 1
self.loss_streak = 0 # 重置连败
else:
self.loss_streak += 1
self.win_streak = 0 # 重置连胜
self.last_match_id = match_id
self.save()
# 返回连胜连败状态变化信息
result = {
'name': self.name,
'win': win,
'win_streak': self.win_streak,
'loss_streak': self.loss_streak,
'win_streak_broken': not win and old_win_streak >= 3,
'loss_streak_broken': win and old_loss_streak >= 3,
'old_win_streak': old_win_streak,
'old_loss_streak': old_loss_streak
}
return result
def get_friends_recent_matches(): def get_friends_recent_matches():
matches = [] matches = []
for friend in Friend.filter(active=True): global streak_updates # 使用全局变量存储连胜连败更新
for match_ in friend.get_recent_matches(): streak_updates = []
if not Match.select().where(Match.match_id == match_.match_id).exists():
logger.info('create match, match info: %s' % match_.__dict__) processed_matches = set() # 记录已处理的比赛ID避免重复处理开黑比赛
match_obj = Match.create( active_friends = list(Friend.filter(active=True))
match_id=match_.match_id,
start_time=datetime.datetime.fromtimestamp(match_.start_time), for friend in active_friends:
duration=match_.duration, try:
radiant_win=match_.radiant_win, recent_matches = friend.get_recent_matches()
party_size=match_.party_size, for match_ in recent_matches:
) # 如果这场比赛已经被处理过开黑情况下其他朋友已处理跳过API调用
matches.append(match_obj.serialize_match()) if match_.match_id in processed_matches:
# 仍需要为当前朋友更新连胜,但不重复创建比赛记录
player_won = match_.radiant_win == (match_.player_slot < 128)
streak_info = friend.update_streak(match_.match_id, player_won)
if streak_info:
streak_updates.append(streak_info)
continue
# 标记此比赛为已处理
processed_matches.add(match_.match_id)
# 判断当前朋友是否获胜
player_won = match_.radiant_win == (match_.player_slot < 128)
# 更新当前朋友的连胜连败
streak_info = friend.update_streak(match_.match_id, player_won)
if streak_info:
streak_updates.append(streak_info)
# 如果是开黑比赛,为其他可能在同一场比赛的朋友也更新连胜
# 但不需要额外的API调用
if match_.party_size and match_.party_size > 1:
# 获取比赛详细信息以找出其他朋友
if not Match.select().where(Match.match_id == match_.match_id).exists():
# 先创建比赛记录,这样可以获取详细信息
match_obj = Match.create(
match_id=match_.match_id,
start_time=datetime.datetime.fromtimestamp(match_.start_time),
duration=match_.duration,
radiant_win=match_.radiant_win,
party_size=match_.party_size,
)
try:
# 获取比赛详细信息
match_obj.serialize_match() # 这会触发获取详细信息
# 解析比赛数据,找出其他朋友
if match_obj.opendota_response:
match_data = json.loads(match_obj.opendota_response)
player_account_ids = [p['account_id'] for p in match_data['players'] if p['account_id']]
# 为其他在这场比赛中的朋友更新连胜
for other_friend in active_friends:
if other_friend != friend and other_friend.steam_id in player_account_ids:
# 找到该朋友在比赛中的信息
for player in match_data['players']:
if player['account_id'] == other_friend.steam_id:
other_player_won = match_.radiant_win == (player['player_slot'] < 128)
other_streak_info = other_friend.update_streak(match_.match_id, other_player_won)
if other_streak_info:
streak_updates.append(other_streak_info)
break
except Exception as e:
logger.error(f'failed to get match details for {match_.match_id}: {e}')
matches.append(match_obj.serialize_match())
else:
# 单排比赛,正常处理
if not Match.select().where(Match.match_id == match_.match_id).exists():
logger.info('create match, match info: %s' % match_.__dict__)
match_obj = Match.create(
match_id=match_.match_id,
start_time=datetime.datetime.fromtimestamp(match_.start_time),
duration=match_.duration,
radiant_win=match_.radiant_win,
party_size=match_.party_size,
)
matches.append(match_obj.serialize_match())
except Exception as e:
logger.error(f'failed to get recent matches for friend {friend.name}: {e}')
continue
return matches return matches
def serialize_match_for_discord(match_): async def serialize_match_for_discord(match_):
# { # {
# "content": "## 天辉\n\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n\n## 夜魇\n\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n", # "content": "## 天辉\n\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n\n## 夜魇\n\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n我LV23 大鱼人): 2 杀 5 死 3 助 12345 经济 13442 伤害\n",
# "tts": false, # "tts": false,
@ -216,11 +430,15 @@ def serialize_match_for_discord(match_):
is_radiant = player['is_radiant'] is_radiant = player['is_radiant']
break break
win = is_radiant == match_['radiant_win'] win = is_radiant == match_['radiant_win']
summary = f"{match_['duration']}"
if not match_['party_size']: if not match_['party_size']:
summary = f"??黑 {match_['duration']}" if Match.filter(match_id=match_['match_id']).exists():
elif match_['party_size'] > 1: match_['party_size'] = Match.get(match_id=match_['match_id']).party_size
else:
match_['party_size'] = 0
if match_['party_size'] > 1:
summary = f"{match_['party_size']}{match_['duration']}" summary = f"{match_['party_size']}{match_['duration']}"
else: elif match_['party_size'] == 1:
summary = f"单排 {match_['duration']}" summary = f"单排 {match_['duration']}"
radiant = [] radiant = []
@ -235,7 +453,11 @@ def serialize_match_for_discord(match_):
dire_highest_damage_idx = 0 dire_highest_damage_idx = 0
for player in match_['players']: for player in match_['players']:
desc = f"{player['nickname'] or player['personaname']}Lv.**{player['level']}** {player['hero']} **{player['kills']}** 杀 **{player['deaths']}** 死 **{player['assists']}** 助 **{utils.shorten_digits(player['total_gold'])}** 经济 **{utils.shorten_digits(player['hero_damage'])}** 伤害 " player_name = player['personaname']
if player['nickname']:
player_name = f"**{player['nickname']}**"
desc = f"{player_name}Lv.**{player['level']}** {utils.get_hero_chinese_name(player['hero'])} **{player['kills']}** 杀 **{player['deaths']}** 死 **{player['assists']}** 助 **{utils.shorten_digits(player['total_gold'])}** 经济 **{utils.shorten_digits(player['hero_damage'])}** 伤害 "
if player['is_radiant']: if player['is_radiant']:
radiant.append(desc) radiant.append(desc)
@ -254,10 +476,10 @@ def serialize_match_for_discord(match_):
dire_highest_damage = player['hero_damage'] dire_highest_damage = player['hero_damage']
dire_highest_damage_idx = len(dire) - 1 dire_highest_damage_idx = len(dire) - 1
radiant[radiant_highest_gold_idx] = radiant[radiant_highest_gold_idx] + '💰' radiant[radiant_highest_gold_idx] = '💰' + radiant[radiant_highest_gold_idx]
radiant[radiant_highest_damage_idx] = radiant[radiant_highest_damage_idx] + '🩸' radiant[radiant_highest_damage_idx] = '🩸' + radiant[radiant_highest_damage_idx]
dire[dire_highest_gold_idx] = dire[dire_highest_gold_idx] + '💰' dire[dire_highest_gold_idx] = '💰' + dire[dire_highest_gold_idx]
dire[dire_highest_damage_idx] = dire[dire_highest_damage_idx] + '🩸' dire[dire_highest_damage_idx] = '🩸'+ dire[dire_highest_damage_idx]
color = 6732650 if win else 16724787 # 66bb6a or FF3333 color = 6732650 if win else 16724787 # 66bb6a or FF3333
@ -265,19 +487,29 @@ def serialize_match_for_discord(match_):
radiant_indicator = '' radiant_indicator = ''
dire_indicator = '' dire_indicator = ''
if is_radiant: if is_radiant:
radiant_indicator = ' 🌟' radiant_indicator = '🌟 '
else: else:
dire_indicator = ' 🌟' dire_indicator = ' 🌟'
# 生成比赛报告图片
image_url = None
try:
# 直接等待异步函数而不是使用asyncio.run()
image_generator = ImageGenerator()
image_url = await image_generator.generate_match_report(match_)
except Exception as e:
logger.error(f"生成比赛报告图片失败: {str(e)}")
data = { data = {
"content": content, "content": content,
"tts": False, "tts": False,
"win": win,
"embeds": [ "embeds": [
{ {
"color": color, "color": color,
"fields": [ "fields": [
{ {
"name": "天辉" + radiant_indicator, "name": radiant_indicator + "天辉",
"value": match_['radiant_score'], "value": match_['radiant_score'],
"inline": True "inline": True
}, },
@ -295,8 +527,206 @@ def serialize_match_for_discord(match_):
"name": "opendota", "name": "opendota",
"url": "https://www.opendota.com/matches/%s" % match_['match_id'] "url": "https://www.opendota.com/matches/%s" % match_['match_id']
}, },
"timestamp": match_['start_time'] "timestamp": match_['end_time']
} }
], ],
} }
# 如果成功生成了图片添加到embeds中
if image_url:
data["embeds"][0]["image"] = {
"url": image_url
}
return data return data
def check_rank_changes_for_discord():
"""Check for rank changes among all active friends and format for Discord"""
rank_changes = []
for friend in Friend.filter(active=True):
changed, old_rank_tier = friend.update_rank_tier()
if changed and friend.rank_tier is not None:
old_rank = utils.get_ranking(old_rank_tier) if old_rank_tier else "未校准"
new_rank = utils.get_ranking(friend.rank_tier)
rank_changes.append({
'name': friend.name,
'old_rank': old_rank,
'new_rank': new_rank,
'increased': friend.rank_tier > (old_rank_tier or 0)
})
if not rank_changes:
return None
data = {
'content': '## 天梯更新',
'embeds': []
}
for change in rank_changes:
direction = "⬆️ 上升" if change['increased'] else "⬇️ 下降"
color = 6732650 if change['increased'] else 16724787 # Green if increased, red if decreased
data['embeds'].append({
'title': f"{change['name']} 的天梯等级变化",
'description': f"{direction}{change['old_rank']}{change['new_rank']}",
'color': color
})
return data
def check_streaks():
"""检查连胜连败并返回通知消息"""
global streak_updates
notifications = []
for update in streak_updates:
# 连胜达到3场或以上
if update['win_streak'] >= 3:
notifications.append(f"🔥 **{update['name']}** 正在**{update['win_streak']}连胜**")
# 连败达到3场或以上
if update['loss_streak'] >= 3:
notifications.append(f"💔 **{update['name']}** 正在**{update['loss_streak']}连败**")
# 连胜被终结
if update['win_streak_broken'] and update['old_win_streak'] >= 3:
notifications.append(f"⚡ **{update['name']}** 的**{update['old_win_streak']}连胜**被终结了!")
# 连败被终结
if update['loss_streak_broken'] and update['old_loss_streak'] >= 3:
notifications.append(f"🌈 **{update['name']}** 终于结束了**{update['old_loss_streak']}连败**")
# 清空更新列表,避免重复通知
streak_updates = []
return notifications
def get_weekly_stats_for_discord():
"""
获取过去7天的统计数据返回Discord格式的消息
"""
import json
# 获取7天前的日期
week_ago = datetime.datetime.now() - datetime.timedelta(days=7)
weekly_data = []
for friend in Friend.filter(active=True):
# 获取该玩家最近20场比赛从中筛选过去7天的
recent_matches = friend.get_recent_matches(limit=20)
# 筛选过去7天的比赛
matches_this_week = []
for match in recent_matches:
match_time = datetime.datetime.fromtimestamp(match.start_time)
if match_time >= week_ago:
matches_this_week.append(match)
if not matches_this_week:
continue
# 统计数据
wins = 0
losses = 0
total_kills = 0
total_deaths = 0
total_assists = 0
hero_stats = {} # {hero_id: {'wins': 0, 'losses': 0, 'games': 0}}
for match in matches_this_week:
player_won = match.radiant_win == (match.player_slot < 128)
if player_won:
wins += 1
else:
losses += 1
total_kills += match.kills
total_deaths += match.deaths
total_assists += match.assists
# 英雄统计
hero_id = match.hero_id
if hero_id not in hero_stats:
hero_stats[hero_id] = {'wins': 0, 'losses': 0, 'games': 0, 'hero_id': hero_id}
hero_stats[hero_id]['games'] += 1
if player_won:
hero_stats[hero_id]['wins'] += 1
else:
hero_stats[hero_id]['losses'] += 1
total_games = wins + losses
win_rate = (wins / total_games * 100) if total_games > 0 else 0
kda = (total_kills + total_assists) / total_deaths if total_deaths > 0 else (total_kills + total_assists)
# 找出最常用英雄和最佳英雄
most_played = max(hero_stats.values(), key=lambda x: x['games']) if hero_stats else None
best_hero = max(
[h for h in hero_stats.values() if h['games'] >= 2],
key=lambda x: x['wins'] / x['games'] if x['games'] > 0 else 0,
default=None
)
weekly_data.append({
'name': friend.name,
'steam_id': friend.steam_id,
'total_games': total_games,
'wins': wins,
'losses': losses,
'win_rate': win_rate,
'kda': kda,
'total_kills': total_kills,
'total_deaths': total_deaths,
'total_assists': total_assists,
'most_played_hero': most_played,
'best_hero': best_hero,
'hero_stats': hero_stats
})
return weekly_data
async def generate_weekly_summary_for_discord():
"""
生成每周总结报告返回Discord格式的消息
"""
weekly_data = get_weekly_stats_for_discord()
if not weekly_data:
return None
# 生成图片
image_url = await image_generator.generate_weekly_summary_image(weekly_data)
content = "## 📊 本周 Dota 战报\n\n"
# 整体统计
total_games = sum(d['total_games'] for d in weekly_data)
total_wins = sum(d['wins'] for d in weekly_data)
total_losses = sum(d['losses'] for d in weekly_data)
content += f"本周共打了 **{total_games}** 场比赛,**{total_wins}** 胜 **{total_losses}** 败\n\n"
# 个人统计
for data in weekly_data:
win_rate_str = f"{data['win_rate']:.1f}%"
kda_str = f"{data['kda']:.2f}"
content += f"**{data['name']}**: {data['wins']}{data['losses']}败 ({win_rate_str}) KDA {kda_str}\n"
result = {
'content': content,
'embeds': []
}
if image_url:
result['embeds'].append({
'image': {'url': image_url},
'color': 3447003
})
return result

10
env.ini Normal file
View File

@ -0,0 +1,10 @@
[cloudflare]
account_id = your_account_id
access_key_id = your_access_key_id
secret_access_key = your_secret_access_key
bucket_name = dotabot-images
region = auto
custom_domain =
[bot]
# 其他机器人配置可以放在这里

5026
heroes.json Normal file

File diff suppressed because it is too large Load Diff

356
image_generator.py Normal file
View File

@ -0,0 +1,356 @@
from playwright.async_api import async_playwright
from jinja2 import Environment, FileSystemLoader
import utils
import json
import os
import datetime
from loguru import logger
class ImageGenerator:
def __init__(self):
self.env = Environment(loader=FileSystemLoader('templates'))
# 加载英雄数据
with open('heroes.json', 'r', encoding='utf-8') as f:
self.heroes_data = json.load(f)
def get_hero_image_url(self, hero_id):
"""根据英雄id获取图片URL"""
hero_info = self.heroes_data.get(str(hero_id))
if hero_info:
return f"https://cdn.cloudflare.steamstatic.com{hero_info['img']}"
return None
async def generate_match_report(self, match_data):
"""
根据match_data生成比赛报告图片
match_data应该是dota.py中serialize_match方法返回的格式
"""
# 处理数据,标记最高经济和最高伤害的玩家
radiant_players = []
dire_players = []
# 分离天辉和夜魇玩家
for player in match_data['players']:
player_data = {
'name': player['personaname'] if player['personaname'] else '-',
'is_friend': bool(player.get('nickname', '')),
'hero': player['hero'],
'level': player['level'],
'kills': player['kills'],
'deaths': player['deaths'],
'assists': player['assists'],
'total_gold': player['total_gold'],
'hero_damage': player['hero_damage'],
'hero_img': self.get_hero_image_url(player['hero_id']),
'highest_gold': False,
'highest_damage': False,
'rank': player.get('rank'),
'rank_tier': player.get('rank_tier'),
}
# 如果是好友,使用昵称
if player.get('nickname'):
player_data['name'] = player['nickname']
# 转换英雄名称为中文
player_data['hero'] = utils.get_hero_chinese_name(player['hero'])
if player['is_radiant']:
radiant_players.append(player_data)
else:
dire_players.append(player_data)
# 找出天辉最高经济和伤害
if radiant_players:
max_gold_player = max(radiant_players, key=lambda p: int(p['total_gold']))
max_damage_player = max(radiant_players, key=lambda p: int(p['hero_damage']))
for player in radiant_players:
if player == max_gold_player:
player['highest_gold'] = True
if player == max_damage_player:
player['highest_damage'] = True
# 找出夜魇最高经济和伤害
if dire_players:
max_gold_player = max(dire_players, key=lambda p: int(p['total_gold']))
max_damage_player = max(dire_players, key=lambda p: int(p['hero_damage']))
for player in dire_players:
if player == max_gold_player:
player['highest_gold'] = True
if player == max_damage_player:
player['highest_damage'] = True
# 短数字
for player in radiant_players:
player['total_gold'] = utils.shorten_digits(player['total_gold'])
player['hero_damage'] = utils.shorten_digits(player['hero_damage'])
for player in dire_players:
player['total_gold'] = utils.shorten_digits(player['total_gold'])
player['hero_damage'] = utils.shorten_digits(player['hero_damage'])
end_time = match_data.get('end_time', '')
# '2025-03-05T01:04:30.000+08:00' get 01:04:30
end_time = end_time.split('T')[1].split('.')[0]
# 准备模板数据
template_data = {
'radiant_players': radiant_players,
'dire_players': dire_players,
'radiant_score': match_data.get('radiant_score', 0),
'dire_score': match_data.get('dire_score', 0),
'duration': match_data.get('duration', 0),
'radiant_win': match_data.get('radiant_win', False),
'start_time': match_data.get('start_time', ''),
'end_time': end_time,
}
# 渲染模板
template = self.env.get_template('match_report.html')
html_content = template.render(**template_data)
# 使用Playwright生成图片
async with async_playwright() as playwright:
browser = await playwright.chromium.launch()
page = await browser.new_page()
await page.set_content(html_content)
await page.set_viewport_size({"width": 800, "height": 800})
# 等待内容完全加载
await page.wait_for_timeout(1000)
# 调整截图高度以适应内容
body_height = await page.evaluate('document.body.scrollHeight')
body_width = await page.evaluate('document.body.offsetWidth')
await page.set_viewport_size({"width": body_width, "height": body_height})
# 设置更高的设备像素比以获得更清晰的图像
await page.evaluate('''() => {
window.devicePixelRatio = 2;
}''')
# 截图
image_path = f"match_report_{match_data.get('match_id')}.png"
await page.screenshot(path=image_path, full_page=True)
await browser.close()
# 上传图片
image_url = utils.upload_image(image_path, image_path)
# 删除本地文件
try:
os.remove(image_path)
except Exception as e:
logger.warning(f"删除本地图片文件失败: {str(e)}")
return image_url
return None
async def generate_recent_matches_image(self, player_name, matches):
"""
生成玩家最近比赛的图片报告
Args:
player_name: 玩家名称
matches: 比赛数据列表
Returns:
str: 上传后的图片URL
"""
# 处理比赛数据
processed_matches = []
for match in matches:
# 获取英雄图片
hero_id = str(match['hero_id'])
hero_img = f"https://cdn.dota2.com/apps/dota2/images/heroes/{self.heroes_data[hero_id]['name'].replace('npc_dota_hero_', '')}_full.png"
# 格式化时间
end_time = datetime.datetime.fromtimestamp(match['end_time']).strftime('%Y-%m-%d %H:%M')
# 格式化时长
duration_hms = utils.convert_seconds_to_hms(match['duration'])
duration_formatted = f"{duration_hms[0]}:{duration_hms[1]:02d}:{duration_hms[2]:02d}"
# 获取英雄中文名
hero_name = utils.get_hero_chinese_name(self.heroes_data[hero_id]['name'])
processed_match = {
'win': match['win'],
'kills': match['kills'],
'deaths': match['deaths'],
'assists': match['assists'],
'hero_img': hero_img,
'hero_name': hero_name,
'duration_formatted': duration_formatted,
'end_time_formatted': end_time,
'party_size': match['party_size'],
'average_rank': match['average_rank']
}
processed_matches.append(processed_match)
# 渲染模板
template = self.env.get_template('recent_matches.html')
html_content = template.render(
player_name=player_name,
matches=processed_matches
)
# 使用Playwright生成图片
async with async_playwright() as playwright:
browser = await playwright.chromium.launch()
page = await browser.new_page()
await page.set_content(html_content)
await page.set_viewport_size({"width": 800, "height": 800})
# 等待内容完全加载
await page.wait_for_timeout(1000)
# 调整截图高度以适应内容
body_height = await page.evaluate('document.body.scrollHeight')
body_width = await page.evaluate('document.body.offsetWidth')
await page.set_viewport_size({"width": body_width, "height": body_height})
# 设置更高的设备像素比以获得更清晰的图像
await page.evaluate('''() => {
window.devicePixelRatio = 2;
}''')
# 截图
image_path = f"recent_matches_{player_name.replace(' ', '_')}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}.png"
await page.screenshot(path=image_path, full_page=True)
await browser.close()
# 上传图片
image_url = utils.upload_image(image_path, image_path)
# 删除本地文件
try:
os.remove(image_path)
except Exception as e:
logger.warning(f"删除本地图片文件失败: {str(e)}")
return image_url
async def generate_weekly_summary_image(self, weekly_data):
"""
生成每周总结报告图片
Args:
weekly_data: dota.py get_weekly_stats_for_discord() 返回的数据列表
Returns:
str: 上传后的图片URL
"""
import datetime
# 计算日期范围
today = datetime.datetime.now()
week_ago = today - datetime.timedelta(days=7)
date_range = f"{week_ago.strftime('%m/%d')} - {today.strftime('%m/%d')}"
# 整体统计
total_games = sum(d['total_games'] for d in weekly_data)
total_wins = sum(d['wins'] for d in weekly_data)
total_losses = sum(d['losses'] for d in weekly_data)
overall_win_rate = round((total_wins / total_games * 100), 1) if total_games > 0 else 0
# 处理每个玩家的数据
players = []
for data in weekly_data:
player_data = {
'name': data['name'],
'wins': data['wins'],
'losses': data['losses'],
'win_rate': round(data['win_rate'], 1),
'kda': round(data['kda'], 2),
'avg_kills': round(data['total_kills'] / data['total_games'], 1) if data['total_games'] > 0 else 0,
'avg_deaths': round(data['total_deaths'] / data['total_games'], 1) if data['total_games'] > 0 else 0,
'avg_assists': round(data['total_assists'] / data['total_games'], 1) if data['total_games'] > 0 else 0,
}
# 处理最常用英雄
if data['most_played_hero']:
hero_id = str(data['most_played_hero']['hero_id'])
hero_info = self.heroes_data.get(hero_id, {})
hero_name = hero_info.get('localized_name', 'Unknown')
hero_img_name = hero_info.get('name', '').replace('npc_dota_hero_', '')
player_data['most_played_hero'] = {
'name': utils.get_hero_chinese_name(hero_name),
'img': f"https://cdn.dota2.com/apps/dota2/images/heroes/{hero_img_name}_full.png",
'wins': data['most_played_hero']['wins'],
'losses': data['most_played_hero']['losses'],
'games': data['most_played_hero']['games']
}
else:
player_data['most_played_hero'] = None
# 处理最佳英雄
if data['best_hero']:
hero_id = str(data['best_hero']['hero_id'])
hero_info = self.heroes_data.get(hero_id, {})
hero_name = hero_info.get('localized_name', 'Unknown')
hero_img_name = hero_info.get('name', '').replace('npc_dota_hero_', '')
win_rate = round(data['best_hero']['wins'] / data['best_hero']['games'] * 100, 1) if data['best_hero']['games'] > 0 else 0
player_data['best_hero'] = {
'name': utils.get_hero_chinese_name(hero_name),
'img': f"https://cdn.dota2.com/apps/dota2/images/heroes/{hero_img_name}_full.png",
'wins': data['best_hero']['wins'],
'losses': data['best_hero']['losses'],
'win_rate': win_rate
}
else:
player_data['best_hero'] = None
players.append(player_data)
# 渲染模板
template = self.env.get_template('weekly_summary.html')
html_content = template.render(
date_range=date_range,
total_games=total_games,
total_wins=total_wins,
total_losses=total_losses,
overall_win_rate=overall_win_rate,
players=players
)
# 使用Playwright生成图片
async with async_playwright() as playwright:
browser = await playwright.chromium.launch()
page = await browser.new_page()
await page.set_content(html_content)
await page.set_viewport_size({"width": 800, "height": 800})
# 等待内容完全加载
await page.wait_for_timeout(1000)
# 调整截图高度以适应内容
body_height = await page.evaluate('document.body.scrollHeight')
body_width = await page.evaluate('document.body.offsetWidth')
await page.set_viewport_size({"width": body_width, "height": body_height})
# 设置更高的设备像素比以获得更清晰的图像
await page.evaluate('''() => {
window.devicePixelRatio = 2;
}''')
# 截图
image_path = f"weekly_summary_{today.strftime('%Y%m%d')}.png"
await page.screenshot(path=image_path, full_page=True)
await browser.close()
# 上传图片
image_url = utils.upload_image(image_path, image_path)
# 删除本地文件
try:
os.remove(image_path)
except Exception as e:
logger.warning(f"删除本地图片文件失败: {str(e)}")
return image_url

View File

@ -1,21 +1,25 @@
import dota import dota
import utils
def serialize_player(player): def serialize_player(player):
friend = dota.Friend.get_or_none(steam_id=player.account_id) friend = dota.Friend.get_or_none(steam_id=player['account_id'])
player_data = { player_data = {
'personaname': player.personaname, 'personaname': player['personaname'] if player['personaname'] else '',
'nickname': friend.name if friend else None, 'nickname': friend.name if friend else '',
'kills': player.kills, 'kills': player['kills'],
'deaths': player.deaths, 'deaths': player['deaths'],
'assists': player.assists, 'assists': player['assists'],
'total_gold': player.total_gold, 'total_gold': player['total_gold'],
'last_hits': player.last_hits, 'last_hits': player['last_hits'],
'denies': player.denies, 'denies': player['denies'],
'hero_damage': player.hero_damage, 'hero_damage': player['hero_damage'],
'party_id': player.party_id, 'party_id': player['party_id'],
'win': player.win, 'win': player['win'],
'level': player.level, 'level': player['level'],
'is_radiant': player.is_radiant, 'is_radiant': player['is_radiant'],
'hero': dota.Hero.get(hero_id=player.hero_id).localized_name, 'hero': dota.Hero.get(hero_id=player['hero_id']).localized_name,
'hero_id': player['hero_id'],
'rank_tier': player['rank_tier'],
'rank': utils.get_ranking(player['rank_tier']),
} }
return player_data return player_data

125
project-info.md Normal file
View File

@ -0,0 +1,125 @@
# DotaBot 项目文档
## 项目概述
DotaBot 是一个 Discord 机器人,用于跟踪 Dota 2 玩家的比赛数据并在 Discord 频道中分享这些信息。它可以监控指定玩家的最近比赛,生成比赛报告图片,并通知连胜/连败等特殊事件。
## 项目架构
### 核心组件
1. **Discord Bot**: 基于 py-cord 库实现的 Discord 机器人
2. **Dota 2 API 集成**: 使用 OpenDota API 获取比赛数据
3. **数据库**: 使用 Peewee ORM 管理本地数据
4. **图片生成**: 使用 Playwright 和 Jinja2 生成比赛报告图片
5. **图片存储**: 使用 Cloudflare R2 存储生成的图片
### 文件结构
```
├── discord_bot.py # Discord 机器人主程序
├── dota.py # Dota 2 数据处理和模型定义
├── image_generator.py # 图片生成模块
├── utils.py # 工具函数
├── matches.py # 比赛数据处理
├── heroes.json # 英雄数据
├── templates/ # HTML 模板目录
│ ├── match_report.html # 比赛报告模板
│ └── recent_matches.html # 最近比赛模板
├── env.ini # 配置文件
└── dotabot.log # 日志文件
```
## 依赖关系
```
project-info.md
discord_bot.py
├── dota.py
│ ├── utils.py
│ └── image_generator.py
└── utils.py
image_generator.py
├── utils.py
├── templates/match_report.html
└── templates/recent_matches.html
dota.py
├── utils.py
└── image_generator.py
```
## 功能模块
### 1. Discord Bot (discord_bot.py)
Discord 机器人的主程序,负责与 Discord 交互,包括:
- 定时任务获取最新比赛数据
- 处理用户命令
- 发送比赛报告和通知
主要命令:
- `/recent_matches [name] [match_count]`: 获取指定玩家的最近比赛
- `/list_friends`: 列出所有已添加的好友
- `/add_friend [steam_id] [name]`: 添加新好友
- `/mod_friend [steam_id] [name]`: 修改好友信息
- `/activate_friend [steam_id]`: 启用好友
- `/deactivate_friend [steam_id]`: 禁用好友
### 2. Dota 数据处理 (dota.py)
处理 Dota 2 比赛数据,定义数据模型,包括:
- `Match`: 比赛数据模型
- `Friend`: 好友数据模型
- 比赛数据序列化
- 连胜/连败检测
主要功能:
- `get_friends_recent_matches()`: 获取所有好友的最近比赛
- `serialize_match_for_discord()`: 将比赛数据格式化为 Discord 消息
- `check_streaks()`: 检查连胜/连败情况
### 3. 图片生成 (image_generator.py)
使用 Playwright 和 Jinja2 生成比赛报告图片,包括:
- `ImageGenerator` 类:负责生成比赛报告和最近比赛图片
- 使用 HTML 模板渲染比赛数据
- 使用 Playwright 将 HTML 转换为图片
- 上传图片到 Cloudflare R2 存储
主要方法:
- `generate_match_report()`: 生成单场比赛报告图片
- `generate_recent_matches_image()`: 生成最近比赛汇总图片
### 4. 工具函数 (utils.py)
提供各种辅助功能,包括:
- 时间格式转换
- 英雄名称中英文转换
- 数字格式化
- 图片上传到 Cloudflare R2
- 日志记录
主要函数:
- `convert_seconds_to_hms()`: 将秒数转换为时分秒
- `get_hero_chinese_name()`: 获取英雄的中文名称
- `upload_image()`: 上传图片到 Cloudflare R2
- `get_ranking()`: 获取天梯段位名称
## 数据流
1. **定时任务流程**:
- Discord Bot 定时调用 `get_friends_recent_matches()`
- 获取所有活跃好友的最新比赛
- 对于新比赛,调用 `serialize_match_for_discord()` 格式化数据
- 调用 `ImageGenerator.generate_match_report()` 生成比赛报告图片
- 发送格式化的比赛数据和图片到 Discord 频道
2. **用户命令流程**:
- 用户发送命令 (如 `/recent_matches`)
- Discord Bot 处理命令并调用相应函数
- 对于 `/recent_matches`,调用 `Friend.serialize_recent_matches_for_discord()`
- 调用 `ImageGenerator.generate_recent_matches_image()` 生成图片
- 发送结果到 Discord

339
templates/match_report.html Normal file
View File

@ -0,0 +1,339 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
html,
body {
width: auto;
margin: 0;
padding: 0;
background: #1a1a1a;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
color: #ffffff;
padding: 15px;
box-sizing: border-box;
width: fit-content; /* 让body宽度适应内容 */
max-width: 500px; /* 设置最大宽度 */
}
.match-header {
display: flex;
flex-direction: column;
padding: 8px 12px;
background: #2a2a2a;
border-radius: 5px;
margin-bottom: 12px;
width: calc(100% - 24px); /* 确保宽度计算正确 */
}
.match-info {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.match-result {
font-weight: bold;
font-size: 1.1em;
text-align: center;
margin: 4px 0;
}
.teams {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%; /* 确保宽度为100% */
}
.team {
background: #2a2a2a;
border-radius: 5px;
padding: 10px;
position: relative;
overflow: hidden;
width: calc(100% - 20px); /* 确保宽度计算正确 */
}
/* 胜利/失败指示条 - 统一颜色 */
.team::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
}
.team.win::before {
background: linear-gradient(to right, #4caf50, #8bc34a);
}
.team.lose::before {
background: linear-gradient(to right, #f44336, #ff9800);
}
.team-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 6px;
padding-top: 4px;
border-bottom: 1px solid #3a3a3a;
}
.team-header h2 {
margin: 0;
font-size: 1.2em;
}
.player-row {
display: flex;
align-items: center;
padding: 6px;
margin: 4px 0;
background: #3a3a3a;
border-radius: 3px;
position: relative;
overflow: hidden;
}
.player-container {
display: flex;
flex: 1;
align-items: center;
}
.hero-img {
width: 45px;
height: 25px;
margin-right: 8px;
border-radius: 3px;
object-fit: cover;
}
.player-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.player-name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
font-size: 0.9em;
line-height: 1.2;
}
.player-rank {
font-size: 0.6em;
color: #aaaaaa;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.friend {
color: #4caf50;
}
.player-stats {
display: flex;
width: 180px;
justify-content: space-between;
}
.stat-header {
display: flex;
margin-bottom: 5px;
padding: 0 6px;
align-items: center;
}
.stat-header-name {
flex: 1;
font-weight: bold;
margin-left: 53px;
}
.stat-header-values {
display: flex;
width: 180px;
justify-content: space-between;
}
.stat-header-cell {
width: 60px;
text-align: center;
font-weight: bold;
font-size: 0.9em;
}
.stat-cell {
width: 60px;
text-align: center;
position: relative;
font-size: 0.9em;
}
.stat-value {
position: relative;
z-index: 1;
}
.highest-gold {
position: relative;
}
.highest-gold::before {
content: "";
position: absolute;
top: 0;
left: 1px;
right: 1px;
bottom: 0;
background: linear-gradient(
to right,
rgba(255, 215, 0, 0.2),
rgba(255, 215, 0, 0.4)
);
border-radius: 3px;
}
.highest-damage {
position: relative;
}
.highest-damage::before {
content: "";
position: absolute;
top: 0;
left: 1px;
right: 1px;
bottom: 0;
background: linear-gradient(
to right,
rgba(255, 0, 0, 0.2),
rgba(255, 0, 0, 0.4)
);
border-radius: 3px;
}
.radiant-win {
color: #4caf50;
}
.dire-win {
color: #f44336;
}
</style>
</head>
<body>
<div class="match-header">
<div class="match-info">
<div>比赛时长: {{ duration }}</div>
<div>结束时间: {{ end_time }}</div>
</div>
<div
class="match-result {% if radiant_win %}radiant-win{% else %}dire-win{% endif %}"
>
{% if radiant_win %}天辉胜利{% else %}夜魇胜利{% endif %}
</div>
<div class="match-info">
<div>天辉: {{ radiant_score }}</div>
<div>夜魇: {{ dire_score }}</div>
</div>
</div>
<div class="teams">
<div
class="team radiant {% if radiant_win %}win{% else %}lose{% endif %}"
>
<div class="team-header">
<h2>天辉</h2>
</div>
<div class="stat-header">
<div class="stat-header-name">玩家</div>
<div class="stat-header-values">
<div class="stat-header-cell">KDA</div>
<div class="stat-header-cell">经济</div>
<div class="stat-header-cell">伤害</div>
</div>
</div>
{% for player in radiant_players %}
<div class="player-row">
<div class="player-container">
<img
class="hero-img"
src="{{ player.hero_img }}"
alt="{{ player.hero }}"
/>
<div class="player-info">
<div
class="player-name {% if player.is_friend %}friend{% endif %}"
>
{{ player.name }}
</div>
{% if player.rank_tier %}
<div class="player-rank">{{ player.rank }}</div>
{% endif %}
</div>
</div>
<div class="player-stats">
<div class="stat-cell">
<span class="stat-value"
>{{ player.kills }}/{{ player.deaths }}/{{ player.assists
}}</span
>
</div>
<div
class="stat-cell {% if player.highest_gold %}highest-gold{% endif %}"
>
<span class="stat-value">{{ player.total_gold }}</span>
</div>
<div
class="stat-cell {% if player.highest_damage %}highest-damage{% endif %}"
>
<span class="stat-value">{{ player.hero_damage }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
<div
class="team dire {% if not radiant_win %}win{% else %}lose{% endif %}"
>
<div class="team-header">
<h2>夜魇</h2>
</div>
<div class="stat-header">
<div class="stat-header-name">玩家</div>
<div class="stat-header-values">
<div class="stat-header-cell">KDA</div>
<div class="stat-header-cell">经济</div>
<div class="stat-header-cell">伤害</div>
</div>
</div>
{% for player in dire_players %}
<div class="player-row">
<div class="player-container">
<img
class="hero-img"
src="{{ player.hero_img }}"
alt="{{ player.hero }}"
/>
<div class="player-info">
<div
class="player-name {% if player.is_friend %}friend{% endif %}"
>
{{ player.name }}
</div>
{% if player.rank_tier %}
<div class="player-rank">{{ player.rank }}</div>
{% endif %}
</div>
</div>
<div class="player-stats">
<div class="stat-cell">
<span class="stat-value"
>{{ player.kills }}/{{ player.deaths }}/{{ player.assists
}}</span
>
</div>
<div
class="stat-cell {% if player.highest_gold %}highest-gold{% endif %}"
>
<span class="stat-value">{{ player.total_gold }}</span>
</div>
<div
class="stat-cell {% if player.highest_damage %}highest-damage{% endif %}"
>
<span class="stat-value">{{ player.hero_damage }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
html,
body {
width: auto;
margin: 0;
padding: 0;
background: #1a1a1a;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
color: #ffffff;
padding: 15px;
box-sizing: border-box;
width: fit-content;
max-width: 600px;
}
.header {
display: flex;
flex-direction: column;
padding: 8px 12px;
background: #2a2a2a;
border-radius: 5px;
margin-bottom: 12px;
width: calc(100% - 24px);
}
.header-title {
font-size: 1.3em;
font-weight: bold;
text-align: center;
margin-bottom: 8px;
}
.matches-container {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.match-card {
background: #2a2a2a;
border-radius: 5px;
padding: 10px;
position: relative;
overflow: hidden;
width: calc(100% - 20px);
}
/* 胜利/失败指示条 */
.match-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
}
.match-card.win::before {
background: linear-gradient(to right, #4caf50, #8bc34a);
}
.match-card.lose::before {
background: linear-gradient(to right, #f44336, #ff9800);
}
.match-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.match-time {
font-size: 0.8em;
color: #aaaaaa;
}
.match-details {
display: flex;
align-items: center;
}
.hero-img {
width: 60px;
height: 34px;
border-radius: 3px;
margin-right: 10px;
object-fit: cover;
}
.match-stats {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.kda {
font-weight: bold;
font-size: 1.1em;
margin-bottom: 3px;
}
.match-info {
display: flex;
gap: 12px;
font-size: 0.9em;
color: #dddddd;
flex-wrap: nowrap;
}
.match-duration,
.match-party,
.match-rank {
display: flex;
align-items: center;
white-space: nowrap;
}
.match-duration::before {
content: "";
margin-right: 3px;
}
.match-party::before {
content: "";
margin-right: 3px;
}
.match-rank::before {
content: "";
margin-right: 3px;
}
.win-text {
color: #4caf50;
}
.lose-text {
color: #f44336;
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">{{ player_name }}的最近比赛</div>
</div>
<div class="matches-container">
{% for match in matches %}
<div class="match-card {% if match.win %}win{% else %}lose{% endif %}">
<div class="match-header">
<div
class="match-result {% if match.win %}win-text{% else %}lose-text{% endif %}"
>
{% if match.win %}胜利{% else %}失败{% endif %}
</div>
<div class="match-time">{{ match.end_time_formatted }}</div>
</div>
<div class="match-details">
<img
class="hero-img"
src="{{ match.hero_img }}"
alt="{{ match.hero_name }}"
/>
<div class="match-stats">
<div class="kda">
{{ match.kills }} / {{ match.deaths }} / {{ match.assists }}
</div>
<div class="match-info">
<div class="match-duration">{{ match.duration_formatted }}</div>
{% if match.party_size %}
<div class="match-party">
{% if match.party_size == 1 %} 单排 {% else %} {{
match.party_size }}黑 {% endif %}
</div>
{% endif %} {% if match.average_rank %}
<div class="match-rank">{{ match.average_rank }}</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</body>
</html>

View File

@ -0,0 +1,244 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
html,
body {
margin: 0;
padding: 0;
background: #1a1a1a;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
color: #ffffff;
padding: 20px;
box-sizing: border-box;
width: 600px;
}
.header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #4caf50;
}
.header h1 {
margin: 0;
font-size: 1.8em;
color: #4caf50;
}
.header .date-range {
font-size: 0.9em;
color: #aaaaaa;
margin-top: 5px;
}
.summary {
background: #2a2a2a;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
text-align: center;
}
.summary-stats {
display: flex;
justify-content: space-around;
margin-top: 10px;
}
.stat-box {
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: #4caf50;
}
.stat-label {
font-size: 0.8em;
color: #aaaaaa;
}
.player-section {
background: #2a2a2a;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
border-left: 4px solid #4caf50;
}
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.player-name {
font-size: 1.3em;
font-weight: bold;
}
.player-record {
font-size: 1em;
color: #aaaaaa;
}
.player-stats {
display: flex;
gap: 15px;
margin-bottom: 10px;
}
.stat-item {
font-size: 0.9em;
}
.stat-item .label {
color: #aaaaaa;
}
.stat-item .value {
font-weight: bold;
}
.win-rate {
color: #4caf50;
}
.kda {
color: #ff9800;
}
.hero-section {
display: flex;
gap: 15px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #3a3a3a;
}
.hero-box {
flex: 1;
background: #3a3a3a;
border-radius: 5px;
padding: 10px;
}
.hero-box-title {
font-size: 0.75em;
color: #aaaaaa;
margin-bottom: 5px;
}
.hero-info {
display: flex;
align-items: center;
gap: 8px;
}
.hero-img {
width: 40px;
height: 22px;
border-radius: 3px;
object-fit: cover;
}
.hero-name {
font-size: 0.9em;
font-weight: bold;
}
.hero-record {
font-size: 0.75em;
color: #aaaaaa;
}
.no-data {
color: #666666;
font-size: 0.85em;
}
</style>
</head>
<body>
<div class="header">
<h1>📊 本周 Dota 战报</h1>
<div class="date-range">{{ date_range }}</div>
</div>
<div class="summary">
<div class="summary-stats">
<div class="stat-box">
<div class="stat-value">{{ total_games }}</div>
<div class="stat-label">总局数</div>
</div>
<div class="stat-box">
<div class="stat-value" style="color: #4caf50">{{ total_wins }}</div>
<div class="stat-label">胜场</div>
</div>
<div class="stat-box">
<div class="stat-value" style="color: #f44336">{{ total_losses }}</div>
<div class="stat-label">败场</div>
</div>
<div class="stat-box">
<div class="stat-value" style="color: #2196f3">{{ overall_win_rate }}%</div>
<div class="stat-label">总胜率</div>
</div>
</div>
</div>
{% for player in players %}
<div class="player-section">
<div class="player-header">
<span class="player-name">{{ player.name }}</span>
<span class="player-record"
>{{ player.wins }}胜 {{ player.losses }}败</span
>
</div>
<div class="player-stats">
<div class="stat-item">
<span class="label">胜率: </span>
<span class="value win-rate">{{ player.win_rate }}%</span>
</div>
<div class="stat-item">
<span class="label">KDA: </span>
<span class="value kda">{{ player.kda }}</span>
</div>
<div class="stat-item">
<span class="label">场均: </span>
<span class="value"
>{{ player.avg_kills }}/{{ player.avg_deaths }}/{{ player.avg_assists
}}</span
>
</div>
</div>
<div class="hero-section">
<div class="hero-box">
<div class="hero-box-title">最常用英雄</div>
{% if player.most_played_hero %}
<div class="hero-info">
<img
class="hero-img"
src="{{ player.most_played_hero.img }}"
alt="{{ player.most_played_hero.name }}"
/>
<div>
<div class="hero-name">{{ player.most_played_hero.name }}</div>
<div class="hero-record">
{{ player.most_played_hero.wins }}胜 {{
player.most_played_hero.losses }}败 ({{ player.most_played_hero.games
}}场)
</div>
</div>
</div>
{% else %}
<div class="no-data">暂无数据</div>
{% endif %}
</div>
<div class="hero-box">
<div class="hero-box-title">最佳英雄 (≥2场)</div>
{% if player.best_hero %}
<div class="hero-info">
<img
class="hero-img"
src="{{ player.best_hero.img }}"
alt="{{ player.best_hero.name }}"
/>
<div>
<div class="hero-name">{{ player.best_hero.name }}</div>
<div class="hero-record">
{{ player.best_hero.wins }}胜 {{ player.best_hero.losses }}败
({{ player.best_hero.win_rate }}%胜率)
</div>
</div>
</div>
{% else %}
<div class="no-data">暂无数据</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</body>
</html>

249
utils.py
View File

@ -2,6 +2,12 @@ import datetime
import requests import requests
from loguru import logger from loguru import logger
import boto3
import os
from botocore.config import Config
import uuid
import configparser
# logger_file = '/root/develop/log/dotabot.log' # logger_file = '/root/develop/log/dotabot.log'
logger_file = 'dotabot.log' logger_file = 'dotabot.log'
logger.add(logger_file) logger.add(logger_file)
@ -12,14 +18,15 @@ def convert_seconds_to_hms(total_seconds):
return hours, minutes, seconds return hours, minutes, seconds
def is_workday(): def is_workday():
return datetime.datetime.today().weekday() < 5 return datetime.datetime.today().weekday() < 4
def is_game_time(): def is_game_time():
# game time is workday 21:00 - 1:00, weekend 10:00 - 1:00 # game time is workday 21:00 - 1:00, weekend 10:00 - 1:00
if is_workday(): if is_workday():
return datetime.datetime.now().hour >= 21 or datetime.datetime.now().hour < 1 # return datetime.datetime.now().hour >= 21 or datetime.datetime.now().hour < 1
return False
else: else:
return datetime.datetime.now().hour >= 10 or datetime.datetime.now().hour < 1 return datetime.datetime.now().hour >= 12 or datetime.datetime.now().hour < 1
def heartbeat(): def heartbeat():
@ -32,6 +39,7 @@ def get_ranking(ranking_int):
# (10-15: Herald, 20-25: Guardian, 30-35: Crusader, 40-45: Archon, 50-55: Legend, 60-65: Ancient, 70-75: Divine, 80-85: Immortal). # (10-15: Herald, 20-25: Guardian, 30-35: Crusader, 40-45: Archon, 50-55: Legend, 60-65: Ancient, 70-75: Divine, 80-85: Immortal).
if not ranking_int: if not ranking_int:
return '' return ''
ranking_int = int(ranking_int)
stars = ranking_int % 10 stars = ranking_int % 10
if ranking_int < 20: if ranking_int < 20:
return '先锋 %s' % stars return '先锋 %s' % stars
@ -64,3 +72,238 @@ def shorten_digits(num):
return '%.1fk' % (num / 1000) return '%.1fk' % (num / 1000)
else: else:
return str(num) return str(num)
def get_hero_chinese_name(english_name):
"""将Dota英雄的英文名转换为中文名
Args:
english_name (str): 英雄的英文名称
Returns:
str: 英雄的中文名称如果找不到对应的中文名则返回原英文名
"""
hero_name_map = {
"alchemist": "炼金术士",
"axe": "斧王",
"bristleback": "钢背兽",
"centaur_warrunner": "半人马战行者",
"chaos_knight": "混沌骑士",
"dawnbreaker": "破晓辰星",
"doom": "末日使者",
"dragon_knight": "龙骑士",
"earth_spirit": "大地之灵",
"earthshaker": "撼地者",
"elder_titan": "上古巨神",
"huskar": "哈斯卡",
"kunkka": "昆卡",
"legion_commander": "军团指挥官",
"lifestealer": "噬魂鬼",
"mars": "玛尔斯",
"night_stalker": "暗夜魔王",
"ogre_magi": "食人魔魔法师",
"omniknight": "全能骑士",
"primal_beast": "",
"pudge": "屠夫",
"slardar": "斯拉达",
"spirit_breaker": "裂魂人",
"sven": "斯温",
"tidehunter": "潮汐猎人",
"timbersaw": "伐木机",
"tiny": "小小",
"treant_protector": "树精卫士",
"tusk": "巨牙海民",
"underlord": "孽主",
"undying": "不朽尸王",
"wraith_king": "冥魂大帝",
"anti-mage": "敌法师",
"arc_warden": "天穹守望者",
"bloodseeker": "血魔",
"bounty_hunter": "赏金猎人",
"clinkz": "克林克兹",
"drow_ranger": "卓尔游侠",
"ember_spirit": "灰烬之灵",
"faceless_void": "虚空假面",
"gyrocopter": "矮人直升机",
"hoodwink": "森海飞霞",
"juggernaut": "主宰",
"kez": "",
"luna": "露娜",
"medusa": "美杜莎",
"meepo": "米波",
"monkey_king": "齐天大圣",
"morphling": "变体精灵",
"naga_siren": "娜迦海妖",
"phantom_assassin": "幻影刺客",
"phantom_lancer": "幻影长矛手",
"razor": "剃刀",
"riki": "力丸",
"shadow_fiend": "影魔",
"slark": "斯拉克",
"sniper": "狙击手",
"spectre": "幽鬼",
"templar_assassin": "圣堂刺客",
"terrorblade": "恐怖利刃",
"troll_warlord": "巨魔战将",
"ursa": "熊战士",
"viper": "冥界亚龙",
"weaver": "编织者",
"ancient_apparition": "远古冰魄",
"crystal_maiden": "水晶室女",
"death_prophet": "死亡先知",
"disruptor": "干扰者",
"enchantress": "魅惑魔女",
"grimstroke": "天涯墨客",
"jakiro": "杰奇洛",
"keeper_of_the_light": "光之守卫",
"leshrac": "拉席克",
"lich": "巫妖",
"lina": "莉娜",
"lion": "莱恩",
"muerta": "琼英碧灵",
"nature's_prophet": "先知",
"necrophos": "瘟疫法师",
"oracle": "神谕者",
"outworld_destroyer": "殁境神蚀者",
"puck": "帕克",
"pugna": "帕格纳",
"queen_of_pain": "痛苦女王",
"ringmaster": "百戏之王",
"rubick": "拉比克",
"shadow_demon": "暗影恶魔",
"shadow_shaman": "暗影萨满",
"silencer": "沉默术士",
"skywrath_mage": "天怒法师",
"storm_spirit": "风暴之灵",
"tinker": "修补匠",
"warlock": "术士",
"witch_doctor": "巫医",
"zeus": "宙斯",
"abaddon": "亚巴顿",
"bane": "祸乱之源",
"batrider": "蝙蝠骑士",
"beastmaster": "兽王",
"brewmaster": "酿酒大师",
"broodmother": "育母蜘蛛",
"chen": "",
"clockwerk": "发条技师",
"dark_seer": "黑暗贤者",
"dark_willow": "邪影芳灵",
"dazzle": "戴泽",
"enigma": "谜团",
"invoker": "祈求者",
"io": "艾欧",
"lone_druid": "德鲁伊",
"lycan": "狼人",
"magnus": "马格纳斯",
"marci": "玛西",
"mirana": "米拉娜",
"nyx_assassin": "司夜刺客",
"pangolier": "石鳞剑士",
"phoenix": "凤凰",
"sand_king": "沙王",
"snapfire": "电炎绝手",
"techies": "工程师",
"vengeful_spirit": "复仇之魂",
"venomancer": "剧毒术士",
"visage": "维萨吉",
"void_spirit": "虚无之灵",
"windranger": "风行者",
"winter_wyvern": "寒冬飞龙"
}
# 将英文名转换为小写并去除空格,用作字典键
key = english_name.lower().replace(' ', '_')
return hero_name_map.get(key, english_name)
def upload_image(image_path, file_name=None):
"""
将图片上传到 Cloudflare R2 存储桶并返回访问URL
Args:
image_path (str): 本地图片文件路径
Returns:
str: 上传成功后的图片URL失败则返回None
"""
try:
# 读取配置文件
config = configparser.ConfigParser()
# config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'env.ini')
config_path = './env.ini'
if not os.path.exists(config_path):
logger.error(f"配置文件不存在: {config_path}")
return None
config.read(config_path)
# 获取Cloudflare R2配置
if 'cloudflare' not in config:
logger.error("配置文件中缺少cloudflare部分")
return None
cf_config = config['cloudflare']
account_id = cf_config.get('account_id')
access_key_id = cf_config.get('access_key_id')
secret_access_key = cf_config.get('secret_access_key')
bucket_name = cf_config.get('bucket_name', 'dotabot-images')
region = cf_config.get('region', 'auto')
custom_domain = cf_config.get('custom_domain', '')
# 检查必要的配置
if not all([account_id, access_key_id, secret_access_key]):
logger.error("缺少Cloudflare R2必要配置")
return None
# 创建S3客户端连接到Cloudflare R2
s3_client = boto3.client(
's3',
endpoint_url=f'https://{account_id}.r2.cloudflarestorage.com',
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
region_name=region,
config=Config(
signature_version='s3v4',
retries={
'max_attempts': 3,
'mode': 'standard'
}
)
)
# 生成唯一的文件名
if not file_name:
timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
random_id = str(uuid.uuid4())[:8]
file_extension = os.path.splitext(image_path)[1]
object_key = f'dotabot/{timestamp}_{random_id}{file_extension}'
else:
file_extension = os.path.splitext(file_name)[1]
object_key = f'dotabot/{file_name}'
# 设置文件的Content-Type
content_type = 'image/png' if file_extension.lower() == '.png' else 'image/jpeg'
# 上传文件
with open(image_path, 'rb') as file_data:
s3_client.upload_fileobj(
file_data,
bucket_name,
object_key,
ExtraArgs={
'ContentType': content_type,
'CacheControl': 'max-age=2592000' # 30天缓存
}
)
# 构建公共访问URL
if custom_domain:
image_url = f'https://{custom_domain}/{object_key}'
else:
image_url = f'https://{bucket_name}.{account_id}.r2.cloudflarestorage.com/{object_key}'
logger.info(f"图片上传成功: {image_url}")
return image_url
except Exception as e:
logger.error(f"上传图片失败: {str(e)}")
return None