├── 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 <url>.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 | --------------------------------------------------------------------------------