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