├── README.md
├── subreddits.json
├── .gitignore
└── titletoimagebot.py
/README.md:
--------------------------------------------------------------------------------
1 | # titletoimagebot
2 |
3 | Abandoned project, see forks.
4 |
--------------------------------------------------------------------------------
/subreddits.json:
--------------------------------------------------------------------------------
1 | [
2 | "boottoobig",
3 | "fakehistoryporn"
4 | ]
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | log/
3 | database.db
4 | apidata.py
5 | *.ttf
6 | test*
7 |
--------------------------------------------------------------------------------
/titletoimagebot.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | """meh"""
4 |
5 | __version__ = '0.7.1'
6 | __author__ = 'gerenook'
7 |
8 | import argparse
9 | import json
10 | import logging
11 | import re
12 | import sqlite3
13 | import sys
14 | import time
15 | from io import BytesIO
16 | from logging.handlers import TimedRotatingFileHandler
17 | from math import ceil
18 | from os import remove
19 |
20 | import praw
21 | import requests
22 | from imgurpython import ImgurClient
23 | from imgurpython.helpers.error import (ImgurClientError,
24 | ImgurClientRateLimitError)
25 | from PIL import Image, ImageDraw, ImageFont
26 | from prawcore.exceptions import RequestException, ResponseException
27 |
28 | import apidata
29 |
30 |
31 | class RedditImage:
32 | """RedditImage class
33 |
34 | :param image: the image
35 | :type image: PIL.Image.Image
36 | """
37 | margin = 10
38 | min_size = 500
39 | # TODO find a font for all unicode chars & emojis
40 | # font_file = 'seguiemj.ttf'
41 | font_file = 'roboto.ttf'
42 | font_scale_factor = 16
43 | regex_resolution = re.compile(r'\s?\[[0-9]+\s?[xX*×]\s?[0-9]+\]')
44 |
45 | def __init__(self, image):
46 | self._image = image
47 | self.upscaled = False
48 | width, height = image.size
49 | # upscale small images
50 | if image.size < (self.min_size, self.min_size):
51 | if width < height:
52 | factor = self.min_size / width
53 | else:
54 | factor = self.min_size / height
55 | self._image = self._image.resize((ceil(width * factor),
56 | ceil(height * factor)),
57 | Image.LANCZOS)
58 | self.upscaled = True
59 | self._width, self._height = self._image.size
60 | self._font_title = ImageFont.truetype(
61 | self.font_file,
62 | self._width // self.font_scale_factor
63 | )
64 |
65 | def _split_title(self, title):
66 | """Split title on [',', ';', '.'] into multiple lines
67 |
68 | :param title: the title to split
69 | :type title: str
70 | :returns: split title
71 | :rtype: list[str]
72 | """
73 | lines = ['']
74 | all_delimiters = [',', ';', '.']
75 | delimiter = None
76 | for character in title:
77 | # don't draw ' ' on a new line
78 | if character == ' ' and not lines[-1]:
79 | continue
80 | # add character to current line
81 | lines[-1] += character
82 | # find delimiter
83 | if not delimiter:
84 | if character in all_delimiters:
85 | delimiter = character
86 | # end of line
87 | if character == delimiter:
88 | lines.append('')
89 | # if a line is too long, wrap title instead
90 | for line in lines:
91 | if self._font_title.getsize(line)[0] + RedditImage.margin > self._width:
92 | return self._wrap_title(title)
93 | # remove empty lines (if delimiter is last character)
94 | return [line for line in lines if line]
95 |
96 | def _wrap_title(self, title):
97 | """Wrap title
98 |
99 | :param title: the title to wrap
100 | :type title: str
101 | :returns: wrapped title
102 | :rtype: list
103 | """
104 | lines = ['']
105 | line_words = []
106 | words = title.split()
107 | for word in words:
108 | line_words.append(word)
109 | lines[-1] = ' '.join(line_words)
110 | if self._font_title.getsize(lines[-1])[0] + RedditImage.margin > self._width:
111 | lines[-1] = lines[-1][:-len(word)].strip()
112 | lines.append(word)
113 | line_words = [word]
114 | # remove empty lines
115 | return [line for line in lines if line]
116 |
117 | def add_title(self, title, boot, bg_color='#fff', text_color='#000'):
118 | """Add title to new whitespace on image
119 |
120 | :param title: the title to add
121 | :type title: str
122 | :param boot: if True, split title on [',', ';', '.'], else wrap text
123 | :type boot: bool
124 | """
125 | # remove resolution appended to title (e.g. '
[1000 x 1000]')
126 | title = RedditImage.regex_resolution.sub('', title)
127 | line_height = self._font_title.getsize(title)[1] + RedditImage.margin
128 | lines = self._split_title(title) if boot else self._wrap_title(title)
129 | whitespace_height = (line_height * len(lines)) + RedditImage.margin
130 | new = Image.new('RGB', (self._width, self._height + whitespace_height), bg_color)
131 | new.paste(self._image, (0, whitespace_height))
132 | draw = ImageDraw.Draw(new)
133 | for i, line in enumerate(lines):
134 | draw.text((RedditImage.margin, i * line_height + RedditImage.margin),
135 | line, text_color, self._font_title)
136 | self._width, self._height = new.size
137 | self._image = new
138 |
139 | def upload(self, imgur, config):
140 | """Upload self._image to imgur
141 |
142 | :param imgur: the imgur api client
143 | :type imgur: imgurpython.client.ImgurClient
144 | :param config: imgur image config
145 | :type config: dict
146 | :returns: imgur url if upload successful, else None
147 | :rtype: str, NoneType
148 | """
149 | path_png = 'temp.png'
150 | path_jpg = 'temp.jpg'
151 | self._image.save(path_png)
152 | self._image.save(path_jpg)
153 | try:
154 | response = imgur.upload_from_path(path_png, config, anon=False)
155 | except ImgurClientError as error:
156 | logging.warning('png upload failed, trying jpg | %s', error)
157 | try:
158 | response = imgur.upload_from_path(path_jpg, config, anon=False)
159 | except ImgurClientError as error:
160 | logging.error('jpg upload failed, returning | %s', error)
161 | return None
162 | finally:
163 | remove(path_png)
164 | remove(path_jpg)
165 | return response['link']
166 |
167 |
168 | class Database:
169 | """Database class
170 |
171 | :param db_filename: database filename
172 | :type db_filename: str
173 | """
174 | def __init__(self, db_filename):
175 | self._sql_conn = sqlite3.connect(db_filename)
176 | self._sql = self._sql_conn.cursor()
177 |
178 | def message_exists(self, message_id):
179 | """Check if message exists in messages table
180 |
181 | :param message_id: the message id to check
182 | :type message_id: str
183 | :returns: True if message was found, else False
184 | :rtype: bool
185 | """
186 | self._sql.execute('SELECT EXISTS(SELECT 1 FROM messages WHERE id=? LIMIT 1)', (message_id,))
187 | if self._sql.fetchone()[0]:
188 | return True
189 | return False
190 |
191 | def message_insert(self, message_id, author, subject, body):
192 | """Insert message into messages table"""
193 | self._sql.execute('INSERT INTO messages (id, author, subject, body) VALUES (?, ?, ?, ?)',
194 | (message_id, author, subject, body))
195 | self._sql_conn.commit()
196 |
197 | def submission_select(self, submission_id):
198 | """Select all attributes of submission
199 |
200 | :param submission_id: the submission id
201 | :type submission_id: str
202 | :returns: query result, None if id not found
203 | :rtype: dict, NoneType
204 | """
205 | self._sql.execute('SELECT * FROM submissions WHERE id=?', (submission_id,))
206 | result = self._sql.fetchone()
207 | if not result:
208 | return None
209 | return {
210 | 'id': result[0],
211 | 'author': result[1],
212 | 'title': result[2],
213 | 'url': result[3],
214 | 'imgur_url': result[4],
215 | 'retry': result[5],
216 | 'timestamp': result[6]
217 | }
218 |
219 | def submission_insert(self, submission_id, author, title, url):
220 | """Insert submission into submissions table"""
221 | self._sql.execute('INSERT INTO submissions (id, author, title, url) VALUES (?, ?, ?, ?)',
222 | (submission_id, author, title, url))
223 | self._sql_conn.commit()
224 |
225 | def submission_set_retry(self, submission_id, delete_message=False, message=None):
226 | """Set retry flag for given submission, delete message from db if desired
227 |
228 | :param submission_id: the submission id to set retry
229 | :type submission_id: str
230 | :param delete_message: if True, delete message from messages table
231 | :type delete_message: bool
232 | :param message: the message to delete
233 | :type message: praw.models.Comment, NoneType
234 | """
235 | self._sql.execute('UPDATE submissions SET retry=1 WHERE id=?', (submission_id,))
236 | if delete_message:
237 | if not message:
238 | raise TypeError('If delete_message is True, message must be set')
239 | self._sql.execute('DELETE FROM messages WHERE id=?', (message.id,))
240 | self._sql_conn.commit()
241 |
242 | def submission_clear_retry(self, submission_id):
243 | """Clear retry flag for given submission_id
244 |
245 | :param submission_id: the submission id to clear retry
246 | :type submission_id: str
247 | """
248 | self._sql.execute('UPDATE submissions SET retry=0 WHERE id=?', (submission_id,))
249 | self._sql_conn.commit()
250 |
251 | def submission_set_imgur_url(self, submission_id, imgur_url):
252 | """Set imgur url for given submission
253 |
254 | :param submission_id: the submission id to set imgur url
255 | :type submission_id: str
256 | :param imgur_url: the imgur url to update
257 | :type imgur_url: str
258 | """
259 | self._sql.execute('UPDATE submissions SET imgur_url=? WHERE id=?',
260 | (imgur_url, submission_id))
261 | self._sql_conn.commit()
262 |
263 |
264 | class TitleToImageBot:
265 | """TitleToImageBot class
266 |
267 | :param subreddit: the subreddit(s) to process, can be concatenated with +
268 | :type subreddit: str
269 | """
270 | def __init__(self, subreddit):
271 | self._db = Database('database.db')
272 | self._reddit = praw.Reddit(**apidata.reddit)
273 | self._subreddit = self._reddit.subreddit(subreddit)
274 | self._imgur = ImgurClient(**apidata.imgur)
275 | self._template = (
276 | '[Image with added title]({image_url})\n\n'
277 | '{upscaled}---\n\n'
278 | 'summon me with /u/titletoimagebot | '
279 | '[feedback](https://reddit.com/message/compose/'
280 | '?to=TitleToImageBot&subject=feedback%20{submission_id}) | '
281 | '[source](https://github.com/gerenook/titletoimagebot)'
282 | )
283 |
284 | def _reply_imgur_url(self, url, submission, source_comment, upscaled=False):
285 | """doc todo
286 |
287 | :param url: -
288 | :type url: str
289 | :param submission: -
290 | :type submission: -
291 | :param source_comment: -
292 | :type source_comment: -
293 | :returns: True on success, False on failure
294 | :rtype: bool
295 | """
296 | logging.debug('Creating reply')
297 | reply = self._template.format(
298 | image_url=url,
299 | upscaled=' (image was upscaled)\n\n' if upscaled else '',
300 | submission_id=submission.id
301 | )
302 | try:
303 | if source_comment:
304 | source_comment.reply(reply)
305 | else:
306 | submission.reply(reply)
307 | except praw.exceptions.APIException as error:
308 | logging.error('Reddit api error, setting retry flag in database | %s', error)
309 | self._db.submission_set_retry(submission.id, bool(source_comment), source_comment)
310 | return False
311 | except Exception as error:
312 | logging.error('Cannot reply, skipping submission | %s', error)
313 | return False
314 | self._db.submission_clear_retry(submission.id)
315 | return True
316 |
317 | def _process_submission(self, submission, source_comment=None, custom_title=None):
318 | """Generate new image with added title and author, upload to imgur, reply to submission
319 |
320 | :param submission: the reddit submission object
321 | :type submission: praw.models.Submission
322 | :param source_comment: the comment that mentioned the bot, reply to this comment.
323 | If None, reply at top level. (default None)
324 | :type source_comment: praw.models.Comment, NoneType
325 | :param custom_title: if not None, use as title instead of submission title
326 | :type custom_title: str
327 | """
328 | # TODO really need to clean this method up
329 | # return if author account is deleted
330 | if not submission.author:
331 | return
332 | sub = submission.subreddit.display_name
333 | # in r/fakehistoryporn, only process upvoted submissions
334 | score_threshold = 500
335 | if sub == 'fakehistoryporn' and not source_comment:
336 | if submission.score < score_threshold:
337 | logging.debug('Score below %d in subreddit %s, skipping submission',
338 | score_threshold, sub)
339 | return
340 | # check db if submission was already processed
341 | author = submission.author.name
342 | title = submission.title
343 | url = submission.url
344 | result = self._db.submission_select(submission.id)
345 | if result:
346 | if result['retry'] or source_comment:
347 | if result['imgur_url']:
348 | # logging.info('Submission id:%s found in database with imgur url set, ' +
349 | # 'trying to create reply', submission.id)
350 | # self._reply_imgur_url(result['imgur_url'], submission, source_comment)
351 | # return
352 | logging.info('Submission id:%s found in database with imgur url set',
353 | submission.id)
354 | # db check disabled to allow custom titles
355 | else:
356 | logging.info('Submission id:%s found in database without imgur url set, ',
357 | submission.id)
358 | else:
359 | # skip submission
360 | logging.debug('Submission id:%s found in database, returning', result['id'])
361 | return
362 | else:
363 | logging.info('Found new submission subreddit:%s id:%s title:%s',
364 | sub, submission.id, title)
365 | logging.debug('Adding submission to database')
366 | self._db.submission_insert(submission.id, author, title, url)
367 | # in r/boottoobig, only process submission with a rhyme in the title
368 | boot = sub == 'boottoobig'
369 | if boot and not source_comment:
370 | triggers = [',', ';', 'roses']
371 | if not any(t in title.lower() for t in triggers):
372 | logging.info('Title is probably not part of rhyme, skipping submission')
373 | return
374 | if url.endswith('.gif') or url.endswith('.gifv'):
375 | logging.info('Image is animated gif, skipping submission')
376 | return
377 | logging.debug('Trying to download image from %s', url)
378 | try:
379 | response = requests.get(url)
380 | img = Image.open(BytesIO(response.content))
381 | except OSError as error:
382 | logging.warning('Converting to image failed, trying with .jpg | %s', error)
383 | try:
384 | response = requests.get(url + '.jpg')
385 | img = Image.open(BytesIO(response.content))
386 | except OSError as error:
387 | logging.error('Converting to image failed, skipping submission | %s', error)
388 | return
389 | image = RedditImage(img)
390 | logging.debug('Adding title')
391 | if custom_title:
392 | image.add_title(custom_title, boot)
393 | else:
394 | image.add_title(title, boot)
395 | logging.debug('Trying to upload new image')
396 | imgur_config = {
397 | 'album': None,
398 | 'name': submission.id,
399 | 'title': '"{}" by /u/{}'.format(title, author),
400 | 'description': submission.shortlink
401 | }
402 | try:
403 | imgur_url = image.upload(self._imgur, imgur_config)
404 | except ImgurClientRateLimitError as rate_error:
405 | logging.error('Imgur ratelimit error, setting retry flag in database | %s', rate_error)
406 | self._db.submission_set_retry(submission.id, bool(source_comment), source_comment)
407 | return
408 | if not imgur_url:
409 | logging.error('Cannot upload new image, skipping submission')
410 | return
411 | self._db.submission_set_imgur_url(submission.id, imgur_url)
412 | if not self._reply_imgur_url(imgur_url, submission, source_comment, upscaled=image.upscaled):
413 | return
414 | logging.info('Successfully processed submission')
415 |
416 | def _process_feedback_message(self, message):
417 | """Forward message to creator
418 |
419 | :param message: the feedback message
420 | :type message: praw.models.Message
421 | """
422 | message_author = message.author.name
423 | logging.info('Found new feedback message from %s', message_author)
424 | subject = 'TitleToImageBot feedback from {}'.format(message_author)
425 | body = 'Subject: {}\n\nBody: {}'.format(message.subject, message.body)
426 | self._reddit.redditor(__author__).message(subject, body)
427 | logging.info('Forwarded message to author')
428 |
429 | def _process_message(self, message):
430 | """Process given message (remove, feedback, mark good/bad bot as read)
431 |
432 | :param message: the inbox message, comment reply or username mention
433 | :type message: praw.models.Message, praw.models.Comment
434 | """
435 | if not message.author:
436 | return
437 | # check db if message was already processed
438 | author = message.author.name
439 | subject = message.subject.lower()
440 | body_original = message.body
441 | body = message.body.lower()
442 | if self._db.message_exists(message.id):
443 | logging.debug('Message %s found in database, returning', message.id)
444 | return
445 | logging.debug('Message: %s | %s', subject, body)
446 | logging.debug('Adding message to database')
447 | self._db.message_insert(message.id, author, subject, body)
448 | # check if message was sent, instead of received
449 | if author == self._reddit.user.me().name:
450 | logging.debug('Message was sent, returning')
451 | return
452 | # process message
453 | if (isinstance(message, praw.models.Comment) and
454 | (subject == 'username mention' or
455 | (subject == 'comment reply' and 'u/titletoimagebot' in body))):
456 | # You win this time, AutoModerator
457 | if message.author.name.lower() == 'automoderator':
458 | message.mark_read()
459 | return
460 | match = re.match(r'.*u/titletoimagebot\s*["“”](.+)["“”].*',
461 | body_original, re.RegexFlag.IGNORECASE)
462 | title = None
463 | if match:
464 | title = match.group(1)
465 | if len(title) > 512:
466 | title = None
467 | else:
468 | logging.debug('Found custom title: %s', title)
469 | self._process_submission(message.submission, message, title)
470 | message.mark_read()
471 | elif subject.startswith('feedback'):
472 | self._process_feedback_message(message)
473 | # mark short good/bad bot comments as read to keep inbox clean
474 | elif 'good bot' in body and len(body) < 12:
475 | logging.debug('Good bot message or comment reply found, marking as read')
476 | message.mark_read()
477 | elif 'bad bot' in body and len(body) < 12:
478 | logging.debug('Bad bot message or comment reply found, marking as read')
479 | message.mark_read()
480 |
481 | def run(self, limit):
482 | """Run the bot
483 |
484 | Process submissions and messages, remove bad comments
485 |
486 | :param limit: amount of submissions/messages to process
487 | :type limit: int
488 | """
489 | logging.debug('Processing last %s submissions...', limit)
490 | for submission in self._subreddit.hot(limit=limit):
491 | self._process_submission(submission)
492 | logging.debug('Processing last %s messages...', limit)
493 | for message in self._reddit.inbox.all(limit=limit):
494 | self._process_message(message)
495 | logging.debug('Removing bad comments...')
496 | for comment in self._reddit.user.me().comments.new(limit=100):
497 | if comment.score <= -1:
498 | logging.info('Removing bad comment id:%s score:%s', comment.id, comment.score)
499 | comment.delete()
500 |
501 |
502 | def _setup_logging(level):
503 | """Setup the root logger
504 |
505 | logs to stdout and to daily log files in ./log/
506 |
507 | :param level: the logging level (e.g. logging.WARNING)
508 | :type level: int
509 | """
510 | console_handler = logging.StreamHandler()
511 | file_handler = TimedRotatingFileHandler('./log/titletoimagebot.log',
512 | when='midnight', interval=1)
513 | file_handler.suffix = '%Y-%m-%d'
514 | module_loggers = ['requests', 'urllib3', 'prawcore', 'PIL.Image', 'PIL.PngImagePlugin']
515 | for logger in module_loggers:
516 | logging.getLogger(logger).setLevel(logging.ERROR)
517 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
518 | datefmt='%Y-%m-%d %H:%M:%S',
519 | level=level,
520 | handlers=[console_handler, file_handler])
521 |
522 |
523 | def _handle_exception(exc_type, exc_value, exc_traceback):
524 | """Log unhandled exceptions (see https://stackoverflow.com/a/16993115)"""
525 | # Don't log ctrl+c
526 | if issubclass(exc_type, KeyboardInterrupt):
527 | sys.__excepthook__(exc_type, exc_value, exc_traceback)
528 | return
529 | logging.critical('Unhandled exception:\n', exc_info=(exc_type, exc_value, exc_traceback))
530 |
531 |
532 | def main():
533 | """Main function
534 |
535 | Usage: ./titletoimagebot.py [-h] limit interval
536 |
537 | e.g. './titletoimagebot 10 60' will process the last 10 submissions/messages every 60 seconds.
538 | """
539 | _setup_logging(logging.INFO)
540 | sys.excepthook = _handle_exception
541 | parser = argparse.ArgumentParser()
542 | parser.add_argument('limit', help='amount of submissions/messages to process each cycle',
543 | type=int)
544 | parser.add_argument('interval', help='time (in seconds) to wait between cycles', type=int)
545 | args = parser.parse_args()
546 | logging.debug('Initializing bot')
547 | with open('subreddits.json') as subreddits_file:
548 | sub = '+'.join(json.load(subreddits_file))
549 | bot = TitleToImageBot(sub)
550 | logging.info('Bot initialized, processing the last %s submissions/messages every %s seconds',
551 | args.limit, args.interval)
552 | while True:
553 | try:
554 | logging.debug('Running bot')
555 | bot.run(args.limit)
556 | logging.debug('Bot finished, restarting in %s seconds', args.interval)
557 | except (requests.exceptions.ReadTimeout,
558 | requests.exceptions.ConnectionError,
559 | ResponseException,
560 | RequestException):
561 | logging.error('Reddit api timed out, restarting')
562 | continue
563 | time.sleep(args.interval)
564 |
565 |
566 | if __name__ == '__main__':
567 | main()
568 |
--------------------------------------------------------------------------------