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
|
backcall==0.2.0
|
||||||
beautifulsoup4==4.12.2
|
beautifulsoup4==4.12.2
|
||||||
blurhash==1.1.4
|
blurhash==1.1.4
|
||||||
|
boto3==1.34.143
|
||||||
bs4==0.0.1
|
bs4==0.0.1
|
||||||
certifi==2022.12.7
|
certifi==2022.12.7
|
||||||
charset-normalizer==3.0.1
|
charset-normalizer==3.0.1
|
||||||
|
|||||||
146
discord_bot.py
146
discord_bot.py
@ -5,6 +5,15 @@ from loguru import logger
|
|||||||
import dota
|
import dota
|
||||||
import utils
|
import utils
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn="https://272f1e4ecb3847ac8d24be796515e558@o4506942768021504.ingest.us.sentry.io/4506986058743808",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# formatter = logging.Formatter('%(levelname)s %(name)s %(asctime)s %(message)s', '%Y-%m-%d %H:%M:%S')
|
# formatter = logging.Formatter('%(levelname)s %(name)s %(asctime)s %(message)s', '%Y-%m-%d %H:%M:%S')
|
||||||
# log_handler = logging.FileHandler(utils.logger_file)
|
# log_handler = logging.FileHandler(utils.logger_file)
|
||||||
@ -18,30 +27,59 @@ logger.info('start bot')
|
|||||||
# bot = discord.Bot(proxy='http://127.0.0.1:1235')
|
# bot = discord.Bot(proxy='http://127.0.0.1:1235')
|
||||||
bot = discord.Bot()
|
bot = discord.Bot()
|
||||||
|
|
||||||
@bot.event
|
|
||||||
async def on_ready():
|
|
||||||
logger.info(f"We have logged in as {bot.user}")
|
|
||||||
|
|
||||||
channel_id = 1152167937852055552
|
channel_id = 1152167937852055552
|
||||||
|
|
||||||
@tasks.loop(minutes=1)
|
@tasks.loop(minutes=1)
|
||||||
async def send_message(channel):
|
async def send_message(channel):
|
||||||
if utils.is_game_time():
|
if utils.is_game_time():
|
||||||
send_message.change_interval(minutes=3)
|
send_message.change_interval(minutes=2)
|
||||||
else:
|
else:
|
||||||
send_message.change_interval(minutes=15)
|
send_message.change_interval(minutes=15)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
matches = dota.get_friends_recent_matches()
|
matches = dota.get_friends_recent_matches()
|
||||||
except:
|
# 获取连胜连败消息
|
||||||
|
streak_notifications = dota.check_streaks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in send_message task: {e}")
|
||||||
return
|
return
|
||||||
for match_ in matches:
|
|
||||||
data = 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']])
|
|
||||||
|
|
||||||
@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"
|
first_match = True
|
||||||
|
|
||||||
|
for match_ in matches:
|
||||||
|
data = await dota.serialize_match_for_discord(match_)
|
||||||
|
logger.info(f"sending match {match_['match_id']}, {data}")
|
||||||
|
try:
|
||||||
|
# 从embed中提取朋友信息,并添加胜负状态
|
||||||
|
friends_info = ""
|
||||||
|
win_status = "✅ **胜利** " if data.get('win') else "❌ **失败** "
|
||||||
|
|
||||||
|
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()
|
await ctx.defer()
|
||||||
logger.info(f"get_friends_recent_matches {name} {match_count}")
|
logger.info(f"get_friends_recent_matches {name} {match_count}")
|
||||||
friends = dota.Friend.filter(name=name)
|
friends = dota.Friend.filter(name=name)
|
||||||
@ -49,8 +87,14 @@ async def get_friends_recent_matches(ctx, name, match_count=5): # a slash comman
|
|||||||
if friends.count() == 0:
|
if friends.count() == 0:
|
||||||
await ctx.respond(content=f'找不到 {name} 的信息')
|
await ctx.respond(content=f'找不到 {name} 的信息')
|
||||||
return
|
return
|
||||||
data = dota.Friend.serialize_recent_matches_for_discord(friends, match_count)
|
data = await dota.Friend.serialize_recent_matches_for_discord(friends, match_count)
|
||||||
|
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']])
|
await ctx.respond(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send recent_matches error {e}")
|
||||||
|
|
||||||
@bot.command(description='获取朋友', name='list_friends')
|
@bot.command(description='获取朋友', name='list_friends')
|
||||||
async def get_friends(ctx):
|
async def get_friends(ctx):
|
||||||
@ -96,6 +140,22 @@ async def deactivate_friend(ctx, steam_id):
|
|||||||
else:
|
else:
|
||||||
await ctx.respond(content=f'找不到 {steam_id}')
|
await ctx.respond(content=f'找不到 {steam_id}')
|
||||||
|
|
||||||
|
@bot.command(description='获取本周统计总结', name='weekly_summary')
|
||||||
|
async def get_weekly_summary(ctx):
|
||||||
|
"""手动触发每周总结"""
|
||||||
|
await ctx.defer()
|
||||||
|
logger.info("Manual weekly summary requested")
|
||||||
|
try:
|
||||||
|
data = await dota.generate_weekly_summary_for_discord()
|
||||||
|
if data:
|
||||||
|
await ctx.respond(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
|
||||||
|
else:
|
||||||
|
await ctx.respond(content='本周暂无数据')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating weekly summary: {e}")
|
||||||
|
await ctx.respond(content=f'生成周报失败: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
@bot.command(description='启用朋友', name='activate_friend')
|
@bot.command(description='启用朋友', name='activate_friend')
|
||||||
async def activate_friend(ctx, steam_id):
|
async def activate_friend(ctx, steam_id):
|
||||||
logger.info(f'activate_friend {steam_id}')
|
logger.info(f'activate_friend {steam_id}')
|
||||||
@ -111,10 +171,70 @@ async def activate_friend(ctx, steam_id):
|
|||||||
async def heartbeat():
|
async def heartbeat():
|
||||||
utils.heartbeat()
|
utils.heartbeat()
|
||||||
|
|
||||||
|
@tasks.loop(hours=24)
|
||||||
|
async def check_rank_changes(channel):
|
||||||
|
logger.info("Checking for rank changes")
|
||||||
|
try:
|
||||||
|
data = await dota.check_rank_changes_for_discord()
|
||||||
|
if data:
|
||||||
|
logger.info(f"Sending rank changes: {data}")
|
||||||
|
await channel.send(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking rank changes: {e}")
|
||||||
|
sentry_sdk.capture_exception(e)
|
||||||
|
|
||||||
|
@check_rank_changes.before_loop
|
||||||
|
async def before_check_rank_changes():
|
||||||
|
# 等待到晚上9点再开始第一次运行
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
target_time = now.replace(hour=21, minute=0, second=0, microsecond=0)
|
||||||
|
if now >= target_time:
|
||||||
|
target_time = target_time + datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
seconds_until_target = (target_time - now).total_seconds()
|
||||||
|
await asyncio.sleep(seconds_until_target)
|
||||||
|
|
||||||
|
|
||||||
|
@tasks.loop(hours=168) # 每7天运行一次
|
||||||
|
async def weekly_summary(channel):
|
||||||
|
"""每周总结任务"""
|
||||||
|
logger.info("Generating weekly summary")
|
||||||
|
try:
|
||||||
|
data = await dota.generate_weekly_summary_for_discord()
|
||||||
|
if data:
|
||||||
|
logger.info(f"Sending weekly summary")
|
||||||
|
await channel.send(content=data['content'], embeds=[discord.Embed.from_dict(embed) for embed in data['embeds']])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating weekly summary: {e}")
|
||||||
|
sentry_sdk.capture_exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
@weekly_summary.before_loop
|
||||||
|
async def before_weekly_summary():
|
||||||
|
# 等待到周日晚上9点再开始第一次运行
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
# 计算下一个周日的21:00
|
||||||
|
days_until_sunday = (6 - now.weekday()) % 7
|
||||||
|
if days_until_sunday == 0 and now.hour >= 21:
|
||||||
|
days_until_sunday = 7
|
||||||
|
target_time = (now + datetime.timedelta(days=days_until_sunday)).replace(hour=21, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
seconds_until_target = (target_time - now).total_seconds()
|
||||||
|
logger.info(f"Weekly summary will start in {seconds_until_target} seconds")
|
||||||
|
await asyncio.sleep(seconds_until_target)
|
||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
|
logger.info(f"We have logged in as {bot.user}")
|
||||||
|
|
||||||
channel = bot.get_channel(channel_id)
|
channel = bot.get_channel(channel_id)
|
||||||
send_message.start(channel)
|
send_message.start(channel)
|
||||||
heartbeat.start()
|
heartbeat.start()
|
||||||
|
|
||||||
|
# 启动天梯检查任务
|
||||||
|
check_rank_changes.start(channel)
|
||||||
|
|
||||||
|
# 启动每周总结任务
|
||||||
|
weekly_summary.start(channel)
|
||||||
|
|
||||||
bot.run('MTE1MjE2NTc3NDMwNDIyMzI2Mg.GEi-17.VvuIkRy_cFD9XF6wtTagY95LKEbTxKaxy-FxGw') # 这里替换成你自己的 token
|
bot.run('MTE1MjE2NTc3NDMwNDIyMzI2Mg.GEi-17.VvuIkRy_cFD9XF6wtTagY95LKEbTxKaxy-FxGw') # 这里替换成你自己的 token
|
||||||
|
|||||||
498
dota.py
498
dota.py
@ -2,15 +2,20 @@ import peewee
|
|||||||
import opendota
|
import opendota
|
||||||
import datetime
|
import datetime
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
import json
|
||||||
import players
|
import players
|
||||||
import utils
|
import utils
|
||||||
|
from image_generator import ImageGenerator
|
||||||
|
import asyncio
|
||||||
|
|
||||||
db = peewee.SqliteDatabase('dota.db')
|
db = peewee.SqliteDatabase('dota.db')
|
||||||
hero_client = opendota.HeroesApi()
|
hero_client = opendota.HeroesApi()
|
||||||
player_client = opendota.PlayersApi()
|
player_client = opendota.PlayersApi()
|
||||||
match_client = opendota.MatchesApi()
|
match_client = opendota.MatchesApi()
|
||||||
|
image_generator = ImageGenerator()
|
||||||
|
|
||||||
|
# 初始化全局变量,用于存储连胜连败更新
|
||||||
|
streak_updates = []
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(peewee.Model):
|
class BaseModel(peewee.Model):
|
||||||
@ -48,26 +53,38 @@ class Match(BaseModel):
|
|||||||
duration = peewee.IntegerField()
|
duration = peewee.IntegerField()
|
||||||
radiant_win = peewee.BooleanField()
|
radiant_win = peewee.BooleanField()
|
||||||
party_size = peewee.IntegerField(null=True)
|
party_size = peewee.IntegerField(null=True)
|
||||||
|
opendota_response = peewee.TextField(null=True)
|
||||||
|
|
||||||
def serialize_match(self):
|
def serialize_match(self):
|
||||||
|
if not self.opendota_response:
|
||||||
try:
|
try:
|
||||||
match_ = match_client.get_matches_by_match_id(self.match_id)
|
match_ = match_client.get_matches_by_match_id(self.match_id)
|
||||||
|
m_dict = match_.to_dict()
|
||||||
|
for player in m_dict['players']:
|
||||||
|
if player['last_login']:
|
||||||
|
# datatime obj to timestamp
|
||||||
|
player['last_login'] = int(player['last_login'].timestamp())
|
||||||
|
self.opendota_response = json.dumps(m_dict)
|
||||||
|
self.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('fail to get match %s' % self.match_id)
|
logger.error('fail to get match %s' % self.match_id)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
md = json.loads(self.opendota_response)
|
||||||
match_data = {
|
match_data = {
|
||||||
'players': [players.serialize_player(player) for player in match_.players],
|
'players': [players.serialize_player(player) for player in md['players']],
|
||||||
'dire_score': match_.dire_score,
|
'dire_score': md['dire_score'],
|
||||||
'radiant_score': match_.radiant_score,
|
'radiant_score': md['radiant_score'],
|
||||||
# isoformat utc+8
|
# isoformat utc+8
|
||||||
'start_time': datetime.datetime.fromtimestamp(match_.start_time).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
'start_time': datetime.datetime.fromtimestamp(md['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
||||||
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_.duration),
|
'end_time': datetime.datetime.fromtimestamp(md['start_time'] + md['duration']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00'),
|
||||||
'radiant_win': match_.radiant_win,
|
'duration': '%d:%02d:%02d' % utils.convert_seconds_to_hms(md['duration']),
|
||||||
|
'radiant_win': md['radiant_win'],
|
||||||
'party_size': self.party_size,
|
'party_size': self.party_size,
|
||||||
'match_id': self.match_id,
|
'match_id': self.match_id,
|
||||||
}
|
}
|
||||||
if not self.party_size:
|
if not self.party_size:
|
||||||
player_account_ids = [player.account_id for player in match_.players if player.account_id]
|
player_account_ids = [player['account_id'] for player in md['players'] if player['account_id']]
|
||||||
match_data['party_size'] = Friend.select().where(Friend.steam_id.in_(player_account_ids)).count()
|
match_data['party_size'] = Friend.select().where(Friend.steam_id.in_(player_account_ids)).count()
|
||||||
return match_data
|
return match_data
|
||||||
|
|
||||||
@ -76,6 +93,10 @@ class Friend(BaseModel):
|
|||||||
steam_id = peewee.IntegerField(primary_key=True)
|
steam_id = peewee.IntegerField(primary_key=True)
|
||||||
name = peewee.CharField()
|
name = peewee.CharField()
|
||||||
active = peewee.BooleanField(default=True)
|
active = peewee.BooleanField(default=True)
|
||||||
|
rank_tier = peewee.IntegerField(null=True)
|
||||||
|
win_streak = peewee.IntegerField(default=0) # 连胜计数
|
||||||
|
loss_streak = peewee.IntegerField(default=0) # 连败计数
|
||||||
|
last_match_id = peewee.IntegerField(null=True) # 上一场比赛ID,用于避免重复计算
|
||||||
|
|
||||||
def get_recent_matches(self, limit=1):
|
def get_recent_matches(self, limit=1):
|
||||||
try:
|
try:
|
||||||
@ -84,6 +105,20 @@ class Friend(BaseModel):
|
|||||||
logger.error('fail to get player %s recent matches. error: %s' % (self.steam_id, e))
|
logger.error('fail to get player %s recent matches. error: %s' % (self.steam_id, e))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def update_rank_tier(self):
|
||||||
|
"""Update player's rank tier from OpenDota API"""
|
||||||
|
try:
|
||||||
|
player_info = player_client.get_players_by_account_id(self.steam_id)
|
||||||
|
if player_info and hasattr(player_info, 'rank_tier') and player_info.rank_tier:
|
||||||
|
old_rank_tier = self.rank_tier
|
||||||
|
self.rank_tier = player_info.rank_tier
|
||||||
|
self.save()
|
||||||
|
return old_rank_tier != self.rank_tier, old_rank_tier
|
||||||
|
return False, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to update rank tier for player {self.steam_id}. Error: {e}')
|
||||||
|
return False, None
|
||||||
|
|
||||||
def serialize_recent_matches(self, limit=1):
|
def serialize_recent_matches(self, limit=1):
|
||||||
matches = self.get_recent_matches(limit=limit)
|
matches = self.get_recent_matches(limit=limit)
|
||||||
data = []
|
data = []
|
||||||
@ -97,6 +132,7 @@ class Friend(BaseModel):
|
|||||||
'assists': match_.assists,
|
'assists': match_.assists,
|
||||||
'party_size': match_.party_size,
|
'party_size': match_.party_size,
|
||||||
'start_time': match_.start_time,
|
'start_time': match_.start_time,
|
||||||
|
'end_time': match_.start_time + match_.duration,
|
||||||
'duration': match_.duration,
|
'duration': match_.duration,
|
||||||
'average_rank': utils.get_ranking(match_.average_rank),
|
'average_rank': utils.get_ranking(match_.average_rank),
|
||||||
'hero_id': match_.hero_id,
|
'hero_id': match_.hero_id,
|
||||||
@ -104,7 +140,7 @@ class Friend(BaseModel):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def serialize_recent_matches_for_discord(cls, friends, limit=5):
|
async def serialize_recent_matches_for_discord(cls, friends, limit=5):
|
||||||
# {
|
# {
|
||||||
# "content": "## 水哥的战报\n",
|
# "content": "## 水哥的战报\n",
|
||||||
# "embeds": [
|
# "embeds": [
|
||||||
@ -119,10 +155,15 @@ class Friend(BaseModel):
|
|||||||
# ],
|
# ],
|
||||||
# }
|
# }
|
||||||
matches = []
|
matches = []
|
||||||
if limit > 10:
|
# if limit > 10:
|
||||||
limit = 10
|
# limit = 10
|
||||||
for friend in friends:
|
for friend in friends:
|
||||||
matches.extend(friend.serialize_recent_matches(limit=limit))
|
matches_ = friend.serialize_recent_matches(limit=limit)
|
||||||
|
if not matches_:
|
||||||
|
continue
|
||||||
|
matches.extend(matches_)
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
# sort matches by start_time from latest to oldest
|
# sort matches by start_time from latest to oldest
|
||||||
matches.sort(key=lambda x: x['start_time'], reverse=True)
|
matches.sort(key=lambda x: x['start_time'], reverse=True)
|
||||||
name = friends[0].name
|
name = friends[0].name
|
||||||
@ -130,35 +171,203 @@ class Friend(BaseModel):
|
|||||||
'content': f'## {name}的战报',
|
'content': f'## {name}的战报',
|
||||||
'embeds': [],
|
'embeds': [],
|
||||||
}
|
}
|
||||||
for match_ in matches[:limit]:
|
|
||||||
|
for match_ in matches[:min(limit, 9)]:
|
||||||
duration = '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_['duration'])
|
duration = '%d:%02d:%02d' % utils.convert_seconds_to_hms(match_['duration'])
|
||||||
|
summary = f"{duration}"
|
||||||
|
if match_['party_size'] == None:
|
||||||
|
if Match.filter(match_id=match_['match_id']).exists():
|
||||||
|
match_['party_size'] = Match.get(match_id=match_['match_id']).party_size
|
||||||
|
if match_['party_size']:
|
||||||
|
match_['party_size'] = 0
|
||||||
if match_['party_size'] and match_['party_size'] > 1:
|
if match_['party_size'] and match_['party_size'] > 1:
|
||||||
summary = f"{match_['party_size']}黑 {duration}"
|
summary = f"{match_['party_size']}黑 {duration}"
|
||||||
elif match_['party_size'] == None:
|
elif match_['party_size'] and match_['party_size'] == 1:
|
||||||
summary = f"??黑 {duration}"
|
|
||||||
else:
|
|
||||||
summary = f"单排 {duration}"
|
summary = f"单排 {duration}"
|
||||||
|
|
||||||
if match_['average_rank']:
|
if match_['average_rank']:
|
||||||
summary += '\n' + match_['average_rank']
|
summary += '\n' + match_['average_rank']
|
||||||
|
|
||||||
start_time = datetime.datetime.fromtimestamp(match_['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00')
|
# start_time = datetime.datetime.fromtimestamp(match_['start_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00')
|
||||||
hero_name = Hero.get(hero_id=match_['hero_id']).localized_name
|
end_time = datetime.datetime.fromtimestamp(match_['end_time']).strftime('%Y-%m-%dT%H:%M:%S.000+08:00')
|
||||||
|
hero_name = utils.get_hero_chinese_name(Hero.get(hero_id=match_['hero_id']).localized_name)
|
||||||
data['embeds'].append({
|
data['embeds'].append({
|
||||||
'title': f"{hero_name} {match_['kills']} 杀 {match_['deaths']} 死 {match_['assists']} 助 ",
|
'title': f"{hero_name} {match_['kills']} 杀 {match_['deaths']} 死 {match_['assists']} 助 ",
|
||||||
'description': summary,
|
'description': summary,
|
||||||
'color': 6732650 if match_['win'] else 16724787, # 66bb6a or FF3333
|
'color': 6732650 if match_['win'] else 16724787, # 66bb6a or FF3333
|
||||||
'fields': [],
|
'fields': [],
|
||||||
'timestamp': start_time,
|
'timestamp': end_time,
|
||||||
'url': f"https://www.opendota.com/matches/{match_['match_id']}",
|
'url': f"https://www.opendota.com/matches/{match_['match_id']}",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 生成图片报告
|
||||||
|
image_url = None
|
||||||
|
try:
|
||||||
|
# 直接等待异步函数,而不是使用asyncio.run()
|
||||||
|
image_generator = ImageGenerator()
|
||||||
|
image_url = await image_generator.generate_recent_matches_image(name, matches[:limit])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"生成最近比赛报告图片失败: {str(e)}")
|
||||||
|
|
||||||
|
# 如果成功生成了图片,添加到最后一个embed中
|
||||||
|
if image_url:
|
||||||
|
data['embeds'].append({
|
||||||
|
'image': {
|
||||||
|
'url': image_url
|
||||||
|
},
|
||||||
|
'color': 3447003 # 蓝色
|
||||||
|
})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def recalculate_streak_from_recent_matches(self):
|
||||||
|
"""获取近20场比赛并重新计算连胜连败记录"""
|
||||||
|
try:
|
||||||
|
# 获取近20场比赛
|
||||||
|
recent_matches = self.get_recent_matches(limit=20)
|
||||||
|
if not recent_matches:
|
||||||
|
logger.warning(f"No recent matches found for {self.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 按时间从旧到新排序(start_time升序)
|
||||||
|
recent_matches.sort(key=lambda x: x.start_time)
|
||||||
|
|
||||||
|
# 重置连胜连败计数
|
||||||
|
self.win_streak = 0
|
||||||
|
self.loss_streak = 0
|
||||||
|
|
||||||
|
# 从最旧的比赛开始计算连胜连败
|
||||||
|
for match in recent_matches:
|
||||||
|
# 判断是否获胜
|
||||||
|
player_won = match.radiant_win == (match.player_slot < 128)
|
||||||
|
|
||||||
|
if player_won:
|
||||||
|
self.win_streak += 1
|
||||||
|
self.loss_streak = 0
|
||||||
|
else:
|
||||||
|
self.loss_streak += 1
|
||||||
|
self.win_streak = 0
|
||||||
|
|
||||||
|
# 更新最后一场比赛的ID,避免重复计算
|
||||||
|
if recent_matches:
|
||||||
|
self.last_match_id = recent_matches[-1].match_id
|
||||||
|
|
||||||
|
# 保存更新后的数据
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
logger.info(f"Updated streak for {self.name}: {self.win_streak} wins, {self.loss_streak} losses")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to recalculate streak for {self.name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_streak(self, match_id, win):
|
||||||
|
"""更新连胜连败计数"""
|
||||||
|
# 避免重复计算同一场比赛
|
||||||
|
if self.last_match_id == match_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_win_streak = self.win_streak
|
||||||
|
old_loss_streak = self.loss_streak
|
||||||
|
|
||||||
|
if win:
|
||||||
|
self.win_streak += 1
|
||||||
|
self.loss_streak = 0 # 重置连败
|
||||||
|
else:
|
||||||
|
self.loss_streak += 1
|
||||||
|
self.win_streak = 0 # 重置连胜
|
||||||
|
|
||||||
|
self.last_match_id = match_id
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# 返回连胜连败状态变化信息
|
||||||
|
result = {
|
||||||
|
'name': self.name,
|
||||||
|
'win': win,
|
||||||
|
'win_streak': self.win_streak,
|
||||||
|
'loss_streak': self.loss_streak,
|
||||||
|
'win_streak_broken': not win and old_win_streak >= 3,
|
||||||
|
'loss_streak_broken': win and old_loss_streak >= 3,
|
||||||
|
'old_win_streak': old_win_streak,
|
||||||
|
'old_loss_streak': old_loss_streak
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_friends_recent_matches():
|
def get_friends_recent_matches():
|
||||||
matches = []
|
matches = []
|
||||||
for friend in Friend.filter(active=True):
|
global streak_updates # 使用全局变量存储连胜连败更新
|
||||||
for match_ in friend.get_recent_matches():
|
streak_updates = []
|
||||||
|
|
||||||
|
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():
|
if not Match.select().where(Match.match_id == match_.match_id).exists():
|
||||||
logger.info('create match, match info: %s' % match_.__dict__)
|
logger.info('create match, match info: %s' % match_.__dict__)
|
||||||
match_obj = Match.create(
|
match_obj = Match.create(
|
||||||
@ -169,10 +378,15 @@ def get_friends_recent_matches():
|
|||||||
party_size=match_.party_size,
|
party_size=match_.party_size,
|
||||||
)
|
)
|
||||||
matches.append(match_obj.serialize_match())
|
matches.append(match_obj.serialize_match())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'failed to get recent matches for friend {friend.name}: {e}')
|
||||||
|
continue
|
||||||
|
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
|
||||||
def serialize_match_for_discord(match_):
|
async def serialize_match_for_discord(match_):
|
||||||
# {
|
# {
|
||||||
# "content": "## 天辉\n\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n\n## 夜魇\n\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n",
|
# "content": "## 天辉\n\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n\n## 夜魇\n\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n我(LV23 大鱼人): 2 杀 5 死 3 助 | 12345 经济 | 13442 伤害\n",
|
||||||
# "tts": false,
|
# "tts": false,
|
||||||
@ -216,11 +430,15 @@ def serialize_match_for_discord(match_):
|
|||||||
is_radiant = player['is_radiant']
|
is_radiant = player['is_radiant']
|
||||||
break
|
break
|
||||||
win = is_radiant == match_['radiant_win']
|
win = is_radiant == match_['radiant_win']
|
||||||
|
summary = f"{match_['duration']}"
|
||||||
if not match_['party_size']:
|
if not match_['party_size']:
|
||||||
summary = f"??黑 {match_['duration']}"
|
if Match.filter(match_id=match_['match_id']).exists():
|
||||||
elif match_['party_size'] > 1:
|
match_['party_size'] = Match.get(match_id=match_['match_id']).party_size
|
||||||
summary = f"{match_['party_size']}黑 {match_['duration']}"
|
|
||||||
else:
|
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']}"
|
summary = f"单排 {match_['duration']}"
|
||||||
|
|
||||||
radiant = []
|
radiant = []
|
||||||
@ -235,7 +453,11 @@ def serialize_match_for_discord(match_):
|
|||||||
dire_highest_damage_idx = 0
|
dire_highest_damage_idx = 0
|
||||||
|
|
||||||
for player in match_['players']:
|
for player in match_['players']:
|
||||||
desc = f"{player['nickname'] or player['personaname']}(Lv.**{player['level']}** {player['hero']}): **{player['kills']}** 杀 **{player['deaths']}** 死 **{player['assists']}** 助 | **{utils.shorten_digits(player['total_gold'])}** 经济 | **{utils.shorten_digits(player['hero_damage'])}** 伤害 "
|
player_name = player['personaname']
|
||||||
|
if player['nickname']:
|
||||||
|
player_name = f"**{player['nickname']}**"
|
||||||
|
|
||||||
|
desc = f"{player_name}(Lv.**{player['level']}** {utils.get_hero_chinese_name(player['hero'])}): **{player['kills']}** 杀 **{player['deaths']}** 死 **{player['assists']}** 助 | **{utils.shorten_digits(player['total_gold'])}** 经济 | **{utils.shorten_digits(player['hero_damage'])}** 伤害 "
|
||||||
|
|
||||||
if player['is_radiant']:
|
if player['is_radiant']:
|
||||||
radiant.append(desc)
|
radiant.append(desc)
|
||||||
@ -254,10 +476,10 @@ def serialize_match_for_discord(match_):
|
|||||||
dire_highest_damage = player['hero_damage']
|
dire_highest_damage = player['hero_damage']
|
||||||
dire_highest_damage_idx = len(dire) - 1
|
dire_highest_damage_idx = len(dire) - 1
|
||||||
|
|
||||||
radiant[radiant_highest_gold_idx] = radiant[radiant_highest_gold_idx] + '💰'
|
radiant[radiant_highest_gold_idx] = '💰' + radiant[radiant_highest_gold_idx]
|
||||||
radiant[radiant_highest_damage_idx] = radiant[radiant_highest_damage_idx] + '🩸'
|
radiant[radiant_highest_damage_idx] = '🩸' + radiant[radiant_highest_damage_idx]
|
||||||
dire[dire_highest_gold_idx] = dire[dire_highest_gold_idx] + '💰'
|
dire[dire_highest_gold_idx] = '💰' + dire[dire_highest_gold_idx]
|
||||||
dire[dire_highest_damage_idx] = dire[dire_highest_damage_idx] + '🩸'
|
dire[dire_highest_damage_idx] = '🩸'+ dire[dire_highest_damage_idx]
|
||||||
|
|
||||||
color = 6732650 if win else 16724787 # 66bb6a or FF3333
|
color = 6732650 if win else 16724787 # 66bb6a or FF3333
|
||||||
|
|
||||||
@ -265,19 +487,29 @@ def serialize_match_for_discord(match_):
|
|||||||
radiant_indicator = ''
|
radiant_indicator = ''
|
||||||
dire_indicator = ''
|
dire_indicator = ''
|
||||||
if is_radiant:
|
if is_radiant:
|
||||||
radiant_indicator = ' 🌟'
|
radiant_indicator = '🌟 '
|
||||||
else:
|
else:
|
||||||
dire_indicator = ' 🌟'
|
dire_indicator = ' 🌟'
|
||||||
|
|
||||||
|
# 生成比赛报告图片
|
||||||
|
image_url = None
|
||||||
|
try:
|
||||||
|
# 直接等待异步函数,而不是使用asyncio.run()
|
||||||
|
image_generator = ImageGenerator()
|
||||||
|
image_url = await image_generator.generate_match_report(match_)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"生成比赛报告图片失败: {str(e)}")
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"content": content,
|
"content": content,
|
||||||
"tts": False,
|
"tts": False,
|
||||||
|
"win": win,
|
||||||
"embeds": [
|
"embeds": [
|
||||||
{
|
{
|
||||||
"color": color,
|
"color": color,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"name": "天辉" + radiant_indicator,
|
"name": radiant_indicator + "天辉",
|
||||||
"value": match_['radiant_score'],
|
"value": match_['radiant_score'],
|
||||||
"inline": True
|
"inline": True
|
||||||
},
|
},
|
||||||
@ -295,8 +527,206 @@ def serialize_match_for_discord(match_):
|
|||||||
"name": "opendota",
|
"name": "opendota",
|
||||||
"url": "https://www.opendota.com/matches/%s" % match_['match_id']
|
"url": "https://www.opendota.com/matches/%s" % match_['match_id']
|
||||||
},
|
},
|
||||||
"timestamp": match_['start_time']
|
"timestamp": match_['end_time']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 如果成功生成了图片,添加到embeds中
|
||||||
|
if image_url:
|
||||||
|
data["embeds"][0]["image"] = {
|
||||||
|
"url": image_url
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def check_rank_changes_for_discord():
|
||||||
|
"""Check for rank changes among all active friends and format for Discord"""
|
||||||
|
rank_changes = []
|
||||||
|
|
||||||
|
for friend in Friend.filter(active=True):
|
||||||
|
changed, old_rank_tier = friend.update_rank_tier()
|
||||||
|
if changed and friend.rank_tier is not None:
|
||||||
|
old_rank = utils.get_ranking(old_rank_tier) if old_rank_tier else "未校准"
|
||||||
|
new_rank = utils.get_ranking(friend.rank_tier)
|
||||||
|
rank_changes.append({
|
||||||
|
'name': friend.name,
|
||||||
|
'old_rank': old_rank,
|
||||||
|
'new_rank': new_rank,
|
||||||
|
'increased': friend.rank_tier > (old_rank_tier or 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
if not rank_changes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'content': '## 天梯更新',
|
||||||
|
'embeds': []
|
||||||
|
}
|
||||||
|
|
||||||
|
for change in rank_changes:
|
||||||
|
direction = "⬆️ 上升" if change['increased'] else "⬇️ 下降"
|
||||||
|
color = 6732650 if change['increased'] else 16724787 # Green if increased, red if decreased
|
||||||
|
|
||||||
|
data['embeds'].append({
|
||||||
|
'title': f"{change['name']} 的天梯等级变化",
|
||||||
|
'description': f"{direction}:{change['old_rank']} → {change['new_rank']}",
|
||||||
|
'color': color
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def check_streaks():
|
||||||
|
"""检查连胜连败并返回通知消息"""
|
||||||
|
global streak_updates
|
||||||
|
notifications = []
|
||||||
|
|
||||||
|
for update in streak_updates:
|
||||||
|
# 连胜达到3场或以上
|
||||||
|
if update['win_streak'] >= 3:
|
||||||
|
notifications.append(f"🔥 **{update['name']}** 正在**{update['win_streak']}连胜**!")
|
||||||
|
|
||||||
|
# 连败达到3场或以上
|
||||||
|
if update['loss_streak'] >= 3:
|
||||||
|
notifications.append(f"💔 **{update['name']}** 正在**{update['loss_streak']}连败**!")
|
||||||
|
|
||||||
|
# 连胜被终结
|
||||||
|
if update['win_streak_broken'] and update['old_win_streak'] >= 3:
|
||||||
|
notifications.append(f"⚡ **{update['name']}** 的**{update['old_win_streak']}连胜**被终结了!")
|
||||||
|
|
||||||
|
# 连败被终结
|
||||||
|
if update['loss_streak_broken'] and update['old_loss_streak'] >= 3:
|
||||||
|
notifications.append(f"🌈 **{update['name']}** 终于结束了**{update['old_loss_streak']}连败**!")
|
||||||
|
|
||||||
|
# 清空更新列表,避免重复通知
|
||||||
|
streak_updates = []
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
def get_weekly_stats_for_discord():
|
||||||
|
"""
|
||||||
|
获取过去7天的统计数据,返回Discord格式的消息
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 获取7天前的日期
|
||||||
|
week_ago = datetime.datetime.now() - datetime.timedelta(days=7)
|
||||||
|
|
||||||
|
weekly_data = []
|
||||||
|
|
||||||
|
for friend in Friend.filter(active=True):
|
||||||
|
# 获取该玩家最近20场比赛(从中筛选过去7天的)
|
||||||
|
recent_matches = friend.get_recent_matches(limit=20)
|
||||||
|
|
||||||
|
# 筛选过去7天的比赛
|
||||||
|
matches_this_week = []
|
||||||
|
for match in recent_matches:
|
||||||
|
match_time = datetime.datetime.fromtimestamp(match.start_time)
|
||||||
|
if match_time >= week_ago:
|
||||||
|
matches_this_week.append(match)
|
||||||
|
|
||||||
|
if not matches_this_week:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 统计数据
|
||||||
|
wins = 0
|
||||||
|
losses = 0
|
||||||
|
total_kills = 0
|
||||||
|
total_deaths = 0
|
||||||
|
total_assists = 0
|
||||||
|
hero_stats = {} # {hero_id: {'wins': 0, 'losses': 0, 'games': 0}}
|
||||||
|
|
||||||
|
for match in matches_this_week:
|
||||||
|
player_won = match.radiant_win == (match.player_slot < 128)
|
||||||
|
if player_won:
|
||||||
|
wins += 1
|
||||||
|
else:
|
||||||
|
losses += 1
|
||||||
|
|
||||||
|
total_kills += match.kills
|
||||||
|
total_deaths += match.deaths
|
||||||
|
total_assists += match.assists
|
||||||
|
|
||||||
|
# 英雄统计
|
||||||
|
hero_id = match.hero_id
|
||||||
|
if hero_id not in hero_stats:
|
||||||
|
hero_stats[hero_id] = {'wins': 0, 'losses': 0, 'games': 0, 'hero_id': hero_id}
|
||||||
|
hero_stats[hero_id]['games'] += 1
|
||||||
|
if player_won:
|
||||||
|
hero_stats[hero_id]['wins'] += 1
|
||||||
|
else:
|
||||||
|
hero_stats[hero_id]['losses'] += 1
|
||||||
|
|
||||||
|
total_games = wins + losses
|
||||||
|
win_rate = (wins / total_games * 100) if total_games > 0 else 0
|
||||||
|
kda = (total_kills + total_assists) / total_deaths if total_deaths > 0 else (total_kills + total_assists)
|
||||||
|
|
||||||
|
# 找出最常用英雄和最佳英雄
|
||||||
|
most_played = max(hero_stats.values(), key=lambda x: x['games']) if hero_stats else None
|
||||||
|
best_hero = max(
|
||||||
|
[h for h in hero_stats.values() if h['games'] >= 2],
|
||||||
|
key=lambda x: x['wins'] / x['games'] if x['games'] > 0 else 0,
|
||||||
|
default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
weekly_data.append({
|
||||||
|
'name': friend.name,
|
||||||
|
'steam_id': friend.steam_id,
|
||||||
|
'total_games': total_games,
|
||||||
|
'wins': wins,
|
||||||
|
'losses': losses,
|
||||||
|
'win_rate': win_rate,
|
||||||
|
'kda': kda,
|
||||||
|
'total_kills': total_kills,
|
||||||
|
'total_deaths': total_deaths,
|
||||||
|
'total_assists': total_assists,
|
||||||
|
'most_played_hero': most_played,
|
||||||
|
'best_hero': best_hero,
|
||||||
|
'hero_stats': hero_stats
|
||||||
|
})
|
||||||
|
|
||||||
|
return weekly_data
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_weekly_summary_for_discord():
|
||||||
|
"""
|
||||||
|
生成每周总结报告,返回Discord格式的消息
|
||||||
|
"""
|
||||||
|
weekly_data = get_weekly_stats_for_discord()
|
||||||
|
|
||||||
|
if not weekly_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 生成图片
|
||||||
|
image_url = await image_generator.generate_weekly_summary_image(weekly_data)
|
||||||
|
|
||||||
|
content = "## 📊 本周 Dota 战报\n\n"
|
||||||
|
|
||||||
|
# 整体统计
|
||||||
|
total_games = sum(d['total_games'] for d in weekly_data)
|
||||||
|
total_wins = sum(d['wins'] for d in weekly_data)
|
||||||
|
total_losses = sum(d['losses'] for d in weekly_data)
|
||||||
|
|
||||||
|
content += f"本周共打了 **{total_games}** 场比赛,**{total_wins}** 胜 **{total_losses}** 败\n\n"
|
||||||
|
|
||||||
|
# 个人统计
|
||||||
|
for data in weekly_data:
|
||||||
|
win_rate_str = f"{data['win_rate']:.1f}%"
|
||||||
|
kda_str = f"{data['kda']:.2f}"
|
||||||
|
content += f"**{data['name']}**: {data['wins']}胜{data['losses']}败 ({win_rate_str}) KDA {kda_str}\n"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'content': content,
|
||||||
|
'embeds': []
|
||||||
|
}
|
||||||
|
|
||||||
|
if image_url:
|
||||||
|
result['embeds'].append({
|
||||||
|
'image': {'url': image_url},
|
||||||
|
'color': 3447003
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
10
env.ini
Normal file
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 dota
|
||||||
|
import utils
|
||||||
|
|
||||||
def serialize_player(player):
|
def serialize_player(player):
|
||||||
friend = dota.Friend.get_or_none(steam_id=player.account_id)
|
friend = dota.Friend.get_or_none(steam_id=player['account_id'])
|
||||||
player_data = {
|
player_data = {
|
||||||
'personaname': player.personaname,
|
'personaname': player['personaname'] if player['personaname'] else '',
|
||||||
'nickname': friend.name if friend else None,
|
'nickname': friend.name if friend else '',
|
||||||
'kills': player.kills,
|
'kills': player['kills'],
|
||||||
'deaths': player.deaths,
|
'deaths': player['deaths'],
|
||||||
'assists': player.assists,
|
'assists': player['assists'],
|
||||||
'total_gold': player.total_gold,
|
'total_gold': player['total_gold'],
|
||||||
'last_hits': player.last_hits,
|
'last_hits': player['last_hits'],
|
||||||
'denies': player.denies,
|
'denies': player['denies'],
|
||||||
'hero_damage': player.hero_damage,
|
'hero_damage': player['hero_damage'],
|
||||||
'party_id': player.party_id,
|
'party_id': player['party_id'],
|
||||||
'win': player.win,
|
'win': player['win'],
|
||||||
'level': player.level,
|
'level': player['level'],
|
||||||
'is_radiant': player.is_radiant,
|
'is_radiant': player['is_radiant'],
|
||||||
'hero': dota.Hero.get(hero_id=player.hero_id).localized_name,
|
'hero': dota.Hero.get(hero_id=player['hero_id']).localized_name,
|
||||||
|
'hero_id': player['hero_id'],
|
||||||
|
'rank_tier': player['rank_tier'],
|
||||||
|
'rank': utils.get_ranking(player['rank_tier']),
|
||||||
}
|
}
|
||||||
return player_data
|
return player_data
|
||||||
|
|||||||
125
project-info.md
Normal file
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
|
import requests
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import os
|
||||||
|
from botocore.config import Config
|
||||||
|
import uuid
|
||||||
|
import configparser
|
||||||
|
|
||||||
# logger_file = '/root/develop/log/dotabot.log'
|
# logger_file = '/root/develop/log/dotabot.log'
|
||||||
logger_file = 'dotabot.log'
|
logger_file = 'dotabot.log'
|
||||||
logger.add(logger_file)
|
logger.add(logger_file)
|
||||||
@ -12,14 +18,15 @@ def convert_seconds_to_hms(total_seconds):
|
|||||||
return hours, minutes, seconds
|
return hours, minutes, seconds
|
||||||
|
|
||||||
def is_workday():
|
def is_workday():
|
||||||
return datetime.datetime.today().weekday() < 5
|
return datetime.datetime.today().weekday() < 4
|
||||||
|
|
||||||
def is_game_time():
|
def is_game_time():
|
||||||
# game time is workday 21:00 - 1:00, weekend 10:00 - 1:00
|
# game time is workday 21:00 - 1:00, weekend 10:00 - 1:00
|
||||||
if is_workday():
|
if is_workday():
|
||||||
return datetime.datetime.now().hour >= 21 or datetime.datetime.now().hour < 1
|
# return datetime.datetime.now().hour >= 21 or datetime.datetime.now().hour < 1
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
return datetime.datetime.now().hour >= 10 or datetime.datetime.now().hour < 1
|
return datetime.datetime.now().hour >= 12 or datetime.datetime.now().hour < 1
|
||||||
|
|
||||||
|
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
@ -32,6 +39,7 @@ def get_ranking(ranking_int):
|
|||||||
# (10-15: Herald, 20-25: Guardian, 30-35: Crusader, 40-45: Archon, 50-55: Legend, 60-65: Ancient, 70-75: Divine, 80-85: Immortal).
|
# (10-15: Herald, 20-25: Guardian, 30-35: Crusader, 40-45: Archon, 50-55: Legend, 60-65: Ancient, 70-75: Divine, 80-85: Immortal).
|
||||||
if not ranking_int:
|
if not ranking_int:
|
||||||
return ''
|
return ''
|
||||||
|
ranking_int = int(ranking_int)
|
||||||
stars = ranking_int % 10
|
stars = ranking_int % 10
|
||||||
if ranking_int < 20:
|
if ranking_int < 20:
|
||||||
return '先锋 %s' % stars
|
return '先锋 %s' % stars
|
||||||
@ -64,3 +72,238 @@ def shorten_digits(num):
|
|||||||
return '%.1fk' % (num / 1000)
|
return '%.1fk' % (num / 1000)
|
||||||
else:
|
else:
|
||||||
return str(num)
|
return str(num)
|
||||||
|
|
||||||
|
|
||||||
|
def get_hero_chinese_name(english_name):
|
||||||
|
"""将Dota英雄的英文名转换为中文名
|
||||||
|
Args:
|
||||||
|
english_name (str): 英雄的英文名称
|
||||||
|
Returns:
|
||||||
|
str: 英雄的中文名称,如果找不到对应的中文名则返回原英文名
|
||||||
|
"""
|
||||||
|
hero_name_map = {
|
||||||
|
"alchemist": "炼金术士",
|
||||||
|
"axe": "斧王",
|
||||||
|
"bristleback": "钢背兽",
|
||||||
|
"centaur_warrunner": "半人马战行者",
|
||||||
|
"chaos_knight": "混沌骑士",
|
||||||
|
"dawnbreaker": "破晓辰星",
|
||||||
|
"doom": "末日使者",
|
||||||
|
"dragon_knight": "龙骑士",
|
||||||
|
"earth_spirit": "大地之灵",
|
||||||
|
"earthshaker": "撼地者",
|
||||||
|
"elder_titan": "上古巨神",
|
||||||
|
"huskar": "哈斯卡",
|
||||||
|
"kunkka": "昆卡",
|
||||||
|
"legion_commander": "军团指挥官",
|
||||||
|
"lifestealer": "噬魂鬼",
|
||||||
|
"mars": "玛尔斯",
|
||||||
|
"night_stalker": "暗夜魔王",
|
||||||
|
"ogre_magi": "食人魔魔法师",
|
||||||
|
"omniknight": "全能骑士",
|
||||||
|
"primal_beast": "兽",
|
||||||
|
"pudge": "屠夫",
|
||||||
|
"slardar": "斯拉达",
|
||||||
|
"spirit_breaker": "裂魂人",
|
||||||
|
"sven": "斯温",
|
||||||
|
"tidehunter": "潮汐猎人",
|
||||||
|
"timbersaw": "伐木机",
|
||||||
|
"tiny": "小小",
|
||||||
|
"treant_protector": "树精卫士",
|
||||||
|
"tusk": "巨牙海民",
|
||||||
|
"underlord": "孽主",
|
||||||
|
"undying": "不朽尸王",
|
||||||
|
"wraith_king": "冥魂大帝",
|
||||||
|
"anti-mage": "敌法师",
|
||||||
|
"arc_warden": "天穹守望者",
|
||||||
|
"bloodseeker": "血魔",
|
||||||
|
"bounty_hunter": "赏金猎人",
|
||||||
|
"clinkz": "克林克兹",
|
||||||
|
"drow_ranger": "卓尔游侠",
|
||||||
|
"ember_spirit": "灰烬之灵",
|
||||||
|
"faceless_void": "虚空假面",
|
||||||
|
"gyrocopter": "矮人直升机",
|
||||||
|
"hoodwink": "森海飞霞",
|
||||||
|
"juggernaut": "主宰",
|
||||||
|
"kez": "凯",
|
||||||
|
"luna": "露娜",
|
||||||
|
"medusa": "美杜莎",
|
||||||
|
"meepo": "米波",
|
||||||
|
"monkey_king": "齐天大圣",
|
||||||
|
"morphling": "变体精灵",
|
||||||
|
"naga_siren": "娜迦海妖",
|
||||||
|
"phantom_assassin": "幻影刺客",
|
||||||
|
"phantom_lancer": "幻影长矛手",
|
||||||
|
"razor": "剃刀",
|
||||||
|
"riki": "力丸",
|
||||||
|
"shadow_fiend": "影魔",
|
||||||
|
"slark": "斯拉克",
|
||||||
|
"sniper": "狙击手",
|
||||||
|
"spectre": "幽鬼",
|
||||||
|
"templar_assassin": "圣堂刺客",
|
||||||
|
"terrorblade": "恐怖利刃",
|
||||||
|
"troll_warlord": "巨魔战将",
|
||||||
|
"ursa": "熊战士",
|
||||||
|
"viper": "冥界亚龙",
|
||||||
|
"weaver": "编织者",
|
||||||
|
"ancient_apparition": "远古冰魄",
|
||||||
|
"crystal_maiden": "水晶室女",
|
||||||
|
"death_prophet": "死亡先知",
|
||||||
|
"disruptor": "干扰者",
|
||||||
|
"enchantress": "魅惑魔女",
|
||||||
|
"grimstroke": "天涯墨客",
|
||||||
|
"jakiro": "杰奇洛",
|
||||||
|
"keeper_of_the_light": "光之守卫",
|
||||||
|
"leshrac": "拉席克",
|
||||||
|
"lich": "巫妖",
|
||||||
|
"lina": "莉娜",
|
||||||
|
"lion": "莱恩",
|
||||||
|
"muerta": "琼英碧灵",
|
||||||
|
"nature's_prophet": "先知",
|
||||||
|
"necrophos": "瘟疫法师",
|
||||||
|
"oracle": "神谕者",
|
||||||
|
"outworld_destroyer": "殁境神蚀者",
|
||||||
|
"puck": "帕克",
|
||||||
|
"pugna": "帕格纳",
|
||||||
|
"queen_of_pain": "痛苦女王",
|
||||||
|
"ringmaster": "百戏之王",
|
||||||
|
"rubick": "拉比克",
|
||||||
|
"shadow_demon": "暗影恶魔",
|
||||||
|
"shadow_shaman": "暗影萨满",
|
||||||
|
"silencer": "沉默术士",
|
||||||
|
"skywrath_mage": "天怒法师",
|
||||||
|
"storm_spirit": "风暴之灵",
|
||||||
|
"tinker": "修补匠",
|
||||||
|
"warlock": "术士",
|
||||||
|
"witch_doctor": "巫医",
|
||||||
|
"zeus": "宙斯",
|
||||||
|
"abaddon": "亚巴顿",
|
||||||
|
"bane": "祸乱之源",
|
||||||
|
"batrider": "蝙蝠骑士",
|
||||||
|
"beastmaster": "兽王",
|
||||||
|
"brewmaster": "酿酒大师",
|
||||||
|
"broodmother": "育母蜘蛛",
|
||||||
|
"chen": "陈",
|
||||||
|
"clockwerk": "发条技师",
|
||||||
|
"dark_seer": "黑暗贤者",
|
||||||
|
"dark_willow": "邪影芳灵",
|
||||||
|
"dazzle": "戴泽",
|
||||||
|
"enigma": "谜团",
|
||||||
|
"invoker": "祈求者",
|
||||||
|
"io": "艾欧",
|
||||||
|
"lone_druid": "德鲁伊",
|
||||||
|
"lycan": "狼人",
|
||||||
|
"magnus": "马格纳斯",
|
||||||
|
"marci": "玛西",
|
||||||
|
"mirana": "米拉娜",
|
||||||
|
"nyx_assassin": "司夜刺客",
|
||||||
|
"pangolier": "石鳞剑士",
|
||||||
|
"phoenix": "凤凰",
|
||||||
|
"sand_king": "沙王",
|
||||||
|
"snapfire": "电炎绝手",
|
||||||
|
"techies": "工程师",
|
||||||
|
"vengeful_spirit": "复仇之魂",
|
||||||
|
"venomancer": "剧毒术士",
|
||||||
|
"visage": "维萨吉",
|
||||||
|
"void_spirit": "虚无之灵",
|
||||||
|
"windranger": "风行者",
|
||||||
|
"winter_wyvern": "寒冬飞龙"
|
||||||
|
}
|
||||||
|
# 将英文名转换为小写并去除空格,用作字典键
|
||||||
|
key = english_name.lower().replace(' ', '_')
|
||||||
|
return hero_name_map.get(key, english_name)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(image_path, file_name=None):
|
||||||
|
"""
|
||||||
|
将图片上传到 Cloudflare R2 存储桶并返回访问URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path (str): 本地图片文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 上传成功后的图片URL,失败则返回None
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 读取配置文件
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
# config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'env.ini')
|
||||||
|
config_path = './env.ini'
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
logger.error(f"配置文件不存在: {config_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
config.read(config_path)
|
||||||
|
|
||||||
|
# 获取Cloudflare R2配置
|
||||||
|
if 'cloudflare' not in config:
|
||||||
|
logger.error("配置文件中缺少cloudflare部分")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cf_config = config['cloudflare']
|
||||||
|
account_id = cf_config.get('account_id')
|
||||||
|
access_key_id = cf_config.get('access_key_id')
|
||||||
|
secret_access_key = cf_config.get('secret_access_key')
|
||||||
|
bucket_name = cf_config.get('bucket_name', 'dotabot-images')
|
||||||
|
region = cf_config.get('region', 'auto')
|
||||||
|
custom_domain = cf_config.get('custom_domain', '')
|
||||||
|
|
||||||
|
# 检查必要的配置
|
||||||
|
if not all([account_id, access_key_id, secret_access_key]):
|
||||||
|
logger.error("缺少Cloudflare R2必要配置")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 创建S3客户端连接到Cloudflare R2
|
||||||
|
s3_client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=f'https://{account_id}.r2.cloudflarestorage.com',
|
||||||
|
aws_access_key_id=access_key_id,
|
||||||
|
aws_secret_access_key=secret_access_key,
|
||||||
|
region_name=region,
|
||||||
|
config=Config(
|
||||||
|
signature_version='s3v4',
|
||||||
|
retries={
|
||||||
|
'max_attempts': 3,
|
||||||
|
'mode': 'standard'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成唯一的文件名
|
||||||
|
if not file_name:
|
||||||
|
timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
random_id = str(uuid.uuid4())[:8]
|
||||||
|
file_extension = os.path.splitext(image_path)[1]
|
||||||
|
object_key = f'dotabot/{timestamp}_{random_id}{file_extension}'
|
||||||
|
else:
|
||||||
|
file_extension = os.path.splitext(file_name)[1]
|
||||||
|
object_key = f'dotabot/{file_name}'
|
||||||
|
|
||||||
|
# 设置文件的Content-Type
|
||||||
|
content_type = 'image/png' if file_extension.lower() == '.png' else 'image/jpeg'
|
||||||
|
|
||||||
|
# 上传文件
|
||||||
|
with open(image_path, 'rb') as file_data:
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
file_data,
|
||||||
|
bucket_name,
|
||||||
|
object_key,
|
||||||
|
ExtraArgs={
|
||||||
|
'ContentType': content_type,
|
||||||
|
'CacheControl': 'max-age=2592000' # 30天缓存
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建公共访问URL
|
||||||
|
if custom_domain:
|
||||||
|
image_url = f'https://{custom_domain}/{object_key}'
|
||||||
|
else:
|
||||||
|
image_url = f'https://{bucket_name}.{account_id}.r2.cloudflarestorage.com/{object_key}'
|
||||||
|
|
||||||
|
logger.info(f"图片上传成功: {image_url}")
|
||||||
|
return image_url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"上传图片失败: {str(e)}")
|
||||||
|
return None
|
||||||
Loading…
x
Reference in New Issue
Block a user