Compare commits

...

36 Commits

Author SHA1 Message Date
Ching L
4076e7ca46 chore: Remove draw_result.py as it is no longer needed for match result visualization
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-17 13:49:45 +08:00
Ching L
cceb927ead refactor: Move on_ready event definition to a more appropriate location and restore logging functionality 2025-03-13 16:35:44 +08:00
Ching L
49843ba4d2 feat: Add streak tracking and notifications for friends' match results 2025-03-13 16:30:40 +08:00
Ching L
2bc72ad3b3 feat: Adjust message sending interval based on game time 2025-03-10 09:12:43 +08:00
Ching L
d4061922ee feat: Implement delayed start for daily rank changes check 2025-03-07 11:45:09 +08:00
Ching L
fde2c6770b refactor: Remove async from rank changes check function 2025-03-07 11:35:23 +08:00
Ching L
0c6163ba84 fix: Ensure ranking conversion handles string input 2025-03-07 11:24:59 +08:00
Ching L
56d47a9c59 feat: Add daily rank tracking for Discord friends TUN-143 2025-03-07 11:22:40 +08:00
Ching L
d4ed563efe fix: Adjust match limit and embed image placement logic 2025-03-07 10:13:56 +08:00
Ching L
91112458f3 fix: Remove unnecessary screenshot quality parameter 2025-03-07 10:00:47 +08:00
Ching L
dd0c1c5f39 feat: Improve screenshot quality by increasing device pixel ratio 2025-03-07 09:46:51 +08:00
Ching L
81e6364cf6 style: Enhance recent matches template layout and responsiveness 2025-03-06 10:10:36 +08:00
Ching L
5ba570e894 fix: Improve file extension handling in image upload utility 2025-03-06 09:30:46 +08:00
Ching L
d5f8b3d892 fix: Update image upload method with explicit file name parameter 2025-03-06 09:26:45 +08:00
Ching L
d6bacd71dc fix: Update config file path for image upload utility 2025-03-06 09:22:33 +08:00
Ching L
9c27bc6eb7 refactor: Convert match serialization methods to async 2025-03-06 09:10:42 +08:00
Ching L
16681be5ec refactor: Migrate image generation to async Playwright 2025-03-05 23:01:00 +08:00
Ching L
d77ed4a969 feat: Add image generation for recent matches report 2025-03-05 21:54:29 +08:00
Ching L
394b2407ef feat: Add hero name translation for match report embeds 2025-03-05 18:30:50 +08:00
Ching L
2dd83b6a15 feat: Add image generation and Cloudflare R2 upload support for match reports 2025-03-05 17:50:52 +08:00
Ching
34f44f6584 feat: Add hero name translation utility function 2025-02-20 22:45:46 +08:00
Ching
657ffab43d ci: 2024-05-13 11:05:06 +08:00
Ching
782d2e5587 fix: 修复获取不到开黑数据时报错的问题 2024-05-12 11:57:21 +08:00
Ching
c1e879ee6b fix: 修复战报中开黑数据返回格式有误的问题 2024-05-12 11:23:34 +08:00
Ching
b61872f298 fix: 修复 openapi 接口报错时,战绩消息为空的问题
Some checks failed
continuous-integration/drone/tag Build is failing
2024-05-12 01:10:47 +08:00
Ching
adcf1478d5 fix: 修复战报中没有开黑信息的问题 TUN-66 2024-05-11 16:37:02 +08:00
Ching
58eb406c07 fix: 修复获取不到战报的问题 TUN-51 catch 发送消息时的 error 2024-05-10 11:07:53 +08:00
Ching
1e939a0f22 feat: Change radiant_indicator position in serialize_match_for_discord TUN-48 2024-04-05 13:24:22 +08:00
Ching
53d2103c4e refactor: Refactor serialize_player function to handle empty player.personaname 2024-03-28 16:03:24 +08:00
Ching
ecc2f9c890 feat: change nickname display in serialize_player function TUN-38 2024-03-28 10:24:45 +08:00
Ching
611bae666b feat: Add Sentry error tracking TUN-39 2024-03-28 09:37:36 +08:00
Ching
95a8bd5352 feat: 修改比赛信息格式 #86enwkvn5 2024-03-20 11:47:30 +08:00
Ching
5c4b0a5896 feat: 修改比赛信息中的开黑队友名字格式 2024-03-19 18:09:16 +08:00
Ching
e30de01f8c ci: 增加部署脚本 2024-03-06 11:13:00 +08:00
Ching
203d30a8c6 feat: Add end time to Match class 2024-03-06 11:01:04 +08:00
Ching
864c3ddce1 feat: 增加创建比赛结果图片逻辑 2024-03-06 10:51:49 +08:00
13 changed files with 6532 additions and 67 deletions

52
.drone.yml Normal file
View File

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

BIN
arial.ttf Normal file

Binary file not shown.

View File

@ -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

View File

@ -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,42 @@ 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
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}")
try:
# 发送比赛结果
await channel.send(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
@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 streak_notifications:
for notification in streak_notifications:
await channel.send(content=notification)
# 发送后清空通知列表,避免重复发送
streak_notifications = []
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 +70,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)
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):
@ -111,10 +138,38 @@ 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)
@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)
bot.run('MTE1MjE2NTc3NDMwNDIyMzI2Mg.GEi-17.VvuIkRy_cFD9XF6wtTagY95LKEbTxKaxy-FxGw') # 这里替换成你自己的 token

260
dota.py
View File

@ -2,15 +2,17 @@ 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()
class BaseModel(peewee.Model):
@ -48,26 +50,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):
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 +90,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 +102,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 +129,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 +137,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 +152,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,33 +168,94 @@ 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 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 = []
global streak_updates # 使用全局变量存储连胜连败更新
streak_updates = []
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():
@ -168,11 +267,21 @@ def get_friends_recent_matches():
radiant_win=match_.radiant_win,
party_size=match_.party_size,
)
# 判断玩家是否获胜
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)
matches.append(match_obj.serialize_match())
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 +325,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:
summary = f"{match_['party_size']}{match_['duration']}"
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']}"
elif match_['party_size'] == 1:
summary = f"单排 {match_['duration']}"
radiant = []
@ -235,7 +348,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 +371,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
@ -269,6 +386,15 @@ def serialize_match_for_discord(match_):
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,
@ -277,7 +403,7 @@ def serialize_match_for_discord(match_):
"color": color,
"fields": [
{
"name": "天辉" + radiant_indicator,
"name": radiant_indicator + "天辉",
"value": match_['radiant_score'],
"inline": True
},
@ -295,8 +421,80 @@ 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

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

238
image_generator.py Normal file
View File

@ -0,0 +1,238 @@
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

View File

@ -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
View File

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

339
templates/match_report.html Normal file
View File

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

View File

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

249
utils.py
View File

@ -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