Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8708c931c8 | ||
|
|
7f81574192 | ||
|
|
bb4ee378d9 | ||
|
|
7498f413bf | ||
|
|
c85eeb9d74 | ||
|
|
21c7b95653 | ||
|
|
e381dce261 | ||
|
|
895737927a | ||
|
|
b5c58f842a | ||
|
|
44336e5ff0 | ||
|
|
e103e8706c | ||
|
|
3b138437d1 | ||
|
|
23309d3976 | ||
|
|
27128c3557 | ||
|
|
f087aa9ba2 | ||
|
|
a697a6e636 | ||
|
|
f9ce233099 | ||
|
|
f055bd7027 | ||
|
|
bbc23217f9 | ||
|
|
33c160b16b | ||
|
|
9f5be12b2e | ||
|
|
9e32de9922 | ||
|
|
5a6b5db082 | ||
|
|
47f4df7803 | ||
|
|
f18f03a7f7 | ||
|
|
9c8b360553 | ||
|
|
2bae19643a | ||
|
|
e2c6c9ea5b | ||
|
|
f24106bef1 | ||
|
|
a86dcb1bbc | ||
|
|
fd24fc01f7 | ||
|
|
3087dd3085 | ||
|
|
eddfcbf7f7 | ||
|
|
f4e17c5126 | ||
|
|
8b97724b08 | ||
|
|
520409f735 | ||
|
|
2185d955ad | ||
|
|
54a32028e2 | ||
|
|
9e9732d406 | ||
|
|
57f7faf4fb | ||
|
|
47ceabdd7a | ||
|
|
6e64f38d64 | ||
|
|
ab5f6695e1 | ||
|
|
e1e5a63fe7 |
52
.drone.yml
Normal file
52
.drone.yml
Normal 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
|
||||
@ -7,6 +7,7 @@ attrs==23.1.0
|
||||
backcall==0.2.0
|
||||
beautifulsoup4==4.12.2
|
||||
blurhash==1.1.4
|
||||
boto3==1.34.143
|
||||
bs4==0.0.1
|
||||
certifi==2022.12.7
|
||||
charset-normalizer==3.0.1
|
||||
|
||||
144
discord_bot.py
144
discord_bot.py
@ -5,6 +5,15 @@ from loguru import logger
|
||||
import dota
|
||||
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')
|
||||
# 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()
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
logger.info(f"We have logged in as {bot.user}")
|
||||
|
||||
channel_id = 1152167937852055552
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def send_message(channel):
|
||||
if utils.is_game_time():
|
||||
send_message.change_interval(minutes=3)
|
||||
send_message.change_interval(minutes=2)
|
||||
else:
|
||||
send_message.change_interval(minutes=15)
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
# 用于标记是否是第一场比赛
|
||||
first_match = True
|
||||
|
||||
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}")
|
||||
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
|
||||
async def get_friends_recent_matches(ctx, name, match_count=5): # a slash command will be created with the name "ping"
|
||||
if data['embeds'] and len(data['embeds']) > 0:
|
||||
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()
|
||||
logger.info(f"get_friends_recent_matches {name} {match_count}")
|
||||
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:
|
||||
await ctx.respond(content=f'找不到 {name} 的信息')
|
||||
return
|
||||
data = 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']])
|
||||
data = await dota.Friend.serialize_recent_matches_for_discord(friends, match_count)
|
||||
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')
|
||||
async def get_friends(ctx):
|
||||
@ -96,6 +140,22 @@ async def deactivate_friend(ctx, steam_id):
|
||||
else:
|
||||
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')
|
||||
async def activate_friend(ctx, steam_id):
|
||||
logger.info(f'activate_friend {steam_id}')
|
||||
@ -111,10 +171,70 @@ async def activate_friend(ctx, steam_id):
|
||||
async def 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
|
||||
async def on_ready():
|
||||
logger.info(f"We have logged in as {bot.user}")
|
||||
|
||||
channel = bot.get_channel(channel_id)
|
||||
send_message.start(channel)
|
||||
heartbeat.start()
|
||||
|
||||
# 启动天梯检查任务
|
||||
check_rank_changes.start(channel)
|
||||
|
||||
# 启动每周总结任务
|
||||
weekly_summary.start(channel)
|
||||
|
||||
bot.run('MTE1MjE2NTc3NDMwNDIyMzI2Mg.GEi-17.VvuIkRy_cFD9XF6wtTagY95LKEbTxKaxy-FxGw') # 这里替换成你自己的 token
|
||||
|
||||
528
dota.py
528
dota.py
@ -2,15 +2,20 @@ import peewee
|
||||
import opendota
|
||||
import datetime
|
||||
from loguru import logger
|
||||
|
||||
import json
|
||||
import players
|
||||
import utils
|
||||
|
||||
from image_generator import ImageGenerator
|
||||
import asyncio
|
||||
|
||||
db = peewee.SqliteDatabase('dota.db')
|
||||
hero_client = opendota.HeroesApi()
|
||||
player_client = opendota.PlayersApi()
|
||||
match_client = opendota.MatchesApi()
|
||||
image_generator = ImageGenerator()
|
||||
|
||||
# 初始化全局变量,用于存储连胜连败更新
|
||||
streak_updates = []
|
||||
|
||||
|
||||
class BaseModel(peewee.Model):
|
||||
@ -48,26 +53,38 @@ class Match(BaseModel):
|
||||
duration = peewee.IntegerField()
|
||||
radiant_win = peewee.BooleanField()
|
||||
party_size = peewee.IntegerField(null=True)
|
||||
opendota_response = peewee.TextField(null=True)
|
||||
|
||||
def serialize_match(self):
|
||||
try:
|
||||
match_ = match_client.get_matches_by_match_id(self.match_id)
|
||||
except Exception as e:
|
||||
logger.error('fail to get match %s' % self.match_id)
|
||||
raise e
|
||||
if not self.opendota_response:
|
||||
try:
|
||||
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:
|
||||
logger.error('fail to get match %s' % self.match_id)
|
||||
raise e
|
||||
|
||||
md = json.loads(self.opendota_response)
|
||||
match_data = {
|
||||
'players': [players.serialize_player(player) for player in match_.players],
|
||||
'dire_score': match_.dire_score,
|
||||
'radiant_score': match_.radiant_score,
|
||||
'players': [players.serialize_player(player) for player in md['players']],
|
||||
'dire_score': md['dire_score'],
|
||||
'radiant_score': md['radiant_score'],
|
||||
# isoformat utc+8
|
||||
'start_time': datetime.datetime.fromtimestamp(match_.start_time).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
||||
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_.duration),
|
||||
'radiant_win': match_.radiant_win,
|
||||
'start_time': datetime.datetime.fromtimestamp(md['start_time']).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(md['duration']),
|
||||
'radiant_win': md['radiant_win'],
|
||||
'party_size': self.party_size,
|
||||
'match_id': self.match_id,
|
||||
}
|
||||
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()
|
||||
return match_data
|
||||
|
||||
@ -76,6 +93,10 @@ class Friend(BaseModel):
|
||||
steam_id = peewee.IntegerField(primary_key=True)
|
||||
name = peewee.CharField()
|
||||
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):
|
||||
try:
|
||||
@ -84,6 +105,20 @@ class Friend(BaseModel):
|
||||
logger.error('fail to get player %s recent matches. error: %s' % (self.steam_id, e))
|
||||
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):
|
||||
matches = self.get_recent_matches(limit=limit)
|
||||
data = []
|
||||
@ -97,6 +132,7 @@ class Friend(BaseModel):
|
||||
'assists': match_.assists,
|
||||
'party_size': match_.party_size,
|
||||
'start_time': match_.start_time,
|
||||
'end_time': match_.start_time + match_.duration,
|
||||
'duration': match_.duration,
|
||||
'average_rank': utils.get_ranking(match_.average_rank),
|
||||
'hero_id': match_.hero_id,
|
||||
@ -104,7 +140,7 @@ class Friend(BaseModel):
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def serialize_recent_matches_for_discord(cls, friends, limit=5):
|
||||
async def serialize_recent_matches_for_discord(cls, friends, limit=5):
|
||||
# {
|
||||
# "content": "## 水哥的战报\n",
|
||||
# "embeds": [
|
||||
@ -119,10 +155,15 @@ class Friend(BaseModel):
|
||||
# ],
|
||||
# }
|
||||
matches = []
|
||||
if limit > 10:
|
||||
limit = 10
|
||||
# if limit > 10:
|
||||
# limit = 10
|
||||
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
|
||||
matches.sort(key=lambda x: x['start_time'], reverse=True)
|
||||
name = friends[0].name
|
||||
@ -130,49 +171,222 @@ class Friend(BaseModel):
|
||||
'content': f'## {name}的战报',
|
||||
'embeds': [],
|
||||
}
|
||||
for match_ in matches[:limit]:
|
||||
|
||||
for match_ in matches[:min(limit, 9)]:
|
||||
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:
|
||||
summary = f"{match_['party_size']}黑 {duration}"
|
||||
elif match_['party_size'] == None:
|
||||
summary = f"??黑 {duration}"
|
||||
else:
|
||||
elif match_['party_size'] and match_['party_size'] == 1:
|
||||
summary = f"单排 {duration}"
|
||||
|
||||
if 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')
|
||||
hero_name = Hero.get(hero_id=match_['hero_id']).localized_name
|
||||
# start_time = datetime.datetime.fromtimestamp(match_['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00')
|
||||
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({
|
||||
'title': f"{hero_name} {match_['kills']} 杀 {match_['deaths']} 死 {match_['assists']} 助 ",
|
||||
'description': summary,
|
||||
'color': 6732650 if match_['win'] else 16724787, # 66bb6a or FF3333
|
||||
'fields': [],
|
||||
'timestamp': start_time,
|
||||
'timestamp': end_time,
|
||||
'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
|
||||
|
||||
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():
|
||||
matches = []
|
||||
for friend in Friend.filter(active=True):
|
||||
for match_ in friend.get_recent_matches():
|
||||
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())
|
||||
global streak_updates # 使用全局变量存储连胜连败更新
|
||||
streak_updates = []
|
||||
|
||||
processed_matches = set() # 记录已处理的比赛ID,避免重复处理开黑比赛
|
||||
active_friends = list(Friend.filter(active=True))
|
||||
|
||||
for friend in active_friends:
|
||||
try:
|
||||
recent_matches = friend.get_recent_matches()
|
||||
for match_ in recent_matches:
|
||||
# 如果这场比赛已经被处理过(开黑情况下其他朋友已处理),跳过API调用
|
||||
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
|
||||
|
||||
|
||||
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",
|
||||
# "tts": false,
|
||||
@ -216,11 +430,15 @@ def serialize_match_for_discord(match_):
|
||||
is_radiant = player['is_radiant']
|
||||
break
|
||||
win = is_radiant == match_['radiant_win']
|
||||
summary = f"{match_['duration']}"
|
||||
if not match_['party_size']:
|
||||
summary = f"??黑 {match_['duration']}"
|
||||
elif match_['party_size'] > 1:
|
||||
if Match.filter(match_id=match_['match_id']).exists():
|
||||
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']}"
|
||||
else:
|
||||
elif match_['party_size'] == 1:
|
||||
summary = f"单排 {match_['duration']}"
|
||||
|
||||
radiant = []
|
||||
@ -235,7 +453,11 @@ def serialize_match_for_discord(match_):
|
||||
dire_highest_damage_idx = 0
|
||||
|
||||
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']:
|
||||
radiant.append(desc)
|
||||
@ -254,10 +476,10 @@ def serialize_match_for_discord(match_):
|
||||
dire_highest_damage = player['hero_damage']
|
||||
dire_highest_damage_idx = len(dire) - 1
|
||||
|
||||
radiant[radiant_highest_gold_idx] = radiant[radiant_highest_gold_idx] + '💰'
|
||||
radiant[radiant_highest_damage_idx] = radiant[radiant_highest_damage_idx] + '🩸'
|
||||
dire[dire_highest_gold_idx] = dire[dire_highest_gold_idx] + '💰'
|
||||
dire[dire_highest_damage_idx] = dire[dire_highest_damage_idx] + '🩸'
|
||||
radiant[radiant_highest_gold_idx] = '💰' + radiant[radiant_highest_gold_idx]
|
||||
radiant[radiant_highest_damage_idx] = '🩸' + radiant[radiant_highest_damage_idx]
|
||||
dire[dire_highest_gold_idx] = '💰' + dire[dire_highest_gold_idx]
|
||||
dire[dire_highest_damage_idx] = '🩸'+ dire[dire_highest_damage_idx]
|
||||
|
||||
color = 6732650 if win else 16724787 # 66bb6a or FF3333
|
||||
|
||||
@ -265,19 +487,29 @@ def serialize_match_for_discord(match_):
|
||||
radiant_indicator = ''
|
||||
dire_indicator = ''
|
||||
if is_radiant:
|
||||
radiant_indicator = ' 🌟'
|
||||
radiant_indicator = '🌟 '
|
||||
else:
|
||||
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 = {
|
||||
"content": content,
|
||||
"tts": False,
|
||||
"win": win,
|
||||
"embeds": [
|
||||
{
|
||||
"color": color,
|
||||
"fields": [
|
||||
{
|
||||
"name": "天辉" + radiant_indicator,
|
||||
"name": radiant_indicator + "天辉",
|
||||
"value": match_['radiant_score'],
|
||||
"inline": True
|
||||
},
|
||||
@ -295,8 +527,206 @@ def serialize_match_for_discord(match_):
|
||||
"name": "opendota",
|
||||
"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
|
||||
|
||||
|
||||
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
10
env.ini
Normal 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
5026
heroes.json
Normal file
File diff suppressed because it is too large
Load Diff
356
image_generator.py
Normal file
356
image_generator.py
Normal 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
|
||||
34
players.py
34
players.py
@ -1,21 +1,25 @@
|
||||
import dota
|
||||
import utils
|
||||
|
||||
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 = {
|
||||
'personaname': player.personaname,
|
||||
'nickname': friend.name if friend else None,
|
||||
'kills': player.kills,
|
||||
'deaths': player.deaths,
|
||||
'assists': player.assists,
|
||||
'total_gold': player.total_gold,
|
||||
'last_hits': player.last_hits,
|
||||
'denies': player.denies,
|
||||
'hero_damage': player.hero_damage,
|
||||
'party_id': player.party_id,
|
||||
'win': player.win,
|
||||
'level': player.level,
|
||||
'is_radiant': player.is_radiant,
|
||||
'hero': dota.Hero.get(hero_id=player.hero_id).localized_name,
|
||||
'personaname': player['personaname'] if player['personaname'] else '',
|
||||
'nickname': friend.name if friend else '',
|
||||
'kills': player['kills'],
|
||||
'deaths': player['deaths'],
|
||||
'assists': player['assists'],
|
||||
'total_gold': player['total_gold'],
|
||||
'last_hits': player['last_hits'],
|
||||
'denies': player['denies'],
|
||||
'hero_damage': player['hero_damage'],
|
||||
'party_id': player['party_id'],
|
||||
'win': player['win'],
|
||||
'level': player['level'],
|
||||
'is_radiant': player['is_radiant'],
|
||||
'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
|
||||
|
||||
125
project-info.md
Normal file
125
project-info.md
Normal 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
339
templates/match_report.html
Normal 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>
|
||||
174
templates/recent_matches.html
Normal file
174
templates/recent_matches.html
Normal 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>
|
||||
244
templates/weekly_summary.html
Normal file
244
templates/weekly_summary.html
Normal 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
249
utils.py
@ -2,6 +2,12 @@ import datetime
|
||||
import requests
|
||||
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 = 'dotabot.log'
|
||||
logger.add(logger_file)
|
||||
@ -12,14 +18,15 @@ def convert_seconds_to_hms(total_seconds):
|
||||
return hours, minutes, seconds
|
||||
|
||||
def is_workday():
|
||||
return datetime.datetime.today().weekday() < 5
|
||||
return datetime.datetime.today().weekday() < 4
|
||||
|
||||
def is_game_time():
|
||||
# game time is workday 21:00 - 1:00, weekend 10:00 - 1:00
|
||||
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:
|
||||
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():
|
||||
@ -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).
|
||||
if not ranking_int:
|
||||
return ''
|
||||
ranking_int = int(ranking_int)
|
||||
stars = ranking_int % 10
|
||||
if ranking_int < 20:
|
||||
return '先锋 %s' % stars
|
||||
@ -64,3 +72,238 @@ def shorten_digits(num):
|
||||
return '%.1fk' % (num / 1000)
|
||||
else:
|
||||
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
|
||||
Loading…
x
Reference in New Issue
Block a user