diff --git a/README.md b/README.md index 17bc047..42416f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,13 @@ -tapatalker +Tapatalker ========== -Tapatalk-to-Discourse bridge. +This project is intended as a Tapatalk-to-Discourse bridge, with the primary +aim of allowing mobile users to receive push notifications of updates in a +Discourse forum. + +It's implemented as a Tapatalk-compliant XMLRPC endpoint that talks to a +Discourse instance using its REST API. Keeping both services at arms-length +should have some benefit. + +The primary motivation is for the new York Minxters roller derby forum, so +some of this might end up being a bit rough and ready. We'll see. diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..2f85254 --- /dev/null +++ b/config.ru @@ -0,0 +1,4 @@ +require 'tapatalker' + +run Sinatra::Application + diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..097ea38 --- /dev/null +++ b/config.yml.example @@ -0,0 +1,11 @@ +--- + # URL of the discourse forum whose API we'll be consuming + discourse_url: "" + # We need an API key for that forum + discourse_api_key: "" + # This is probably needed for push notifications + tapatalk_url: "" + # This is definitely needed for push notifications + tapatalk_api_key: "" + + diff --git a/lib/tapatalker.rb b/lib/tapatalker.rb new file mode 100644 index 0000000..52854fe --- /dev/null +++ b/lib/tapatalker.rb @@ -0,0 +1,355 @@ +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 ) + +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 = #{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 ) + template_txt.to_s.gsub( "{size}", "160" ) + 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 + + + # 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 + uri = settings.discourse_api + uri = URI.parse(uri) unless uri.is_a?( URI::Generic ) + @discourse ||= begin + client = DiscourseApi::Client.new( uri.host, uri.port, uri.scheme ) + client.api_key = settings.discourse_api_key + # client.api_username = # session[:username] # ? + + 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 = { + "sys_version" => "0.1", # For now + "version" => "dev", # For now + "api_level" => "3", # Maybe? + "is_open" => true, # Set to false to stop working + "guest_ok" => true, # Maybe? + "report_post" => false, # TODO + "report_pm" => false, # TODO + "goto_post" => false, # TODO + "goto_unread" => false, # TODO + "mark_read" => "0", # TODO + "mark_forum" => "0", # TODO + "no_refresh_on_post" => "0", # TODO + "subscribe_forum" => "0", # TODO + "get_latest_topic" => "0", # TODO + "get_id_by_url" => "0", # TODO + "delete_reason" => "0", # TODO + "mod_approve" => "0", # TODO + "mod_delete" => "0", # TODO + "mod_report" => "0", # TODO + "guest_search" => "0", # TODO + "anonymous" => "0", # TODO + "guest_whosonline" => "0", # TODO + "searchid" => "0", # TODO + "avatar" => "0", # TODO + "pm_load" => "0", # TODO + "subscribe_load" => "0", # TODO + "min_search_length" => 3, # Boring default + "inbox_stat" => "0", # TODO + "multi_quote" => "0", # TODO + "default_smilies" => "1", # Probably? + "can_unread" => "0", # Probably + "announcement" => "0", # Probably? + "emoji" => "0", # Probably? + "support_md5" => "0", # TODO + "support_sha1" => "0", # TODO + "conversation" => "0", # TODO + "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 + "advanced_delete" => "0", # TODO + "mark_topic_read" => "0", # TODO + "first_unread" => "0", # TODO + "alert" => "0", # TODO + "direct_unsubscribe" => "0", # TODO + "get_activity" => "0", # TODO + "prefix_edit" => "0", # TODO + "push_type" => "", # TODO - HIGH PRIORITY + "ban_delete_type" => "0", # TODO + "anonymous_login" => "0", # TODO + "search_user" => "0", # TODO + "user_recommend" => "0", # TODO + "inappreg" => "0", # TODO + "inappsignin" => "0", # TODO - HIGH PRIORITY + "ignore_user" => "0" # TODO + } + + respond_xmlrpc( result ) + 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' => 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'] = hsh['description'] + end + + tmp + } + + respond_xmlrpc( result ) + end + + def xmlrpc_get_topic( forum_id, start_num = 0, last_num = 0, mode = "DATE" ) + + category = 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! + nil + else + # FIXME: Wasteful! + # Normal-ish mode + # TODO: Need to respect pagination, restrict to just one category + forum_info = discourse.categories({}) + category_list = forum_info.fetch( "category_list" ) + categories = category_list.fetch( "categories" ) + cat = categories.find {|hsh| hsh['id'].to_s == forum_id } + cat['can_create_topic'] = category_list['can_create_topic'] if cat # monkey-patch this in + cat + end + + # 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' => category['name'].to_s, + 'can_subscribe' => false, # TODO + 'is_subscribed' => false, # TODO + 'require_prefix' => false, + 'prefixes' => [], + 'prefix_id' => "", + 'prefix_display_name' => "", + 'can_post' => !!category['can_create_topic'], # FIXME: may not be correct! + 'can_upload' => false, + 'require_prefix' => false, + } + + result['topics'] = category['topics'].collect {|hsh| + next if hsh['archived'] + + tmp = { "forum_id" => category["id"].to_s, + "topic_id" => hsh["id"].to_s, + "topic_title" => hsh["title"].to_s, + "prefix" => "", + "topic_author_id" => "0", # FIXME: This isn't included by discourse?! + "topic_author_name" => "(Unknown)", # FIXME: this either! + "is_subscribed" => false, + "can_subscribe" => false, + "is_closed" => !!hsh["closed"], + "last_reply_time" => make_xmlrpc_datetime( hsh["last_posted_at"] ), # FIXME: Doesn't seem to like being a DateTime? + "reply_number" => category["posts_count"].to_i, + "new_post" => !!category["unseen"], + "view_number" => 0, # FIXME: Isn't included in above + "short_content" => "", + } + + ## Srs bsns + # if request.headers["Mobiquoid"] == "11" + # tmp["participated_uids"] = [] + # end + + tmp + }.compact + + 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 + + # TODO! Very important + def xmlrpc_login( user = nil, pass = nil, anonymous = false, push = '1' ) + respond_xmlrpc( 'result' => false, 'result_text' => "Login not implemented yet" ) + end + + # also TODO + def xmlrpc_logout_user + nil + end + + # def get_smilies # TODO - not a priority + # end + +end + +post "/" do + dispatch +end + +post "/mobiquo/mobiquo.php" do + dispatch +end