🢀︎ scroll :: 7ae1770


commit 7ae1770133b9690af1c0bc0437ffa5515fc872f6
Author: acidvegas <acid.vegas@acid.vegas>
Date:   Mon Jun 24 22:50:43 2019 -0400

    Initial commit

diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b63b809
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2019, acidvegas <acid.vegas@acid.vegas>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4097abf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,70 @@
+![](screens/art.png)
+
+###### Requirements
+* [Python](https://www.python.org/downloads/) *(**Note:** This script was developed to be used with the latest version of Python.)*
+* [Pillow](https://pypi.org/project/Pillow/)
+
+###### Information
+Place your ASCII files in the [data/ascii]https://git.supernets.org/acidvegas/scroll/src/branch/master/scroll/data/ascii) directory.
+
+A large organized ASCII art pack is included in the [ircart](https://git.supernets.org/acidvegas/ircart) repository.
+
+The `.update` command requires that you `git clone` the repository above to the ASCII dir.
+
+###### Trunc
+The \<trunc> argument for the `.ascii` command allows you to truncate lines off of an ASCII.
+
+The data is TOP,BOTTOM,LEFT,RIGHT,SPACES. Top being how many lines to remove from the top. Bottom being the same except from the bottom. Left and right remove characters from each side, and spaces will prefix lines with this many spaces.
+
+**Example:** `.ascii funnyguy 3,5,0,10,30` *(This will remove 3 lines from the top, 5 lines from the bottom, no characters from the left, 10 characters from the right, and append 30 spaces before each line.)*
+
+###### Commands
+| Command | Description |
+| --- | --- |
+| @scroll | Information about the bot. |
+| .ascii dirs | List directories in the ASCII folder. |
+| .ascii list | Send a link to a list of the ASCII files. |
+| .ascii png \<url> | Convert ASCII to a PNG and upload it to IMGUR. *(The \<url> must be a [Pastebin](https://pastebin.com/) or [Termbin](https://termbin.com/) link.) |
+| .ascii random [dir] | Play a random ASCII file, optionally from the [dir] directory only. |
+| .ascii remote \<url> | Play a remote ASCII file. *(The \<url> must be a [Pastebin](https://pastebin.com/) or [Termbin](https://termbin.com/) link.) |
+| .ascii search \<query> | Search through the ASCII files for \<query>. |
+| .ascii stop | Stop playing the current ASCII being scrolled. |
+| .ascii upload [\<url> \<title>] | List uploaded files or upload \<url> as \<title>. *(The \<url> must be a [Pastebin](https://pastebin.com/) or [Termbin](https://termbin.com/) link.)* |
+| .ascii \<name> [\<trunc>] | Play the \<name> ASCII file. *(See above about \<trunc>)* |
+
+###### Admin Commands (Private Message)
+| Command | Description |
+| --- | --- |
+| .config [\<setting> \<value>] | View the config settings or change \<setting> to \<value>. |
+| .ignore [\<add/del> \<ident>] | View the ignore list or \<add/del> an \<ident> to the ignore list. *(Must be in nick!user@host format. Wildcards accepted.)*|
+| .raw \<data> | Send RAW \<data> to the server. |
+| .update | Update the internal [ircart](https://git.supernets.org/acidvegas/ircart) repository. |
+
+###### Config Settings
+| Setting | Description |
+| --- | --- |
+| cmd_throttle | Delay between command usage for flood control. |
+| max_lines | Maximum number of lines an ASCII can be to be played outside of the *#scroll* channel. |
+| max_png_size | Maximum size (in byte) for `.ascii png` usage.
+| max_results | Maximum number of results returned from a search. |
+| max_uploads | Maximum number of uploads to store. |
+| max_uploads_per | Maximum number of uploads per-nick. |
+| msg_throttle | Delay between each line send to the channel. |
+| png_throttle | Delay between `.ascii png` usage. |
+| png_max_bytes | Maximum bytes for `.ascii png` uploads. |
+| rnd_exclude | Directories to ignore with `.ascii random`. *(Comma-seperated list)* |
+
+###### Todo
+* Check pastebin size before downloading.
+* Store image bytes in memory so creating files locally is not required.
+* Improve truncation of the left and right by retaining the last known color code.
+* Add ascii tagging to database. Lets you tag asciis with hashtags for searching. Maybe include an author tag also.
+* Add an ascii amplification factor to the trunc array, and maybe rename trunc to morph.
+* Use the maximum allowed upload size for IMGUR upload, and track it to a maximum of 50 per hour. 
+* Controls for admins to move, rename, delete, and download ascii on the fly.
+
+###### Mirrors
+- [acid.vegas](https://acid.vegas/scroll) *(main)*
+- [SuperNETs](https://git.supernets.org/ircart/scroll)
+- [GitHub](https://github.com/ircart/scroll)
+- [GitLab](https://gitlab.com/ircart/scroll)
\ No newline at end of file
diff --git a/screens/art.png b/screens/art.png
new file mode 100644
index 0000000..3f8f8e3
Binary files /dev/null and b/screens/art.png differ
diff --git a/scroll/core/ascii2png.py b/scroll/core/ascii2png.py
new file mode 100644
index 0000000..54ccbcf
--- /dev/null
+++ b/scroll/core/ascii2png.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Scroll IRC Bot -Developed by acidvegas in Python (https://acid.vegas/scroll)
+# ascii2png.py
+
+'''
+Credits to VXP for making the original "pngbot" script:
+	https://github.com/lalbornoz/MiRCARTools
+'''
+
+import os
+import urllib.request
+
+from PIL import Image, ImageDraw, ImageFont
+
+def flip_cell_state(cellState, bit):
+	if cellState & bit:
+		return cellState & ~bit
+	else:
+		return cellState | bit
+
+def parse_char(colourSpec, curColours):
+	if len(colourSpec) > 0:
+		colourSpec = colourSpec.split(',')
+		if len(colourSpec) == 2 and len(colourSpec[1]) > 0:
+			return (int(colourSpec[0] or curColours[0]), int(colourSpec[1]))
+		elif len(colourSpec) == 1 or len(colourSpec[1]) == 0:
+			return (int(colourSpec[0]), curColours[1])
+	else:
+		return (15, 1)
+
+def ascii_png(url):
+	text_file = os.path.join('data','temp.txt')
+	if os.path.isfile(text_file):
+		os.remove(text_file)
+	urllib.request.urlretrieve(url, text_file)
+	data = open(text_file)
+	inCurColourSpec = ''
+	inCurRow = -1
+	inLine = data.readline()
+	inSize = [0, 0]
+	inMaxCols = 0
+	outMap = []
+	while inLine:
+		inCellState = 0x00
+		inParseState = 1
+		inCurCol = 0
+		inMaxCol = len(inLine)
+		inCurColourDigits = 0
+		inCurColours = (15, 1)
+		inCurColourSpec = ''
+		inCurRow += 1
+		outMap.append([])
+		inRowCols = 0
+		inSize[1] += 1
+		while inCurCol < inMaxCol:
+			inChar = inLine[inCurCol]
+			if inChar in set('\r\n'):
+				inCurCol += 1
+			elif inParseState == 1:
+				inCurCol += 1
+				if inChar == '':
+					inCellState = flip_cell_state(inCellState, 0x01)
+				elif inChar == '':
+					inParseState = 2
+				elif inChar == '':
+					inCellState = flip_cell_state(inCellState, 0x02)
+				elif inChar == '':
+					inCellState |= 0x00
+					inCurColours = (15, 1)
+				elif inChar == '':
+					inCurColours = (inCurColours[1], inCurColours[0])
+				elif inChar == '':
+					inCellState = flip_cell_state(inCellState, 0x04)
+				else:
+					inRowCols += 1
+					outMap[inCurRow].append([*inCurColours, inCellState, inChar])
+			elif inParseState == 2 or inParseState == 3:
+				if inChar == ',' and inParseState == 2:
+					if (inCurCol + 1) < inMaxCol and not inLine[inCurCol + 1] in set('0123456789'):
+						inCurColours = parse_char(inCurColourSpec, inCurColours)
+						inCurColourDigits = 0
+						inCurColourSpec = ''
+						inParseState = 1
+					else:
+						inCurCol += 1
+						inCurColourDigits = 0
+						inCurColourSpec += inChar
+						inParseState = 3
+				elif inChar in set('0123456789') and inCurColourDigits == 0:
+					inCurCol += 1
+					inCurColourDigits += 1
+					inCurColourSpec += inChar
+				elif inChar in set('0123456789') and inCurColourDigits == 1 and inCurColourSpec[-1] == '0':
+					inCurCol += 1
+					inCurColourDigits += 1
+					inCurColourSpec += inChar
+				elif inChar in set('012345') and inCurColourDigits == 1 and inCurColourSpec[-1] == '1':
+					inCurCol += 1
+					inCurColourDigits += 1
+					inCurColourSpec += inChar
+				else:
+					inCurColours = parse_char(inCurColourSpec, inCurColours)
+					inCurColourDigits = 0
+					inCurColourSpec = ''
+					inParseState = 1
+		inMaxCols = max(inMaxCols, inRowCols)
+		inLine = data.readline()
+	inSize[0] = inMaxCols
+	canvas_data = outMap
+	numRowCols = 0
+	for numRow in range(len(outMap)):
+		numRowCols = max(numRowCols, len(outMap[numRow]))
+	for numRow in range(len(outMap)):
+		if len(outMap[numRow]) != numRowCols:
+			for numColOff in range(numRowCols - len(outMap[numRow])):
+				outMap[numRow].append([1,1,0,' '])
+		outMap[numRow].insert(0,[1,1,0,' '])
+		outMap[numRow].append([1,1,0,' '])
+	outMap.insert(0,[[1,1,0,' ']] * len(outMap[0]))
+	outMap.append([[1,1,0,' ']] * len(outMap[0]))
+	inCanvasMap = outMap
+	outImgFont = ImageFont.truetype(os.path.join('data','DejaVuSansMono.ttf'), 11)
+	outImgFontSize = [*outImgFont.getsize(' ')]
+	outImgFontSize[1] += 3
+	ColorsBold   = [[255,255,255],[85,85,85],[85,85,255],[85,255,85],[255,85,85],[255,85,85],[255,85,255],[255,255,85],[255,255,85],[85,255,85],[85,255,255],[85,255,255],[85,85,255],[255,85,255],[85,85,85],[255,255,255]]
+	ColorsNormal = [[255,255,255],[0,0,0],[0,0,187],[0,187,0],[255,85,85],[187,0,0],[187,0,187],[187,187,0],[255,255,85],[85,255,85],[0,187,187],[85,255,255],[85,85,255],[255,85,255],[85,85,85],[187,187,187]]
+	inSize = (len(inCanvasMap[0]), len(inCanvasMap))
+	outSize = [a*b for a,b in zip(inSize, outImgFontSize)]
+	outCurPos = [0, 0]
+	outImg = Image.new('RGBA', outSize, (*ColorsNormal[1], 255))
+	outImgDraw = ImageDraw.Draw(outImg)
+	for inCurRow in range(len(inCanvasMap)):
+		for inCurCol in range(len(inCanvasMap[inCurRow])):
+			inCurCell = inCanvasMap[inCurRow][inCurCol]
+			outColours = [0, 0]
+			if inCurCell[2] & 0x01:
+				if inCurCell[3] != ' ':
+					if inCurCell[3] == '█':
+						outColours[1] = ColorsNormal[inCurCell[0]]
+					else:
+						outColours[0] = ColorsBold[inCurCell[0]]
+						outColours[1] = ColorsNormal[inCurCell[1]]
+				else:
+					outColours[1] = ColorsNormal[inCurCell[1]]
+			else:
+				if inCurCell[3] != ' ':
+					if inCurCell[3] == '█':
+						outColours[1] = ColorsNormal[inCurCell[0]]
+					else:
+						outColours[0] = ColorsNormal[inCurCell[0]]
+						outColours[1] = ColorsNormal[inCurCell[1]]
+				else:
+					outColours[1] = ColorsNormal[inCurCell[1]]
+			outImgDraw.rectangle((*outCurPos,outCurPos[0] + outImgFontSize[0], outCurPos[1] + outImgFontSize[1]), fill=(*outColours[1], 255))
+			if  not inCurCell[3] in ' █' and outColours[0] != outColours[1]:
+				outImgDraw.text(outCurPos,inCurCell[3], (*outColours[0], 255), outImgFont)
+			if inCurCell[2] & 0x04:
+				outColours[0] = ColorsNormal[inCurCell[0]]
+				outImgDraw.line(xy=(outCurPos[0], outCurPos[1] + (outImgFontSize[1] - 2), outCurPos[0] + outImgFontSize[0], outCurPos[1] + (outImgFontSize[1] - 2)), fill=(*outColours[0], 255))
+			outCurPos[0] += outImgFontSize[0]
+		outCurPos[0] = 0
+		outCurPos[1] += outImgFontSize[1]
+	out_file = os.path.join('data','temp.png')
+	if os.path.isfile(out_file):
+		os.remove(out_file)
+	outImg.save(out_file)
\ No newline at end of file
diff --git a/scroll/core/config.py b/scroll/core/config.py
new file mode 100644
index 0000000..a1914dd
--- /dev/null
+++ b/scroll/core/config.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python
+# Scroll IRC Bot -Developed by acidvegas in Python (https://acid.vegas/scroll)
+# config.py
+
+class connection:
+	server     = 'irc.server.com'
+	port       = 6697
+	proxy      = None
+	ipv6       = False
+	ssl        = True
+	ssl_verify = False
+	vhost      = None
+	channel    = '#chats'
+	key        = None
+
+class cert:
+	key      = None
+	file     = None
+	password = None
+
+class ident:
+	nickname = 'scroll'
+	username = 'scroll'
+	realname = 'acid.vegas/scroll'
+
+class login:
+	network  = None
+	nickserv = None
+	operator = None
+
+class settings:
+	admin = 'nick!user@host' # Must be in nick!user@host format (Can use wildcards here)
+	log   = False
+	modes = None
+
+IMGUR_API_KEY = 'CHANGEME' # https://apidocs.imgur.com/
diff --git a/scroll/core/constants.py b/scroll/core/constants.py
new file mode 100644
index 0000000..194e22c
--- /dev/null
+++ b/scroll/core/constants.py
@@ -0,0 +1,229 @@
+#!/usr/bin/env python
+# Scroll IRC Bot -Developed by acidvegas in Python (https://acid.vegas/scroll)
+# constants.py
+
+# Control Characters
+bold      = '\x02'
+color     = '\x03'
+italic    = '\x1D'
+underline = '\x1F'
+reverse   = '\x16'
+reset     = '\x0f'
+
+# Color Codes
+white       = '00'
+black       = '01'
+blue        = '02'
+green       = '03'
+red         = '04'
+brown       = '05'
+purple      = '06'
+orange      = '07'
+yellow      = '08'
+light_green = '09'
+cyan        = '10'
+light_cyan  = '11'
+light_blue  = '12'
+pink        = '13'
+grey        = '14'
+light_grey  = '15'
+
+# Events
+PASS     = 'PASS'
+NICK     = 'NICK'
+USER     = 'USER'
+OPER     = 'OPER'
+MODE     = 'MODE'
+SERVICE  = 'SERVICE'
+QUIT     = 'QUIT'
+SQUIT    = 'SQUIT'
+JOIN     = 'JOIN'
+PART     = 'PART'
+TOPIC    = 'TOPIC'
+NAMES    = 'NAMES'
+LIST     = 'LIST'
+INVITE   = 'INVITE'
+KICK     = 'KICK'
+PRIVMSG  = 'PRIVMSG'
+NOTICE   = 'NOTICE'
+MOTD     = 'MOTD'
+LUSERS   = 'LUSERS'
+VERSION  = 'VERSION'
+STATS    = 'STATS'
+LINKS    = 'LINKS'
+TIME     = 'TIME'
+CONNECT  = 'CONNECT'
+TRACE    = 'TRACE'
+ADMIN    = 'ADMIN'
+INFO     = 'INFO'
+SERVLIST = 'SERVLIST'
+SQUERY   = 'SQUERY'
+WHO      = 'WHO'
+WHOIS    = 'WHOIS'
+WHOWAS   = 'WHOWAS'
+KILL     = 'KILL'
+PING     = 'PING'
+PONG     = 'PONG'
+ERROR    = 'ERROR'
+AWAY     = 'AWAY'
+REHASH   = 'REHASH'
+DIE      = 'DIE'
+RESTART  = 'RESTART'
+SUMMON   = 'SUMMON'
+USERS    = 'USERS'
+WALLOPS  = 'WALLOPS'
+USERHOST = 'USERHOST'
+ISON     = 'ISON'
+
+# Event Numerics
+RPL_WELCOME          = '001'
+RPL_YOURHOST         = '002'
+RPL_CREATED          = '003'
+RPL_MYINFO           = '004'
+RPL_ISUPPORT         = '005'
+RPL_TRACELINK        = '200'
+RPL_TRACECONNECTING  = '201'
+RPL_TRACEHANDSHAKE   = '202'
+RPL_TRACEUNKNOWN     = '203'
+RPL_TRACEOPERATOR    = '204'
+RPL_TRACEUSER        = '205'
+RPL_TRACESERVER      = '206'
+RPL_TRACESERVICE     = '207'
+RPL_TRACENEWTYPE     = '208'
+RPL_TRACECLASS       = '209'
+RPL_STATSLINKINFO    = '211'
+RPL_STATSCOMMANDS    = '212'
+RPL_STATSCLINE       = '213'
+RPL_STATSILINE       = '215'
+RPL_STATSKLINE       = '216'
+RPL_STATSYLINE       = '218'
+RPL_ENDOFSTATS       = '219'
+RPL_UMODEIS          = '221'
+RPL_SERVLIST         = '234'
+RPL_SERVLISTEND      = '235'
+RPL_STATSLLINE       = '241'
+RPL_STATSUPTIME      = '242'
+RPL_STATSOLINE       = '243'
+RPL_STATSHLINE       = '244'
+RPL_LUSERCLIENT      = '251'
+RPL_LUSEROP          = '252'
+RPL_LUSERUNKNOWN     = '253'
+RPL_LUSERCHANNELS    = '254'
+RPL_LUSERME          = '255'
+RPL_ADMINME          = '256'
+RPL_ADMINLOC1        = '257'
+RPL_ADMINLOC2        = '258'
+RPL_ADMINEMAIL       = '259'
+RPL_TRACELOG         = '261'
+RPL_TRYAGAIN         = '263'
+RPL_NONE             = '300'
+RPL_AWAY             = '301'
+RPL_USERHOST         = '302'
+RPL_ISON             = '303'
+RPL_UNAWAY           = '305'
+RPL_NOWAWAY          = '306'
+RPL_WHOISUSER        = '311'
+RPL_WHOISSERVER      = '312'
+RPL_WHOISOPERATOR    = '313'
+RPL_WHOWASUSER       = '314'
+RPL_ENDOFWHO         = '315'
+RPL_WHOISIDLE        = '317'
+RPL_ENDOFWHOIS       = '318'
+RPL_WHOISCHANNELS    = '319'
+RPL_LIST             = '322'
+RPL_LISTEND          = '323'
+RPL_CHANNELMODEIS    = '324'
+RPL_NOTOPIC          = '331'
+RPL_TOPIC            = '332'
+RPL_INVITING         = '341'
+RPL_INVITELIST       = '346'
+RPL_ENDOFINVITELIST  = '347'
+RPL_EXCEPTLIST       = '348'
+RPL_ENDOFEXCEPTLIST  = '349'
+RPL_VERSION          = '351'
+RPL_WHOREPLY         = '352'
+RPL_NAMREPLY         = '353'
+RPL_LINKS            = '364'
+RPL_ENDOFLINKS       = '365'
+RPL_ENDOFNAMES       = '366'
+RPL_BANLIST          = '367'
+RPL_ENDOFBANLIST     = '368'
+RPL_ENDOFWHOWAS      = '369'
+RPL_INFO             = '371'
+RPL_MOTD             = '372'
+RPL_ENDOFINFO        = '374'
+RPL_MOTDSTART        = '375'
+RPL_ENDOFMOTD        = '376'
+RPL_YOUREOPER        = '381'
+RPL_REHASHING        = '382'
+RPL_YOURESERVICE     = '383'
+RPL_TIME             = '391'
+RPL_USERSSTART       = '392'
+RPL_USERS            = '393'
+RPL_ENDOFUSERS       = '394'
+RPL_NOUSERS          = '395'
+ERR_NOSUCHNICK       = '401'
+ERR_NOSUCHSERVER     = '402'
+ERR_NOSUCHCHANNEL    = '403'
+ERR_CANNOTSENDTOCHAN = '404'
+ERR_TOOMANYCHANNELS  = '405'
+ERR_WASNOSUCHNICK    = '406'
+ERR_TOOMANYTARGETS   = '407'
+ERR_NOSUCHSERVICE    = '408'
+ERR_NOORIGIN         = '409'
+ERR_NORECIPIENT      = '411'
+ERR_NOTEXTTOSEND     = '412'
+ERR_NOTOPLEVEL       = '413'
+ERR_WILDTOPLEVEL     = '414'
+ERR_BADMASK          = '415'
+ERR_UNKNOWNCOMMAND   = '421'
+ERR_NOMOTD           = '422'
+ERR_NOADMININFO      = '423'
+ERR_FILEERROR        = '424'
+ERR_NONICKNAMEGIVEN  = '431'
+ERR_ERRONEUSNICKNAME = '432'
+ERR_NICKNAMEINUSE    = '433'
+ERR_NICKCOLLISION    = '436'
+ERR_USERNOTINCHANNEL = '441'
+ERR_NOTONCHANNEL     = '442'
+ERR_USERONCHANNEL    = '443'
+ERR_NOLOGIN          = '444'
+ERR_SUMMONDISABLED   = '445'
+ERR_USERSDISABLED    = '446'
+ERR_NOTREGISTERED    = '451'
+ERR_NEEDMOREPARAMS   = '461'
+ERR_ALREADYREGISTRED = '462'
+ERR_NOPERMFORHOST    = '463'
+ERR_PASSWDMISMATCH   = '464'
+ERR_YOUREBANNEDCREEP = '465'
+ERR_KEYSET           = '467'
+ERR_CHANNELISFULL    = '471'
+ERR_UNKNOWNMODE      = '472'
+ERR_INVITEONLYCHAN   = '473'
+ERR_BANNEDFROMCHAN   = '474'
+ERR_BADCHANNELKEY    = '475'
+ERR_BADCHANMASK      = '476'
+ERR_BANLISTFULL      = '478'
+ERR_NOPRIVILEGES     = '481'
+ERR_CHANOPRIVSNEEDED = '482'
+ERR_CANTKILLSERVER   = '483'
+ERR_UNIQOPRIVSNEEDED = '485'
+ERR_NOOPERHOST       = '491'
+ERR_UMODEUNKNOWNFLAG = '501'
+ERR_USERSDONTMATCH   = '502'
+RPL_STARTTLS         = '670'
+ERR_STARTTLS         = '691'
+RPL_MONONLINE        = '730'
+RPL_MONOFFLINE       = '731'
+RPL_MONLIST          = '732'
+RPL_ENDOFMONLIST     = '733'
+ERR_MONLISTFULL      = '734'
+RPL_LOGGEDIN         = '900'
+RPL_LOGGEDOUT        = '901'
+ERR_NICKLOCKED       = '902'
+RPL_SASLSUCCESS      = '903'
+ERR_SASLFAIL         = '904'
+ERR_SASLTOOLONG      = '905'
+ERR_SASLABORTED      = '906'
+ERR_SASLALREADY      = '907'
+RPL_SASLMECHS        = '908'
diff --git a/scroll/core/database.py b/scroll/core/database.py
new file mode 100644
index 0000000..a19f623
--- /dev/null
+++ b/scroll/core/database.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python
+# Scroll IRC Bot -Developed by acidvegas in Python (https://acid.vegas/scroll)
+# database.py
+
+import os
+import re
+import sqlite3
+
+db  = sqlite3.connect(os.path.join('data', 'scroll.db'), check_same_thread=False)
+sql = db.cursor()
+
+def check():
+	tables = sql.execute('SELECT name FROM sqlite_master WHERE type=\'table\'').fetchall()
+	if not len(tables):
+		sql.execute('CREATE TABLE IGNORE (IDENT TEXT NOT NULL);')
+		sql.execute('CREATE TABLE SETTINGS (SETTING TEXT NOT NULL, VALUE TEXT NOT NULL);')
+		sql.execute('CREATE TABLE UPLOADS (NICK TEXT NOT NULL, URL TEXT NOT NULL, TITLE TEXT NOT NULL);')
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('cmd_throttle', '3'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('max_lines', '300'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('max_results', '10'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('max_uploads', '25'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('max_uploads_per', '5'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('msg_throttle', '0.03'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('png_throttle', '0.03'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('png_max_bytes', '2000000'))
+		sql.execute('INSERT INTO SETTINGS (SETTING,VALUE) VALUES (?,?)', ('rnd_exclude', 'ansi,big,birds,hang,pokemon'))
+		db.commit()
+
+class Ignore:
+	def add(ident):
+		sql.execute('INSERT INTO IGNORE (IDENT) VALUES (?)', (ident,))
+		db.commit()
+
+	def check(ident):
+		for ignored_ident in Ignore.read():
+			if re.compile(ignored_ident.replace('*','.*')).search(ident):
+				return True
+		return False
+
+	def read():
+		return [item[0] for item in sql.execute('SELECT IDENT FROM IGNORE').fetchall()]
+
+	def remove(ident):
+		sql.execute('DELETE FROM IGNORE WHERE IDENT=?', (ident,))
+		db.commit()
+
+class Settings:
+	def get(setting):
+		return sql.execute('SELECT VALUE FROM SETTINGS WHERE SETTING=?', (setting,)).fetchone()[0]
+
+	def read():
+		return sql.execute('SELECT SETTING,VALUE FROM SETTINGS ORDER BY SETTING ASC').fetchall()
+
+	def settings():
+		return list(item[0] for item in sql.execute('SELECT SETTING FROM SETTINGS').fetchall())
+
+	def update(setting, value):
+		sql.execute('UPDATE SETTINGS SET VALUE=? WHERE SETTING=?', (value, setting))
+		db.commit()
+
+class Uploads:
+	def add(nick, url, title):
+		sql.execute('INSERT INTO UPLOADS (NICK,URL,TITLE) VALUES (?,?,?)', (nick,url,title))
+		db.commit()
+
+	def read(nick=None):
+		if nick:
+			return sql.execute('SELECT NICK,URL,TITLE FROM UPLOADS WHERE NICK=?', (nick,)).fetchall()
+		else:
+			return sql.execute('SELECT NICK,URL,TITLE FROM UPLOADS').fetchall()
+
+	def remove(url):
+		sql.execute('DELETE FROM UPLOADS WHERE URL=?', (url,))
+		db.commit()
diff --git a/scroll/core/debug.py b/scroll/core/debug.py
new file mode 100644
index 0000000..e6118c9
--- /dev/null
+++ b/scroll/core/debug.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+# Scroll IRC Bot -Developed by acidvegas in Python (https://acid.vegas/scroll)
+# debug.py
+
+import ctypes
+import logging
+import os
+import sys
+import time
+
+from logging.handlers import RotatingFileHandler
+
+import config
+
+def check_privileges():
+	if check_windows():
+		return True if ctypes.windll.shell32.IsUserAnAdmin() else False
+	else:
+		return True if os.getuid() == 0 or os.geteuid() == 0 else False
+
+def check_version(major):
+	return True if sys.version_info.major == major else False
+
+def check_windows():
+	return True if os.name == 'nt' else False
+
+def clear():
+	os.system('cls') if check_windows() else os.system('clear')
+
+def error(msg, reason=None):
+	logging.error(f'[!] - {msg} ({reason})') if reason else logging.error('[!] - ' + msg)
+
+def error_exit(msg):
+	raise SystemExit('[!] - ' + msg)
+
+def info():
+	clear()
+	print('#'*56)
+	print('#{0}#'.format(''.center(54)))
+	print('#{0}#'.format('Scroll IRC Bot'.center(54)))
+	print('#{0}#'.format('Developed by acidvegas in Python'.center(54)))
+	print('#{0}#'.format('https://acid.vegas/scroll'.center(54)))
+	print('#{0}#'.format(''.center(54)))
+	print('#'*56)
+
+def irc(msg):
+	logging.debug('[~] - ' + msg)
+
+def setup_logger():
+	stream_handler = logging.StreamHandler(sys.stdout)
+	if config.settings.log:
+		log_file     = os.path.join(os.path.join('data','logs'), 'scroll.log')
+		file_handler = RotatingFileHandler(log_file, maxBytes=256000, backupCount=3)
+		logging.basicConfig(level=logging.NOTSET, format='%(asctime)s | %(message)s', datefmt='%I:%M:%S', handlers=(file_handler,stream_handler))
+	else:
+		logging.basicConfig(level=logging.NOTSET, format='%(asctime)s | %(message)s', datefmt='%I:%M:%S', handlers=(stream_handler,))
diff --git a/scroll/core/functions.py b/scroll/core/functions.py
new file mode 100644
index 0000000..68e4da5
--- /dev/null
+++ b/scroll/core/functions.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python
+# Scroll IRC Bot -Developed by acidvegas in Python (https://acid.vegas/scroll)
+# functions.py
+
+import base64
+import json
+import os
+import random
+import re
+import subprocess
+import urllib.parse
+import urllib.request
+
+import config
+
+def cmd(command):
+	return subprocess.check_output(command, shell=True).decode()
+
+def check_trunc(trunc):
+	trunc = trunc.split(',')
+	if len(trunc) == 5 and ''.join(trunc).isdigit():
+		trunc = [int(item) for item in trunc]
+		up,down,left,right,space=trunc
+		if down == 0:
+			down = 1
+		if right == 0:
+			right = 1
+		return trunc
+	else:
+		return False
+
+def get_size(file_path):
+	return os.path.getsize(file_path)
+
+def get_source(url):
+	return urllib.request.urlopen(url, timeout=15).read().decode('utf8')
+
+def imgur_upload(file_path):
+	data = urllib.parse.urlencode({'image':base64.b64encode(open(file_path,'rb').read()),'key':config.IMGUR_API_KEY,'name':'ASCII uploaded via Scroll','title':'ASCII uploaded via Scroll','type':'base64'}).encode('utf8')
+	headers = {'Authorization':'Client-ID ' + config.IMGUR_API_KEY}
+	req = urllib.request.Request('https://api.imgur.com/3/upload.json', data, headers)
+	response = json.loads(urllib.request.urlopen(req).read())
+	return response['data']['link']
+
+def is_admin(ident):
+	return re.compile(config.settings.admin.replace('*','.*')).search(ident)
+
+def floatint(data):
+	if data.isdigit():
+		return int(data)
+	else:
+		try:
+			return float(data)
+		except:
+			return data
+
+def random_int(min, max):
+	return random.randint(min, max)
diff --git a/scroll/core/irc.py b/scroll/core/irc.py
new file mode 100644
index 0000000..5786903
--- /dev/null
+++ b/scroll/core/irc.py
@@ -0,0 +1,354 @@
+#!/usr/bin/env python
+# Scroll IRC Bot -Developed by acidvegas in Python (https://acid.vegas/scroll)
+# irc.py
+
+import glob
+import os
+import random
+import socket
+import threading
+import time
+
+import ascii2png
+import config
+import constants
+import database
+import debug
+import functions
+
+# Load optional modules
+if config.connection.ssl:
+	import ssl
+if config.connection.proxy:
+	try:
+		import sock
+	except ImportError:
+		debug.error_exit('Missing PySocks module! (https://pypi.python.org/pypi/PySocks)') # Required for proxy support.
+
+def color(msg, foreground, background=None):
+	return f'\x03{foreground},{background}{msg}{constants.reset}' if background else f'\x03{foreground}{msg}{constants.reset}'
+
+ascii_dir = os.path.join('data', 'ascii')
+
+class IRC(object):
+	def __init__(self):
+		self.last     = 0
+		self.last_png = 0
+		self.playing  = False
+		self.slow     = False
+		self.stopper  = False
+		self.sock     = None
+
+	def connect(self):
+		try:
+			self.create_socket()
+			self.sock.connect((config.connection.server, config.connection.port))
+			self.register()
+		except socket.error as ex:
+			debug.error('Failed to connect to IRC server.', ex)
+			Events.disconnect()
+		else:
+			self.listen()
+
+	def create_socket(self):
+		family = socket.AF_INET6 if config.connection.ipv6 else socket.AF_INET
+		if config.connection.proxy:
+			proxy_server, proxy_port = config.connection.proxy.split(':')
+			self.sock = socks.socksocket(family, socket.SOCK_STREAM)
+			self.sock.setblocking(0)
+			self.sock.settimeout(15)
+			self.sock.setproxy(socks.PROXY_TYPE_SOCKS5, proxy_server, int(proxy_port))
+		else:
+			self.sock = socket.socket(family, socket.SOCK_STREAM)
+		if config.connection.vhost:
+			self.sock.bind((config.connection.vhost, 0))
+		if config.connection.ssl:
+			ctx = ssl.create_default_context()
+			if config.cert.file:
+				ctx.load_cert_chain(config.cert.file, config.cert.key, config.cert.password)
+			if config.connection.ssl_verify:
+				ctx.verify_mode = ssl.CERT_REQUIRED
+				ctx.load_default_certs()
+			else:
+				ctx.check_hostname = False
+				ctx.verify_mode = ssl.CERT_NONE
+			self.sock = ctx.wrap_socket(self.sock)
+
+	def listen(self):
+		while True:
+			try:
+				data = self.sock.recv(1024).decode('utf-8')
+				for line in (line for line in data.split('\r\n') if len(line.split()) >= 2):
+					debug.irc(line)
+					Events.handle(line)
+			except (UnicodeDecodeError, UnicodeEncodeError):
+				pass
+			except Exception as ex:
+				debug.error('Unexpected error occured.', ex)
+				break
+		Events.disconnect()
+
+	def register(self):
+		if config.login.network:
+			Commands.raw('PASS ' + config.login.network)
+		Commands.raw(f'USER {config.ident.username} 0 * :{config.ident.realname}')
+		Commands.raw('NICK '+ config.ident.nickname)
+
+class Commands:
+	def error(chan, msg, reason=None):
+		if reason:
+			Commands.sendmsg(chan, '[{0}] {1} {2}'.format(color('ERROR', constants.red), msg, color(f'({reason})', constants.grey)))
+		else:
+			Commands.sendmsg(chan, '[{0}] {1}'.format(color('ERROR', constants.red), msg))
+
+	def join_channel(chan, key=None):
+		Commands.raw(f'JOIN {chan} {key}') if key else Commands.raw('JOIN ' + chan)
+
+	def play(chan, ascii_file, trunc=None):
+		try:
+			Bot.playing = True
+			data = open(ascii_file, encoding='utf8', errors='replace').read()
+			if len(data.splitlines()) > int(database.Settings.get('max_lines')) and chan != '#scroll':
+				Commands.error(chan, 'File is too big.', 'Take it to #scroll')
+			else:
+				name = ascii_file.split(ascii_dir)[1]
+				data = data.splitlines()[trunc[0]:-trunc[1]] if trunc else data.splitlines()
+				Commands.sendmsg(chan, ascii_file.split(ascii_dir)[1])
+				for line in (line for line in data if line):
+					if Bot.stopper:
+						break
+					elif trunc:
+						line = line[trunc[2]:-trunc[3]]
+					Commands.sendmsg(chan, ' '*trunc[4] + line) if trunc else Commands.sendmsg(chan, line)
+		except Exception as ex:
+			debug.error('Error occured in the play function!', ex)
+		finally:
+			Bot.stopper = False
+			Bot.playing = False
+
+	def raw(msg):
+		Bot.sock.send(bytes(msg + '\r\n', 'utf-8'))
+
+	def sendmsg(target, msg):
+		Commands.raw(f'PRIVMSG {target} :{msg}')
+		time.sleep(functions.floatint(database.Settings.get('msg_throttle')))
+
+class Events:
+	def connect():
+		if config.settings.modes:
+			Commands.raw(f'MODE {config.ident.nickname} +{config.settings.modes}')
+		if config.login.nickserv:
+			Commands.sendmsg('NickServ', f'IDENTIFY {config.ident.nickname} {config.login.nickserv}')
+		if config.login.operator:
+			Commands.raw(f'OPER {config.ident.username} {config.login.operator}')
+		Commands.join_channel(config.connection.channel, config.connection.key)
+		Commands.join_channel('#scroll')
+
+	def disconnect():
+		Bot.sock.close()
+		time.sleep(15)
+		Bot.connect()
+
+	def kick(chan, kicked):
+		if kicked == config.ident.nickname:
+			if chan == config.connection.channel:
+				time.sleep(3)
+				Commands.join_channel(chan, config.connection.key)
+			elif chan == '#scroll':
+				time.sleep(3)
+				Commands.join_channel(chan)
+
+	def message(ident, nick, chan, msg):
+		try:
+			args = msg.split()
+			if msg == '@scroll':
+				Commands.sendmsg(chan, constants.bold + 'Scroll IRC Bot - Developed by acidvegas in Python - https://acid.vegas/scroll')
+			elif args[0] == '.ascii' and not database.Ignore.check(ident):
+				if Bot.playing and msg == '.ascii stop':
+					Bot.stopper = True
+				elif time.time() - Bot.last < int(database.Settings.get('cmd_throttle')) and not functions.is_admin(ident):
+					if not Bot.slow:
+						Commands.error(chan, 'Slow down nerd!')
+						Bot.slow = True
+				elif len(args) >= 2:
+					Bot.slow = False
+					if args[1] == 'dirs' and len(args) == 2:
+						dirs = sorted(glob.glob(os.path.join(ascii_dir, '*/')))
+						for directory in dirs:
+							name = os.path.basename(os.path.dirname(directory))
+							file_count = str(len(glob.glob(os.path.join(directory, '*.txt'))))
+							Commands.sendmsg(chan, '[{0}] {1} {2}'.format(color(str(dirs.index(directory)+1).zfill(2), constants.pink), name.ljust(10), color(f'({file_count})', constants.grey)))
+					elif args[1] == 'png' and len(args) == 3:
+						url = args[2]
+						if url.startswith('https://pastebin.com/raw/') or url.startswith('http://termbin.com/'):
+							ascii2png.ascii_png(url)
+							ascii_file = os.path.join('data','temp.png')
+							if os.path.getsize(ascii_file) < int(database.Settings.get('png_max_bytes')):
+								Commands.sendmsg(chan, functions.imgur_upload(os.path.join('data','temp.png')))
+								Bot.last_png = time.time()
+							else:
+								Commands.error(chan, 'File too large', '2MB Max')
+						else:
+							Commands.error(chan, 'Invalid URL.', 'Only PasteBin & TermBin URLs can be used.')
+					elif args[1] == 'remote' and len(args) == 3:
+						url = args[2]
+						if 'pastebin.com/raw/' in url or 'termbin.com/' in url:
+							data = functions.get_source(url)
+							if data:
+								for item in data.split('\n'):
+									Commands.sendmsg(chan, item)
+							else:
+								Commands.error(chan, 'Found no data on URL!')
+						else:
+							Commands.error(chan, 'Invalid URL!', 'Must be a pastebin.com or termbin.com link.')
+					elif args[1] == 'search' and len(args) == 3:
+						query   = args[2]
+						results = glob.glob(os.path.join(ascii_dir, f'**/*{query}*.txt'), recursive=True)
+						if results:
+							results = results[:int(database.Settings.get('max_results'))]
+							for file_name in results:
+								count = str(results.index(file_name)+1)
+								Commands.sendmsg(chan, '[{0}] {1}'.format(color(count.zfill(2), constants.pink), os.path.basename(file_name)))
+						else:
+							Commands.error(chan, 'No results found.')
+					elif args[1] == 'upload':
+						if len(args) == 2:
+							uploads = database.Uploads.read()
+							if uploads:
+								for upload in uploads:
+									Commands.sendmsg(chan, '[{0}] {1} - {2} - {3}.txt'.format(color(uploads.index(upload)+1, constants.pink), color(upload[0], constants.yellow), color(upload[1], constants.grey), upload[2]))
+							else:
+								Commands.error(chan, 'No uploaded files!', 'Use ".ascii upload <url> <title>" to upload.')
+						elif len(args) == 4:
+							url = args[2]
+							if url.startswith('https://pastebin.com/raw/') or url.startswith('https://termbin.com/'):
+								if url not in [item[1] for item in database.Uploads.read()]:
+									title = args[3].lower()[:20]
+									check = (glob.glob(os.path.join(ascii_dir, f'**/{title}.txt'), recursive=True)[:1] or [None])[0]