Блог 7even

про ruby, rails, sinatra, git и всё на свете

Асинхронная работа с ВКонтакте

У широко известной соцсети ВКонтакте есть чуть менее известный API, насчитывающий немалое количество методов, а также различные способы загрузки файлов для использования в методах - и даже long-polling для получения сообщений из мессенджера ВКонтакте. В этой статье я попытаюсь рассказать о том, как работать в ruby с этим API в асинхронном режиме.

Для этого напишем собственный web-based мессенджер, умеющий отправлять сообщения, принимать и помечать сообщения прочитанными. Большая часть приложения будет оперировать на клиенте: фронт-энд будет устанавливать постоянное соединение с бэк-эндом, отправлять ему запросы и получать ответы; а бэк-энд займется непосредственной работой с API.

Самый примечательный момент в таком приложении - асинхронность: бэк-энд должен постоянно ждать от ВКонтакте новых сообщений, и параллельно с этим обрабатывать данные, присылаемые фронт-эндом, после чего вызывать нужный метод API (например, фронт-энд сообщает, что 2 входящих сообщения прочитаны - необходимо вызвать API-метод messages.markAsRead).

Задача

Итак, попробуем описать схему работы всего приложения.

  • браузер устанавливает WebSocket-соединение с сервером
  • бэк-энд запрашивает у ВКонтакте список друзей и непрочитанных сообщений
  • получив друзей и сообщения, бэк-энд отправляет данные на фронт-энд, и происходит первоначальный рендеринг интерфейса
  • отправив данные в браузер, бэк-энд получает параметры long-polling и начинает опрашивать ВКонтакте в вечном цикле
  • в конце каждой удачной итерации бэк-энд отправляет полученные данные в веб-сокет
  • в браузере каждая полученная от сервера порция обновлений разбирается по типам и рендерится
  • когда пользователь открывает вкладку одного из своих друзей, фронт-энд запрашивает у бэк-энда последние сообщения от этого пользователя
  • бэк-энд запрашивает сообщения через API, получает и отправляет в веб-сокет, после чего фронт-энд рендерит их в нужной вкладке

Дальнейшие действия происходят по следующей схеме: фронт-энд сообщает о действии пользователя бэк-энду, тот вызывает нужный метод API, после чего соответствующее обновление приходит от ВКонтакте в основном цикле, передается обратно на фронт-энд и рендерится. Таким образом происходит отправка сообщений из формы, а также пометка входящих сообщений прочитанными (при заходе пользователя в соответствующую вкладку интерфейса). Например, при отправке сообщения мы не рисуем его сразу после сабмита формы, а ждем, когда оно придет как обновление из основного цикла.

Реализация

Инструментарий

Существует определенное количество ruby-врапперов для ВКонтакте API, но из известных мне библиотек только vkontakte_api позволяет работать с API асинхронно, поэтому в данном приложении будем использовать его. Для организации асинхронной работы на бэк-энде возьмем eventmachine, избежим callback spaghetti с помощью em-synchrony, а общаться с фронт-эндом будем посредством WebSocket (для чего используем em-websocket).

Файловую иерархию организуем следующим образом: в lib/ положим ruby-код, в public/ отправится единственный нужный нам html-файл index.html, а в public/css/, public/img/ и public/js/ соответственно стили, картинки и яваскрипт + кофескрипт.

Бэк-энд

Чтобы не усложнять себе жизнь, подключим все необходимые гемы через bundler (не забывая выполнить bundle install после этого):

Gemfile (Gemfile) download
1
2
3
4
5
6
7
8
9
source :rubygems

gem 'eventmachine'
gem 'em-synchrony'
gem 'em-websocket'
gem 'em-http-request'
gem 'vkontakte_api', '~> 1.0'

gem 'foreman'

Для упрощения локального запуска и деплоя приложения используем библиотеку foreman, которая позволит описать процесс запуска в Procfile. Основной рабочий скрипт будет находиться в lib/main.rb:

Procfile (Procfile) download
1
web: ruby lib/main.rb

lib/main.rb нужен для обработки запросов, приходящих по WebSocket; тут мы конфигурируем VkontakteApi, создаем клиент API в глобальной переменной (т.к. для всех запросов будем использовать именно его) и делегируем основную работу классу Messenger (передавая ему параметром объект WebSocket-а, дабы тот смог отправлять данные на фронт-энд):

lib/main.rb (main.rb) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
require 'bundler'
Bundler.require

require_relative 'messenger'

# нужно выключить буферизацию вывода,
# дабы видеть логгирование в реальном времени
$stdout.sync = true

VkontakteApi.configure do |config|
  # совершаем запросы через em_synchrony-адаптер
  config.adapter = :em_synchrony
  # в основном цикле получения сообщений соединения будут
  # висеть до 25 секунд, поэтому ставим таймаут на полминуты
  config.faraday_options = { request: { timeout: 30 } }
end

# создаем клиент API, через него будем отправлять все запросы к ВКонтакте
$client = VkontakteApi::Client.new(ENV['TOKEN'])

EM.synchrony do
  EventMachine::WebSocket.start(host: '0.0.0.0', port: 8080) do |ws|
    ws.onopen do
      # при открытии соединения с браузером создаем новый мессенджер
      VkontakteApi.logger.debug 'Connection open'
      $messenger = Messenger.new(ws)
      $messenger.start
    end

    ws.onclose do
      # при закрытии соединения останавливаем мессенджер,
      # чтобы он перестал запрашивать обновления
      $messenger.stop
      VkontakteApi.logger.debug 'Connection closed'
    end

    ws.onmessage do |msg|
      # сообщение приходит в формате uid=12345&message=abcde
      # парсим его в Hashie::Mash и отправляем мессенджеру
      data = CGI.parse(msg).inject(Hashie::Mash.new) do |mash, (key, value)|
        mash.merge(key => value.first)
      end

      VkontakteApi.logger.debug "Received message: #{data.inspect}"

      action = data.delete(:action)
      if %w[send_message load_previous_messages mark_as_read].include?(action)
        $messenger.send(action, data)
      end
    end
  end
end

Long-polling в исполнении ВКонтакте работает следующим образом: приложение делает HTTP-запрос на определенный URL с определенными параметрами (и URL, и параметры нужно предварительно получить специальным API-методом messages.getLongPollServer), и соединение зависает на время не более указанного в параметре wait кол-ва секунд (документация рекомендует устанавливать этот параметр на 25). Если до истечения этого интервала происходит какое-то событие (пришло новое сообщение, кто-то из друзей вышел в онлайн), немедленно приходит ответ; если же за 25 секунд ничего не происходит, то по истечении этого времени приходит пустой ответ. После получения ответа нужно отправить новый запрос, и так до бесконечности.

В описанном выше запросе на получение обновлений используется параметр key - ключ, время действия которого ограничено 3-4 часами; когда это время проходит, запрос начинает возвращать в ответ {"failed": 2}. В этом случае необходимо снова получить параметры запроса API-методом messages.getLongPollServer. Также присутствует параметр ts - что-то вроде идентификатора последней полученной порции обновлений; каждый long-polling запрос возвращает это значение, и его нужно использовать в следующем запросе, чтобы не получать обновления повторно.

Итак, основной класс. Сетевые запросы, пропатченные в em-synchrony, нельзя выполнять в корневом файбере, поэтому приходится создавать отдельный файбер и запускать код в нем - для этого добавлен хелпер #in_fiber.

lib/messenger.rb (messenger.rb) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
class Messenger
  # сохраняем объект EventMachine::WebSocket::Connection
  # в инстанс-переменной, чтобы отправлять данные на фронт-энд
  def initialize(ws)
    @ws = ws
  end

  # запуск бесконечного цикла получения обновлений
  def start
    in_fiber do
      # получаем список друзей
      friends = $client.friends.get(fields: [:screen_name, :photo])
      # и кол-во непрочитанных сообщений, разбитое по отправителям
      unread = get_unread_messages
      # складываем в один хэш
      friends.each do |friend|
        friend.unread = unread[friend.uid]
      end
      # и отправляем фронт-энду, чтобы тот отрендерил интерфейс
      send_to_websocket(friends_list: friends)

      # получаем параметры long-polling
      url, params = get_polling_params
      # и делаем запросы, пока мессенджер не остановлен
      while self.running? && response = VkontakteApi::API.connection.get(url, params).body
        if response.failed?
          # время действия ключа истекло, нужно получить новый
          url, params = get_polling_params
          next
        else
          # все нормально - отправляем обновления на фронт-энд
          send_to_websocket(updates: response.updates)
          # и обновляем параметр ts для использования
          # в следующем запросе к ВКонтакте
          params.ts = response.ts
        end
      end
    end
  end

  # остановка мессенджера
  def stop
    @stopped = true
  end

  # запущен ли мессенджер
  def running?
    !@stopped
  end

  # отправка сообщения пользователю
  def send_message(params = {})
    in_fiber do
      # мини-костыль для вызова $client.messages.send
      # т.к. метод :send определен в Kernel
      VkontakteApi::Method.new('send', resolver: $client.messages).call(params)
    end
  end

  # загрузка сообщений, отправленных
  # до запуска мессенджера
  def load_previous_messages(params = {})
    in_fiber do
      # выбрасываем первый элемент массива (там будет общее кол-во сообщений)
      # и сортируем в хронологическом порядке
      messages = $client.messages.get_history(uid: params.uid).tap(&:shift).reverse

      data = {
        uid:      params.uid,
        messages: messages
      }
      # отправляем сообщения на фронт-энд с типом previous_messages
      send_to_websocket(previous_messages: data)
    end
  end

  # пометка сообщений прочитанными
  def mark_as_read(params = {})
    in_fiber do
      $client.messages.mark_as_read(mids: params.mids)
      # на фронт-энд тут ничего отправлять не нужно,
      # т.к. изменение статуса "прочитано" придет в основном цикле
    end
  end

private
  # хелпер для отправки данных в веб-сокет
  def send_to_websocket(messages)
    messages.each do |type, data|
      # если data - хэш, то преобразовываем его символьные ключи в строковые
      # (дабы не получить после JSON-кодирования ":messages")
      data = data.inject({}) do |hash, (key, value)|
        hash[key.to_s] = value
        hash
      end if data.is_a?(Hash)

      json = Oj.dump(
        'type' => type.to_s,
        'data' => data
      )

      @ws.send json
    end
  end

  # получение параметров для long-polling запроса
  def get_polling_params
    params = $client.messages.get_long_poll_server

    [
      'http://' + params.delete(:server),
      params.merge(act: 'a_check', wait: 25, mode: 2)
    ]
  end

  # кол-во непрочитанных входящих сообщений, разбитое по отправителям
  def get_unread_messages
    messages = $client.messages.get(filters: 1)
    # снова выбрасываем первый элемент за ненадобностью
    messages.shift

    # и складываем все в хэш, проиндексированный по id отправителя
    counts = Hash.new(0)
    messages.inject(counts) do |hash, message|
      hash[message.uid] += 1
      hash
    end
  end

  # хелпер для запуска кода в отдельном файбере
  def in_fiber(&block)
    Fiber.new(&block).resume
  end
end

Как видно, Messenger#start запускает основной цикл получения обновлений, Messenger#stop его останавливает (это нужно при закрытии вебсокет-соединения, иначе после обновления страницы будет уже два бесконечных цикла), Messenger#send_message отправляет сообщение указанному пользователю, Messenger#load_previous_messages загружает последнюю историю сообщений от и к указанному пользователю, а Messenger#mark_as_read помечает сообщения прочитанными.

На этом бэк-энд готов, переходим к фронт-энду.

Фронт-энд

Во-первых, недолго думая, возьмем Twitter Bootstrap для создания интерфейса, дабы не тратить на это лишнее время и нервы. Во-вторых, по тем же причинам, используем CoffeeScript для программирования клиентской части.

Интерфейс будет выглядеть следующим образом: слева будет сайд-бар, в котором будет располагаться меню, содержащее весь список друзей пользователя; справа разместим основную контентную область, переключаемую с помощью меню. Т.е. по клику на друге в меню справа будет открываться блок с сообщениями от/к этому другу, а также с его переходами в онлайн/оффлайн.

Также в меню добавим метки: для друга, который сейчас онлайн, и для счетчика непрочитанных входящих сообщений от этого человека.

Верстка основной страницы выглядит следующим образом:

public/index.html (index.html) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<!DOCTYPE html>
<html>
  <head>
    <title>VkontakteOnEM</title>
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/main.css">
    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.0/jquery.min.js"></script>
    <script src="js/bootstrap.min.js"></script>
    <script src="js/main.js" charset="utf-8"></script>
  </head>

  <body>
    <div class="container">
      <div class="row">
        <div class="span12">
          <h2>VKontakte messenger</h2>
        </div>
      </div>

      <div class="row">
        <div class="span3">
          <div class="well">
            <ul class="nav nav-list" id="navbar">
              <li class="active">
                <a href="#debug" data-toggle="tab">
                  <i class="icon-info-sign"></i>
                  Debug info
                </a>
              </li>

              <li>
                <a href="#feed" data-toggle="tab">
                  <i class="icon-th-list"></i>
                  Feed
                </a>
              </li>
              <li class="divider"></li>
              <li class="loading">Loading friends list...</li>
            </ul>
          </div>
        </div>

        <div class="span9">
          <div class="tab-content" id="main" style="width: 100%;">
            <div class="tab-pane fade in active" id="debug">
              <h6>Debug info</h6>

              <div class="hero-unit loading">
                <h2>Loading...</h2>
              </div>
            </div>

            <div class="tab-pane fade" id="feed">
              <h6>Activity feed</h6>

              <ul class="feed"></ul>
            </div>
          </div>
        </div>
      </div>
    </div>
  </body>
</html>

Теперь займемся обработкой данных, приходящих из веб-сокета, а также повесим обработчики на переключение вкладок пользователей (нужно подгружать предыдущие сообщения при первом открытии вкладки, и помечать все непрочитанные сообщения прочитанными) и сабмит формы отправки сообщения.

public/js/main.coffee (main.coffee) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# хелпер для логгирования
log = (param) -> console.log param

$(document).ready ->
  # обработчик перехода в табу юзера
  $('#navbar').on 'shown', 'li.user a[data-toggle="tab"]', (e) ->
    user_id = e.target.id.split('_')[1]
    user = usersList.list[user_id]

    # если предыдущие сообщения еще не загружены, грузим
    user.loadPreviousMessages() unless user.previousMessagesLoaded

    # если есть непрочитанные сообщения - помечаем прочитанными
    user.markAllAsRead()

  # обработчик сабмита формы
  $(document).on 'submit', 'form.message', (e) ->
    form = $(e.target)
    message =
      action:  'send_message'
      uid:     form.data('user-id')
      message: form[0].message.value

    ws.send $.param(message)
    form[0].message.value = ''
    false

  window.ws = new WebSocket('ws://0.0.0.0:8080')

  # обработчик сообщений от бэк-энда
  ws.onmessage = (event) ->
    message = $.parseJSON event.data

    switch message.type
      when 'friends_list'
        usersList.load message.data
      when 'previous_messages'
        usersList.list[message.data.uid].loadPreviousMessages message.data.messages
      when 'updates'
        feed.process message.data
      else
        log 'received unknown message:'
        log message

    $('#debug').append '<pre>' + event.data + '</pre>'

  ws.onopen = ->
    log 'connected...'

  ws.onclose = ->
    log 'socket closed'

Список друзей и связанные с ним методы будем хранить в глобальной переменной usersList, а работу с обновлениями из основного цикла организуем через глобальный объект feed.

При получении списка друзей вызываем usersList.load, который в свою очередь удаляет все элементы .loading и рендерит интерфейс:

public/js/users_list.coffee (users_list.coffee) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
window.usersList =
  list: {}

  load: (list) ->
    for user_attributes in list
      @list[user_attributes.uid] = new User(user_attributes)

    @clearOnLoad()
    @renderMenu()
    @renderPanes()

  # очистка и ререндер менюшки
  renderMenu: ->
    # запоминаем активную табу
    activeTabId = $('li.active a').attr('id')
    # чистим меню
    $('#navbar .user').remove()

    # наполняем заново
    for id, user of @list
      li = '<li class="user"><a href="#user_' + id + '" id="tab_' + id + '" data-toggle="tab">'
      li += '<i class="icon-user"></i> ' + user.name
      li += ' <span class="label label-success">Online</span>' if user.online
      li += ' <span class="badge badge-warning">' + user.unreadCount() + '</span>' if user.hasUnread()
      li += '</a></li>'

      $('#navbar').append li

    # восстанавливаем активную табу
    $('#' + activeTabId).parent().addClass('active')

  # метод должен вызываться один раз после загрузки usersList
  renderPanes: ->
    for id, user of @list
      pane = '<div class="tab-pane fade user" id="user_' + id + '">'
      pane += '<h6>' + user.name + '</h6><ul class="feed"></ul>'
      pane += '<form class="well message" data-user-id="' + id + '">'
      pane += '<textarea class="span8" name="message" placeholder="Сообщение"></textarea>'
      pane += '<button class="btn btn-primary" type="submit">Отправить</button>'
      pane += '</form></div>'

      $('#main').append pane

  clearOnLoad: ->
    $('.loading').remove()

При загрузке списка пользователей создается массив объектов класса User. Объекты этого класса должны уметь запрашивать историю сообщений, следить за кол-вом непрочитанных входящих сообщений от соответствующего друга, а также помечать их прочитанными (все сразу, т.к. это будет происходить при заходе пользователя на вкладку с этим другом).

public/js/user.coffee (user.coffee) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
class User
  constructor: (data) ->
    @uid    = data.uid
    @name   = [data.first_name, data.last_name].join(' ')
    @online = data.online

    # счетчик непрочитанных сообщений,
    # нужный на то время, пока @previousMessagesLoaded = false;
    # потом подсчет идет по @messages
    @unread = data.unread

    @messages = {}
    @previousMessagesLoaded = false

  # перегруженная функция:
  # без параметров запрашивает сообщения у бэк-энда;
  # когда тот отвечает, эта же функция вызывается
  # с полученными сообщениями, и происходит рендеринг
  loadPreviousMessages: (messages) ->
    if messages?
      # очищаем все уже загруженные сообщения
      @messages = {}
      $("#user_#{@uid} ul.feed").html('')

      # и рендерим полученные сообщения
      for message in messages
        unread = if (message.read_state == 1) then 0 else 1
        flags = unread + message.out * 2

        params = [
          message.mid
          flags
          @uid
          message.date
          null
          message.body
          message.attachments
        ]
        # сообщение рендерится в конструкторе Message
        new Message(params...)

      @previousMessagesLoaded = true
      # если открыта таба этого пользователя,
      # нужно сразу пометить все сообщения прочитанными
      @markAllAsRead() if @paneActive()
    else
      # запрашиваем сообщения из вебсокета
      data =
        action: 'load_previous_messages'
        uid:    @uid
      ws.send $.param(data)

  unreadCount: ->
    # кол-во непрочитанных входящих сообщений считается по-разному
    # в зависимости от @previousMessagesLoaded:
    if @previousMessagesLoaded
      # когда предыдущие сообщения уже загружены в @messages,
      # обходим их и подсчитываем
      @unreadMessagesIds().length
    else
      # до загрузки предыдущих сообщений считаем по @unread, полученной
      # при изначальной загрузке списка пользователей
      @unread

  hasUnread: ->
    @unreadCount() > 0

  paneActive: ->
    $("#user_#{@uid}").hasClass('active')

  unreadMessagesIds: ->
    # возвращаем id непрочитанных входящих сообщений из @messages
    id for id, message of @messages when message.unreadAndIncoming()

  addMessage: (message) ->
    @messages[message.id] = message

    # если кол-во непрочитанных входящих изменилось, рендерим меню
    if message.unreadAndIncoming()
      # если предыдущие сообщения еще не загружены, увеличиваем счетчик
      @unread += 1 if !@previousMessagesLoaded
      usersList.renderMenu()

    # если панель активна, сразу помечаем все сообщения прочитанными
    @markAllAsRead() if @paneActive()

  markAllAsRead: ->
    if @previousMessagesLoaded and @hasUnread()
      message =
        action: 'mark_as_read'
        mids:   @unreadMessagesIds().join(',')
      ws.send $.param(message)

Все сообщения пользователя сохраняются в @messages, это происходит в методе addMessage - как и работа со счетчиком и меню. Классу Message остается лишь вызвать @user.addMessage и отрендерить сообщение:

public/js/message.coffee (message.coffee) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Message
  constructor: (@id, flags, from_id, timestamp, @subject, @text, @attachments) ->
    @unread = !!(flags & 1)
    @outgoing = !!(flags & 2)
    @user = usersList.list[from_id]
    @date = new Date(timestamp * 1000)

    @user.addMessage this
    @render()

  unreadAndIncoming: ->
    @unread and !@outgoing

  # помечаем сообщение прочитанным в интерфейсе приложения
  # (используется, когда ВКонтакте сообщает, что это сообщение прочитано)
  read: ->
    @unread = false

    # если сообщение входящее, нужно заново отрендерить меню
    unless @outgoing
      # а если у юзера еще не загружены предыдущие сообщения,
      # надо еще и вычесть единицу из счетчика
      @user.unread -= 1 unless @user.previousMessagesLoaded
      usersList.renderMenu()

  render: ->
    classes = ['message']
    classes.push 'pull-right' if @outgoing
    sender = if @outgoing then 'Я' else @user.name

    messageString = '<blockquote id="' + @id + '" class="' + classes.join(' ') + '">'
    messageString += "<p>#{@text}</p>"
    messageString += '<small><i class="icon-user"></i> ' + sender
    messageString += ' | ' + feed.formatDate(@date) + '</small>'
    messageString += '</blockquote>'
    messageString += '<div class="clearfix"></div>'

    $("#user_#{@user.uid} ul.feed").append messageString

И последнее, что остается - обработка обновлений, постоянно поступающих с сервера.

public/js/feed.coffee (feed.coffee) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
window.feed =
  process: (updates) ->
    for update in updates
      code = update.shift()

      switch code
        # изменение флагов сообщения
        when 3
          [message_id, flags, user_id] = update
          usersList.list[user_id].messages[message_id].read() if flags & 1

        # добавление нового сообщения
        when 4
          message = new Message(update...)

        # друг стал онлайн(8) / оффлайн(9)
        when 8, 9
          user_id = update[0]
          user = usersList.list[-user_id]
          user.online = if code == 8 then 1 else 0
          usersList.renderMenu()

          date = '<span class="badge">' + @formatDate() + '</span>'
          if code == 8
            label = '<span class="label label-info">online</span>'
          else
            label = '<span class="label label-important">offline</span>'

          @addStatus [date, label].join(' '), user

  # форматирование даты в стиле "04.09.2012 в 23:50:16" или "сегодня в 01:46:23"
  formatDate: (date = new Date) ->
    dateParts = [
      date.getDate()
      date.getMonth() + 1
      date.getFullYear()
    ]

    today = new Date
    if dateParts[0] == today.getDate() and dateParts[1] == today.getMonth() + 1 and dateParts[2] == today.getFullYear()
      dateString = 'сегодня'
    else
      dateParts = for part in dateParts
        if part.toString().length is 1 then "0#{part}" else part
      dateString = dateParts.join '.'

    timeParts = [
      date.getHours()
      date.getMinutes()
      date.getSeconds()
    ]

    timeParts = for part in timeParts
      if part.toString().length is 1 then "0#{part}" else part
    timeString = timeParts.join ':'

    "#{dateString} в #{timeString}"

  addStatus: (statusString, user) ->
    # добавляем полную запись (лейблы плюс юзернейм) в общий фид
    $('#feed ul.feed').append "<li>#{statusString} #{user.name}</li>"
    # и укороченную запись (только лейблы) в персональный фид
    $("#user_#{user.uid} ul.feed").append "<li>#{statusString}</li>"

Мессенджер готов. И вот как он выглядит:

Запуск

К сожалению, ВКонтакте позволяет использовать API-методы для работы с сообщениями только десктопным и мобильным приложениям, поэтому придется получить токен доступа вручную и хранить его в конфигурации приложения. Благодаря foreman можно положить токен в файл .env в корне приложения, и он будет передаваться в main.rb как переменная окружения.

Итак, чтобы получить токен, сначала нужно зарегистрировать свое приложение на ВКонтакте. На этой странице нужно выбрать тип приложения “Standalone-приложение” и задать имя. Далее видим страницу настроек приложения, здесь нам нужно только поле “ID приложения”.

Теперь можно получать токен. Запускаем любую ruby-REPL (я использую pry, можно также взять irb) и подключаем гем vkontakte_api:

1
2
3
4
5
6
7
8
$ pry -r vkontakte_api
# в app_id вписываем ID приложения
[1] pry(main)> VkontakteApi.app_id = '3074156'
# тут нужно обязательно указать http://oauth.vk.com/blank.html
[2] pry(main)> VkontakteApi.redirect_uri = 'http://oauth.vk.com/blank.html'
# получаем URL авторизации
[3] pry(main)> VkontakteApi.authorization_url(type: :client, scope: [:friends, :messages, :offline])
"https://oauth.vk.com/authorize?response_type=token&client_id=3074156&scope=friends%2Cmessages%2Coffline&redirect_uri=http%3A%2F%2Foauth.vk.com%2Fblank.html"

Дальше просто копируем полученный URL и идем по нему в браузере. Там будет страница, предлагающая подтвердить права приложения на доступ к друзьям и личным сообщениям, а также доступ в любое время (это важно, поскольку позволяет получить “вечный” токен - его больше не придется обновлять). После нажатия на кнопку “Разрешить” идет редирект на страницу с текстом “Login success” - при этом в URL страницы будет параметр access_token, который нам и нужен.

Копируем токен и вставляем в файл .env в следующем формате:

1
TOKEN=1234567890

Теперь при запуске приложения foreman положит токен в переменную окружения TOKEN, и в lib/main.rb он будет доступен как ENV['TOKEN'] - откуда мы его и берем при создании клиента API.

1
2
3
4
# компилируем кофескрипт в яваскрипт
$ coffee -cj public/js/main.js public/js/{user,message,users_list,feed,main}.coffee
# запускаем бэк-энд
$ foreman start

Остается лишь открыть в браузере файл public/index.html. Сразу после загрузки страницы фронт-энд открывает вебсокет-соединение, получает список друзей, рендерит интерфейс и начинает ожидать новых сообщений. Мессенджер работает :)

P.S.

Как всегда, код на гитхабе.

P.P.S.

В получившемся приложении остается еще довольно много возможностей для доработки - показывать аттачменты к сообщениям (картинки, аудио, видео), находить и рендерить URL-ы в виде ссылок; но проект показывает, что асинхронно работать с ВКонтакте API довольно удобно.

Комментарии