유챗 봇 제작 #2 – 접속정보 처리

 이전까지의 글에서는 유챗 서버와의 통신에 필요한 맨 아랫 부분을 다루어 보았다. 여기서 부터는 밑바닥에 대한 부분에서 벗어나서 실질적으로 봇을 이용하기 위한 (일종의) 클래스를 구현해 볼 것이다.

 글을 쓰는 도중에 tokio가 1.0으로 버전이 올라갔다. 아직 종속된 라이브러리들이 1.0을 지원하지 않아서 0.3버전을 바탕으로 글을 작성하였다. 1.0 버전으로 올리는 과정에 대해서는 따로 글을 쓸 것 같지는 않으니, 필요가 있으면 직접 Github의 리포를 확인해야 할 것이다.


 이전글에서는 아래와 같은 코드를 이용해서 직접적으로 접속 정보를 전송하였다:

ws_stream.send(tokio_tungstenite::tungstenite::Message::binary("j#ZXN1a21lYW4=AR7sEOzREQsBrdAUoSdRHtu18bvG1QtKutf-8\n"))

 하지만 위의 코드로는 접속정보를 바꾸기가 쉽지 않다. 여러개의 방에 접속하기 위해서는 매번 접속정보를 생성 해 줘야 하며, 인증 토큰을 필요로 하는 방에 접속하려면 해줄것이 너무 많아진다.

물론 안될 것은 없겠지만 이것들을 직접 구워먹고자 하면 상당히 번거롭고 귀찮은 방법이다. 그러므로 앞으로 접속정보를 관리하는 구조체를 만들어서 관련된 작업은 모두 거기서 이루어 지게 한다. 

우선 접속정보를 담아낼 수 있는 구조체를 만든다. cache_token과 profile_imageuchat.js를 뜯어보면서 ‘이런게 있는구나~’ 를 알아냈다. 실제로는 어떻게 사용되는지 까지는 모르겠다. 겉으로는 드러나지 않지만 계속해서 내부에선 업데이트가 진행되는듯 한 것으로 보아, 언젠가 기능 구현에 쓰일지도 모르겠다.

pub struct JoinConfig {
	pub room: String,
	pub token: Option<String>,
	pub nick: Option<String>,
	pub id: Option<String>,
	pub level: Option<String>,
	pub auth: UChatAuthLevel,
	pub icon: Option<String>,
	pub nickcon: Option<String>,
	pub other: Option<String>,
	pub password: Option<String>,
	pub cache_token: Option<String>,
	pub profile_image: Option<String>,
}

uchat.js를 뜯어봤더니, 무조건 있어야 하는 값은 방 ID 하나 뿐이었다. 접속 인증을 사용하지 않는 방은 token이 필요 없다. nick이 없으면 알아서 손님1234 같은 닉네임을 만들어 준다. auth 또한 Option으로 뺄려면 뺄 수 있으나 구현에 있어서 한단계 추가 할 것이 생겨서 UChatAuthLevel이라는 enum에 None을 추가해 놓는것으로 타협하였다.

값을 설정하는 방식으로는 builder 패턴을 이용하였다. getter / setter를 만들 필요도 없을것 같아서 저 수준에서 마감을 했다. (어짜피 필요하면 구조체 내에 접근 가능하기도 하다) 

pub fn token(mut self, token: Option<String>) -> Self {
	self.token = token;
	return self;
}
pub fn nick(mut self, nick: Option<String>) -> Self {
	self.nick = nick;
	return self;
}
pub fn id(mut self, id: Option<String>) -> Self {
	self.id = id;
	return self;
}
pub fn level(mut self, level: Option<String>) -> Self {
	self.level = level;
	return self;
}
pub fn auth(mut self, auth: UChatAuthLevel) -> Self {
	self.auth = auth;
	return self;
}
pub fn icon(mut self, icon: Option<String>) -> Self {
	self.icon = icon;
	return self;
}
pub fn nickcon(mut self, nickcon: Option<String>) -> Self {
	self.nickcon = nickcon;
	return self;
}
pub fn other(mut self, other: Option<String>) -> Self {
	self.other = other;
	return self;
}
pub fn password(mut self, password: Option<String>) -> Self {
	self.password = password;
	return self;
}
pub fn client_token(mut self, token: Option<String>) -> Self {
	self.cache_token = token;
	return self;
}
pub fn profile_image(mut self, profile_image: Option<String>) -> Self {
	self.profile_image = profile_image;
	return self;
}

Builder 패턴을 모르는 사람을 위해 설명하자면, 값을 설정하는 함수를 실행하면 자신을 리턴하여 계속 이어서 값 설정을 가능하도록 하는 패턴이다. 아래와 같이 setter를 사용할 수도 있지만, 빌더 페턴에 비해서는 해야 할 것이 더 많다:

let mut b = builder::new();
b.set_nick("test");
b.set_id("test_id");
b.set_password("sample_password");
b.build()

빌더 패턴을 이용하면 위의 코드를 더 간단하게 호출할 수 있다.

let b = builder::new().nick("test").id("test_id").set_password("sample_password").build();

nick()은 닉네임 설정후 빌더(자신)를 그대로 돌려주고, id()도 마찬가지로 자신을 돌려주고 하기 때문에 가능하다. 값이 설정된 빌더를 계속 주고 받고 한다고 생각하면 된다.


이제 필요한 데이터는 모두 모았다. 이제 이것을 실제 접속 정보로 바꾸는 부분이 필요하다. 우선 유챗에서 채팅방 접속 토큰을 어떻게 생성하는지 보자:

<?php
if(!function_exists('uchat_array2data')) {
	function uchat_array2data($arr) {
		$arr['time'] = time();
		ksort($arr);
		$arr = array_filter($arr);
		$arr['hash'] = md5(implode($arr['token'], $arr));
		unset($arr['token']);
		foreach ($arr as $k => &$v){ $v = $k.' '.urlencode($v); }
		return implode("|", $arr);
	}
}
$joinData = array();
$joinData['room'] = 'esukmean';
$joinData['token'] = '3cbaf6bbf57ae6242ba49a41f282a358';

$joinData['nick'] = $닉네임변수;
$joinData['id'] = $아이디변수;
$joinData['level'] = $레벨변수;
$joinData['auth'] = ''; // (admin, subadmin, member, guest)중 하나선택, 미선택시 자동(권장)
$joinData['icons'] = $아이콘주소변수;
//$joinData['nickcon'] = $닉콘주소변수;
//$joinData['other'] = '';
?>
<script async src="//client.uchat.io/uchat.js"></script>
<u-chat room='<?php echo $joinData['room'];?>' user_data='<?php echo uchat_array2data($joinData); ?>' style="display:inline-block; width:500px; height:500px;"></u-chat>

여기서 우리가 봐야할 부분은 uchat_array2data() 이다. 한줄한줄 해석해 보자.

  • $arr[‘time’] = time();
    – 접속토큰 변조 방지를 위해 시간 값을 구한다
  • ksort($arr);
    – 배열을 Key순서로 정렬한다. implodemd5를 할 때 순서가 뒤죽박죽인체로 하면 서버에서 검증이 어려워서 그렇게 한 것 같다.
  • $arr = array_filter($arr);
    – 배열내 비어있는 항목을 제거한다
  • $arr[‘hash’] = md5(implode($arr[‘token’], $arr));
    – 위변조를 방지하기 위해 토큰값과 배열 데이터를 함께 해싱한다
  • unset($arr[‘token’]);
    – 아래에서 배열 전체를 출력할때 토큰값이 나오면 안되기에 미리 삭제한다. 
  • foreach ($arr as $k => &$v){ $v = $k.’ ‘.urlencode($v); }
    – 출력하기 전에 적절한 처리를 하고
  • return implode(“|”, $arr);
    – 배열내 데이터를 모두 합쳐서 출력한다.

이렇게 하면 [토큰값으로 해싱된 배열 정보]와 [배열 정보]가 함께 출력된다. 이렇게 생성된 정보는 uchat.js가 적절한 처리를 거쳐서 최종적으로 아래와 같이 서버에 전송한다.

ws_stream.send(tokio_tungstenite::tungstenite::Message::binary("j#ZXN1a21lYW4=AR7sEOzREQsBrdAUoSdRHtu18bvG1QtKutf-8\n"))

사실 별로 복잡한것은 없는데, 토큰값을 이용해서 배열을 해싱하는 부분이 조금 귀찮은 감이 있다. 우선 토큰값을 만들어야 하니 implode를 구현해 보자.


fn implode(&self, token: &str, time: &String) -> String {
	// ksort => ["auth"]["icons"]["id"]["level"]["nick"]["nickcon"]["other"]["room"]["time"]["token"]
	let mut result = String::with_capacity(128);

	match &self.auth {
		UChatAuthLevel::None => (),
		auth => {
			result.push_str(&auth.to_string());
			result.push_str(token);
		}
	};
	if let Some(v) = self.icon.as_ref() {
		result.push_str(&v.to_string());
		result.push_str(token);
	}
	if let Some(v) = self.id.as_ref() {
		result.push_str(&v.to_string());
		result.push_str(token);
	}
	if let Some(v) = self.level.as_ref() {
		result.push_str(&v.to_string());
		result.push_str(token);
	}
	if let Some(v) = self.nick.as_ref() {
		result.push_str(&v.to_string());
		result.push_str(token);
	}
	if let Some(v) = self.nickcon.as_ref() {
		result.push_str(&v.to_string());
		result.push_str(token);
	}
	if let Some(v) = self.other.as_ref() {
		result.push_str(&v.to_string());
		result.push_str(token);
	}

	{
		result.push_str(&self.room);
		result.push_str(token);
	}
	{
		result.push_str(&format!("{}", time));
		result.push_str(token);
	}
	{
		result.push_str(token);
	}

	return result;
}

ksort를 직접 구현하는것은 어려움이 있어서(사실 귀찮아서), ksort의 결과값 순서대로 implode를 하도록 작성하였다. 동적인 배열이면 모를까 고정적인 배열이어서 이렇게 했는데, 먼 미래에 커스텀 정보도 넣을수 있다거나 필드가 엄청나게 늘어난다면 ksort 부분까지도 구현 해야할 것이다.

이제 이 모든것을 실제로 전송하는 데이터로 바꿀 차례이다. uchat.js에서 서버로 전송할때는 “비어 있는것도 자리는 차지하도록” 해야한다. 그래서 대부분의 모든 칸에 unwrap_or(String::new()) 를 넣게 되었다. 

pub fn build_with_time(&self, time_delta: std::time::Duration) -> uMessage {
	use rand::Rng;
	use std::time::SystemTime;

	let session: String = rand::thread_rng()
		.sample_iter(rand::distributions::Alphanumeric)
		.take(32)
		.collect();
	let mut time: Option<String> = None;

	let hash = match self.token.as_ref() {
		None => "".to_string(),
		Some(token) => {
			let t = format!(
				"{}",
				(SystemTime::now()
					.duration_since(SystemTime::UNIX_EPOCH)
					.unwrap() + time_delta)
					.as_secs()
			);
			let hash_base = self.implode(token, &t);

			time = Some(t);
			format!("{:x}", md5::compute(hash_base.as_bytes()))
		}
	};

	//jtest1nicknameidlevelauthiconnickcon6fe01303583a9e71e6ece678a4f268ef8TkDUyBiousTta7Ko7qwyIMU6fqgTl9lutf-81604136208
	//this.socket.send(['j', this.id, data.nick, data.id, data.level, (data.auth||''), data.icons, data.nickcon, data.other, data.hash, session, ua.charset, data.time, this.installData.password, cache['client_token'], data.profileimg]);
	let mut buf: uMessage = uMessage::new();
	buf.push(Message::Text("j".to_string()));
	buf.push(Message::Text(self.room.clone()));
	buf.push(Message::Text(self.nick.clone().unwrap_or(String::new())));
	buf.push(Message::Text(self.id.clone().unwrap_or(String::new())));
	buf.push(Message::Text(self.level.clone().unwrap_or(String::new())));
	buf.push(Message::Text(self.nick.clone().unwrap_or(String::new())));
	buf.push(Message::Text(self.icon.clone().unwrap_or(String::new())));
	buf.push(Message::Text(self.nickcon.clone().unwrap_or(String::new())));
	if let Some(other) = self.other.as_ref() {
		buf.push(Message::Text(other.clone()));
	} else {
		buf.push(Message::None);
	}
	buf.push(Message::Text(hash));
	buf.push(Message::Text(session));
	buf.push(Message::Text("utf-8".to_string()));
	buf.push(Message::Text(time.unwrap_or(String::new())));
	if let Some(password) = self.password.as_ref() {
		buf.push(Message::Text(password.clone()));
	} else {
		buf.push(Message::None);
	}
	if let Some(cache_token) = self.cache_token.as_ref() {
		buf.push(Message::Text(cache_token.clone()));
	} else {
		buf.push(Message::None);
	}
	if let Some(profile_image) = self.profile_image.as_ref() {
		buf.push(Message::Text(profile_image.clone()));
	} else {
		buf.push(Message::None);
	}

	return buf;
}

위와 같은 방식으로 실제 서버로 보내는 데이터 형식으로 변환까지 완료할 수 있다. 이 데이터를 웹소켓에 실어서 보내면 바로 서버에서 접속 처리를 해 준다. 한편, 이 함수는 &self를 사용했는데 self로 바꾸면 각 필드를 clone할 필요 없이 바로 처리할 수 있다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

댓글을 작성하기 위해 아래의 숫자를 입력해 주세요. *Captcha loading…