require 'sinatra'
require 'xmlrpc/marshal'
require 'discourse_api'
require 'json'
require 'pp'
require 'chronic'
CONFIG = YAML.load(
File.read(
File.join(
File.expand_path( File.join( File.dirname( __FILE__ ), ".." ) ),
"config.yml"
)
)
)
#use Rack::Session::Cookie, :key => "rack.session",
# :domain => DOMAIN,
# :path => "/",
# :expire_after => 86400 * 7, # login every 7 days...
# :secret => "change_me", # change these
# :old_secret => "change_me"
set :discourse_api, URI::parse( CONFIG.fetch( 'discourse_url' ) )
set :discourse_api_key, CONFIG.fetch( 'discourse_api_key' )
# For push notifications. TODO.
set :tapatalk_api, CONFIG.fetch( 'tapatalk_url', nil )
set :tapatalk_api_key, CONFIG.fetch( 'tapatalk_api_key', nil )
before do
# Pass through all cookies to the discourse instance
discourse( request.cookies )
end
helpers do
# Use this as our entry point
def dispatch
xml = @request.body.read
fixup_tapatalk_xml( xml )
halt 400, "Empty XMLRPC request" if xml.empty?
# Parse xml
method, arguments = XMLRPC::Marshal.load_call(xml)
method = "xmlrpc_#{method.gsub(/([A-Z])/, '_ ').downcase}"
puts "method = #{method}, args = #{ method =~ /login/ ? "*REDACTED*" : arguments.inspect }"
# Check if method exists
if(respond_to?(method))
content_type("text/xml", :charset => "utf-8")
send(method, *arguments)
else
raise Sinatra::NotFound
end
end
# Apparently, tapatalk formats booleans contrary to the XMLRPC standard.
# https://github.com/svdgraaf/django-tapatalk/blob/master/tapatalk/dispatcher.py
def fixup_tapatalk_xml( text )
text.gsub( 'true', '1' )
end
def make_xmlrpc_datetime( string )
Chronic.parse( string )
end
def binary_xmlrpc( string )
# assume it's utf-8 for now, wrap in base64 encoding
XMLRPC::Base64.new( string )
end
def make_avatar_url( template_txt, size = 120 )
template_txt.to_s.
gsub(/\A\/\//, "http://"). # srsly wat, discourse? Well, it's probably for selection between HTTP or HTTPS more easily. But still.
gsub( "{size}", size.to_s ) # url parameter
end
# Clean up the text, converting it to BBCode (TODO) unless use_html
def format_post_content_for_xmlrpc( html_text, use_html )
raise NotImplementedError.new( "Can't convert to BBCode yet" ) unless
use_html
html_text
end
def build_tapatalk_topic( discourse_topic, extra = {} )
result = {
'forum_id' => discourse_topic["category_id"].to_s,
'topic_id' => discourse_topic["id"].to_s,
'topic_title' => binary_xmlrpc( discourse_topic["title"].to_s ),
# 'prefix' => binary_xmlrpc( "" ), # optional
'post_author_name' => binary_xmlrpc( "" ),
# 'post_author_id' => 0, # required, Int (apparently), level 4
# 'is_subscribed' => false, # optional
# 'can_subscribe' => false, # optiona;
# 'is_closed' => false, # optional
# 'icon_url' => "" # optional
# participated_uids # optional, level 4
'post_time' => make_xmlrpc_datetime( discourse_topic['last_posted_at'] ),
'reply_number' => discourse_topic['posts_count'].to_i, # reply_count ?
'new_post' => !!discourse_topic['unseen'],
'view_number' => discourse_topic['views'].to_i,
# 'short_content' => binary_xmlrpc( "" ) # api level 4, not provided by discourse in some calls
}
# Fill in some association-related data
if extra[:categories]
category = extra[:categories].find {|hsh| hsh["id"] == discourse_topic["category_id"] } || {}
result["forum_name"] = binary_xmlrpc( category.fetch( "name", "Uncategorised" ) )
end
if discourse_topic["posters"]
orig_poster = discourse_topic["posters"].find {|hsh| hsh['description'].to_s =~ /Original Poster/i }
result["post_author_id"] = orig_poster["user_id"] if orig_poster
if extra[:users] && result["post_author_id"]
user = extra[:users].find {|hsh| hsh["id"] == result["post_author_id"] }
if user
result["post_author_name"] = binary_xmlrpc( user["username"].to_s )
result["icon_url"] = make_avatar_url( user["avatar_template"] )
end
end
end
# FIXME: how do these differ from post_author_name/id ? most recent / original?
result['topic_author_name'] = result['post_author_name']
result['topic_author_id'] = result['post_author_id'].to_s
result['last_reply_time'] = result["post_time"]
result
end
# Try to keep this instance around for as long as possible...
# TODO: To keep it around, push into Rack::Session, or cookies? Some sort of
# long-lived store. Can go away with process restarts, of course.
def discourse( cookies = {} )
@discourse ||= begin
uri = settings.discourse_api
uri = URI.parse(uri) unless uri.is_a?( URI::Generic )
client = DiscourseApi::Client.new( uri.host, uri.port, uri.scheme )
client.api_key = settings.discourse_api_key
client.cookies = cookies
client
end
end
def respond_xmlrpc( rsp )
XMLRPC::Marshal.dump_response( rsp )
end
## http://tapatalk.com/api/api_section.php ##
# First function called by tapatalk, apparently we should always return the
# whole hash.
def xmlrpc_get_config( name = nil )
result = {
# Uncontroversial
"sys_version" => "0.1", # For now
"version" => "dev", # For now
"api_level" => "3", # Maybe?
"is_open" => true, # Set to false to stop working
"guest_okay" => true,
# These are optional, and assume 1 if missing
"mark_read" => "0", # TODO
"subscribe_forum" => "0", # TODO
"announcement" => "0", # Probably?
# These are optional, and assume 0 if missing
# "delete_reason" => "0", # TODO
# "m_approve" => "0", # TODO
# "m_delete" => "0", # TODO
# "m_report" => "0", # TODO
# "guest_search" => "0", # TODO
# "guest_whosonline" => "0", # TODO
# "emoji" => "0", # Probably?
# "support_md5" => "0", # TODO
# "support_sha1" => "0", # TODO
# "conversation" => "0", # TODO
# "advanced_delete" => "0", # TODO
# "get_activity" => "0", # TODO
# "prefix_edit" => "0", # TODO
# "anonymous_login" => "0", # TODO
# "search_user" => "0", # TODO
# "user_recommend" => "0", # TODO
# Other optional params
"min_search_length" => 3, # Boring default
"alert" => "0", # TODO
"direct_unsubscribe" => "0", # TODO
# "push_type" => "", # TODO - HIGH PRIORITY
# "ban_delete_type" => "none", # TODO
"inappreg" => "0", # TODO
"inappsignin" => "1",
"ignore_user" => "0", # TODO
# These are API level 4 items. Ignore for now.
# "goto_post" => false, # TODO
# "goto_unread" => false, # TODO
# "mark_forum" => "0", # TODO
# "no_refresh_on_post" => "0", # TODO
# "get_latest_topic" => "0", # TODO
# "get_id_by_url" => "0", # TODO
# "anonymous" => "0", # TODO
# "searchid" => "0", # TODO
# "avatar" => "0", # TODO
# "pm_load" => "0", # TODO
# "subscribe_load" => "0", # TODO
# "inbox_stat" => "0", # TODO
# "multi_quote" => "0", # TODO
# "default_smilies" => "1", # Probably?
# "can_unread" => "0", # Probably
# "get_forum" => "1", # No sub-forums in discourse but it needs this anyway
# "get_topic_status" => "0", # TODO
# "get_participated_forum" => "0", # TODO
# "get_forum_status" => "1", # TODO
# "get_smilies" => "0", # TODO
# "advanced_online_users" => "0", # TODO
# "mark_pm_unread" => "0", # Probably?
# "advanced_search" => "0", # TODO
# "mass_subscribe" => "0", # TODO
# "user_id" => "0", # TODO
# "mark_topic_read" => "0", # TODO
# "first_unread" => "0", # TODO
# These are returned by some other forums but aren't documented
"key" => settings.tapatalk_api_key, # tapatalk API key, at a guess?
"push" => "0",
# "allow_moderate",
# "charset" => "ISO-8859-1",
"disable_bbcode" => "0",
# "disable_subscribe_forum" => "0",
# "forum_signature" => 1,
# "get_online_users" => "1",
# No idea what this does
# "hide_forum_id" => "",
# "reg_url" => "register.php",
"sign_in" => "0", # what does this do?
# "stats" => {"user"=>320812, "topic"=>233040, "post"=>2389071}
}
respond_xmlrpc( result )
end
# TODO! Very important
def xmlrpc_login( user = nil, pass = nil, anonymous = false, push = '1' )
return respond_xmlrpc(
'result' => false,
'result_text' => binary_xmlrpc( 'Anonymous login not supported' )
) if anonymous
# We need to send on the cookies we received from discourse, and ensure they're
# passed in for future API requests
# we need one of these to play with the session API
if discourse.csrf_token.nil?
discourse.csrf_token = discourse.session_csrf({}).fetch("csrf")
end
# We raise if session creation fails
user_info = begin
rsp = discourse.session_create( :login => user, :password => pass )
return respond_xmlrpc( 'result' => false, 'result_text' => binary_xmlrpc( rsp['error'] ) ) if rsp['error']
rsp
rescue => err
puts err.inspect
puts err.backtrace.join("\n")
return respond_xmlrpc( 'result' => false )
end
# We can just proxy the entire cookie over untouched
headers "Set-Cookie" => discourse.last_response['Set-Cookie']
# user = user_info.fetch("user")
respond_xmlrpc(
'result' => true,
# 'user_id' => user["id"], # api level 4
# 'username' => binary_xmlrpc( "username" ), # api level 4
# 'usergroup_id' => [], # api level 4
# 'email' => binary_xmlrpc( "" ) # api level 4 # not sent by discourse
# 'icon_url' => make_avatar_url( user["avatar_template"] ), # api level 4
# 'post_count' => , # api level 4 # discourse gives back stats with action_type and count members
'can_pm' => false, # TODO!
'can_send_pm' => false, # TODO! # can_send_private_message_to_user is always false...?
'can_moderate' => false, # TODO! user["moderator"]
# can_search => true, # api level 4
# can_whosonline => true, # api level 4
# can_profile => true, # api level 4
'can_upload_avatar' => false, # TODO
# push_type ...
)
end
# also TODO
def xmlrpc_logout_user
discourse.session_destroy({})
respond_xmlrpc( 'result' => true )
end
# Ignore forum_id for now. Naughty naughty.
def xmlrpc_get_forum( return_description = nil, forum_id = nil)
forum_info = discourse.categories({})
category_list = forum_info.fetch( "category_list" )
categories = category_list.fetch( "categories" )
result = categories.collect {|hsh|
tmp = {
'forum_id' => hsh['id'].to_s,
'forum_name' => binary_xmlrpc( hsh['name'].to_s ), # FIXME: python encodes this to UTF8. Can't be nil
'parent_id' => "-1", # no such thing as sub-categories in Discourse
'sub_only' => false,
# 'child' => [], # Don't return this if we have no children
# 'is_protected' => false, # ?
'is_subscribed' => false, # TODO
'can_subscribe' => false, # TODO
'can_post' => !!category_list["can_create_topic"] # FIXME: this may not be correct!
}
# FIXME: may not be right?
# if return_description
# tmp['description'] = binary_xmlrpc( hsh['description'].to_s )
# end
tmp
}
respond_xmlrpc( result )
end
# FIXME: STUB.
# In API level 4, we can say this doesn't exist and force tapatalk to use get_topic
def xmlrpc_get_unread_topic( start_num = 0, last_num = 0, search_id = nil, filters = nil )
result = {
'result' => true,
# optional
# 'result_text' => binary_xmlrpc( '' ),
# 'search_id' => '',
}
result['topics'] = [].collect {|hsh| build_tapatalk_topic( hsh ) }
result['total_topic_num'] = result['topics'].count
respond_xmlrpc( result )
end
def xmlrpc_get_latest_topic( start_num = 0, last_num = 0, search_id = nil, filters = nil )
result = { 'result' => true }
latest_topics = discourse.topics_latest({})
topic_list = latest_topics.fetch( "topic_list" )
topics = topic_list.fetch( "topics" )
result['topics'] = topics.collect {|hsh|
build_tapatalk_topic( hsh, :users => latest_topics["users"], :categories => latest_topics["categories"] )
}
result['total_topic_num'] = result['topics'].count # api level 4
respond_xmlrpc( result )
end
def xmlrpc_get_topic( forum_id, start_num = 0, last_num = 0, mode = "DATE" )
if forum_id == "0" # && %w|ANN TOP|.include?(mode)
# In these cases, we're not interested in a particular forum
# TODO! This is for announcements (mode = "ANN")! FIXME!
# TODO: also for latest, like xmlrpc_get_latest_topic! FIXME!
return respond_xmlrpc( 'result' => false )
end
# TODO: Need to respect pagination, restrict to just one category
forum_info = discourse.category(:category_id => forum_id)
categories = forum_info.fetch( "categories" )
users = forum_info["users"]
categories.each {|hsh| hsh["can_create_topic"] = forum_info["topic_list"]["can_create_topic"] }
category = categories.find {|hsh| hsh["id"].to_s == forum_id }
# Null category to handle failure-to-find cases above
category ||= {
'topic_count' => 0,
'id' => forum_id.to_i,
"name" => "",
'can_create_topic' => false,
'topics' => []
}
result = {
'total_topic_num' => category['topic_count'].to_i,
'forum_id' => category['id'].to_s,
'forum_name' => binary_xmlrpc( category['name'].to_s ),
# 'unread_sticky_count' => 0, # TODO - api level 4
# 'unread_announce_count' => 0,
# 'can_subscribe' => false, # TODO - api level 4
# 'is_subscribed' => false, # TODO - api level 4
# 'require_prefix' => false, # TODO - api level 4
'prefixes' => [],
'prefix_id' => "",
'prefix_display_name' => binary_xmlrpc( "" ),
'can_post' => !!category['can_create_topic'], # FIXME: may not be correct!
'can_upload' => false,
# 'require_prefix' => false, # api level 4
}
topic_list = forum_info.fetch( "topic_list" )
result['topics'] = topic_list.fetch( "topics" ).collect {|hsh|
build_tapatalk_topic( hsh, :users => users, :categories => categories )
}
respond_xmlrpc( result )
end
# def xmlrpc_get_id_by_url( url ) # TODO
# end
def xmlrpc_get_thread( topic_id, start_num = 0, end_num = 0, return_html = false )
topic_info = discourse.topic(:topic_id => topic_id.to_i)
post_stream = topic_info.fetch( "post_stream" )
posts = post_stream.fetch( "posts" )
result = {
"forum_id" => topic_info["category_id"].to_s,
"forum_name" => binary_xmlrpc( "Unknown" ), # FIXME: not returned in topic info, need to pull from cache with forum_id
"forum_title" => binary_xmlrpc( "Unknown" ), # django insists it's called this
"topic_id" => topic_info["id"].to_s,
"topic_title" => binary_xmlrpc( topic_info["title"].to_s ),
"prefix" => "",
"is_subscribed" => false,
"can_subscribe" => false,
"can_reply" => true, # FIXME: ensure this is accurate
"is_approved" => true, # FIXME: wat is das?
"can_upload" => false,
"is_poll" => false,
"is_closed" => !!topic_info["closed"],
"can_report" => false, # TODO
"breadcrumb" => [], # TODO ?
}
result["posts"] = posts.collect {|hsh|
{ "post_id" => hsh["id"].to_s,
"post_title" => binary_xmlrpc( hsh["user_title"].to_s ),
"post_content" => binary_xmlrpc( format_post_content_for_xmlrpc( hsh["cooked"], return_html ) ),
"forum_name" => result["forum_name"], # django says we need this
"forum_id" => result["forum_id"],
"post_author_id" => hsh["user_id"].to_s,
"post_author_name" => binary_xmlrpc( hsh["display_username"].to_s ), # FIXME: display username or real username?
"is_online" => !!hsh["yours"], # TODO: not included in discourse output?
"can_edit" => !!hsh["can_edit"], # TODO
"is_approved" => true, # django again
"icon_url" => make_avatar_url( hsh["avatar_template"] ), # TODO: this is a template, may need formatting
"post_time" => make_xmlrpc_datetime( hsh["updated_at"] ),
"allow_smilies" => true,
"reply_number" => hsh["post_number"].to_i,
"view_count" => hsh["reads"].to_i,
# "attachments" => [], # TODO!
# "thanks_info" => [], # TODO # Probably not needed anyway
# "likes_info" => [], # TODO!
}
}
result["total_post_num"] = result["posts"].count.to_i
respond_xmlrpc( result )
end
# def get_smilies # TODO - not a priority
# end
end
post "/" do
dispatch
end
post "/mobiquo/mobiquo.php" do
dispatch
end