feat: Add image generation and Cloudflare R2 upload support for match reports

This commit is contained in:
Ching L 2025-03-05 17:50:52 +08:00
parent f24106bef1
commit e2c6c9ea5b
9 changed files with 5676 additions and 34 deletions

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

@ -33,7 +33,8 @@ 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=3)
pass
else: else:
send_message.change_interval(minutes=15) send_message.change_interval(minutes=15)

46
dota.py
View File

@ -2,15 +2,16 @@ 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
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()
class BaseModel(peewee.Model): class BaseModel(peewee.Model):
@ -48,27 +49,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):
if not self.opendota_response:
try: try:
match_ = match_client.get_matches_by_match_id(self.match_id) match_ = match_client.get_matches_by_match_id(self.match_id)
m_dict = match_.to_dict()
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: except Exception as e:
logger.error('fail to get match %s' % self.match_id) logger.error('fail to get match %s' % self.match_id)
raise e 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'),
'end_time': datetime.datetime.fromtimestamp(match_.start_time + match_.duration).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'), 'end_time': datetime.datetime.fromtimestamp(md['start_time'] + md['duration']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_.duration), 'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(md['duration']),
'radiant_win': match_.radiant_win, '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
@ -289,6 +301,13 @@ def serialize_match_for_discord(match_):
else: else:
dire_indicator = ' 🌟' dire_indicator = ' 🌟'
# 生成比赛报告图片
try:
image_url = image_generator.generate_match_report(match_)
except Exception as e:
logger.error(f"生成比赛报告图片失败: {str(e)}")
image_url = None
data = { data = {
"content": content, "content": content,
"tts": False, "tts": False,
@ -319,4 +338,11 @@ def serialize_match_for_discord(match_):
} }
], ],
} }
# 如果成功生成了图片添加到embeds中
if image_url:
data["embeds"][0]["image"] = {
"url": image_url
}
return data return data

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

135
image_generator.py Normal file
View File

@ -0,0 +1,135 @@
from playwright.sync_api import sync_playwright
from jinja2 import Environment, FileSystemLoader
import utils
import json
import os
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
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生成图片
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page()
page.set_content(html_content)
page.set_viewport_size({"width": 800, "height": 800})
# 等待内容完全加载
page.wait_for_timeout(1000)
# 调整截图高度以适应内容
body_height = page.evaluate('document.body.scrollHeight')
body_width = page.evaluate('document.body.offsetWidth')
page.set_viewport_size({"width": body_width, "height": body_height})
# 截图
image_path = "match_report_%s.png" % match_data.get('match_id')
page.screenshot(path=image_path, full_page=True)
browser.close()
image_url = utils.upload_image(image_path, image_path)
os.remove(image_path)
return image_url
return None

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 if player.personaname else '', 'personaname': player['personaname'] if player['personaname'] else '',
'nickname': friend.name if friend else '', '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

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>

106
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():
@ -204,3 +211,96 @@ def get_hero_chinese_name(english_name):
# 将英文名转换为小写并去除空格,用作字典键 # 将英文名转换为小写并去除空格,用作字典键
key = english_name.lower().replace(' ', '_') key = english_name.lower().replace(' ', '_')
return hero_name_map.get(key, english_name) 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')
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.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:
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