discord.py로 금지어 삭제봇 개발하기

요즘 게임과 각종 온라인 커뮤니케이션 수단으로 디스코드를 많이 사용하는 것 같다. 어떤 대회나 컨퍼런스 등등 문의사항이나 커뮤니케이션을 위한 디스코드 채널을 개설하는걸 많이 봤기 때문이다.

필자 또한 친구들과 게임을 하거나 단톡방 느낌으로 디스코드 채널을 이용하고 있는데 채널 안에 여러 사람이 있을 시 발생하는 여러가지 문제점이 있다.

이는 카카오톡을 이용할 떄에도 비슷하게 발생하는데, 각종 욕설이나 금지어 등등 이런 룰을 정해 숙지하는 편이다.

필자가 속한 채널은 금지어를 작성하면 필자와 다른 관리자 인원이 해당 채팅을 지우는 방식을 사용하고 있는데 이는 관리자 들에게 많은 피로감을 가져왔고, 관리자가 자리를 비웠 때 발생하는 비매너 채팅에 대해서 반응할 수 없다는 단점이 있었다. 따라서 여기서 디스코드 봇을 만들기로 생각했다.

봇이 사용자들의 메시지를 인식해 금지어가 포함되어 있으면 자동으로 삭제하는 루틴이다.

기본적으로 사용할 환경은 Python에 discord.py 라이브러리를 이용할 예정이고 서버는 집에 남는 orangepi를 이용하려고 한다. 서버같은 경우 데스크탑을 24시간 돌리기 부담스러워 이런 방법을 선택했으나 heroku를 이용해 서버 호스팅도 가능하다.

개발하는 사람이 편한 방법을 찾으면 될것 같다. 필자는 직접 서버를 구축해 사용하기 때문에 orangepi에 docker를 설치에 서버를 만들었다.

우선 여기서 사용할 라이브러리는 discord.py라는 라이브러리로, 파이썬 코루틴(비동기)기반으로 작동하는 라이브러리이다.

기본적으로 코드는 https://discordpy-ko.github.io/ 의 가이드를 따라했다.

https://discord.com/developers/applications 에서 봇을 생성하고 기본적인 봇에대한 정보를 설정해 준다.

우측 상단에 New Application을 누르면 위 사진처럼 뜬다. 봇 이름을 적고 create를 누른다. 그럼 다음과 같은 페이지가 나온다.

General Information에서 기본적인 정보를 수정하고 좌측에 있는 Bot을 클릭한다.

여기서 add bot 버튼을 누르면 이 엑션은 되돌릴수 없다는 경고문이 뜨는데 다음을 눌러 진행한다.

여기서 봇 사용을 위한 권한 설정을 해준다. 그런 다음 reset token을 누르면 해당 봇에 대한 엑세스 토큰이 발행된다. 이 토큰값이 유출되면 외부에서 부정 사용이 발생할 수 있으니 안전한 곳에 백업해 두길 바란다.

이제 죄측 메유늬 OAuth2 > URL Generator로 들어간다. 여기서 자기가 만들고자 하는 봇에 필요한 권한을 설정해 봇을 추가하기 위한 URL을 생성할 수 있다.

필자는 메시지 제어를 위해 Text permissions의 모든 권한을 부여했다. 이후 Genrated URL 부분에 URL를 복사해 브라우저에 붙여넣기를 하면 본인이 봇을 추가할 수 있는 채널의 리스트가 보이며 추가가 완료된다.



이제 파이썬 사용을 위해서 다음 명령어를 통해 discord.py 라이브러리를 설치한다.

pip3 install discord.py

이후 import discord 구문을 통해 라이브러리를 불러온다.

다음 코드는 discord.py에서 올려준 빠른시작 코드이다.

import discord

client = discord.Client()

@client.event
async def on_ready():
    print('We have logged in as {0.user}'.format(client))

@client.event
async def on_message(message):
    if message.author == client.user:
        return

    if message.content.startswith('$hello'):
        await message.channel.send('Hello!')

client.run('your token here')

위 코드는 on_ready 구문을 통해 봇이 준비되면 “We have logged in as bot#tag”를 출력한다. 여기서 bot#tag는 봇의 이름이다.

필자가 작성한 코드는 다음과 같다.

import discord, logging, sqlite3, os
from discord.ext import commands
import muyahoDB as db
import drawTableModule as dt

dirctory = os.path.dirname(__file__)

#logger setting
logger = logging.getLogger('discord')
logger.setLevel(logging.DEBUG)
handler = logging.FileHandler(filename='discord.log', encoding='utf-8', mode='w')
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
logger.addHandler(handler)

#discord setting
client = discord.Client()
bot = commands.Bot(command_prefix='$')

#db connect
con = sqlite3.connect('muyaho.db')
db.initDB(con)

@client.event
async def on_ready():
	print('We have logged in as {0.user}'.format(client))

def help():
	_return = ""
	_return += "$showTextBL	=>	텍스트 블랙리스트 출력\n"
	_return += "$addBLAllT		=>	인수로 받은 단어를 모든사용자 대상의 블랙리스트로 지정\n"
	_return += "$addBLUserT	=>	첫번째 인수로 대상 사용자이름, 두번째 인수로 지정할 \n\t\t\t\t\t\t\t\t\t 블랙리스트 단어를 적용\n"
	_return += "$showLog		=>	삭제된 텍스트의 로그를 출력, 인수로 출력할 로그의 라인 수를 지정"
	_return += "$showAtmBL	=>	첨부파일 블랙리스트 출력\n"
	_return += "$addBLAllA		=>	인수로 받은 첨부파일을 모든사용자 대상의 블랙리스트로 지정\n"
	_return += "$addBLUserA	=>	첫번째 인수로 대상 사용자이름, 두번째 인수로 지정할 \n\t\t\t\t\t\t\t\t\t 블랙리스트 첨부파일을 적용\n"
	_return += "$rmTextBL	=>	지정한 pk의 텍스트 BL를 삭제, 인수는 int\n"
	_return += "$rmAtmBL	=>	지정한 pk의 첨부파일 BL를 삭제, 인수는 int\n"
	return _return

def commandCheck(msg):
	#text
	if(msg.content.startswith("$addBLAllT")):
		if(db.addBLAllT(con, msg) == 1):return 1
		else: return 0
	elif(msg.content.startswith("$addBLUserT")):
		if(db.addBLUserT(con, msg) == 1):return 1
		else: return 0
	elif(msg.content.startswith("$rmTextBL")):
		result = db.rmTextBL(con, msg)
		if(result == 1): return 1
		else: return 0
	#attachments
	elif(msg.content.startswith("$addBLAllA")):
		if(db.addBLAllA(con, msg) == 1):return 1
		else: return 0
	elif(msg.content.startswith("$addBLUserA")):
		if(db.addBLUserA(con, msg) == 1):return 1
		else: return 0
	elif(msg.content.startswith("$rmAtmBL")):
		result = db.rmAtmBL(con, msg)
		if(result == 1): return 1
		else: return 0
	#출력부분
	elif(msg.content.startswith("$showTextBL")):
		result = db.showTextBL(con, msg)
		if(result == 1): return 1
		else: return result
	elif(msg.content.startswith("$showAtmBL")):
		result = db.showAtmBL(con, msg)
		if(result == 1): return 1
		else: return result
	elif(msg.content.startswith("$showLog")):
		result = db.showLog(con, msg)
		if(result == 1): return 1
		else: return result
	elif(msg.content.startswith("$help")):
		return help()
	else:
		return 100



#@client.event
#async def on_message(message):
#	if message.author == client.user:
#		return
#
#	if message.content.startswith('$hello'):
#		await message.channel.send('Hello!')
#		print("Hello print! to "+message.author.name)

@client.event
async def on_message(msg):
	if(msg.author == client.user):
		return
	else:
			db.checkUser(con, msg)

	if(str(msg.author.id) == "개발자 user ID"): #개발자만 명령어 사용 가능
		commandReturn = commandCheck(msg)
		if(commandReturn == 100):
			pass
		elif(commandReturn == 1):#return 값 bool 확인
			await msg.channel.send('명령어에 오류가 발생했습니다');return
		elif(commandReturn == 0):
			await msg.channel.send('적용 완료');return
		elif(commandReturn != 0 and commandReturn != 1 and commandReturn == "logData"):
			txt = discord.File("delLog.txt", filename=dirctory+"/delLog.txt")
			await msg.channel.send(file=txt);return
		elif(commandReturn != 0 and commandReturn != 1 and commandReturn.find(".txt") != -1):# return 값 bool 아닌 명령어들
			txt = discord.File(commandReturn, filename=dirctory+"/"+commandReturn)
			await msg.channel.send(file=txt);return
		elif(commandReturn != 0 and commandReturn != 1):# return 값 bool 아닌 명령어들
			await msg.channel.send(commandReturn);return

	if(msg.attachments == []):
		for i in db.selectTextBL(con, msg):
			if(msg.content.find(i[0]) != -1):
				print("delete =>  "+msg.content)
				db.logging(con, msg, i[0], "text")
				await msg.delete(); return

	elif(msg.attachments != []):
		for i in db.selectAtmBL(con, msg):
			if(str(msg.attachments).find(i[0]) != -1):
				print("delete =>  "+str(msg.attachments))
				db.logging(con, msg, i[0], "text")
				await msg.delete(); return
		for i in db.selectTextBL(con, msg):
			if(msg.content.find(i[0]) != -1):
				print("delete =>  "+msg.content)
				db.logging(con, msg, i[0], "text")
				await msg.delete(); return




client.run('your token')
def initDB(con):
	cur = con.cursor()
	cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
	if(cur.fetchall() == []):
		print("DB 	=>	empty DB! init start, create tables")
		cur.execute("CREATE TABLE user (pk integer not null primary key autoincrement, userName text not null, userId text not null)")
		cur.execute("CREATE TABLE textBL (pk integer not null primary key autoincrement, BL text not null, userId text not null, userName text not null)")
		cur.execute("CREATE TABLE atmBL (pk integer not null primary key autoincrement, BL text not null, userId text not null, userName text not null)")
		cur.execute("CREATE TABLE delLog (pk integer not null primary key autoincrement, userId text not null, userName text not null, BL text not null, target text not null, type text not null, delTime TIMESTAMP DEFAULT (DATETIME('now', 'localtime')))")
		con.commit()

def logging(con, msg, BL, type):
	cur = con.cursor()
	if(type == "text"):
		cur.execute("insert into delLog (userId, userName, BL, target, type) values (?, ?, ?, ?, ?)", (msg.author.id, msg.author.name, BL, msg.content, "text"))
	else:
		cur.execute("insert into delLog (userId, userName, BL, target, type) values (?, ?, ?, ?, ?)", (msg.author.id, msg.author.name, BL, msg.content, "atm"))
	con.commit()

def showLog(con, msg):
	try:
		temp = int(msg.content.strip("$showLog "))
		print(temp)
		cur = con.cursor()
		cur.execute("select pk, userName, BL, target, delTime from delLog order by pk desc limit (cast(? as integer))", (temp,))
		_return = ""
		for _tuple in cur.fetchall():
			_return+=(str(_tuple)+"\n")
		f = open("delLog.txt", "w")
		f.write(_return)
		f.close()
		return "logData"#"delLog.txt"
	except:
		return 1

def checkUser(con, msg):
	cur = con.cursor()
	cur.execute("select userId from user")
	if(str(cur.fetchall()).find(str(msg.author.id)) == -1):
		cur.execute("insert into user (userName, userId) values (?, ?)", (str(msg.author.name), str(msg.author.id)))
		con.commit()

def showTextBL(con, msg): #text blackList 보기
	try:
		cur = con.cursor()
		cur.execute("select * from textBL")
		dbTuples = cur.fetchall()
		returnData = ""
		for dbTuple in dbTuples:
			returnData += str(dbTuple)+"\n"
		f = open("textBL.txt", "w")
		f.write(returnData)
		f.close()
		return("textBL.txt")
	except:
		return 1

def showAtmBL(con, msg): #attachments blackList 보기
	try:
		cur = con.cursor()
		cur.execute("select * from atmBL")
		dbTuples = cur.fetchall()
		returnData = ""
		for dbTuple in dbTuples:
			returnData += str(dbTuple)+"\n"
		f = open("atmBL.txt", "w")
		f.write(returnData)
		f.close()
		return("atmBL.txt")
	except:
		return 1

def selectTextBL(con, msg):
	returnList = []
	cur = con.cursor()
	cur.execute("select BL from textBL where userId = 'all'")
	dbTuples = cur.fetchall()
	for item in dbTuples:
		returnList.append(list(item))
	cur.execute("select BL from textBL where userId = ?", (msg.author.id, ))
	dbTuples = cur.fetchall()
	for item in dbTuples:
		returnList.append(list(item))
	return returnList

def selectAtmBL(con, msg):
	returnList = []
	cur = con.cursor()
	cur.execute("select BL from atmBL where userId = 'all'")
	dbTuples = cur.fetchall()
	for item in dbTuples:
		returnList.append(list(item))
	cur.execute("select BL from atmBL where userId = ?", (msg.author.id, ))
	dbTuples = cur.fetchall()
	for item in dbTuples:
		returnList.append(list(item))
	return returnList

def addBLAllT(con, arg):#text
	try:
		temp = arg.content.strip("$addBLAllT ")
		cur = con.cursor()
		cur.execute("insert into textBL (BL, userId, userName) values(?, 'all', 'all')", (temp, ))
		con.commit()
		return 0
	except:
		return 1

def addBLUserT(con, arg):#text
	try:
		temp = arg.content.strip("$addBLUserT ")
		argList = list(temp.split(' '))
		userName = argList[0]
		del argList[0]
		BL = ""
		count = 1
		for i in argList:
			if(count == len(argList)):
				BL = BL + str(i)
			else:
				BL =  BL + (str(i)+" ")
			count += 1
		cur = con.cursor()
		cur.execute("select userId, userName from user where userName = ?", (userName, ))
		returnText = list(cur.fetchall()[0])
		cur.execute("insert into textBL (BL, userId, userName) values(?, ?, ?)", (BL, returnText[0], returnText[1]))
		con.commit()
		return 0
	except:
		return 1

def addBLAllA(con, arg):#attachments
	try:
		temp = arg.content.strip("$addBLAllA ")
		cur = con.cursor()
		cur.execute("insert into atmBL (BL, userId, userName) values(?, 'all', 'all')", (temp, ))
		con.commit()
		return 0
	except:
		return 1

def addBLUserA(con, arg):#attachments
	try:
		temp = arg.content.strip("$addBLUserA ")
		argList = list(temp.split(' '))
		cur = con.cursor()
		cur.execute("select userId, userName from user where userName = ?", (argList[0], ))
		returnText = list(cur.fetchall()[0])
		cur.execute("insert into atmBL (BL, userId, userName) values(?, ?, ?)", (argList[1], returnText[0], returnText[1]))
		con.commit()
		return 0
	except:
		return 1

def rmTextBL(con, arg):
	temp = arg.content.strip("$rmTextBL ")
	cur = con.cursor()
	cur.execute("delete from textBL where pk = (cast(? as integer))", (temp, ))
	con.commit()
	return 0

def rmAtmBL(con, arg):
	temp = arg.content.strip("$rmAtmBL ")
	cur = con.cursor()
	cur.execute("delete from atmBL where pk = (cast(? as integer))", (temp, ))
	con.commit()
	return 0

처음에는 텍스트 파일을 메시지 액션이 있을때 마다 BlackList로 읽어 문자열을 검색했지만 sqlite3로 대체하면서 db 쿼리문이 포함된 함수들을 따로 만들어 주었다.

from discord.ext import commands
from discord.ext.commands import Bot

bot = commands.Bot(command_prefix='$')

위 구문을 이용해서 bot.command를 쓰면 명령어를 더 편하게 쓸 수 있지만 어떤 이유에서인지 on_message와 bot.command가 같이 동작하지 않아, on_message 안에서 commandCheck 함수를 통해 명령어 기능을 적용했다. 이렇게 되면 명령어를 추가 해야하는 시점에서 commandCheck와 다른 함수들일 직접 구현해야 하지만 실시간으로 올라오는 모든 메시지에 대해서 금지어 검사를 위해 위처럼 코드를 작성했다.

여러 서버에서 사용하려면 서버ID, 채널ID 등을 추가로 DB로 만들어 비교하는 코드를 짜주면 된다.

msg.attachments를 통해 첨부파일의 유무도 확인할 수 있는데, 등록한 문자열이 첨부파일 이름이나 링크에 추가되어 있으면 이 또한 메시지가 삭제된다. 주로 이미지를 삭제하기 위해 만든 기능인데, 이미지 이름을 바꾸면 쉽게 우회가 가능해서 다음에는 openCV를 통해 이미지 유사도 검사기능을 넣어 비교하는것을 테스트 할 생각이다. 다만 비교하는데 얼만큼의 리소스 사용, 걸리는 시간 등을 잘 판단해 어떤 방식이 더 득이 많을지 생각해 봐야할 문제이다.

전체사용자에 대한 금지어 설정도 가능하지만 사용자들이 채팅을 입력하면 db에 사용자 이름과 고유 USER ID를 저장하며 비교하므로 $addBLUserT 유저이름 금지어 를 통해 지정도 가능하다. 물론 첨부파일도 마찬가지이다.

삭제로그 출력의 경우 일부 로그는 상관 없지만 2000글자가 넘어가는 대용량 로그의 경우 디스코드에서 막아둔것 같다. 이 때문에 로그를 txt 파일로 만들어 첨부파일 형태로 보내는 방식을 이용했다. PC에서는 파일 미리보기를 통해 쉽게 확인이 가능하지만, 스마트폰에서는 파일을 직접 다운받아 열어봐야하는 단점이 있다.

블랙리스트 출력 또한 위 txt 파일 업로드 방식을 사용했다.

봇 커멘드의 경우 $help를 치면 출력이 되도록 설정했다.