Skip to content

SJTU canvas日历

1. 插件简介

插件名称 父类 触发关键词 触发权限 内容
CanvasiCalBind StandardPlugin '-ics bind ${url}' None 绑定canvas ics链接
CanvasiCalUnbind StandardPlugin '-ics unbind' None 解绑canvas ics链接
GetCanvas StandardPlugin '-ddl' / '-canvas' None 获取canvas日历馈送

2. 示范样例

代码部分:

ROOT_ADMIN_ID = [111, # 用户A  
                 222, # 用户B
                 444] # 用户D
GroupPluginList = [ # 群聊启用插件
    # .....
    PluginGroupManager([GetCanvas(), CanvasiCalBind(), CanvasiCalUnbind()], 'canvas'), # 日历馈送
    # .....
]
PrivatePluginList=[ # 私聊启用插件
    # .....
    GetCanvas(), CanvasiCalBind(), CanvasiCalUnbind()
    # .....
]

群聊部分:

# 群内尚未打开canvas
333> -ddl
# 无事发生

# 接下来管理员打开canvas
111> -grpcfg enable canvas
bot> OK

# 此时用户C尚未绑定ics url
333> -ddl
bot>    查询失败
        请群聊或私聊使用-ics bind ${url} 绑定您的Canvas iCal馈送链接
        ${url}可在 canvas - 日历📅 - 日历馈送 中获取

# 用户C绑定canvas
333> -ics bind ${用户C的icsUrl}
bot> OK
333> -ddl
bot> ${333的canvas日历图片}

# 用户C解绑后查询
333> -ics unbind
bot> OK
333> -ddl
bot>    查询失败
        请群聊或私聊使用-ics bind ${url} 绑定您的Canvas iCal馈送链接
        ${url}可在 canvas - 日历📅 - 日历馈送 中获取

图片展示:

3. 代码分析

注意:旧的绘图代码

这是早期完成的插件,绘图部分代码并未使用推荐的 responseImage 库

from utils.standardPlugin import StandardPlugin, Any, Union
import requests
from utils.basicEvent import *
from utils.basicConfigs import *
from pathlib import Path
import json
from icalendar import Calendar
from datetime import datetime, timedelta
import re
import mysql.connector
class CanvasiCalUnbind(StandardPlugin):
    def judgeTrigger(self, msg: str, data: Any) -> bool:
        return msg.strip() == '-ics unbind'
    def executeEvent(self, msg: str, data: Any) -> Union[None, str]:
        target = data['group_id'] if data['message_type']=='group' else data['user_id']
        if unbind_ics(data['user_id']):
            send(target,"解绑成功", data['message_type'])
        else:
            send(target,"解绑失败", data['message_type'])
        return "OK"
    def getPluginInfo(self, )->Any:
        return {
            'name': 'CanvasiCalUnbind',
            'description': 'canvas日历解绑',
            'commandDescription': '-ics unbind',
            'usePlace': ['group', 'private'],
            'showInHelp': True,
            'pluginConfigTableNames': [],
            'version': '1.0.0',
            'author': 'Unicorn',
        }
class CanvasiCalBind(StandardPlugin): 
    def __init__(self) -> None:
        # 外部服务,防止sql注入、url注入
        self.urlRegex = re.compile(r'https://(oc.sjtu.edu.cn|jicanvas.com)/feeds/calendars/user_[a-zA-Z0-9]{40}.ics')
        # 检查sql是否开了BOT_DATA.canvasIcs
        try:
            mydb = mysql.connector.connect(**sqlConfig)
            mycursor = mydb.cursor()
            mycursor.execute("""create table if not exists `BOT_DATA`.`canvasIcs` (
                `qq` bigint not null,
                `icsUrl` char(128) not null,
                primary key (`qq`)
            );""")
        except BaseException as e:
            warning('canvas ics 无法连接至数据库, error: {}'.format(e))
    def judgeTrigger(self, msg:str, data:Any) -> bool:
        return startswith_in(msg, ['-ics bind '])
    def executeEvent(self, msg:str, data:Any) -> Union[None, str]:
        msg=msg.replace('-ics bind','',1).strip()
        target = data['group_id'] if data['message_type']=='group' else data['user_id']
        if self.urlRegex.match(msg) == None or len(msg) > 110:
            send(target,'格式错误,请检查ics链接符合格式:\n'+
                r're.compile(r"https://(oc.sjtu.edu.cn|jicanvas.com)/feeds/calendars/user_[a-zA-Z0-9]{40}.ics")'+
                "\n【已做防注入处理】", data['message_type'])
        else:
            if edit_bind_ics(data['user_id'], msg):
                send(target,"绑定成功", data['message_type'])
            else:
                send(target,"绑定失败", data['message_type'])
        return "OK"
    def getPluginInfo(self, )->Any:
        return {
            'name': 'CanvasiCalBind',
            'description': 'canvas日历绑定',
            'commandDescription': '-ics bind [url]',
            'usePlace': ['group', 'private'],
            'showInHelp': True,
            'pluginConfigTableNames': [],
            'version': '1.0.0',
            'author': 'Unicorn',
        }
class GetCanvas(StandardPlugin): 
    def judgeTrigger(self, msg:str, data:Any) -> bool:
        return msg.strip() in ['-ddl', '-canvas']
    def executeEvent(self, msg:str, data:Any) -> Union[None, str]:
        ret = getCanvas(data['user_id'])
        target = data['group_id'] if data['message_type']=='group' else data['user_id']
        if not ret[0]:
            send(target ,ret[1], data['message_type'])
        else:
            canvasPicPath = os.path.join(ROOT_PATH, ret[1])
            send(target,f'[CQ:image,file=files://{canvasPicPath},id=40000]', data['message_type'])
        return "OK"
    def getPluginInfo(self, )->Any:
        return {
            'name': 'GetCanvas',
            'description': 'canvas活动查询',
            'commandDescription': '-ddl/-canvas',
            'usePlace': ['group', 'private', ],
            'showInHelp': True,
            'pluginConfigTableNames': ['canvasIcs'],
            'version': '1.0.0',
            'author': 'Unicorn',
        }
def unbind_ics(qq_id: Union[int, str])->bool:
    if isinstance(qq_id, str):
        qq_id = int(qq_id)
    try:
        mydb = mysql.connector.connect(**sqlConfig)
        mycursor = mydb.cursor()
        mycursor.execute("delete from `BOT_DATA`.`canvasIcs` where qq=%d"%(qq_id))
        mydb.commit()
        return True
    except BaseException as e:
        warning("error in canvasSync, error: {}".format(e))
        return False

def edit_bind_ics(qq_id: Union[int, str], ics_url: str)->bool:
    if isinstance(qq_id, str):
        qq_id = int(qq_id)
    try:
        mydb = mysql.connector.connect(**sqlConfig)
        mycursor = mydb.cursor()
        mycursor.execute("replace into `BOT_DATA`.`canvasIcs` values (%d, '%s')"%(qq_id, escape_string(ics_url)))
        mydb.commit()
        return True
    except BaseException as e:
        warning("error in canvasSync, error: {}".format(e))
        return False

FAIL_REASON_1="请群聊或私聊使用-ics bind ${url} 绑定您的Canvas iCal馈送链接\n ${url}可在 canvas - 日历📅 - 日历馈送 中获取"
FAIL_REASON_2="无法获取或解析日历文件"

def getCanvas(qq_id) -> Tuple[bool, str]:
    if isinstance(qq_id, str):
        qq_id = int(qq_id)
    try:
        mydb = mysql.connector.connect(**sqlConfig)
        mycursor = mydb.cursor()
        mycursor.execute("select icsUrl from `BOT_DATA`.`canvasIcs` where qq=%d"%(qq_id))
        urls = list(mycursor)
        if len(urls) == 0:
            return False, f"查询失败\n{FAIL_REASON_1}"
        else:
            url = urls[0][0]
    except BaseException as e:
        warning("error in canvasSync, error: {}".format(e))
    qq_id=str(qq_id)
    try:
        ret = requests.get(url=url)
        data = ret.content
        gcal = Calendar.from_ical(data)
        event_list = []
        for component in gcal.walk():
            if component.name == "VEVENT":
                now = time.localtime()
                ddl_time = component.get('dtend').dt
                if not isinstance(ddl_time,datetime):
                    tmp=datetime.strftime(ddl_time,"%Y-%m-%d")+" 23:59:59"
                    ddl_time = datetime.strptime(tmp,"%Y-%m-%d %H:%M:%S")
                else:
                    ddl_time+=timedelta(hours=8)
                    tmp = datetime.strftime(ddl_time, "%Y-%m-%d %H:%M:%S")
                if time.mktime(time.strptime(tmp, "%Y-%m-%d %H:%M:%S")) < time.mktime(now):
                    continue
                ddl_time = datetime.strftime(ddl_time,"%Y-%m-%d %H:%M")
                event_list.append([component.get('summary'), component.get('description'), ddl_time])
        return True, DrawEventListPic(event_list, qq_id)
    except Exception as e:
        print(e)
        return False, f"查询失败\n{FAIL_REASON_2}"

def DrawEventListPic(event_list, qq_id):
    proceed_list = []
    width=880
    h_title, h_des = 0, 0
    for summary, description, deadline in event_list[:10]:
        # print(description.replace('\n','-sep-'))
        if description==None:
            description = ''
        h_block=0 #块内动态高度
        txt_line=""
        title_parse=[]
        summary = summary.replace('[本-', '\n[本-')
        for word in summary:
            if txt_line=="" and word in [',',';','。','、','"',':','.','”']: #避免标点符号在首位
                title_parse[-1]+=word
                continue
            txt_line+=word
            if font_syhtmed_24.getsize(txt_line)[0]>width-180:
                title_parse.append(txt_line)
                txt_line=""
            if word=='\n':
                title_parse.append(txt_line)
                txt_line=""
                continue
        if txt_line!="":
            title_parse.append(txt_line)
        h_title+=len(title_parse)*33

        txt_line=""
        description_parse=[]
        description_re = re.sub('\n+','\n', description)
        description_re = description_re.replace('\xa0','')
        for word in description_re:
            if txt_line=="" and word in [',',';','。','、','"',':','.','”']: #避免标点符号在首位
                description_parse[-1]+=word
                continue
            txt_line+=word
            if font_syhtmed_18.getsize(txt_line)[0]>width-180:
                description_parse.append(txt_line)
                txt_line=""
            if word=='\n':
                description_parse.append(txt_line)
                txt_line=""
                continue
        if txt_line!="":
            description_parse.append(txt_line)

        h_des+=len(description_parse)*27
        # print(description_parse)
        h_block+=(len(title_parse)*33+len(description_parse)*27)
        if len(description_parse)==0:
            h_des-=15
            h_block-=15
        proceed_list.append([title_parse, description_parse, deadline, h_block])

    height=len(event_list[:10])*150+190+(240 if len(event_list)==0 else 0)+h_title+h_des+(40 if len(event_list)>10 else 0)
    img, draw, h = init_image_template('Canvas 日历馈送', width, height, (0, 142, 226, 255))
    h+=130
    for title, description, deadline, h_block in proceed_list:
        img = draw_rounded_rectangle(img, x1=60, y1=h, x2=width-60 ,y2=h+120+h_block, fill=(255,255,255,255))
        l = 90
        t = h+30 # 距 块顶 坐标
        for line in title:
            draw.text((l,t), line, fill=(0,0,0,255),font = font_syhtmed_24)
            t+=33
        t+=17
        draw.text((l,t), '结束时间:'+deadline, fill=(0, 142, 226, 255),font = font_syhtmed_24)
        t+=50
        for line in description:
            draw.text((l,t), line, fill=(115,115,115,255),font = font_syhtmed_18)
            t+=27

        h+=(140+h_block)
    if len(event_list)==0:
        img = draw_rounded_rectangle(img, x1=60, y1=h, x2=width-60 ,y2=h+210, fill=(255,255,255,255))
        txt_size=draw.textsize('恭喜你,没有即将到期的ddl~', font = font_syhtmed_32)
        draw.text(((width-txt_size[0])/2+5,h+80), '恭喜你,没有即将到期的ddl~', fill=(0,0,0,255),font = font_syhtmed_32)

    if len(event_list)>10:
        txt_size = draw.textsize('日历项太多啦,只显示了前10条qwq',font=font_syhtmed_18)
        draw.text((width/2-txt_size[0]/2, height-85),'日历项太多啦,只显示了前10条qwq', fill=(115,115,115,255) ,font = font_syhtmed_18) 

    save_path= os.path.join(SAVE_TMP_PATH, f'{qq_id}_canvas.png')
    img.save(save_path)
    return save_path