diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..ac26fb9
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Paimon_Calender/__init__.py b/Paimon_Calender/__init__.py
new file mode 100644
index 0000000..45bea90
--- /dev/null
+++ b/Paimon_Calender/__init__.py
@@ -0,0 +1,160 @@
+import logging
+from typing import Union
+
+from nonebot import require, get_bot, on_command
+from nonebot.adapters.onebot.v11 import GroupMessageEvent, MessageEvent, Bot, Message, MessageSegment, ActionFailed
+from nonebot.params import CommandArg
+
+from ..utils.config import config
+from ..utils.file_handler import load_json, save_json
+from ..utils.generate import *
+import re
+
+HELP_STR = '''
+原神活动日历
+原神日历 : 查看本群订阅服务器日历
+原神日历 on/off : 订阅/取消订阅指定服务器的日历推送
+原神日历 time 时:分 : 设置日历推送时间
+原神日历 status : 查看本群日历推送设置
+'''.strip()
+
+calendar = on_command('原神日历', aliases={"原神日历", 'ysrl', '原神日程'}, priority=24, block=True)
+scheduler = require('nonebot_plugin_apscheduler').scheduler
+
+
+async def send_calendar(group_id, group_data):
+ for server in group_data[str(group_id)]['server_list']:
+ im = await generate_day_schedule(server)
+ base64_str = im2base64str(im)
+ if 'cardimage' not in group_data or not group_data['cardimage']:
+ msg = MessageSegment.image(base64_str)
+ else:
+ msg = f'[CQ:cardimage,file={base64_str}]'
+
+ await get_bot().send_group_msg(group_id=int(group_id), message=msg)
+
+
+def update_group_schedule(group_id, group_data):
+ group_id = str(group_id)
+ if group_id not in group_data:
+ return
+
+ scheduler.add_job(
+ func=send_calendar,
+ trigger='cron',
+ args=(group_id, group_data),
+ id=f'genshin_calendar_{group_id}',
+ replace_existing=True,
+ hour=group_data[group_id]['hour'],
+ minute=group_data[group_id]['minute'],
+ misfire_grace_time=10
+ )
+
+
+@calendar.handle()
+async def _(bot: Bot, event: Union[GroupMessageEvent, MessageEvent], msg: Message = CommandArg()):
+ if event.message_type == 'private':
+ await calendar.finish('仅支持群聊模式下使用本指令')
+
+ group_id = str(event.group_id)
+ group_data = load_json('calender_push.json')
+ server = 'cn'
+ fun = str(msg).strip()
+ action = re.search(r'(?Pon|off|time|status|test)', fun)
+
+ if group_id not in config.paimon_calender_group:
+ await calendar.finish(f"尚未在群 {group_id} 开启本功能!", at_sender=True)
+
+ if not fun:
+ im = await generate_day_schedule(server)
+ base64_str = im2base64str(im)
+ group_data = load_json('calender_push.json')
+
+ try:
+ if group_id not in group_data or 'cardimage' not in group_data[group_id] or not group_data[group_id]['cardimage']:
+ await calendar.finish(MessageSegment.image(base64_str))
+ else:
+ await calendar.finish(f'[CQ:cardimage,file={base64_str}]')
+ except ActionFailed as e:
+ await logging.ERROR(e)
+
+ elif action:
+
+ # 添加定时推送任务
+ if action.group('action') == 'on':
+ group_data[group_id] = {
+ 'server_list': [
+ str(server)
+ ],
+ 'hour': 8,
+ 'minute': 0,
+ 'cardimage': False
+ }
+ if event.message_type == 'guild':
+ await calendar.finish("暂不支持频道内推送~")
+
+ if scheduler.get_job('genshin_calendar_' + group_id):
+ scheduler.remove_job("genshin_calendar_" + group_id)
+ save_json(group_data, 'calender_push.json')
+
+ scheduler.add_job(
+ func=send_calendar,
+ trigger='cron',
+ hour=8,
+ minute=0,
+ id="genshin_calendar_" + group_id,
+ args=(group_id, group_data[group_id]),
+ misfire_grace_time=10
+ )
+
+ await calendar.finish('原神日程推送已开启')
+
+ # 关闭推送功能
+ elif action.group('action') == 'off':
+ del group_data[group_id]
+ if scheduler.get_job("genshin_calendar_" + group_id):
+ scheduler.remove_job("genshin_calendar_" + group_id)
+ await calendar.finish('原神日程推送已关闭')
+
+ # 设置推送时间
+ elif action.group('action') == 'time':
+ match = str(msg).split(" ")
+ time = re.search(r'(\d{1,2}):(\d{2})', match[1])
+
+ if re.match(r'(\d{1,2}):(\d{2})', match[1]):
+ if not time or len(time.groups()) < 2:
+ await calendar.finish("请指定推送时间")
+ else:
+ group_data[group_id]['hour'] = int(time.group(1))
+ group_data[group_id]['minute'] = int(time.group(2))
+ save_json(group_data, 'calender_push.json')
+ update_group_schedule(group_id, group_data)
+
+ await calendar.finish(
+ f"推送时间已设置为: {group_data[group_id]['hour']}:{group_data[group_id]['minute']:02d}")
+
+ else:
+ await calendar.finish("请给出正确的时间,格式为12:00", at_sender=True)
+ # DEBUG
+ elif action.group('action') == 'test':
+ return
+
+ # 查询订阅推送状态
+ elif action.group('action') == "status":
+ message = f"订阅日历: {group_data[group_id]['server_list']}"
+ message += f"\n推送时间: {group_data[group_id]['hour']}:{group_data[group_id]['minute']:02d}"
+ await calendar.finish(message)
+ else:
+ await calendar.finish('指令错误')
+
+# 自动推送任务
+for group_id, group_data in load_json('calender_push.json').items():
+ scheduler.add_job(
+ func=send_calendar,
+ trigger='cron',
+ hour=group_data['hour'],
+ minute=group_data['minute'],
+ id="genshin_calendar_" + group_id,
+ args=(group_id, group_data),
+ misfire_grace_time=10
+ )
diff --git a/res/wqy-microhei.ttc b/res/wqy-microhei.ttc
new file mode 100644
index 0000000..2c9bc2d
Binary files /dev/null and b/res/wqy-microhei.ttc differ
diff --git a/utils/config.py b/utils/config.py
index 3495489..67d985f 100644
--- a/utils/config.py
+++ b/utils/config.py
@@ -35,6 +35,8 @@ class PluginConfig(BaseModel):
paimon_chat_group: List[int] = []
# 派蒙猜语音持续时间
paimon_guess_voice: int = 30
+ # 原神日历开启群组
+ paimon_calender_group: List[int] = []
driver = get_driver()
diff --git a/utils/draw.py b/utils/draw.py
new file mode 100644
index 0000000..2a37248
--- /dev/null
+++ b/utils/draw.py
@@ -0,0 +1,96 @@
+from PIL import Image, ImageDraw, ImageFont
+import os
+
+font_Path = res_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'res')
+font = ImageFont.truetype(os.path.join(res_path, 'wqy-microhei.ttc'), 20)
+
+width = 500
+
+color = [
+ {'front': 'black', 'back': 'white'},
+ {'front': 'white', 'back': 'ForestGreen'},
+ {'front': 'white', 'back': 'DarkOrange'},
+ {'front': 'white', 'back': 'BlueViolet'},
+]
+
+
+def create_image(item_number):
+ height = item_number * 30
+ im = Image.new('RGBA', (width, height), (255, 255, 255, 0))
+ return im
+
+
+def draw_rec(im, color, x, y, w, h, r):
+ draw = ImageDraw.Draw(im)
+ draw.rectangle((x+r, y, x+w-r, y+h), fill=color)
+ draw.rectangle((x, y+r, x+w, y+h-r), fill=color)
+ r = r * 2
+ draw.ellipse((x, y, x+r, y+r), fill=color)
+ draw.ellipse((x+w-r, y, x+w, y+r), fill=color)
+ draw.ellipse((x, y+h-r, x+r, y+h), fill=color)
+ draw.ellipse((x+w-r, y+h-r, x+w, y+h), fill=color)
+
+
+def draw_text(im, x, y, w, h, text, align, color):
+ draw = ImageDraw.Draw(im)
+ tw, th = draw.textsize(text, font=font)
+ y = y + (h - th) / 2
+ if align == 0: # 居中
+ x = x + (w - tw) / 2
+ elif align == 1: # 左对齐
+ x = x + 5
+ elif align == 2: # 右对齐
+ x = x + w - tw - 5
+ draw.text((x, y), text, fill=color, font=font)
+
+
+def draw_item(im, n, t, text, days, forever):
+ if t >= len(color):
+ t = 1
+ x = 0
+ y = n * 30
+ height = 28
+
+ draw_rec(im, color[t]['back'], x, y, width, height, 6)
+
+ im1 = Image.new('RGBA', (width - 120, 28), (255, 255, 255, 0))
+ draw_text(im1, 0, 0, width, height, text, 1, color[t]['front'])
+ _, _, _, a = im1.split()
+ im.paste(im1, (x, y), mask=a)
+
+ if days > 0:
+ if forever:
+ text1 = '永久开放'
+ else:
+ text1 = f'{days}天后结束'
+ elif days < 0:
+ text1 = f'{-days}天后开始'
+ else:
+ text1 = '即将结束'
+ draw_text(im, x, y, width, height, text1, 2, color[t]['front'])
+
+
+def draw_title(im, n, left=None, middle=None, right=None):
+ x = 0
+ y = n * 30
+ height = 28
+
+ draw_rec(im, color[0]['back'], x, y, width, height, 6)
+ if middle:
+ draw_text(im, x, y, width, height, middle, 0, color[0]['front'])
+ if left:
+ draw_text(im, x, y, width, height, left, 1, color[0]['front'])
+ if right:
+ draw_text(im, x, y, width, height, right, 2, color[0]['front'])
+
+
+def draw_title1(im, n, day_list):
+ x = 0
+ y = n * 30
+ height = 28
+ color = 'black'
+
+ n = len(day_list)
+ for i in range(n):
+ x = width / n * i
+ draw_text(im, x, y, width, height, day_list[i], 1, color)
diff --git a/utils/event.py b/utils/event.py
new file mode 100644
index 0000000..0fe8436
--- /dev/null
+++ b/utils/event.py
@@ -0,0 +1,234 @@
+import os
+import json
+from datetime import datetime, timedelta
+from dateutil.relativedelta import relativedelta
+import aiohttp
+import asyncio
+import math
+import functools
+import re
+
+# type 0 普通常驻任务深渊 1 新闻 2 蛋池 3 限时活动H5
+
+event_data = {
+ 'cn': [],
+}
+
+event_updated = {
+ 'cn': '',
+}
+
+lock = {
+ 'cn': asyncio.Lock(),
+}
+
+ignored_key_words = [
+ "修复",
+ "版本内容专题页",
+ "米游社",
+ "调研",
+ "防沉迷"
+]
+
+ignored_ann_ids = [
+ 495, # 有奖问卷调查开启!
+ 1263, # 米游社《原神》专属工具一览
+ 423, # 《原神》玩家社区一览
+ 422, # 《原神》防沉迷系统说明
+ 762, # 《原神》公平运营声明
+]
+
+list_api = 'https://hk4e-api.mihoyo.com/common/hk4e_cn/announcement/api/getAnnList?game=hk4e&game_biz=hk4e_cn&lang=zh-cn&bundle_id=hk4e_cn&platform=pc®ion=cn_gf01&level=55&uid=100000000'
+detail_api = 'https://hk4e-api.mihoyo.com/common/hk4e_cn/announcement/api/getAnnContent?game=hk4e&game_biz=hk4e_cn&lang=zh-cn&bundle_id=hk4e_cn&platform=pc®ion=cn_gf01&level=55&uid=100000000'
+
+
+def cache(ttl=timedelta(hours=1), arg_key=None):
+ def wrap(func):
+ cache_data = {}
+
+ @functools.wraps(func)
+ async def wrapped(*args, **kw):
+ nonlocal cache_data
+ default_data = {"time": None, "value": None}
+ ins_key = 'default'
+ if arg_key:
+ ins_key = arg_key + str(kw.get(arg_key, ''))
+ data = cache_data.get(ins_key, default_data)
+ else:
+ data = cache_data.get(ins_key, default_data)
+
+ now = datetime.now()
+ if not data['time'] or now - data['time'] > ttl:
+ try:
+ data['value'] = await func(*args, **kw)
+ data['time'] = now
+ cache_data[ins_key] = data
+ except Exception as e:
+ raise e
+
+ return data['value']
+
+ return wrapped
+
+ return wrap
+
+
+@cache(ttl=timedelta(hours=3), arg_key='url')
+async def query_data(url):
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url) as resp:
+ return await resp.json()
+ except:
+ pass
+ return None
+
+
+async def load_event_cn():
+ result = await query_data(url=list_api)
+ detail_result = await query_data(url=detail_api)
+ if result and 'retcode' in result and result['retcode'] == 0 and detail_result and 'retcode' in detail_result and detail_result['retcode'] == 0:
+ event_data['cn'] = []
+ event_detail = {}
+ for detail in detail_result['data']['list']:
+ event_detail[detail['ann_id']] = detail
+
+ datalist = result['data']['list']
+ for data in datalist:
+ for item in data['list']:
+ # 1 活动公告 2 游戏公告
+ if item['type'] == 2:
+ ignore = False
+ for ann_id in ignored_ann_ids:
+ if ann_id == item["ann_id"]:
+ ignore = True
+ break
+ if ignore:
+ continue
+
+ for keyword in ignored_key_words:
+ if keyword in item['title']:
+ ignore = True
+ break
+ if ignore:
+ continue
+
+ start_time = datetime.strptime(
+ item['start_time'], r"%Y-%m-%d %H:%M:%S")
+ end_time = datetime.strptime(
+ item['end_time'], r"%Y-%m-%d %H:%M:%S")
+
+ # 从正文中查找开始时间
+ if event_detail[item["ann_id"]]:
+ content = event_detail[item["ann_id"]]['content']
+ searchObj = re.search(
+ r'(\d+)\/(\d+)\/(\d+)\s(\d+):(\d+):(\d+)', content, re.M | re.I)
+ try:
+ datelist = searchObj.groups() # ('2021', '9', '17')
+ if datelist and len(datelist) >= 6:
+ ctime = datetime.strptime(
+ f'{datelist[0]}-{datelist[1]}-{datelist[2]} {datelist[3]}:{datelist[4]}:{datelist[5]}', r"%Y-%m-%d %H:%M:%S")
+ if start_time < ctime < end_time:
+ start_time = ctime
+ except Exception as e:
+ pass
+
+ event = {'title': item['title'],
+ 'start': start_time,
+ 'end': end_time,
+ 'forever': False,
+ 'type': 0}
+ if '任务' in item['title']:
+ event['forever'] = True
+ if item['type'] == 1:
+ event['type'] = 1
+ if '扭蛋' in item['tag_label']:
+ event['type'] = 2
+ if '倍' in item['title']:
+ event['type'] = 3
+ event_data['cn'].append(event)
+ # 深渊提醒
+ i = 0
+ while i < 2:
+ curmon = datetime.today() + relativedelta(months=i)
+ nextmon = curmon + relativedelta(months=1)
+ event_data['cn'].append({
+ 'title': '「深境螺旋」',
+ 'start': datetime.strptime(
+ curmon.strftime("%Y/%m/01 04:00"), r"%Y/%m/%d %H:%M"),
+ 'end': datetime.strptime(
+ curmon.strftime("%Y/%m/16 03:59"), r"%Y/%m/%d %H:%M"),
+ 'forever': False,
+ 'type': 3
+ })
+ event_data['cn'].append({
+ 'title': '「深境螺旋」',
+ 'start': datetime.strptime(
+ curmon.strftime("%Y/%m/16 04:00"), r"%Y/%m/%d %H:%M"),
+ 'end': datetime.strptime(
+ nextmon.strftime("%Y/%m/01 03:59"), r"%Y/%m/%d %H:%M"),
+ 'forever': False,
+ 'type': 3
+ })
+ i = i+1
+
+ return 0
+ return 1
+
+
+async def load_event(server):
+ if server == 'cn':
+ return await load_event_cn()
+ return 1
+
+
+def get_pcr_now(offset):
+ pcr_now = datetime.now()
+ if pcr_now.hour < 4:
+ pcr_now -= timedelta(days=1)
+ pcr_now = pcr_now.replace(
+ hour=18, minute=0, second=0, microsecond=0) # 用晚6点做基准
+ pcr_now = pcr_now + timedelta(days=offset)
+ return pcr_now
+
+
+async def get_events(server, offset, days):
+ events = []
+ pcr_now = datetime.now()
+ if pcr_now.hour < 4:
+ pcr_now -= timedelta(days=1)
+ pcr_now = pcr_now.replace(
+ hour=18, minute=0, second=0, microsecond=0) # 用晚6点做基准
+
+ await lock[server].acquire()
+ try:
+ t = pcr_now.strftime('%y%m%d')
+ if event_updated[server] != t:
+ if await load_event(server) == 0:
+ event_updated[server] = t
+ finally:
+ lock[server].release()
+
+ start = pcr_now + timedelta(days=offset)
+ end = start + timedelta(days=days)
+ end -= timedelta(hours=18) # 晚上12点结束
+
+ for event in event_data[server]:
+ if end > event['start'] and start < event['end']: # 在指定时间段内 已开始 且 未结束
+ event['start_days'] = math.ceil(
+ (event['start'] - start) / timedelta(days=1)) # 还有几天开始
+ event['left_days'] = math.floor(
+ (event['end'] - start) / timedelta(days=1)) # 还有几天结束
+ events.append(event)
+ # 按type从大到小 按剩余天数从小到大
+ events.sort(key=lambda item: item["type"]
+ * 100 - item['left_days'], reverse=True)
+ return events
+
+
+if __name__ == '__main__':
+ async def main():
+ await load_event_cn()
+
+ loop = asyncio.get_event_loop()
+ loop.run_until_complete(main())
diff --git a/utils/generate.py b/utils/generate.py
new file mode 100644
index 0000000..6699fe9
--- /dev/null
+++ b/utils/generate.py
@@ -0,0 +1,46 @@
+
+import base64
+from io import BytesIO
+from .event import *
+from .draw import *
+
+
+def im2base64str(im):
+ io = BytesIO()
+ im.save(io, 'png')
+ base64_str = f"base64://{base64.b64encode(io.getvalue()).decode()}"
+ return base64_str
+
+
+async def generate_day_schedule(server='cn'):
+ events = await get_events(server, 0, 15)
+
+ has_prediction = False
+ for event in events:
+ if event['start_days'] > 0:
+ has_prediction = True
+ if has_prediction:
+ im = create_image(len(events) + 2)
+ else:
+ im = create_image(len(events) + 1)
+
+ title = f'原神日历'
+ pcr_now = get_pcr_now(0)
+ draw_title(im, 0, title, pcr_now.strftime('%Y/%m/%d'), '正在进行')
+
+ if len(events) == 0:
+ draw_item(im, 1, 1, '无数据', 0, False)
+ i = 1
+ for event in events:
+ if event['start_days'] <= 0:
+ draw_item(im, i, event['type'], event['title'],
+ event['left_days'], event['forever'])
+ i += 1
+ if has_prediction:
+ draw_title(im, i, right='即将开始')
+ for event in events:
+ if event['start_days'] > 0:
+ i += 1
+ draw_item(im, i, event['type'], event['title'], -
+ event['start_days'], event['forever'])
+ return im