thrift 파일은 https://gist.github.com/ssut/9150461 에서 내려받으실 수 있습니다.
이 글은 Apache Thrift 와 RSA 암호화(기초)에 대한 사전 지식, Ruby 코드를 볼 수 있는 실력(?)이 있는 전제 하로 작성하였습니다.
글에서는 일단 잘 작동한다는 가정 하에 작성하였기 때문에 따로 예외처리를 하지 않았습니다. 실제로 사용할 코드를 만들 때에는 꼭! 예외처리를 해두세요.
글을 medium.com 에 작성했다가 여러가지 문제로 텀블러로 옮기고 조금 수정했습니다만, 제대로 나오지 않는 부분이 있을 수 있습니다 ㅠㅠ
Apache Thrift Tutorial 에 있는 방식대로 thrift 파일로 원하는 언어의 클라이언트 코드를 생성합니다. OS X 에서 Homebrew 를 이용하여 Thrift 를 설치하려면 brew install thrift 를 터미널에서 실행해주시면 됩니다.
thrift -r -gen rb line.thrift
이렇게 Thrift 코드를 생성한 후에 같은 폴더를 보면 gen-rb (또는 언어에 따라서 gen-py 등) 라는 폴더가 생성되어 있습니다.
이 폴더를 작업할 디렉토리로 옮긴 후에 코드 작성을 시작합니다.
루비 LOAD_PATH 에 gen-rb 폴더를 추가해주고 필요한 모듈을 불러옵니다.
$:.push(‘gen-rb’)
require ‘thrift’
require ‘line’
로그인 전에 라인 서버에서 RSA 키와 세션 키를 가져와야 합니다. 이 때 로그인 방식이 두가지(네이버, 라인)로 나뉘게 됩니다.
네이버 로그인은 LINE 계정이 네이버와 연결되어 있는 경우에, LINE 로그인은 LINE 계정이 이메일로 등록되어 있는 경우에 사용합니다.
저는 해외 번호를 이용하여 가입한 관계로 이메일로 로그인합니다.
아 우선 로그인 전에 기본적인 정보는 미리 설정해둡시다. 굵게 칠한 부분만 수정하시면 됩니다.
require ‘socket’
version_string = “3.4.0"
user_agent = “DESKTOP:MAC:10.9.1-MAVERICKS-x64(#{version_string})”
header_line_application = “DESKTOPMAC\t#{version_string}\tMAC\t#{version_string}-MAVERICKS-x64"
comname = “클라이언트 이름”
user = “메일”
pass = “비밀번호“
ip = IPSocket.getaddress(Socket.gethostbyname(Socket.gethostname()).first)
headers = { ‘User-Agent’ => user_agent, ‘X-Line-Application’ => header_line_application
}
자 이제 로그인을 위한 키를 가져옵시다.
위 페이지로 접근해서 JSON 으로 구성된 데이터를 받아오고 파싱합니다.
아래와 같이 코드를 작성했습니다.
앞으로 계속 쓰일 예정이기 때문에 mechanize 라이브러리를 사용했습니다.
require ‘mechanize’
agent = Mechanize.new
data = JSON.parse(agent.get(‘http://gd2.line.naver.jp/authct/v1/keys/line’).body)
Code result
로그인을 위한 RSA 키와 세션 키를 가져왔으면 이제 저 키를 이용해서 로그인을 위한 암호를 만들면 되겠죠.
구조는 [세션 키 길이의 ASCII 코드를 한 문자열] + [세션 키] + [사용자 아이디 길이의 ASCII 코드를 한 문자열] + [사용자 아이디] + [사용자 비밀번호 길이의 ASCII 코드를 한 문자열] + [사용자 비밀번호] 입니다.
RSA 키는 ‘,’ 를 기준으로 나눠서 [키 번호],[e값],[n값] 입니다.
require ‘openssl’
pass_key = data.session_key.size.chr + data.session_key + user.size.chr + user + pass.size.chr + pass
rsa_key = data[‘rsa_key’].split(‘,’)
keyname = rsa_key[0]
pub = OpenSSL::PKey::RSA::new
pub.n, pub.e = rsa_key[1].hex, rsa_key[2].hex
cipher = pub.public_encrypt(pass_key).unpack(‘H*’).first
뭔가 조금 복잡해지기는 했는데 이 뒤로는 쭉 쉽습니다 (…)
이제 로그인을 해봅시다.
로그인을 위해 Thrift client 인스턴스를 만들어줘야 하는데 LINE 은 HTTPClient — BufferedTransport—CompactProtocol 을 사용하고 있습니다. 아래처럼 생성하시면 됩니다.
transport = Thrift::HTTPClientTransport.new ‘http://gd2.line.naver.jp/api/v4/TalkService.do’
transport.add_headers(headers)
transport = Thrift::BufferedTransport.new(transport)
protocol = Thrift::CompactProtocol.new(transport)
client = Line::Client.new(protocol)
이제 로그인을…
여기서 Provider::LINE 부분은 thrift types 에 정의되어 있는 부분입니다. 네이버 로그인을 위해서는 Provider::NAVER_KR 로 바꿔주시면 됩니다.
msg = client.loginWithIdentityCredentialForCertificate(user, pass, keyname, cipher, false, ip, comname, Provider::LINE, ‘’)
가져온 메시지를 p msg (= msg.inspect) 로 확인해 보면
<LoginWithIdentityCredentialForCertificateResult certificate:nil, key64:nil, verifier:”MO7XqGdzJ6mrLs2Y7MJ1rsB2fbxYa0Tu”, auth_digit:”6959”, code:3>
성공적으로 로그인 되었음을 확인할 수 있습니다..만
auth_digit.. 네, 모바일에서 라인 앱을 켜고 저 코드를 입력해 넣으시면 됩니다..만 코드로 더 구현해봅시다.
msg 의 code 가 3 일 경우에는 성공적으로 로그인이 되었을 경우입니다.
이제 gd2.line.naver.jp/Q 라는 곳에 요청하고 Long-Polling 하듯 느긋하게 기다리면 됩니다. 데이터가 도착했다면 시간이 지났거나 성공적으로 로그인이 완료된 경우입니다.
더 이상의 설명은 아래 코드와 주석으로 확인해주세요.
if msg.code == 3
puts “Input #{msg.auth_digit} from your mobile phone Line app in 2 minutes.”
headers[‘X-Line-Access’] = msg.verifier # verifier 를 X-Line-Access Token 으로 사용합니다
agent.request_headers = headers # agent에 헤더를 설정해줍니다
data = JSON.parse(agent.get(‘http://gd2.line.naver.jp/Q’).body) # Long-Polling
verifier = data[‘result’][‘verifier’]
msg = client.loginWithVerifierForCertificate(verifier) # 서버에 verifier 를 보내 이후 로그인에 계속 사용할 수 있는 토큰을 가져옵니다.
@cert = msg.certificate
end
자 로그인 과정이 끝났습니다.
저기 있는 저 @cert 변수는 꼭 갖고 있어야 합니다.
다음 번 로그인부터는 위와 같은 로그인 과정은 더 이상 거치지 않아도 되고, 저기 @cert 변수 안에 있는 인증 키만 갖고 있으면 됩니다.
이제부터 너무너무 쉽습니다. 사실 로그인만 끝냈으면 다 끝낸거나 다름없어요.
위에서 만든 headers 변수에 X-Line-Access 헤더의 값을 위에 있는 @cert 변수의 값으로 바꿔줍니다. headrs[“X-Line-Access”] = @cert 면 되겠죠.
그리고 앞으로 사용하기 위해 client 를 만들어줍니다.
코드가 많이 더러운데(…) 양해해주세요.
transport = Thrift::HTTPClientTransport.new ‘http://gd2.line.naver.jp/api/v4/TalkService.do’
transport_in = Thrift::HTTPClientTransport.new ‘http://gd2.line.naver.jp/P4’
transport.add_headers(headers)
transport_in.add_headers(headers)
transport = Thrift::BufferedTransport.new(transport)
transport_in = Thrift::BufferedTransport.new(transport_in) protocol = Thrift::CompactProtocol.new(transport)
protocol_in = Thrift::CompactProtocol.new(transport_in)
@client = Line::Client.new(protocol)
@client_in = Line::Client.new(protocol_in)
transport.open
transport_in.open
여기서 @client_in 은 Long-Polling 용도로 사용하게 되고 @client 는 평범한 HTTP 요청처럼 데이터를 원할 때 가져오기 위해 사용합니다.
프로필을 조회하려면 다음처럼 입력하시면 됩니다.
@client.getProfile
결과는 다음과 같이 나오게 됩니다.
<Profile mid:”유저 고유ID“, userid:nil, phone:”전화번호”, email:nil, regionCode:”국가”, displayName:”닉네임”, phoneticName:nil, pictureStatus:nil, statusMessage:nil, allowSearchByUserid:true, allowSearchByEmail:true, picturePath:””>
혹시나 더 사용할 수 있는 method 목록을 보고 싶다면 아래와 같이 코드를 입력하거나 gen-rb/line.rb 파일을 열어 확인하시면 됩니다.
@client.methods.sort
우선 Revision 을 가져와 합니다. Revision 은 Event 를 어디까지 확인했는지 체크하기 위한 용도로 사용됩니다.
@localRev = @client.getLastOpRevision
이제 Long-Polling 함수를 구현하고 계속해서 실행만 시키면 끝..
def start_polling
puts “[%s] now polling ..” % (Time.now.strftime(“%Y/%m/%d %H:%M:%S”))
begin
ops = @client_in.fetchOperations(@localRev, 100) # Operations 들을 가져옵니다. 인자로 넘어가는 값은 SQL 문에서 LIMITS @localRev, 100 와 동일합니다.
rescue
return
endops.each do |op|case op.typewhen OperationType::SEND_MESSAGE
handle_message(op.message, true, false)
when OperationType::RECEIVE_MESSAGE
handle_message(op.message, false, false)
else
puts “[WARN] Unhandled operation type: #{op.type}”
endif op.revision > @localRev # @localRev 와 서버에서 가져온 revision 을 비교해서 서버에서 가져온 revision 이 더 클 경우 @localRev 를 서버에서 가져온 revision 으로 설정합니다.
@localRev = op.revision
end
end
loop do
start_polling
end
handle_message 함수는 아래처럼 구현했습니다.
@mid 는 @client.getProfile 에서 가져온 mid(member id) 입니다.
def handle_message(msg, sent, replay)text, mtime = msg.text, msg.createdTime / 1000# actually text
if not text.nil?
begin
[실행할 코드]
rescue Exception => e
puts e
end
end
end
handle_message 함수 내에서 ?안녕 이라고 요청이 왔을 때 “안녕하세요.” 라고 답하는 코드를 만들어보겠습니다. 실제 봇 구현에서는 처리를 위한 클래스를 따로 만들었지만 이 글에서는 깊게 구현하지는 않겠습니다.
…
if not text.nil? and text == “?안녕”
begin
send_msg = Message.new send_msg.from, send_msg.to, send_msg.text = @mid, msg.to, “안녕하세요.”
@client.sendMessage(0, send_msg)
rescue Exception => e
puts e
end
…
잠시 thrift 파일에서 Message 의 Struct 를 보면 아래와 같이 구성이 되어있습니다.
enum ContentType {
NONE = 0;
IMAGE = 1;
…
}struct Message {
1: string from;
2: string to;
3: ToType toType;
4: string id;
5: i64 createdTime;
6: i64 deliveredTime;
10: string text;
11: optional Location location;
14: bool hasContent;
15: ContentType contentType;
17: string contentPreview;
18: optional map<string, string> contentMetadata;
}
이번에는 이미지를 보내보겠습니다. LINE 서버에 이미지를 올려서 보내는 방법이 있지만 os.line.naver.jp (Object Storage) 와 또 연결을 해야하니 이 글에서는 puush api 를 이용해서 puush 서버에 이미지를 올리고 그 링크를 가져와서 라인에 이미지 링크와 이미지 Preview 만 넘기는 형태로 구현하겠습니다. (실제 이미지 경로가 존재하지 않으면 이미지가 표시되지 않습니다)
같은 디렉토리 내에 test.jpg 파일이 있다는 가정 하에 코드를 작성합니다.
손쉽게 Content-Type 이 multipart/form-data 인 콘텐츠를 보내기 위해 mechanize 를 사용했습니다.
require ‘mechanize’
http = Mechanize.new
html = <<HTML
<form method=”post” action=”http://puush.me/api/up” enctype=”multipart/form-data”> <input type=”text” name=”z” value=”클라이언트 이름”> <input type=”text” name=”e” value=”puush 메일”> <input type=”text” name=”k” value=”puush API 키“> <input type=”file” name=”f”>
</form>
HTML
page = Mechanize::Page.new(nil, {‘Content-Type’ => ‘text/html’}, html, nil, http)result = page.form_with(:method => /POST/) do |form|
form.file_uploads.first.file_name = “test.jpg” # 이미지 파일 경로
end.submiturl = result.body.split(‘,’)[1] # puush 이미지 주소
이제 Message 를 생성하고 이미지 주소와 이미지 파일을 실어 보내면 됩니다.
send_msg = Message.new
send_msg.from, send_msg.to, send_msg.text = @mid, msg.to, nil
msg.contentType = ContentType::IMAGE
msg.contentPreview = open(‘test.jpg’).read.force_encoding(‘UTF-8’)
msg.contentMetadata = { ‘PREVIEW_URL’ => url, ‘DOWNLOAD_URL’ => url, ‘PUBLIC’ => ‘TRUE’, }
@client.sendMessage(1, send_msg)
이미지 파일을 보낼 때 contentPreview 에 들어가는 이미지는 반드시 JPG 형식이어야 하며, UTF-8 문자셋으로 변경을 해주어야 합니다.
아무래도 서버와 하는 통신도, 데이터 형식도 다 thrift 가 맡아서 해주니 로그인 빼고는 무지무지 쉽습니다. 루비 OpenSSL 은 왠지 모르게 evalue 와 nvalue 순서가 반대라서 처음 구현하는데 시간이 조금 걸리긴 했습니다(…)
사용하다 보면 어느순간 로그인이 팍-(..) 끊기면서 세션이 종료되고 세션 키가 만료되는 현상이 있긴 하는데 원인은 잘 모르겠습니다. IP 가 바뀌어서 그런건지, 뭔가 방식이 잘못됐다던가 (…)