ssut* dev

ssut@ssut.me, fb.com/ssssut
ssut@ssut.me, fb.com/ssssut
ssut@ssut.me, fb.com/ssssut
  • rss
  • archive
  • Using LINE(라인) with apache thrift protocol

    thrift 파일은 https://gist.github.com/ssut/9150461 에서 내려받으실 수 있습니다.
    이 글은 Apache Thrift 와 RSA 암호화(기초)에 대한 사전 지식, Ruby 코드를 볼 수 있는 실력(?)이 있는 전제 하로 작성하였습니다.
    글에서는 일단 잘 작동한다는 가정 하에 작성하였기 때문에 따로 예외처리를 하지 않았습니다. 실제로 사용할 코드를 만들 때에는 꼭! 예외처리를 해두세요.
    글을 medium.com 에 작성했다가 여러가지 문제로 텀블러로 옮기고 조금 수정했습니다만, 제대로 나오지 않는 부분이 있을 수 있습니다 ㅠㅠ

    Thrift 파일로 클라이언트 코드 생성하기

    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
    }

    자 이제 로그인을 위한 키를 가져옵시다.

    이메일 로그인: http://gd2.line.naver.jp/authct/v1/keys/line
    네이버 로그인: http://gd2.line.naver.jp/authct/v1/keys/naver

    위 페이지로 접근해서 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

    Long-Polling 으로 Event 받기

    우선 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
      end
      ops.each do |op|
        case op.type
        when 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}”
      end
      if 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.submit
    url = 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 가 바뀌어서 그런건지, 뭔가 방식이 잘못됐다던가 (…)

    • March 18, 2014 (5:03 am)
    • 2 notes
    • #라인
    • #line
    • #thrift
    • #apache
    • #ruby
    1. abouts likes this
    2. wonnyz likes this
    3. ssut-dev posted this
© 2014–2015 ssut* dev