





using Discord;
using Discord.Commands;
using Discord.WebSocket;
namespace Scum_Log_Bot
{
class Program
{
static string BOT_TOKEN = "YOUR_BOT_TOKEN";
static ulong raidChannelId = 0000000000000000000; // YOUR CHANNEL ID
static ulong chatChannelId = 0000000000000000000; // YOUR CHANNEL ID
static DiscordSocketClient client;
static CommandService commandService;
Dictionary<string, KeyValuePair<string, ulong>> infos = new Dictionary<string, KeyValuePair<string, ulong>>();
static Dictionary<string, LoggingTask> tasks = new Dictionary<string, LoggingTask>();
static void Main(string[] args)
{
Console.WriteLine("SCUM BOT - ON (By. 4K)\r\n");
new Program().BotMain().GetAwaiter().GetResult();
}
private async Task BotMain()
{
client = new DiscordSocketClient(new DiscordSocketConfig()
{
LogLevel = LogSeverity.Debug
});
commandService = new CommandService(new CommandServiceConfig()
{
LogLevel = LogSeverity.Debug
});
client.Log += OnClientLogReceived;
client.Ready += OnReady;
client.MessageReceived += OnClientMessage;
client.MessageDeleted += OnClientMessageDeleted;
commandService.Log += OnClientLogReceived;
await client.LoginAsync(TokenType.Bot, BOT_TOKEN);
await client.StartAsync();
await Task.Delay(-1);
}
private Task OnClientLogReceived(LogMessage msg)
{
//Console.WriteLine(msg.ToString());
return Task.CompletedTask;
}
private async Task OnReady()
{
infos.Add("admin", new KeyValuePair<string, ulong>("어드민 명령어", 0000000000000000000)); // YOUR CHANNEL ID
infos.Add("login", new KeyValuePair<string, ulong>("유저 입장", 0000000000000000000)); // YOUR CHANNEL ID
infos.Add("lockpick", new KeyValuePair<string, ulong>("락픽", 0000000000000000000)); // YOUR CHANNEL ID
infos.Add("vehicle_destruction", new KeyValuePair<string, ulong>("차량 파괴", 0000000000000000000)); // YOUR CHANNEL ID
infos.Add("kill", new KeyValuePair<string, ulong>("킬", 0000000000000000000)); // YOUR CHANNEL ID
infos.Add("chat", new KeyValuePair<string, ulong>("인게임 채팅", chatChannelId)); // YOUR CHANNEL ID
infos.Add("violations", new KeyValuePair<string, ulong>("벤", 0000000000000000000)); // YOUR CHANNEL ID
foreach (KeyValuePair<string, KeyValuePair<string, ulong>> info in infos)
{
tasks.Add(info.Key, new LoggingTask(client, info));
Console.WriteLine($"● {info.Value.Key} 로그 활성화");
}
Console.WriteLine();
}
private async Task OnClientMessage(SocketMessage arg)
{
var message = arg as SocketUserMessage;
if (message == null)
{
return;
}
if (message.Author.IsBot && !message.Content.Contains("➡️ 채팅 타입 - Admin") && message.Content.Contains("➡️ 채팅 내용 - !raid") && message.Channel.Id == chatChannelId)
{
await ((IMessageChannel)client.GetChannel(raidChannelId)).SendMessageAsync(message.Content.Replace("📊 PK | ", "📊 FK | "));
}
}
private async Task OnClientMessageDeleted(Cacheable<IMessage, ulong> cachedMessage, Cacheable<IMessageChannel, ulong> channel)
{
foreach (var info in infos)
{
if (info.Value.Value == channel.Id)
{
foreach (var task in tasks)
{
task.Value.run = false;
}
Console.WriteLine($"\r\n- 디스코드 '{channel.Value.Name.Substring(3)}' 채널에서 로그 삭제 이벤트가 발생 되었습니다. 프로그램을 재실행 해주세요.");
Console.Write("이 창을 닫으려면 아무 키나 누르세요...");
Console.ReadKey();
Environment.Exit(0);
}
}
}
}
}
using Discord;
using Discord.WebSocket;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
namespace Scum_Log_Bot
{
class LoggingTask
{
public bool run = true;
private DiscordSocketClient client;
private IMessageChannel channel;
private KeyValuePair<string, KeyValuePair<string, ulong>> info;
private string programPath;
private ulong newLogCount;
private readonly string host = "ftp://YOUR_FTP_IP:YOUR_FTP_PORT";
private readonly string user = "YOUR_FTP_USER_ID";
private readonly string password = "YOUR_FTP_USER_PASSWORD";
private readonly string remote = "/SCUM/Saved/SaveFiles/Logs/";
private string[] logFiles = new string[0];
private ulong lastFileDate;
private ulong lastFileLine;
public LoggingTask(DiscordSocketClient client, KeyValuePair<string, KeyValuePair<string, ulong>> info)
{
this.client = client;
this.info = info;
new Thread(Run).Start();
}
private async void Run()
{
channel = client.GetChannel(info.Value.Value) as IMessageChannel;
programPath = Directory.GetCurrentDirectory() + "/Logs/" + info.Key;
if (!Directory.Exists(programPath))
{
Directory.CreateDirectory(programPath);
}
await ReadLastFileAsync();
while (run)
{
await PrintSpecificFilesFromFtp(host, user, password, remote);
}
}
private async Task ReadLastFileAsync()
{
try
{
lastFileDate = ulong.Parse(File.ReadAllText(programPath + "/LastProgramFileDate.log"));
}
catch (Exception ex)
{
//Console.WriteLine($"Error: {ex.StackTrace}");
lastFileDate = 0;
}
try
{
lastFileLine = ulong.Parse(File.ReadAllText(programPath + "/LastProgramFileLine.log"));
} catch (Exception ex)
{
//Console.WriteLine($"Error: {ex.StackTrace}");
lastFileLine = 0;
}
}
private async Task WriteLastFileAsync()
{
try
{
File.WriteAllText(programPath + "/LastProgramFileDate.log", lastFileDate + "");
}
catch (Exception ex)
{
//Console.WriteLine($"Error: {ex.StackTrace}");
}
try
{
File.WriteAllText(programPath + "/LastProgramFileLine.log", lastFileLine + "");
}
catch (Exception ex)
{
//Console.WriteLine($"Error: {ex.StackTrace}");
}
}
private async Task PrintSpecificFilesFromFtp(string ftpServer, string ftpUsername, string ftpPassword, string remotePath)
{
try
{
await ListFilesOnFtpServer(ftpServer, ftpUsername, ftpPassword, remotePath);
newLogCount = 0;
foreach (string file in logFiles)
{
ulong currentFileDate = ulong.Parse(file.Substring(file.Length - 18, 14));
if (currentFileDate < lastFileDate)
{
continue;
}
string remoteFilePath = $"{remotePath}{file}";
await PrintFileFromFtp(ftpServer, ftpUsername, ftpPassword, remoteFilePath, currentFileDate);
}
if (newLogCount > 0)
{
Console.WriteLine($"- 디스코드 '{channel.Name.Substring(3)}' 채널에 새로운 로그 {newLogCount}개가 갱신 되었습니다.");
}
}
catch (Exception ex)
{
//Console.WriteLine($"Error: {ex.StackTrace}");
}
}
private async Task ListFilesOnFtpServer(string ftpServer, string ftpUsername, string ftpPassword, string remotePath)
{
string ftpUri = $"{ftpServer}{remotePath}";
FtpWebRequest request = (FtpWebRequest)WebRequest.Create(ftpUri);
request.Method = WebRequestMethods.Ftp.ListDirectoryDetails;
request.Credentials = new NetworkCredential(ftpUsername, ftpPassword);
request.UsePassive = true;
try
{
using (FtpWebResponse response = (FtpWebResponse)request.GetResponse())
using (Stream responseStream = response.GetResponseStream())
using (StreamReader reader = new StreamReader(responseStream))
{
string line;
var files = new List<string>();
while ((line = reader.ReadLine()) != null)
{
string[] parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
string fileName = parts[parts.Length - 1];
if (fileName.StartsWith(ExceptionKey()))
{
files.Add(fileName);
}
}
logFiles = files.ToArray();
}
}
catch (WebException ex)
{
//Console.WriteLine($"Error: {ex.StackTrace}");
logFiles = new string[0];
}
}
private async Task PrintFileFromFtp(string ftpServer, string ftpUsername, string ftpPassword, string remoteFilePath, ulong currentFileDate)
{
ulong currentFileLine = 1;
string ftpUri = $"{ftpServer}{remoteFilePath}";
FtpWebRequest request = (FtpWebRequest)WebRequest.Create(ftpUri);
request.Method = WebRequestMethods.Ftp.DownloadFile;
request.Credentials = new NetworkCredential(ftpUsername, ftpPassword);
request.UsePassive = true;
try
{
using (FtpWebResponse response = (FtpWebResponse)request.GetResponse())
using (Stream responseStream = response.GetResponseStream())
using (StreamReader reader = new StreamReader(responseStream, Encoding.Unicode))
{
string allData = reader.ReadToEnd();
string[] contents = allData.Split("\n");
int size = contents.Length;
for (int i = 0; i < size; i++)
{
if (contents[i].Length > 0 && !contents[i].Contains("Game version"))
{
if (!ExceptionConditionCheck(contents[i]))
{
continue;
}
if (currentFileDate > lastFileDate)
{
lastFileLine = 0;
}
if (currentFileLine <= lastFileLine)
{
currentFileLine++;
continue;
}
string data = ParseData(contents[i], currentFileLine);
await channel.SendMessageAsync($"```{data}```");
lastFileDate = currentFileDate;
lastFileLine = currentFileLine;
await WriteLastFileAsync();
currentFileLine++;
newLogCount++;
await Task.Delay(1000);
}
}
}
}
catch (WebException ex)
{
//Console.WriteLine($"Error: {ex.StackTrace}");
}
}
private string ParseData(string data, ulong currentFileLine)
{
string pData = "📊 PK | " + info.Value.Key + " | " + DateTime.ParseExact(data.Substring(0, 19), "yyyy.MM.dd-HH.mm.ss", CultureInfo.InvariantCulture).AddHours(9).ToString("yyyy.MM.dd-HH:mm:ss", CultureInfo.InvariantCulture) + " | " + currentFileLine + "\r\n";
switch (info.Key)
{
case "admin":
string userPattern = @"'(\d+):(.+?)\((\d+)\)'";
string commandPattern = @"Command:\s'(.+?)'";
string teleportPattern = @"Target\sof\sTeleportTo:\s'(\d+):(.+?)\((\d+)\)'\sLocation:\sX=(-?\d+\.\d+)\sY=(-?\d+\.\d+)\sZ=(-?\d+\.\d+)";
string mapClickPattern = @"Used\smap\sclick\steleport\sto\s(player|vehicle):\s(?:'(\d+):(.+?)\((\d+)\)'|(.+?))\sLocation:\sX=(-?\d+\.\d+)\sY=(-?\d+\.\d+)\sZ=(-?\d+\.\d+)";
var userMatch = Regex.Match(data, userPattern);
if (userMatch.Success)
{
pData += $"➡️ 사용자 정보 - {userMatch.Groups[2].Value} ({userMatch.Groups[1].Value})\r\n";
var commandMatch = Regex.Match(data, commandPattern);
var teleportMatch = Regex.Match(data, teleportPattern);
var mapClickMatch = Regex.Match(data, mapClickPattern);
if (commandMatch.Success)
{
pData += $"➡️ 명령어 타입 - 채팅\r\n";
pData += $"➡️ 명령어 정보 - {commandMatch.Groups[1].Value}";
}
else if (teleportMatch.Success)
{
pData += $"➡️ 명령어 타입 - 맵 클릭\r\n";
pData += $"➡️ 텔레포트 대상 - {teleportMatch.Groups[2].Value} ({teleportMatch.Groups[1].Value})\r\n";
pData += $"➡️ 텔레포트 좌표 - X={teleportMatch.Groups[4].Value} Y={teleportMatch.Groups[5].Value} Z={teleportMatch.Groups[6].Value}";
}
else if (mapClickMatch.Success)
{
string steamId = mapClickMatch.Groups[2].Value;
pData += $"➡️ 명령어 타입 - 맵 클릭\r\n";
if (!string.IsNullOrEmpty(steamId))
{
pData += $"➡️ 텔레포트 대상 - {mapClickMatch.Groups[3].Value} ({steamId})\r\n";
} else
{
pData += $"➡️ 텔레포트 대상 - {mapClickMatch.Groups[5].Value.Replace("'", "")}\r\n";
}
pData += $"➡️ 텔레포트 좌표 - X={mapClickMatch.Groups[6].Value} Y={mapClickMatch.Groups[7].Value} Z={mapClickMatch.Groups[8].Value}";
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 2)";
}
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 1)";
}
break;
case "login":
string loginPattern = @"'(\d+\.\d+\.\d+\.\d+)\s(\d+):(.+?)\((\d+)\)' logged in at:\sX=(-?\d+\.\d+)\sY=(-?\d+\.\d+)\sZ=(-?\d+\.\d+)";
var loginMatch = Regex.Match(data, loginPattern);
if (loginMatch.Success)
{
pData += $"➡️ 계정 정보 - {loginMatch.Groups[3].Value} ({loginMatch.Groups[2].Value})\r\n";
pData += $"➡️ IP 정보 - {loginMatch.Groups[1].Value}\r\n";
pData += $"➡️ 입장 좌표 - X={loginMatch.Groups[5].Value} Y={loginMatch.Groups[6].Value} Z={loginMatch.Groups[7].Value}";
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 3)";
}
break;
case "lockpick":
string lockpickPattern = @"\[LogMinigame\]\s\[(?:BP_DialLockMinigame_C|LockpickingMinigame_C)\]\sUser:\s(.+?)\s\((\d+),\s(\d+)\)\.\sSuccess:\s(\w+)\.\sElapsed\stime:\s([\d\.]+)\.\sFailed\sattempts:\s(\d+)\.\sTarget\sobject:\s(.+?)\(ID:\s(.+?)\)\.\sLock\stype:\s(.+?)\.\sUser\sowner:\s(.+?)\.\sLocation:\sX=(-?\d+\.\d+)\sY=(-?\d+\.\d+)\sZ=(-?\d+\.\d+)";
var lockpickMatch = Regex.Match(data, lockpickPattern);
if (lockpickMatch.Success)
{
string lockType = lockpickMatch.Groups[9].Value;
pData += $"➡️ 계정 정보 - {lockpickMatch.Groups[1].Value} ({lockpickMatch.Groups[3].Value})\r\n";
pData += $"➡️ 대상 정보 - {lockpickMatch.Groups[7].Value} ({lockpickMatch.Groups[8].Value})\r\n";
pData += $"➡️ 대상 주인 - {lockpickMatch.Groups[10].Value}\r\n";
pData += $"➡️ 대상 좌표 - X={lockpickMatch.Groups[11].Value} Y={lockpickMatch.Groups[12].Value} Z={lockpickMatch.Groups[13].Value}\r\n";
pData += $"➡️ 잠금 종류 - {(lockType.Equals("DialLock") ? "다이얼 락" : lockType.Equals("Advanced") ? "금장" : lockType.Equals("Medium") ? "은장" : lockType.Equals("Basic") ? "동장" : "똥장")}\r\n";
pData += $"➡️ 성공 여부 - {(lockpickMatch.Groups[4].Value.Equals("Yes") ? "⭕" : "❌")}\r\n";
pData += $"➡️ 실패 횟수 - {lockpickMatch.Groups[6].Value}회\r\n";
pData += $"➡️ 소요 시간 - {lockpickMatch.Groups[5].Value}초";
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 4)";
}
break;
case "vehicle_destruction":
string vdPattern = @"\[Destroyed\]\s([\w_]+)\.\sVehicleId:\s(\d+)\.\sOwner:\s([^\.]+)\.\sLocation:\sX=([-?\d.]+)\sY=([-?\d.]+)\sZ=([-?\d.]+)";
var vdMatch = Regex.Match(data, vdPattern);
if (vdMatch.Success)
{
pData += $"➡️ 차량 정보 - {vdMatch.Groups[1].Value} ({vdMatch.Groups[2].Value})\r\n";
pData += $"➡️ 차량 주인 - {vdMatch.Groups[3].Value}\r\n";
pData += $"➡️ 파괴 좌표 - X={vdMatch.Groups[4].Value} Y={vdMatch.Groups[5].Value} Z={vdMatch.Groups[6].Value}";
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 5)";
}
break;
case "kill":
string killerPattern = @"Killer:\s(.+?)\s\((\d+)\)";
string diedPattern = @"Died:\s(.+?)\s\((\d+)\)";
string weaponPattern = @"Weapon:\s(.+?)\s\[(.+?)\]";
string locationsPattern = @"KillerLoc\s:\s([\d\.\-]+),\s([\d\.\-]+),\s([\d\.\-]+)\sVictimLoc:\s([\d\.\-]+),\s([\d\.\-]+),\s([\d\.\-]+),\sDistance:\s([\d\.\-]+)\sm";
var killerMatch = Regex.Match(data, killerPattern);
var diedMatch = Regex.Match(data, diedPattern);
var weaponMatch = Regex.Match(data, weaponPattern);
var locationsMatch = Regex.Match(data, locationsPattern);
if (killerMatch.Success && diedMatch.Success && weaponMatch.Success && locationsMatch.Success)
{
pData += $"➡️ 공격자 정보 - {killerMatch.Groups[1].Value} ({killerMatch.Groups[2].Value})\r\n";
pData += $"➡️ 공격자 좌표 - X={locationsMatch.Groups[1].Value} Y={locationsMatch.Groups[2].Value} Z={locationsMatch.Groups[3].Value}\r\n";
pData += $"➡️ 피해자 정보 - {diedMatch.Groups[1].Value} ({diedMatch.Groups[2].Value})\r\n";
pData += $"➡️ 피해자 좌표 - X={locationsMatch.Groups[4].Value} Y={locationsMatch.Groups[5].Value} Z={locationsMatch.Groups[6].Value}\r\n";
pData += $"➡️ 무기 정보 - {weaponMatch.Groups[1].Value} ({weaponMatch.Groups[2].Value})\r\n";
pData += $"➡️ 거리 정보 - {locationsMatch.Groups[7].Value}m";
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 6)";
}
break;
case "chat":
string chatPattern = @"'(\d{17}):([^']*)' '([^:]*):\s(.*)'";
var chatMatch = Regex.Match(data, chatPattern);
if (chatMatch.Success)
{
pData += $"➡️ 계정 정보 - {chatMatch.Groups[2].Value} ({chatMatch.Groups[1].Value})\r\n";
pData += $"➡️ 채팅 타입 - {chatMatch.Groups[3].Value}\r\n";
pData += $"➡️ 채팅 내용 - {chatMatch.Groups[4].Value}";
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 7)";
}
break;
case "violations":
string violationPattern = @"AConZGameMode::BanPlayerById:\sUser id:\s'(\d{17})'";
var violationMatch = Regex.Match(data, violationPattern);
if (violationMatch.Success)
{
pData += $"➡️ 계정 정보 - {violationMatch.Groups[1].Value}";
}
else
{
pData = $"⚠️ 오류 정보 - 로그 기록 중 예기치 못한 오류가 발생 했습니다. (Error Code. 8)";
}
break;
}
return pData;
}
private string ExceptionKey()
{
return
info.Key.Equals("lockpick") ? "gameplay" :
info.Key;
}
private bool ExceptionConditionCheck(string data)
{
if (info.Key.Equals("admin") && !data.Contains("Command") && !data.Contains("Location"))
{
return false;
}
if (info.Key.Equals("login") && !data.Contains("logged in"))
{
return false;
}
if (info.Key.Equals("lockpick") && !data.Contains("LockpickingMinigame_C") && !data.Contains("BP_DialLockMinigame_C"))
{
return false;
}
if (info.Key.Equals("vehicle_destruction") && !data.Contains("Destroyed"))
{
return false;
}
if (info.Key.Equals("kill") && !data.Contains("Died"))
{
return false;
}
if (info.Key.Equals("violations") && !data.Contains("BanPlayerById"))
{
return false;
}
return true;
}
}
}
➡️ SCUM 게임 서버 관리자가 직접 GPORTAL 사의 FTP 서버에 연결하지 않고도 실시간으로 손쉽게 로그 확인을 할 수 있도록 하기 위해 제작
➡️ SCUM 이라는 게임의 서버 호스팅을 제공하는 GPORTAL 사의 FTP 서버와 연결해 실시간으로 로그 파일을 크롤링 하고 그 데이터를 디스코드 봇을 통해 메세지 형태로 시각화 하는 방식으로 동작
➡️ FTP 연결 라이브러리
➡️ Discord 봇 API
➡️ 데이터 크롤링 및 파싱
➡️ 데이터 캡슐화
➡️ 멀티 쓰레딩
➡️ 자원 비동기화
➡️ 파일 입출력