Taka005 commited on
Commit ·
0d3f763
1
Parent(s): 926c883
完成
Browse files- base-gd-4.png +0 -3
- base-gd-5.png +0 -3
- base-gd.png +2 -2
- base-w.png → base.png +0 -0
- bot.py +0 -444
- base-gd-2.png → icon.png +2 -2
- main.py +112 -0
- modules/emojistore.py +0 -143
- base-gd-3.png → quote.png +2 -2
base-gd-4.png
DELETED
Git LFS Details
|
base-gd-5.png
DELETED
Git LFS Details
|
base-gd.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
base-w.png → base.png
RENAMED
|
File without changes
|
bot.py
DELETED
|
@@ -1,444 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from misskey import Misskey, NoteVisibility
|
| 3 |
-
import websockets
|
| 4 |
-
import asyncio, aiohttp
|
| 5 |
-
import json
|
| 6 |
-
import datetime
|
| 7 |
-
import sys
|
| 8 |
-
import traceback
|
| 9 |
-
import re
|
| 10 |
-
import math
|
| 11 |
-
import time
|
| 12 |
-
import textwrap
|
| 13 |
-
import requests
|
| 14 |
-
|
| 15 |
-
try:
|
| 16 |
-
import config_my as config
|
| 17 |
-
except ImportError:
|
| 18 |
-
import config
|
| 19 |
-
|
| 20 |
-
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
|
| 21 |
-
from pilmoji import Pilmoji
|
| 22 |
-
from io import BytesIO
|
| 23 |
-
from modules.emojistore import EmojiStore
|
| 24 |
-
import sqlite3
|
| 25 |
-
|
| 26 |
-
logging.getLogger("websockets").setLevel(logging.INFO)
|
| 27 |
-
logging.getLogger("PIL.Image").setLevel(logging.ERROR)
|
| 28 |
-
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
|
| 29 |
-
|
| 30 |
-
WS_URL = f'wss://{config.MISSKEY_INSTANCE}/streaming?i={config.MISSKEY_TOKEN}'
|
| 31 |
-
|
| 32 |
-
MISSKEY_EMOJI_REGEX = re.compile(r':([a-zA-Z0-9_]+)(?:@?)(|[a-zA-Z0-9\.-]+):')
|
| 33 |
-
|
| 34 |
-
_tmp_cli = Misskey(config.MISSKEY_INSTANCE, i=config.MISSKEY_TOKEN)
|
| 35 |
-
i = _tmp_cli.i()
|
| 36 |
-
|
| 37 |
-
eStore = EmojiStore(sqlite3.connect('emoji_cache.db'))
|
| 38 |
-
|
| 39 |
-
session = requests.Session()
|
| 40 |
-
session.headers.update({
|
| 41 |
-
'User-Agent': f'Mozilla/5.0 (Linux; x64; Misskey Bot; {i["id"]}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36'
|
| 42 |
-
})
|
| 43 |
-
|
| 44 |
-
msk = Misskey(config.MISSKEY_INSTANCE, i=config.MISSKEY_TOKEN, session=session)
|
| 45 |
-
|
| 46 |
-
MY_ID = i['id']
|
| 47 |
-
ACCT = f'@{i["username"]}'
|
| 48 |
-
print('Bot user id: ' + MY_ID)
|
| 49 |
-
|
| 50 |
-
BASE_GRADATION_IMAGE = Image.open('base-gd-5.png')
|
| 51 |
-
BASE_WHITE_IMAGE = Image.open('base-w.png')
|
| 52 |
-
|
| 53 |
-
FONT_FILE = 'fonts/MPLUSRounded1c-Regular.ttf'
|
| 54 |
-
FONT_FILE_SERIF = 'fonts/NotoSerifJP-Regular.otf'
|
| 55 |
-
FONT_FILE_OLD_JAPANESE = 'fonts/YujiSyuku-Regular.ttf'
|
| 56 |
-
FONT_FILE_POP = 'fonts/MochiyPopPOne-Regular.ttf'
|
| 57 |
-
|
| 58 |
-
#MPLUS_FONT_TEXT = ImageFont.truetype(FONT_FILE, size=45)
|
| 59 |
-
#MPLUS_FONT_NAME = ImageFont.truetype(FONT_FILE, size=30)
|
| 60 |
-
MPLUS_FONT_16 = ImageFont.truetype('fonts/MPLUSRounded1c-Regular.ttf', size=16)
|
| 61 |
-
|
| 62 |
-
session = aiohttp.ClientSession()
|
| 63 |
-
|
| 64 |
-
default_format = '%(asctime)s:%(name)s: %(levelname)s:%(message)s'
|
| 65 |
-
|
| 66 |
-
logging.basicConfig(level=logging.DEBUG, filename='debug.log', encoding='utf-8', format=default_format)
|
| 67 |
-
# also write log to stdout
|
| 68 |
-
stdout_handler = logging.StreamHandler(sys.stdout)
|
| 69 |
-
stdout_handler.setLevel(logging.DEBUG)
|
| 70 |
-
stdout_handler.setFormatter(logging.Formatter(default_format))
|
| 71 |
-
logging.getLogger().addHandler(stdout_handler)
|
| 72 |
-
|
| 73 |
-
logger = logging.getLogger('miq-fedi')
|
| 74 |
-
logger.info('Starting')
|
| 75 |
-
def parse_misskey_emoji(host, tx):
|
| 76 |
-
emojis = []
|
| 77 |
-
for emoji in MISSKEY_EMOJI_REGEX.findall(tx):
|
| 78 |
-
h = emoji[1] or host
|
| 79 |
-
if h == '.':
|
| 80 |
-
h = host
|
| 81 |
-
e = eStore.get(h, emoji[0])
|
| 82 |
-
if e:
|
| 83 |
-
emojis.append(e)
|
| 84 |
-
return emojis
|
| 85 |
-
|
| 86 |
-
def remove_mentions(text, mymention):
|
| 87 |
-
mentions = sorted(re.findall(r'(@[a-zA-Z0-9_@\.]+)', text), key=lambda x: len(x), reverse=True)
|
| 88 |
-
|
| 89 |
-
for m in mentions:
|
| 90 |
-
if m == mymention:
|
| 91 |
-
continue
|
| 92 |
-
else:
|
| 93 |
-
text = text.replace(m, '')
|
| 94 |
-
|
| 95 |
-
return text
|
| 96 |
-
|
| 97 |
-
def draw_text(im, ofs, string, font='fonts/MPLUSRounded1c-Regular.ttf', size=16, color=(0,0,0,255), split_len=None, padding=4, auto_expand=False, emojis: list = [], disable_dot_wrap=False):
|
| 98 |
-
|
| 99 |
-
draw = ImageDraw.Draw(im)
|
| 100 |
-
fontObj = ImageFont.truetype(font, size=size)
|
| 101 |
-
|
| 102 |
-
# 改行、句読点(。、.,)で分割した後にさらにワードラップを行う
|
| 103 |
-
pure_lines = []
|
| 104 |
-
pos = 0
|
| 105 |
-
l = ''
|
| 106 |
-
|
| 107 |
-
if not disable_dot_wrap:
|
| 108 |
-
for char in string:
|
| 109 |
-
if char == '\n':
|
| 110 |
-
pure_lines.append(l)
|
| 111 |
-
l = ''
|
| 112 |
-
pos += 1
|
| 113 |
-
elif char == '、' or char == ',':
|
| 114 |
-
pure_lines.append(l + ('、' if char == '、' else ','))
|
| 115 |
-
l = ''
|
| 116 |
-
pos += 1
|
| 117 |
-
elif char == '。' or char == '.':
|
| 118 |
-
pure_lines.append(l + ('。' if char == '。' else '.'))
|
| 119 |
-
l = ''
|
| 120 |
-
pos += 1
|
| 121 |
-
else:
|
| 122 |
-
l += char
|
| 123 |
-
pos += 1
|
| 124 |
-
|
| 125 |
-
if l:
|
| 126 |
-
pure_lines.append(l)
|
| 127 |
-
else:
|
| 128 |
-
pure_lines = string.split('\n')
|
| 129 |
-
|
| 130 |
-
lines = []
|
| 131 |
-
|
| 132 |
-
for line in pure_lines:
|
| 133 |
-
lines.extend(textwrap.wrap(line, width=split_len))
|
| 134 |
-
|
| 135 |
-
dy = 0
|
| 136 |
-
|
| 137 |
-
draw_lines = []
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
# 計算
|
| 141 |
-
for line in lines:
|
| 142 |
-
tsize = fontObj.getsize(line)
|
| 143 |
-
|
| 144 |
-
ofs_y = ofs[1] + dy
|
| 145 |
-
t_height = tsize[1]
|
| 146 |
-
|
| 147 |
-
x = int(ofs[0] - (tsize[0]/2))
|
| 148 |
-
#draw.text((x, ofs_y), t, font=fontObj, fill=color)
|
| 149 |
-
draw_lines.append((x, ofs_y, line))
|
| 150 |
-
ofs_y += t_height + padding
|
| 151 |
-
dy += t_height + padding
|
| 152 |
-
|
| 153 |
-
# 描画
|
| 154 |
-
adj_y = -30 * (len(draw_lines)-1)
|
| 155 |
-
for dl in draw_lines:
|
| 156 |
-
with Pilmoji(im) as p:
|
| 157 |
-
p.text((dl[0], (adj_y + dl[1])), dl[2], font=fontObj, fill=color, emojis=emojis, emoji_position_offset=(-4, 4))
|
| 158 |
-
|
| 159 |
-
real_y = ofs[1] + adj_y + dy
|
| 160 |
-
|
| 161 |
-
return (0, dy, real_y)
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
receivedNotes = set()
|
| 165 |
-
|
| 166 |
-
async def on_post_note(note):
|
| 167 |
-
pass
|
| 168 |
-
|
| 169 |
-
async def on_mention(note):
|
| 170 |
-
# HTLとGTLを監視している都合上重複する恐れがあるため
|
| 171 |
-
if note['id'] in receivedNotes:
|
| 172 |
-
return
|
| 173 |
-
|
| 174 |
-
receivedNotes.add(note['id'])
|
| 175 |
-
|
| 176 |
-
command = False
|
| 177 |
-
|
| 178 |
-
childLogger = logger.getChild(note["id"])
|
| 179 |
-
|
| 180 |
-
forceRun = '/make' in note['text']
|
| 181 |
-
if forceRun:
|
| 182 |
-
childLogger.info('forceRun enabled')
|
| 183 |
-
|
| 184 |
-
# 他のメンション取り除く
|
| 185 |
-
split_text = note['text'].split(' ')
|
| 186 |
-
new_st = []
|
| 187 |
-
|
| 188 |
-
note['text'] = remove_mentions(note['text'], ACCT)
|
| 189 |
-
|
| 190 |
-
if (note['text'].strip() == '') and (not forceRun):
|
| 191 |
-
childLogger.info('text is empty, ignoring')
|
| 192 |
-
return
|
| 193 |
-
|
| 194 |
-
try:
|
| 195 |
-
content = note['text'].strip().split(' ', 1)[1].strip()
|
| 196 |
-
command = True
|
| 197 |
-
except IndexError:
|
| 198 |
-
logger.getChild(f'{note["id"]}').info('no command found, ignoring')
|
| 199 |
-
pass
|
| 200 |
-
|
| 201 |
-
# メンションだけされた?
|
| 202 |
-
if note.get('reply'):
|
| 203 |
-
|
| 204 |
-
reply_note = note['reply']
|
| 205 |
-
|
| 206 |
-
# ボットの投稿への返信の場合は応答しない
|
| 207 |
-
if reply_note['user']['id'] == MY_ID:
|
| 208 |
-
childLogger.info('this is reply to myself, ignoring')
|
| 209 |
-
return
|
| 210 |
-
|
| 211 |
-
reply_note['text'] = remove_mentions(reply_note['text'], None)
|
| 212 |
-
|
| 213 |
-
if not reply_note['text'].strip():
|
| 214 |
-
childLogger.info('reply text is empty, ignoring')
|
| 215 |
-
return
|
| 216 |
-
|
| 217 |
-
if reply_note['cw']:
|
| 218 |
-
reply_note['text'] = reply_note['cw'] + '\n' + reply_note['text']
|
| 219 |
-
|
| 220 |
-
username = note["user"]["name"] or note["user"]["username"]
|
| 221 |
-
|
| 222 |
-
target_user = msk.users_show(reply_note['user']['id'])
|
| 223 |
-
|
| 224 |
-
if '#noquote' in target_user.get('description', ''):
|
| 225 |
-
childLogger.info(f'{reply_note["user"]["id"]} does not allow quoting, rejecting')
|
| 226 |
-
msk.notes_create(text='このユーザーは引用を許可していません\nThis user does not allow quoting.', reply_id=note['id'])
|
| 227 |
-
return
|
| 228 |
-
|
| 229 |
-
if not (reply_note['visibility'] in ['public', 'home']):
|
| 230 |
-
childLogger.info('visibility is not public, rejecting')
|
| 231 |
-
msk.notes_create(text='この投稿はプライベートであるため、処理できません。\nThis post is private and cannot be processed.', reply_id=note['id'])
|
| 232 |
-
return
|
| 233 |
-
|
| 234 |
-
# 引用する
|
| 235 |
-
img = BASE_WHITE_IMAGE.copy()
|
| 236 |
-
# アイコン画像ダウンロード
|
| 237 |
-
if not reply_note['user'].get('avatarUrl'):
|
| 238 |
-
childLogger.info('user has no avatar, rejecting')
|
| 239 |
-
msk.notes_create(text='アイコン画像がないので作れません\nWe can\'t continue because user has no avatar.', reply_id=note['id'])
|
| 240 |
-
return
|
| 241 |
-
|
| 242 |
-
childLogger.info('downloading avatar image( ' + reply_note['user']['avatarUrl'] + ' )')
|
| 243 |
-
|
| 244 |
-
async with session.get(reply_note['user']['avatarUrl']) as resp:
|
| 245 |
-
if resp.status != 200:
|
| 246 |
-
msk.notes_create(text='アイコン画像ダウンロードに失敗しました\nFailed to download avatar image.', reply_id=note['id'])
|
| 247 |
-
return
|
| 248 |
-
avatar = await resp.read()
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
childLogger.info('avatar image downloaded')
|
| 252 |
-
childLogger.info('generating image')
|
| 253 |
-
|
| 254 |
-
icon = Image.open(BytesIO(avatar))
|
| 255 |
-
icon = icon.resize((720, 720), Image.ANTIALIAS)
|
| 256 |
-
icon = icon.convert('L') # グレースケール変換
|
| 257 |
-
icon_filtered = ImageEnhance.Brightness(icon)
|
| 258 |
-
|
| 259 |
-
img.paste(icon_filtered.enhance(0.7), (0,0))
|
| 260 |
-
|
| 261 |
-
# 黒グラデ合成
|
| 262 |
-
img.paste(BASE_GRADATION_IMAGE, (0,0), BASE_GRADATION_IMAGE)
|
| 263 |
-
|
| 264 |
-
# テキスト合成
|
| 265 |
-
tx = ImageDraw.Draw(img)
|
| 266 |
-
|
| 267 |
-
base_x = 890
|
| 268 |
-
|
| 269 |
-
font_path = FONT_FILE
|
| 270 |
-
|
| 271 |
-
if '%serif' in note['text']:
|
| 272 |
-
font_path = FONT_FILE_SERIF
|
| 273 |
-
elif '%pop' in note['text']:
|
| 274 |
-
font_path = FONT_FILE_POP
|
| 275 |
-
elif '%oldjp' in note['text']:
|
| 276 |
-
font_path = FONT_FILE_OLD_JAPANESE
|
| 277 |
-
|
| 278 |
-
# 文章描画
|
| 279 |
-
emojis = parse_misskey_emoji(config.MISSKEY_INSTANCE, reply_note['text'])
|
| 280 |
-
tsize_t = draw_text(img, (base_x, 270), note['reply']['text'], font=font_path, size=45, color=(255,255,255,255), split_len=16, auto_expand=True, emojis=emojis)
|
| 281 |
-
|
| 282 |
-
# 名前描画
|
| 283 |
-
uname = reply_note['user']['name'] or reply_note['user']['username']
|
| 284 |
-
name_y = tsize_t[2] + 40
|
| 285 |
-
user_emojis = parse_misskey_emoji(config.MISSKEY_INSTANCE, uname)
|
| 286 |
-
tsize_name = draw_text(img, (base_x, name_y), uname, font=font_path, size=25, color=(255,255,255,255), split_len=25, emojis=user_emojis, disable_dot_wrap=True)
|
| 287 |
-
|
| 288 |
-
# ID描画
|
| 289 |
-
id = reply_note['user']['username']
|
| 290 |
-
id_y = name_y + tsize_name[1] + 4
|
| 291 |
-
tsize_id = draw_text(img, (base_x, id_y), f'(@{id}@{reply_note["user"]["host"] or config.MISSKEY_INSTANCE})', font=font_path, size=18, color=(180,180,180,255), split_len=45, disable_dot_wrap=True)
|
| 292 |
-
|
| 293 |
-
# クレジット
|
| 294 |
-
tx.text((980, 694), '<Make it a quote for Fedi> by CyberRex', font=MPLUS_FONT_16, fill=(120,120,120,255))
|
| 295 |
-
|
| 296 |
-
childLogger.info('image generated')
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
# ドライブにアップロード
|
| 300 |
-
childLogger.info('uploading image')
|
| 301 |
-
try:
|
| 302 |
-
data = BytesIO()
|
| 303 |
-
img.save(data, format='JPEG')
|
| 304 |
-
data.seek(0)
|
| 305 |
-
for i in range(5):
|
| 306 |
-
try:
|
| 307 |
-
f = msk.drive_files_create(file=data, name=f'{datetime.datetime.utcnow().timestamp()}.jpg')
|
| 308 |
-
msk.drive_files_update(file_id=f['id'], comment=f'"{reply_note["text"][:400]}" —{reply_note["user"]["name"]}')
|
| 309 |
-
except:
|
| 310 |
-
childLogger.info('upload failed, retrying (attempt ' + str(i) + ')')
|
| 311 |
-
continue
|
| 312 |
-
break
|
| 313 |
-
else:
|
| 314 |
-
childLogger.error('upload failed')
|
| 315 |
-
raise Exception('Image upload failed.')
|
| 316 |
-
except Exception as e:
|
| 317 |
-
childLogger.error('upload failed')
|
| 318 |
-
childLogger.error(traceback.format_exc())
|
| 319 |
-
if 'INTERNAL_ERROR' in str(e):
|
| 320 |
-
msk.notes_create('Internal Error occured in Misskey!', reply_id=note['id'])
|
| 321 |
-
return
|
| 322 |
-
if 'RATE_LIMIT_EXCEEDED' in str(e):
|
| 323 |
-
msk.notes_create('利用殺到による一時的なAPI制限が発生しました。しばらく時間を置いてから再度お試しください。\nA temporary API restriction has occurred due to overwhelming usage. Please wait for a while and try again.', reply_id=note['id'])
|
| 324 |
-
return
|
| 325 |
-
if 'YOU_HAVE_BEEN_BLOCKED' in str(e):
|
| 326 |
-
msk.notes_create(f'@{note["user"]["username"]}@{note["user"]["host"] or config.MISSKEY_INSTANCE}\n引用元のユーザーからブロックされています。\nI am blocked by the user who posted the original post.', reply_id=note['id'])
|
| 327 |
-
return
|
| 328 |
-
msk.notes_create('画像アップロードに失敗しました\nFailed to upload image.\n```plaintext\n' + traceback.format_exc() + '\n```', reply_id=note['id'])
|
| 329 |
-
return
|
| 330 |
-
|
| 331 |
-
childLogger.info('image uploaded')
|
| 332 |
-
childLogger.info('posting')
|
| 333 |
-
|
| 334 |
-
try:
|
| 335 |
-
msk.notes_create(text='.', file_ids=[f['id']], reply_id=note['id'])
|
| 336 |
-
except Exception as e:
|
| 337 |
-
childLogger.error('post failed')
|
| 338 |
-
childLogger.error(traceback.format_exc())
|
| 339 |
-
return
|
| 340 |
-
|
| 341 |
-
childLogger.info('Finshed')
|
| 342 |
-
|
| 343 |
-
return
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
if command:
|
| 347 |
-
|
| 348 |
-
if content == 'ping':
|
| 349 |
-
|
| 350 |
-
postdate = datetime.datetime.fromisoformat(note['createdAt'][:-1]).timestamp()
|
| 351 |
-
nowdate = datetime.datetime.utcnow().timestamp()
|
| 352 |
-
sa = nowdate - postdate
|
| 353 |
-
text = f'{sa*1000:.2f}ms'
|
| 354 |
-
msk.notes_create(text=text, reply_id=note['id'])
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
async def on_followed(user):
|
| 359 |
-
try:
|
| 360 |
-
msk.following_create(user['id'])
|
| 361 |
-
except:
|
| 362 |
-
pass
|
| 363 |
-
|
| 364 |
-
async def main():
|
| 365 |
-
|
| 366 |
-
logger.info(f'Connecting to {config.MISSKEY_INSTANCE}...')
|
| 367 |
-
async with websockets.connect(WS_URL) as ws:
|
| 368 |
-
reconnect_counter = 0
|
| 369 |
-
logger.info(f'Connected to {config.MISSKEY_INSTANCE}')
|
| 370 |
-
logger.info('Attemping to watching timeline...')
|
| 371 |
-
p = {
|
| 372 |
-
'type': 'connect',
|
| 373 |
-
'body': {
|
| 374 |
-
'channel': 'globalTimeline',
|
| 375 |
-
'id': 'GTL1'
|
| 376 |
-
}
|
| 377 |
-
}
|
| 378 |
-
await ws.send(json.dumps(p))
|
| 379 |
-
p = {
|
| 380 |
-
'type': 'connect',
|
| 381 |
-
'body': {
|
| 382 |
-
'channel': 'homeTimeline',
|
| 383 |
-
'id': 'HTL1'
|
| 384 |
-
}
|
| 385 |
-
}
|
| 386 |
-
await ws.send(json.dumps(p))
|
| 387 |
-
p = {
|
| 388 |
-
'type': 'connect',
|
| 389 |
-
'body': {
|
| 390 |
-
'channel': 'main'
|
| 391 |
-
}
|
| 392 |
-
}
|
| 393 |
-
await ws.send(json.dumps(p))
|
| 394 |
-
|
| 395 |
-
logger.info('Now watching timeline...')
|
| 396 |
-
while True:
|
| 397 |
-
data = await ws.recv()
|
| 398 |
-
j = json.loads(data)
|
| 399 |
-
# print(j)
|
| 400 |
-
|
| 401 |
-
if j['type'] == 'channel':
|
| 402 |
-
|
| 403 |
-
if j['body']['type'] == 'note':
|
| 404 |
-
note = j['body']['body']
|
| 405 |
-
try:
|
| 406 |
-
await on_post_note(note)
|
| 407 |
-
except Exception as e:
|
| 408 |
-
print(traceback.format_exc())
|
| 409 |
-
logger.error(traceback.format_exc())
|
| 410 |
-
continue
|
| 411 |
-
|
| 412 |
-
if j['body']['type'] == 'mention':
|
| 413 |
-
note = j['body']['body']
|
| 414 |
-
try:
|
| 415 |
-
await on_mention(note)
|
| 416 |
-
except Exception as e:
|
| 417 |
-
print(traceback.format_exc())
|
| 418 |
-
logger.error(traceback.format_exc())
|
| 419 |
-
continue
|
| 420 |
-
|
| 421 |
-
if j['body']['type'] == 'followed':
|
| 422 |
-
try:
|
| 423 |
-
await on_followed(j['body']['body'])
|
| 424 |
-
except Exception as e:
|
| 425 |
-
print(traceback.format_exc())
|
| 426 |
-
logger.error(traceback.format_exc())
|
| 427 |
-
continue
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
reconnect_counter = 0
|
| 431 |
-
|
| 432 |
-
while True:
|
| 433 |
-
try:
|
| 434 |
-
asyncio.get_event_loop().run_until_complete(main())
|
| 435 |
-
except KeyboardInterrupt:
|
| 436 |
-
break
|
| 437 |
-
except:
|
| 438 |
-
time.sleep(10)
|
| 439 |
-
reconnect_counter += 1
|
| 440 |
-
logger.warning('Disconnected from WebSocket. Reconnecting...')
|
| 441 |
-
if reconnect_counter > 10:
|
| 442 |
-
logger.critical('Too many reconnects. Exiting.')
|
| 443 |
-
sys.exit(1)
|
| 444 |
-
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
base-gd-2.png → icon.png
RENAMED
|
File without changes
|
main.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
|
| 2 |
+
from pilmoji import Pilmoji
|
| 3 |
+
import textwrap
|
| 4 |
+
|
| 5 |
+
BASE_GRADATION_IMAGE = Image.open('base-gd.png')
|
| 6 |
+
BASE_WHITE_IMAGE = Image.open('base.png')
|
| 7 |
+
|
| 8 |
+
ICON = 'icon.png'
|
| 9 |
+
|
| 10 |
+
MPLUS_FONT_16 = ImageFont.truetype('fonts/MPLUSRounded1c-Regular.ttf', size=16)
|
| 11 |
+
|
| 12 |
+
def draw_text(im, ofs, string, font='fonts/MPLUSRounded1c-Regular.ttf', size=16, color=(0,0,0,255), split_len=None, padding=4, auto_expand=False, emojis: list = [], disable_dot_wrap=False):
|
| 13 |
+
|
| 14 |
+
draw = ImageDraw.Draw(im)
|
| 15 |
+
fontObj = ImageFont.truetype(font, size=size)
|
| 16 |
+
|
| 17 |
+
# 改行、句読点(。、.,)で分割した後にさらにワードラップを行う
|
| 18 |
+
pure_lines = []
|
| 19 |
+
pos = 0
|
| 20 |
+
l = ''
|
| 21 |
+
|
| 22 |
+
if not disable_dot_wrap:
|
| 23 |
+
for char in string:
|
| 24 |
+
if char == '\n':
|
| 25 |
+
pure_lines.append(l)
|
| 26 |
+
l = ''
|
| 27 |
+
pos += 1
|
| 28 |
+
elif char == '、' or char == ',':
|
| 29 |
+
pure_lines.append(l + ('、' if char == '、' else ','))
|
| 30 |
+
l = ''
|
| 31 |
+
pos += 1
|
| 32 |
+
elif char == '。' or char == '.':
|
| 33 |
+
pure_lines.append(l + ('。' if char == '。' else '.'))
|
| 34 |
+
l = ''
|
| 35 |
+
pos += 1
|
| 36 |
+
else:
|
| 37 |
+
l += char
|
| 38 |
+
pos += 1
|
| 39 |
+
|
| 40 |
+
if l:
|
| 41 |
+
pure_lines.append(l)
|
| 42 |
+
else:
|
| 43 |
+
pure_lines = string.split('\n')
|
| 44 |
+
|
| 45 |
+
lines = []
|
| 46 |
+
|
| 47 |
+
for line in pure_lines:
|
| 48 |
+
lines.extend(textwrap.wrap(line, width=split_len))
|
| 49 |
+
|
| 50 |
+
dy = 0
|
| 51 |
+
|
| 52 |
+
draw_lines = []
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# 計算
|
| 56 |
+
for line in lines:
|
| 57 |
+
tsize = fontObj.getsize(line)
|
| 58 |
+
|
| 59 |
+
ofs_y = ofs[1] + dy
|
| 60 |
+
t_height = tsize[1]
|
| 61 |
+
|
| 62 |
+
x = int(ofs[0] - (tsize[0]/2))
|
| 63 |
+
draw_lines.append((x, ofs_y, line))
|
| 64 |
+
ofs_y += t_height + padding
|
| 65 |
+
dy += t_height + padding
|
| 66 |
+
|
| 67 |
+
# 描画
|
| 68 |
+
adj_y = -30 * (len(draw_lines)-1)
|
| 69 |
+
for dl in draw_lines:
|
| 70 |
+
with Pilmoji(im) as p:
|
| 71 |
+
p.text((dl[0], (adj_y + dl[1])), dl[2], font=fontObj, fill=color, emojis=emojis, emoji_position_offset=(-4, 4))
|
| 72 |
+
|
| 73 |
+
real_y = ofs[1] + adj_y + dy
|
| 74 |
+
|
| 75 |
+
return (0, dy, real_y)
|
| 76 |
+
|
| 77 |
+
content = "これってなんですかね?知らないんですけどwwww でも結局はあれだよね"
|
| 78 |
+
# 引用する
|
| 79 |
+
img = BASE_WHITE_IMAGE.copy()
|
| 80 |
+
|
| 81 |
+
icon = Image.open(ICON)
|
| 82 |
+
icon = icon.resize((720, 720), Image.ANTIALIAS)
|
| 83 |
+
icon = icon.convert('L')
|
| 84 |
+
icon_filtered = ImageEnhance.Brightness(icon)
|
| 85 |
+
|
| 86 |
+
img.paste(icon_filtered.enhance(0.7), (0,0))
|
| 87 |
+
|
| 88 |
+
# 黒グラデ合成
|
| 89 |
+
img.paste(BASE_GRADATION_IMAGE, (0,0), BASE_GRADATION_IMAGE)
|
| 90 |
+
|
| 91 |
+
# テキスト合成
|
| 92 |
+
tx = ImageDraw.Draw(img)
|
| 93 |
+
|
| 94 |
+
base_x = 890
|
| 95 |
+
|
| 96 |
+
# 文章描画
|
| 97 |
+
tsize_t = draw_text(img, (base_x, 270), content, size=45, color=(255,255,255,255), split_len=16, auto_expand=True)
|
| 98 |
+
|
| 99 |
+
# 名前描画
|
| 100 |
+
uname = 'Taka005#6668'
|
| 101 |
+
name_y = tsize_t[2] + 40
|
| 102 |
+
tsize_name = draw_text(img, (base_x, name_y), uname, size=25, color=(255,255,255,255), split_len=25, disable_dot_wrap=True)
|
| 103 |
+
|
| 104 |
+
# ID描画
|
| 105 |
+
id = '000000000000'
|
| 106 |
+
id_y = name_y + tsize_name[1] + 4
|
| 107 |
+
tsize_id = draw_text(img, (base_x, id_y), f'({id})', size=18, color=(180,180,180,255), split_len=45, disable_dot_wrap=True)
|
| 108 |
+
|
| 109 |
+
# クレジット
|
| 110 |
+
tx.text((1125, 694), 'TakasumiBOT#7189', font=MPLUS_FONT_16, fill=(120,120,120,255))
|
| 111 |
+
|
| 112 |
+
img.save('quote.png', quality=95)
|
modules/emojistore.py
DELETED
|
@@ -1,143 +0,0 @@
|
|
| 1 |
-
import orjson
|
| 2 |
-
import sqlite3
|
| 3 |
-
import requests
|
| 4 |
-
import time
|
| 5 |
-
import math
|
| 6 |
-
import logging
|
| 7 |
-
|
| 8 |
-
CACHE_EXPIRE_TIME = 60 * 60 * 12
|
| 9 |
-
|
| 10 |
-
logger = logging.getLogger('EmojiStore')
|
| 11 |
-
|
| 12 |
-
class EmojiStore:
|
| 13 |
-
|
| 14 |
-
def __init__(self, db, **kwargs):
|
| 15 |
-
self.db: sqlite3.Connection = db
|
| 16 |
-
self.db.row_factory = sqlite3.Row
|
| 17 |
-
cur = self.db.cursor()
|
| 18 |
-
cur.execute('CREATE TABLE IF NOT EXISTS emoji_cache(host TEXT, data TEXT, last_updated INTEGER)')
|
| 19 |
-
cur.close()
|
| 20 |
-
|
| 21 |
-
self.emoji_cache = {}
|
| 22 |
-
if kwargs.get('session'):
|
| 23 |
-
self.session = kwargs['session']
|
| 24 |
-
else:
|
| 25 |
-
self.session = requests.Session()
|
| 26 |
-
self.session.headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
|
| 27 |
-
|
| 28 |
-
def _generate_emoji_url(self, host, emoji: dict):
|
| 29 |
-
if emoji.get('url'):
|
| 30 |
-
return emoji['url']
|
| 31 |
-
else:
|
| 32 |
-
# v>=13?
|
| 33 |
-
return f'https://{host}/emoji/{emoji["name"]}.webp'
|
| 34 |
-
|
| 35 |
-
def _fetch_nodeinfo(self, host):
|
| 36 |
-
r = self.session.get(f'https://{host}/.well-known/nodeinfo')
|
| 37 |
-
if r.status_code != 200:
|
| 38 |
-
logger.getChild('fetch_nodeinfo').error(f'Failed to fetch nodeinfo for {host} (well-known/nodeinfo)')
|
| 39 |
-
raise Exception(f'Failed to fetch nodeinfo for {host}')
|
| 40 |
-
res = orjson.loads(r.content)
|
| 41 |
-
if res.get('links'):
|
| 42 |
-
for link in res['links']:
|
| 43 |
-
if link['rel'].endswith('nodeinfo.diaspora.software/ns/schema/2.0'):
|
| 44 |
-
r2 = self.session.get(link['href'])
|
| 45 |
-
if r2.status_code != 200:
|
| 46 |
-
logger.getChild('fetch_nodeinfo').error(f'Failed to fetch nodeinfo for {host} (nodeinfo)')
|
| 47 |
-
raise Exception(f'Failed to fetch nodeinfo for {host}')
|
| 48 |
-
return orjson.loads(r2.content)
|
| 49 |
-
logger.getChild('fetch_nodeinfo').error(f'Failed to fetch nodeinfo for {host}')
|
| 50 |
-
raise Exception(f'Failed to fetch nodeinfo for {host}')
|
| 51 |
-
|
| 52 |
-
def _fetch_emoji_data(self, host):
|
| 53 |
-
logger.getChild('fetch_emoji_data').info(f'Fetching emoji data for {host}')
|
| 54 |
-
try:
|
| 55 |
-
ni = self._fetch_nodeinfo(host)
|
| 56 |
-
r = self.session.post(f'https://{host}/api/meta', headers={'Content-Type': 'application/json'}, data=b'{}')
|
| 57 |
-
if r.status_code != 200 and r.status_code != 404:
|
| 58 |
-
logger.getChild('fetch_emoji_data').error(f'Failed to fetch emoji data for {host} (api/meta)')
|
| 59 |
-
raise Exception(f'Failed to fetch emoji data for {host}')
|
| 60 |
-
if r.status_code != 404:
|
| 61 |
-
meta = orjson.loads(r.content)
|
| 62 |
-
v = meta['version'].split('.')
|
| 63 |
-
# Misskey v13以降は別エンドポイントに問い合わせ
|
| 64 |
-
if ni['software']['name'] == 'misskey' and int(v[0]) >= 13:
|
| 65 |
-
r2 = self.session.post(f'https://{host}/api/emojis', headers={'Content-Type': 'application/json'}, data=b'{}')
|
| 66 |
-
if r2.status_code != 200:
|
| 67 |
-
logger.getChild('fetch_emoji_data').error(f'Failed to fetch emoji data for {host} (Misskey v13)')
|
| 68 |
-
raise Exception(f'Failed to fetch emoji data for {host} (Misskey v13)')
|
| 69 |
-
return orjson.loads(r2.content)['emojis']
|
| 70 |
-
else:
|
| 71 |
-
return meta['emojis']
|
| 72 |
-
else:
|
| 73 |
-
# Mastodon/Pleroma?
|
| 74 |
-
r3 = self.session.get(f'https://{host}/api/v1/custom_emojis')
|
| 75 |
-
if r3.status_code != 200:
|
| 76 |
-
logger.getChild('fetch_emoji_data').error(f'Failed to fetch emoji data for {host} (mastodon, pleroma)')
|
| 77 |
-
raise Exception(f'Failed to fetch emoji data for {host} (mastodon, pleroma)')
|
| 78 |
-
res = orjson.loads(r3.content)
|
| 79 |
-
# Misskey形式に変換
|
| 80 |
-
return [{'name': x['shortcode'], 'url': x['static_url'], 'aliases': [''], 'category': ''} for x in res]
|
| 81 |
-
except:
|
| 82 |
-
return []
|
| 83 |
-
|
| 84 |
-
def _download(self, host):
|
| 85 |
-
emoji_data = self._fetch_emoji_data(host)
|
| 86 |
-
cur = self.db.cursor()
|
| 87 |
-
cur.execute('REPLACE INTO emoji_cache(host, data, last_updated) VALUES (?, ?, ?)', (host, orjson.dumps(emoji_data), math.floor(time.time())))
|
| 88 |
-
self.db.commit()
|
| 89 |
-
self.emoji_cache[host] = emoji_data
|
| 90 |
-
|
| 91 |
-
def _load(self, host) -> list:
|
| 92 |
-
emojis = []
|
| 93 |
-
if host in self.emoji_cache.keys():
|
| 94 |
-
emojis = self.emoji_cache[host]
|
| 95 |
-
else:
|
| 96 |
-
cur = self.db.cursor()
|
| 97 |
-
cur.execute('SELECT * FROM emoji_cache WHERE host = ?', (host,))
|
| 98 |
-
row = cur.fetchone()
|
| 99 |
-
if row is None:
|
| 100 |
-
logger.getChild('load').error(f'emoji data not found. fetching')
|
| 101 |
-
self._download(host)
|
| 102 |
-
return self._load(host)
|
| 103 |
-
else:
|
| 104 |
-
expire = CACHE_EXPIRE_TIME
|
| 105 |
-
# 前回取得失敗してる?
|
| 106 |
-
if row['data'] == '[]':
|
| 107 |
-
expire = 60 * 5
|
| 108 |
-
if math.floor(time.time()) - row['last_updated'] > expire:
|
| 109 |
-
logger.getChild('load').error(f'emoji cache expired. refreshing')
|
| 110 |
-
self._download(host)
|
| 111 |
-
return self._load(host)
|
| 112 |
-
self.emoji_cache[host] = orjson.loads(row['data'])
|
| 113 |
-
emojis = self.emoji_cache[host]
|
| 114 |
-
|
| 115 |
-
return emojis
|
| 116 |
-
|
| 117 |
-
# ----------------------
|
| 118 |
-
|
| 119 |
-
def refresh(self, host):
|
| 120 |
-
self._download(host)
|
| 121 |
-
|
| 122 |
-
def find_by_keyword(self, host, k) -> list:
|
| 123 |
-
emojis = self._load(host)
|
| 124 |
-
res = []
|
| 125 |
-
for emoji in emojis:
|
| 126 |
-
if k in emoji['name'].lower():
|
| 127 |
-
res.append({'name': emoji['name'], 'url': self._generate_emoji_url(host, emoji)})
|
| 128 |
-
return res
|
| 129 |
-
|
| 130 |
-
def find_by_alias(self, host, t) -> list:
|
| 131 |
-
emojis = self._load(host)
|
| 132 |
-
res = []
|
| 133 |
-
for emoji in emojis:
|
| 134 |
-
if t in emoji['aliases']:
|
| 135 |
-
res.append({'name': emoji['name'], 'url': self._generate_emoji_url(host, emoji)})
|
| 136 |
-
return res
|
| 137 |
-
|
| 138 |
-
def get(self, host, name):
|
| 139 |
-
emojis = self._load(host)
|
| 140 |
-
for emoji in emojis:
|
| 141 |
-
if emoji['name'] == name:
|
| 142 |
-
return {'name': emoji['name'], 'url': self._generate_emoji_url(host, emoji)}
|
| 143 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
base-gd-3.png → quote.png
RENAMED
|
File without changes
|