Newer
Older
Ruby / jtserv.rb
#!/usr/bin/env ruby
# coding: utf-8
require 'em-websocket'
require 'json'
require 'csv'
require 'sqlite3'

PORT		= (ENV["JTSV_PORT"]||8814).to_i
WARNMAX		= 3
LOCALCSS	= ENV["JTSV_LOCALCSS"]||"local.css"
TIMEOUT		= (ENV["JTSV_TIMEOUT"]||60*5).to_i

# Interchange of Information is done in JSON form.
# (IN)	{"cmd": Command, OtherArgs...}
# (OUT)
#
# Command is one of:
#	team
#	text


class UserDB
  def initialize()
    sq3 = ENV["JTSV_DB"]||"users.sq3"
    @db = SQLite3::Database.new(sq3)
    ### @db.results_as_hash = true
    @expireskey = '+5 days'
    @expiretmpkey = '+1 hour'
    @from = 'postmaster@koeki-prj.org'
    @rank = %w(必須 標準 目標 一流 超一流 頂点)
    File.chmod(0600, sq3)
    File.chmod(0700, File.dirname(sq3))
    dbinit
  end
  def dbinit()
    # "at" field is like: YYYY-MM-DDThh:mm:ss at finish time
    @db.execute_batch(<<~EOF)
        -- DROP TABLE score;
	CREATE TABLE IF NOT EXISTS score(
	  user, at, text,
          step INTEGER, score INTEGER, types INTEGER,
          miss INTEGER, time INTEGER,
          UNIQUE(user, at),
          FOREIGN KEY(user) REFERENCES users(user)
          ON DELETE CASCADE ON UPDATE CASCADE
	);
	EOF
  end
  def email(user)
    r = @db.execute("SELECT email FROM users WHERE user = ?", user)
    if r[0] && /.+@.+/ =~ r[0][0] then
      r[0][0]
    else
      nil
    end
  end
  def sendmail(rcpt, subj, body)
    require 'nkf'
    rcpt = rcpt.join(" ") if rcpt.is_a?(Array)
    sj = NKF.nkf('-jM', subj)
    body = NKF.nkf('-j', body)

    open("| sendmail -f#{@from} #{rcpt}", "w") do |m|
      m.puts(<<~EOF)
	Subject: #{sj}
	From: jsTrr Admin <#{@from}>
	Mime-Version: 1.0
	Content-type: text/plain; charset=iso-2022-jp

	EOF
      m.puts(body)
    end
  end
  def genToken(user, email)
    d1 = ("a".."z").to_a.sample
    d2 = ("a".."z").to_a.sample
    token = d1 + d2 + sprintf("%02d", rand(100))
    sendmail(email, 'jsTrr: passcode',
             "Your(%s) passcode = %s\n" % [user, token])
    token
  end
  def expire
    @db.execute("DELETE FROM skey WHERE expire < datetime('now','localtime')")
  end
  def genKey(n=50)
    rand(10**n).to_s(36)
  end
  def genSkey(user)
    # First, erase expired session keys
    expire
    sKey = genKey
    @db.execute("INSERT INTO skey VALUES(?, ?,
       datetime('now', 'localtime', '#{@expireskey}'));", user, sKey)
    sKey
  end
  def storeTmpKey(user, token)
    # First, erase expired session keys
    @db.execute("DELETE FROM tmpkey
	 WHERE expire < datetime('now','localtime')")
    tmpkey = genKey
    @db.execute("INSERT INTO tmpkey
     VALUES(?, ?, ?, datetime('now', 'localtime', '#{@expiretmpkey}'));",
                user, tmpkey, token)
    tmpkey
  end
  def authTmpKey(user, tmpkey, token)
    r = @db.execute("SELECT user FROM tmpkey
		WHERE user=? AND tmpkey=? AND token=?", user, tmpkey, token)
    p token
    p r[0]
    if r && r[0] && r[0].length==1
      # Auth OK!
      genSkey(user)
    end
  end
  def updateSkey(user, skey)
    @db.execute("REPLACE INTO skey VALUES(?, ?,
		 datetime('now', 'localtime', '#{@expireskey}'));", user, skey)
    skey
  end
  def authKey(user, skey)
    expire
    r = @db.execute("SELECT user FROM skey
		WHERE user=? AND skey=?", user, skey)
    if r && r[0] && r[0].length==1
      # Auth OK!
      updateSkey(user, skey)
    end
  end
  def userScoreInfo(user)
    rval = Hash.new
    @db.execute("SELECT text, max(score), max(step), count(user) FROM score
                 WHERE user=? GROUP BY user, text;", user) do |row|
      rval[row[0]] = {"highscore" => row[1].to_i, "step" => row[2].to_i,
                      "trial" => row[3].to_i}
    end
    if r=@db.execute("SELECT gecos FROM users WHERE user=?", user)
      rval["gecos"] = r[0][0]
    end
    steptrial = Hash.new{|k, v| k[v]=Hash.new}
    @db.execute("SELECT text, step, count(step) FROM score
                WHERE user=? GROUP BY user, text, step;", user) do |row|
      txt, step, trial = row
      steptrial[txt][step] = trial
    end
    rval["steptrial"] = steptrial
    p rval
    rval
  end
  ################### Trr STEP Calculation ###################
  def msgSecret(s)
    if s > 300
      "こんな高い得点を出す方がどうして秘密にしておくの?"
    elsif s > 200
      "業界標準を越えてるわ。秘密にする必要は全くないわよ。"
    elsif s > 120
      "恥ずかしくない点だわ。秘密にするのはもうやめましょう。"
    else
      "公開するとちょっと恥ずかしい点だわ。しばらく秘密で続けましょう。"
    end
  end
  def msgBeginner(s)
    if s <= 0
      "0点というのは問題だわ。これからかなりの努力が必要よ。道のりは長いけど頑張りましょう。"
    elsif s <40
      "少なくとも英文字ぐらいは絶対覚えること。業界必須の100点に向けてこれから頑張りましょう。"
    elsif s < 80
      "キー配置も大分覚えたようだけどまだまだだわ。業界必須の100点に向けてこれから頑張りましょう。"
    elsif s < 130
      "基礎的な技術は身に付けているようだけどまだまだだわ。業界標準の200点に向けてこれから頑張りましょう。"
    elsif s < 180
      "なかなかの実力ね。でもスピードと正確さが少し足りないわ。業界標準の200点に向けてもう少し頑張りましょう。"
    elsif s < 280
      "なかなかやるわね。もう少し頑張れば業界目標の300点をきっと突破できるわ。"
    elsif s < 380
      "すごいわね。初めてでこれぐらい出せれば十分だわ。でも業界一流の400点に向けてもう少し頑張りましょう。"
    elsif s < 480
      "すっごい!こんな点を出す人は滅多にいないわよ。ひょっとしてプロではないかしら?"
    else
      "あまりにも超人的だわ。きっとギネスブックに載るわよ。"
    end
  end
  def msgForRecordBreaker(s)
    rank = (s/100).to_i
    sxx  = s%100
STDERR.printf("s=%d, rank=%d, sxx=%d\n", s, rank, sxx)
    if s < 600
      sprintf("業界%sの%d点%s。",
              @rank[rank], rank*100+100,
              sxx < 67 ? "目指して頑張って" : "までもうすぐよ")
    else
      "よくここまでやるわね。あなたの目標は一体何なの?"
    end
  end
  def msgSpecialForRecordBreaker(s)
    rank = (s/100).to_i
    sprintf("ついに業界%sの%d点突破ね!これからは業界%sの%d点を目指して頑張りましょう。",
            @rank[rank], rank*100+100,
            @rank[rank+1], rank*100+200)
  end
  def msgSuccess(user, step, highscore, score)
    diff = [highscore - score, 0].max
    trial = 0
    if r=@db.execute("SELECT count(score) FROM score
                      WHERE user=? AND step=? GROUP BY user, step",
                     user, step)
      trial = r[0][0].to_i
    end
    STDERR.printf("Trial=%d\n", trial)
    if (score-step*10) > 100
      "あなたには簡単すぎたようね。"
    else
      m = %w(軽く突破したわね。 わりと簡単に突破したわね。
             ちょっと手こずったようね。 だいぶ手こずったようね。
             よく頑張ったわね。 随分苦労したようね。 苦しみ抜いたわね。)
      m[[Math.log2(trial)-1, m.length-1].min]
    end
  end
  def msgFail(diff, highscore, score, missratio)
    if diff<10
      "あとほんの少しだったのに...本当に惜しかったわね。"
    elsif diff<20
      "惜しかったわね。"
    elsif diff<30
      "その調子よ。"
    elsif diff<40
      "もう一息だわ。でも息抜きはだめよ。。"
    elsif diff<50
      "頑張ればきっとできるわ。"
    elsif diff < 60
      "努力あるのみよ。"
    elsif diff < 100 || missratio > 30
      if missratio > 60
        "練習に練習を重ねなさい。"
      elsif missratio > 40
        "ミスが多過ぎるわ。初心に帰って一つ一つ慎重に打つ練習をしなさい。"
      elsif missratio > 24
        "ミスが多いわ。正確に打つ練習をしなさい。"
      elsif missratio > 8
        "練習に練習を重ねなさい。"
      else
        "正確に打ってるようだけどスピードが遅すぎるわ。速く打つ練習に励みなさい。"
      end
    elsif diff < 110
      "「trrの道は一日にしてならず」"
    elsif diff < 120
      "「trrに王道なし」"
    elsif diff < 130
      "あらまぁ。%d点を出した人がたったの%d点なんていったいどうしたのよ。" \
      % [highscore, score]
    elsif diff < 140
      "%d点はまぐれだったの?" % [highscore]
    else
      "あなたの実力ってこの程度だったのね。"
    end
  end
  def evalStep(user, h)	# h == Hash from client
    # Return the Hash whose keys are "step", "message", "time"
    highscore = step = 0
    update =   nil	# Break Hiscore
    pass =     nil	# Go to next step
    beginner = nil	# Beginner flag
    secret   = (/secret/i =~ h["trrmode"].to_s)
    time  = h["finish"] - h["start"]
    types = h["types"].to_i
    miss  = h["miss"].to_i
    text  = h["text"]
    speed = (60*types/time).to_i
    score = [(60*(types-miss)/time).to_i, 0].max
    missratio  = 1000*h["miss"].to_i/h["types"].to_i	# 1/1000!!!
    r = @db.execute("SELECT max(score) ms,max(step) mst
		     FROM score WHERE user=? AND text=?
                     GROUP BY user, text", user, text)
    if r && r[0] && r[0][0]
      highscore, step = r[0]
      if score >= step*10	# Qualify current step
        step += 1; pass = true
      end
      update = (score > highscore)
    else
      step = score/10; beginner = true
    end
    if time <= 1
      return {"step" => h["step"],
              "message" => "ごめんなさい。時間計るの忘れてたわ。"}
    end
    # p score
    # user, at, text, step, score, types, miss, time
    secret or
      @db.execute(<<~EOF, user, text, step, score, types, miss, time)
	REPLACE INTO score VALUES(
          ?, datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?);
	EOF
    diff = step*10 - score
    recbreaker = (score > highscore && highscore/100 != score/100)
    msg = if score > 750
            "そんなことでいいの? 恥を知りなさい。"
          elsif secret
            msgSecret(score)
          elsif beginner
            msgBeginner(score)
          elsif pass               # STEP clear!
            sprintf("ステップ%d突破%sおめでとう!\n%s",
                    step,
                    update ? "そして記録更新" : "",
                    recbreaker ?
                      msgSpecialForRecordBreaker(highscore)
                    : msgForRecordBreaker(highscore)
                   )
            
          elsif update
            "記録更新おめでとう!\n"+msgSuccess(user, step, highscore, score)
          else
            msgFail(diff, highscore, score, missratio)
          end
    rv = {"score" => score, "step" => step, "time" => time.round(1),
          "speed" => speed, "message" => msg, "types"=>types,
          "highscore" => [highscore, score].max}
    if false
      # Peek trial count of this level
      r = @db.execute("SELECT count(*) FROM score
                     WHERE user=? AND text=? AND step=?
                     GROUP BY user, text, step;", user, text, step)
      rv["strial"] = r[0][0].to_i
      r = @db.execute("SELECT count(*) FROM score
                     WHERE user=? AND text=? GROUP by user, text", user, text)
      rv["trial"] = r[0][0].to_i
    else
      rv["scoreinfo"] = userScoreInfo(user)
    end
    return rv
  end
  def ranking(text, mode=nil, user=nil)
    rankbase = <<~EOF
	SELECT "User", "Team", "Score", "Step", "Try", "Minutes", "Time", "Gecos"
	  UNION
	SELECT user, team, max(score) hs, max(step), count(score),
               cast(round(sum(time)/60) as INT), max(at), gecos
        FROM score NATURAL LEFT JOIN users WHERE text=? %s
        GROUP BY user ORDER BY hs DESC;
	EOF
    teambase = <<~EOF
	WITH hs AS (
	  SELECT user, gecos, team, max(score) hs
          FROM   score NATURAL LEFT JOIN users
          WHERE text=? %s
          GROUP BY USER
	) SELECT team,
                 round(cast(sum(hs) as real)/count(team), 1) avg,
                 count(team) n
          FROM hs GROUP BY team ORDER BY avg DESC;
	EOF
    limit = if mode && /(\d+)([hdw])/ =~ mode
              n = $1.to_i
              t = 3600*[1, 24, 24*7]["hdw".index($2.downcase)]
              (Time.now-t*n).strftime("%F %T")
            end
    p limit
    case mode
    when "today", /^(\d+)[hdw]/i
      limit = Time.now.strftime("%F") if mode == "today"
      STDERR.printf("limit=%s\n", limit)
      @db.execute(sprintf(rankbase, "AND at > ?"), text, limit)
    when /^byteam/
      [["Team", "Average", "n"]] +
        if limit
          @db.execute(sprintf(teambase, "AND at > ?"), text, limit)
        else
          @db.execute(sprintf(teambase, ""), text)
        end
    when /^inteam/
      return unless user
      cond2 = " AND team=(SELECT team FROM users WHERE user=?)"
      if limit
        @db.execute(sprintf(rankbase, cond2 + " AND at > ?"), text, user, limit)
      else
        @db.execute(sprintf(rankbase, cond2), text, user)
      end
    else
      @db.execute(sprintf(rankbase, ""), text)
    end
  end
end
class Text
  def initialize()
    @textlist = {}
    IO.readlines("TEXT").each do |tx|
      file, title = tx.split(",")
      @textlist[file] = {"title" => title, "text" => IO.readlines(file)}
    end
  end
  def getFilledText(file, lines, fill)
    text = @textlist[file]["text"]
    array = []
    return nil unless text
    buffer = 5; n=text.length-lines-buffer
    text = text[rand(n), lines+buffer]
    if /^[0-9A-Z]/ !~ text[0]
      text[0].sub!(/.*?\.  */, "")
    end
    subtxt = text.join(" ")
    thisline = ""
    subtxt.gsub(/\s+/, " ").split(" ").each do |w|
      if thisline.length + w.length < fill
        w += " " if /\.$/ =~ w
        thisline += (w + " ")
      else
        array << thisline.strip
        thisline = ""
        break if array.length >= lines
      end
    end
    array.join("\n")+"\n"
  end
  def textfiles
    @textlist.collect{|file,v| {"file" => file, "title" => v["title"]}}
  end
end

db	= UserDB.new
textDB	= Text.new
connections = {}
team = Hash.new{|h,k| h[k]=Hash.new}
socksend = lambda{|sock, msg|
  begin
    sock.send(msg)
  rescue
    begin
      connections.close		# Try to close
    rescue
    end
    connections.delete(sock)
  end
}
broadcast = lambda{|hash|
  printf("sending %s\n", hash)
  connections.keys.each do |c|
    socksend.call(c, JSON.generate(hash))
  end
}
countUser = lambda{|user|
  connections.values.select{|c| c["user"] == user}.length
}

EM::WebSocket.start({:host => "0.0.0.0", :port => PORT}) do |ws_conn|
  Thread.new {
    while cmd = gets
      case cmd.chop
      when 's'
        broadcast.call({start: Time.now.to_f})
      end
    end
  }
  ws_conn.onopen do
    # First, clean up idling connections
    connections.each do |conn, info|
      STDERR.printf("OPENat: %s\n", info.inspect)
      next if !info || !info["openat"]	# For avoidance of exception..
      if TIMEOUT < Time.now - (info["stamp"]||info["openat"])
        STDERR.printf("Force Close client %s after long idle\n", conn)
        conn.close
      end
    end
    # Start to accept a new client
    connections[ws_conn] = {"openat" => Time.now}
    ws_conn.send(JSON.generate({myid: connections.length}))
    if test(?e, File.expand_path("..", LOCALCSS))
      ws_conn.send(JSON.generate({css: LOCALCSS}))
    end
    STDERR.printf("OP: %d clients [%s]\n",
                  connections.length,
                  connections.values.collect{|c| c["user"]}.join(", "))
  end
  ws_conn.onclose do
    connections.delete(ws_conn)
    STDERR.printf("CL: %d clients\n", connections.length)
  end
  ##
  ## Main Loop
  ##
  ws_conn.onmessage do |msg|
    begin
      json = JSON.parse(msg)
    rescue
      next			## Skip client that sends invalid JSON
    end
    userinfo = connections[ws_conn]
    user, skey = userinfo['user'], userinfo['skey']
    STDERR.printf("RECV%s: [%s]\n", user ? "("+user+")" : "",
                  json.reject{|k,v|k=="Passcode"}.to_json)
    userinfo["stamp"] = Time.now	# Remember time stamp
    if skey then
      userinfo["trial"] = 0		# Reset auth count
      if json["team"] then
        #team[json["team"]][json["name"]] ||= nil
        userinfo = {"team" => json["team"], "name" => json["name"]}
      elsif json["settext"]
        # {"松" => {"name1"=>1, "name2" => 2}}
        tm = userinfo["team"]
        nm = userinfo["name"]
        team[tm][nm] = json["text"]
        connections.each do |k, v|
          p v["team"]
          next unless tm == v["team"]	# Skip other team
          next if ws_conn==k		# Skip myself
          begin
            k.send(JSON.generate({"disable" => json["text"]}))
          rescue
            begin
              k.close
            rescue
            end
            connections.delete(k)
          end
        end
        userinfo["text"] = json["text"].to_i
        si = {"scoreinfo" => db.userScoreInfo(userinfo["user"])}
        socksend.call(ws_conn, JSON.generate(si))
      elsif json["finish"]
        user = userinfo["user"]
        if user && user > ""
          if false
            ## start  = # json["start"].to_i
            start  = userinfo["start"]
            ## finish = json["finish"].to_i
            finish = Time.now.to_f
            types  = json["types"].to_i
            STDERR.printf("types=%d", types)
            miss   = json["miss"].to_i
            text   = json["text"]
            time   = finish-start
            score  = 60*(types - 10*miss)/time
          end
          json["start"] = userinfo["start"]
          json["finish"] = Time.now.to_f
STDERR.printf("Start=%.2f, Finish=%.2f\n", json["start"], json["finish"])
          eval  = db.evalStep(user, json)
          socksend.call(ws_conn, JSON.generate(eval))
          userinfo.delete("typelist")
        end
      elsif json["types"]
        unless userinfo["typelist"]
          p "START!"
          userinfo["start"] = Time.now.to_f
          userinfo["typelist"] = Hash.new
        end
        userinfo["typelist"][json["types"]] = Time.now.to_f
      elsif json["ranking"]
        text = json["ranking"]
        mode = json["mode"]
        rank = db.ranking(text, mode, user)
        socksend.call(ws_conn, JSON.generate({"ranking": rank, "mode": mode}))
      elsif json["gettext"]
        db.updateSkey(user, skey)
        userinfo.delete("typelist")	# Reset running typing information 
        r = textDB.getFilledText(json["gettext"], json["lines"], json["fill"])
        socksend.call(ws_conn, JSON.generate({"yourtext" => r}))
      end
    else ########## Before authenticated
      userinfo["trial"] ||= 0
      p userinfo["trial"]
      if json["Login"]
        user = json["Login"]
        email = db.email(user)
        if email
          token = db.genToken(user, email)
          tmpkey  = db.storeTmpKey(user, token)
          j = JSON.generate({"email" => email, "tmpkey" => tmpkey, "user" => user})
        else
          j = JSON.generate("tmpkey" => "notregistered")
        end
        p token
        socksend.call(ws_conn, j)
      elsif json["skey"]
        user, skey = json["user"], json["skey"]
        if countUser.call(user) >= WARNMAX
          msg = "いったい何回繋いだら気が済むの?\n"+
                "限度をわきまえなさい。"
          socksend.call(ws_conn, JSON.generate({"fail" => "overlimit",
                                                "message" => msg}))
          next
        end
        skey = db.authKey(user, json["skey"])
        STDERR.printf("SKey=%s", skey)
        if skey
          userinfo["skey"] = skey	# Cache skey
          STDERR.printf("CACHE skey for user %s\n", user)
          userinfo["user"] = user	# Cache User
          si = db.userScoreInfo(user)
        STDERR.printf("scoreinfo = %s\n", si)
          r = {"user"=>user, "skey"=>skey, "scoreinfo"=>si}
          r["textfiles"] = textDB.textfiles
          j = JSON.generate(r)
          socksend.call(ws_conn, j)		# Login success by skey
        else
          ## Need too frequent trial barrier here?
          socksend.call(ws_conn, JSON.generate({"fail" => "nokey"}))
        end
      elsif json["Passcode"]
        token = json["Passcode"]
        tmpkey = json["tmpkey"]
        user = json["user"]
        authkey = db.authTmpKey(user, tmpkey, token)
        STDERR.printf("authkey=%s", authkey)
        if authkey
          userinfo["skey"] = authkey	# Cache skey
          STDERR.printf("Cache skey for user %s\n", user)
          userinfo["user"] = user	# Cache User
          si = db.userScoreInfo(user)
          r = {"user"=>user, "skey"=>authkey, "scoreinfo"=>si}
          r["textfiles"] = textDB.textfiles
          j = JSON.generate(r)
          socksend.call(ws_conn, j)	# Login success by token+tmpkey
        else
          case userinfo["trial"]+=1
          when 0..3
          when 4..8
            sleep 3
          else
            socksend.call(ws_conn, JSON.generate({"user"=>user, "fail"=>"byebye"}))
            ws_conn.close
            next
          end
          j = JSON.generate({"user" => user, "fail" => "fail"})
          socksend.call(ws_conn, j)	# Login failure by token
        end
      else
        socksend(ws_conn, "No such user")
        ws_conn.close           	# Force guest out
      end
    # broadcast.call({"message" => msg})
    end
  end
end