Taka005
commited on
Commit
·
f8c7305
1
Parent(s):
a28bbcf
完成
Browse files- base-gd-2.png +0 -0
- base-gd-3.png +0 -0
- base-gd-4.png +0 -0
- base-gd-5.png +0 -0
- base-gd.png +0 -0
- base-w.png → base.png +0 -0
- bot.py +0 -444
- icon.png +0 -0
- main.py +112 -0
- modules/emojistore.py +0 -143
- quote.png +0 -0
base-gd-2.png
DELETED
Binary file (301 kB)
|
|
base-gd-3.png
DELETED
Binary file (179 kB)
|
|
base-gd-4.png
DELETED
Binary file (35 kB)
|
|
base-gd-5.png
DELETED
Binary file (34.9 kB)
|
|
base-gd.png
CHANGED
![]() |
![]() |
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
icon.png
ADDED
![]() |
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
quote.png
ADDED
![]() |