1# frozen_string_literal: true
   2
   3require "pg/em/connection_pool"
   4require "bandwidth"
   5require "bigdecimal"
   6require "blather/client/dsl"
   7require "date"
   8require "dhall"
   9require "em-hiredis"
  10require "em_promise"
  11require "ougai"
  12require "ruby-bandwidth-iris"
  13require "sentry-ruby"
  14require "statsd-instrument"
  15
  16require_relative "lib/background_log"
  17
  18$stdout.sync = true
  19LOG = Ougai::Logger.new(BackgroundLog.new($stdout))
  20LOG.level = ENV.fetch("LOG_LEVEL", "info")
  21LOG.formatter = Ougai::Formatters::Readable.new(
  22	nil,
  23	nil,
  24	plain: !$stdout.isatty
  25)
  26Blather.logger = LOG
  27EM::Hiredis.logger = LOG
  28StatsD.logger = LOG
  29LOG.info "Starting"
  30
  31def log
  32	Thread.current[:log] || LOG
  33end
  34
  35Sentry.init do |config|
  36	config.logger = LOG
  37	config.breadcrumbs_logger = [:sentry_logger]
  38end
  39
  40CONFIG = Dhall::Coder
  41	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
  42	.load(
  43		"(#{ARGV[0]}) : #{__dir__}/config-schema.dhall",
  44		transform_keys: ->(k) { k&.to_sym }
  45	)
  46WEB_LISTEN =
  47	if CONFIG[:web].is_a?(Hash)
  48		[CONFIG[:web][:interface], CONFIG[:web][:port]]
  49	else
  50		[CONFIG[:web]]
  51	end
  52
  53singleton_class.class_eval do
  54	include Blather::DSL
  55	Blather::DSL.append_features(self)
  56end
  57
  58require_relative "lib/session_manager"
  59
  60IQ_MANAGER = SessionManager.new(self, :id)
  61COMMAND_MANAGER = SessionManager.new(
  62	self,
  63	:sessionid,
  64	timeout: 60 * 60,
  65	error_if: ->(s) { s.cancel? }
  66)
  67
  68require_relative "lib/polyfill"
  69require_relative "lib/alt_top_up_form"
  70require_relative "lib/admin_command"
  71require_relative "lib/backend_sgx"
  72require_relative "lib/bwmsgsv2_repo"
  73require_relative "lib/bandwidth_iris_patch"
  74require_relative "lib/bandwidth_tn_order"
  75require_relative "lib/bandwidth_tn_repo"
  76require_relative "lib/btc_sell_prices"
  77require_relative "lib/buy_account_credit_form"
  78require_relative "lib/configure_calls_form"
  79require_relative "lib/command"
  80require_relative "lib/command_list"
  81require_relative "lib/customer"
  82require_relative "lib/customer_info"
  83require_relative "lib/customer_info_form"
  84require_relative "lib/customer_repo"
  85require_relative "lib/dummy_command"
  86require_relative "lib/db_notification"
  87require_relative "lib/electrum"
  88require_relative "lib/empty_repo"
  89require_relative "lib/expiring_lock"
  90require_relative "lib/em"
  91require_relative "lib/form_to_h"
  92require_relative "lib/low_balance"
  93require_relative "lib/port_in_order"
  94require_relative "lib/patches_for_sentry"
  95require_relative "lib/payment_methods"
  96require_relative "lib/paypal_done"
  97require_relative "lib/postgres"
  98require_relative "lib/reachability_form"
  99require_relative "lib/reachability_repo"
 100require_relative "lib/registration"
 101require_relative "lib/transaction"
 102require_relative "lib/tel_selections"
 103require_relative "lib/sim_repo"
 104require_relative "lib/sim_order"
 105require_relative "lib/snikket"
 106require_relative "lib/welcome_message"
 107require_relative "web"
 108require_relative "lib/statsd"
 109
 110ELECTRUM = Electrum.new(**CONFIG[:electrum])
 111ELECTRUM_BCH = Electrum.new(**CONFIG[:electrum_bch])
 112EM::Hiredis::Client.load_scripts_from("./redis_lua")
 113
 114Faraday.default_adapter = :em_synchrony
 115BandwidthIris::Client.global_options = {
 116	account_id: CONFIG[:creds][:account],
 117	username: CONFIG[:creds][:username],
 118	password: CONFIG[:creds][:password]
 119}
 120BANDWIDTH_VOICE = Bandwidth::Client.new(
 121	voice_basic_auth_user_name: CONFIG[:creds][:username],
 122	voice_basic_auth_password: CONFIG[:creds][:password]
 123).voice_client.client
 124
 125class AuthError < StandardError; end
 126
 127require_relative "lib/async_braintree"
 128BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
 129
 130def panic(e, hub=nil)
 131	log.fatal(
 132		"Error raised during event loop: #{e.class}",
 133		e
 134	)
 135	if e.is_a?(::Exception)
 136		(hub || Sentry).capture_exception(e, hint: { background: false })
 137	else
 138		(hub || Sentry).capture_message(e.to_s, hint: { background: false })
 139	end
 140	exit 1
 141end
 142
 143EM.error_handler(&method(:panic))
 144
 145require_relative "lib/blather_client"
 146@client = BlatherClient.new
 147
 148setup(
 149	CONFIG[:component][:jid],
 150	CONFIG[:component][:secret],
 151	CONFIG[:server][:host],
 152	CONFIG[:server][:port],
 153	nil,
 154	nil,
 155	async: true
 156)
 157
 158# Infer anything we might have been notified about while we were down
 159def catchup_notify_low_balance(db)
 160	db.query(<<~SQL).each do |c|
 161		SELECT customer_id
 162		FROM balances INNER JOIN customer_plans USING (customer_id)
 163		WHERE balance < 5 AND expires_at > LOCALTIMESTAMP
 164	SQL
 165		db.query("SELECT pg_notify('low_balance', $1)", c.values)
 166	end
 167end
 168
 169def catchup_notify_possible_renewal(db)
 170	db.query(<<~SQL).each do |c|
 171		SELECT customer_id
 172		FROM customer_plans INNER JOIN balances USING (customer_id)
 173		WHERE
 174			expires_at < LOCALTIMESTAMP
 175			AND expires_at >= LOCALTIMESTAMP - INTERVAL '3 months'
 176			AND balance >= 5
 177	SQL
 178		db.query("SELECT pg_notify('possible_renewal', $1)", c.values)
 179	end
 180end
 181
 182def poll_for_notify(db)
 183	db.wait_for_notify_defer.then { |notify|
 184		repo = CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 185		repo.find(notify[:extra]).then do |customer|
 186			DbNotification.for(notify, customer, repo)
 187		end
 188	}.then(&:call).catch { |e|
 189		log.fatal("Error during poll_for_notify", e)
 190		Sentry.capture_exception(e)
 191	}.then { EM.add_timer(0.5) { poll_for_notify(db) } }
 192end
 193
 194def load_plans_to_db!
 195	DB.transaction do
 196		DB.exec("TRUNCATE plans")
 197		CONFIG[:plans].each do |plan|
 198			DB.exec("INSERT INTO plans VALUES ($1)", [plan.to_json])
 199		end
 200	end
 201end
 202
 203def db_notify_listen(conn)
 204	Sentry.with_scope do |scope|
 205		scope.clear_breadcrumbs
 206		scope.set_transaction_name("DB NOTIFY")
 207		Thread.current[:log] = ::LOG.child(transaction: scope.transaction_name)
 208		conn.query("LISTEN low_balance")
 209		conn.query("LISTEN possible_renewal")
 210		catchup_notify_low_balance(conn)
 211		catchup_notify_possible_renewal(conn)
 212		poll_for_notify(conn).sync
 213	end
 214end
 215
 216when_ready do
 217	log.info "Ready"
 218	BLATHER = self
 219	REDIS = EM::Hiredis.connect
 220	MEMCACHE = EM::P::Memcache.connect
 221	BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
 222	DB = Postgres.connect(dbname: "jmp", size: 25)
 223	TEL_SELECTIONS = TelSelections.new
 224
 225	DB.hold do |conn|
 226		EMPromise.resolve(nil).then do
 227			Sentry.clone_hub_to_current_thread
 228			db_notify_listen(conn)
 229		end
 230	end
 231
 232	load_plans_to_db!
 233
 234	EM.add_periodic_timer(3600) do
 235		ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host])
 236		ping.from = CONFIG[:component][:jid]
 237		self << ping
 238	end
 239
 240	Web.run(LOG.child, *WEB_LISTEN)
 241end
 242
 243message to: /\Aaccount@/, body: /./ do |m|
 244	StatsD.increment("deprecated_account_bot")
 245
 246	self << m.reply.tap { |out|
 247		out.body = "This bot is deprecated. Please talk to xmpp:cheogram.com"
 248	}
 249end
 250
 251before(
 252	:iq,
 253	type: [:error, :result],
 254	to: /\Acustomer_/,
 255	from: /(\A|@)#{CONFIG[:sgx]}(\/|\Z)/
 256) { |iq| halt if IQ_MANAGER.fulfill(iq) }
 257
 258SPAM_ERRS = [
 259	"rejected-spam-detected",
 260	"destination-spam-detected",
 261	"destination-rejected-due-to-spam-detection"
 262].freeze
 263
 264before nil, to: /\Acustomer_/, from: /(\A|@)#{CONFIG[:sgx]}(\/|\Z)/ do |s|
 265	StatsD.increment("stanza_customer")
 266
 267	Sentry.get_current_scope.set_transaction_name("stanza_customer")
 268	CustomerRepo.new(set_user: Sentry.method(:set_user)).find(
 269		s.to.node.delete_prefix("customer_")
 270	).then do |customer|
 271		if s.is_a?(Blather::Stanza::Message) && s.error?
 272			err = Blather::StanzaError.import(s).text
 273			if SPAM_ERRS.include?(err)
 274				REDIS.setex(
 275					"jmp_customer_spam_detected-#{customer.customer_id}",
 276					30 * 60,
 277					err
 278				)
 279			end
 280		end
 281
 282		ReachabilityRepo::SMS.new
 283			.find(customer, s.from.node, stanza: s).then do |reach|
 284				reach.filter do
 285					customer.stanza_to(s)
 286				end
 287			end
 288	end
 289
 290	halt
 291end
 292
 293ADDRESSES_NS = "http://jabber.org/protocol/address"
 294message(
 295	to: /\A#{CONFIG[:component][:jid]}\Z/,
 296	from: /(\A|@)#{CONFIG[:sgx]}(\/|\Z)/
 297) do |m|
 298	StatsD.increment("inbound_group_text")
 299	Sentry.get_current_scope.set_transaction_name("inbound_group_text")
 300
 301	address = m.find("ns:addresses", ns: ADDRESSES_NS).first
 302		&.find("ns:address", ns: ADDRESSES_NS)
 303		&.find { |el| el["jid"].to_s.start_with?("customer_") }
 304	pass unless address
 305
 306	CustomerRepo
 307		.new(set_user: Sentry.method(:set_user))
 308		.find_by_jid(address["jid"]).then { |customer|
 309			m.from = m.from.with(domain: CONFIG[:component][:jid])
 310			m.to = m.to.with(domain: customer.jid.domain)
 311			address["jid"] = customer.jid.to_s
 312			BLATHER << m
 313		}.catch_only(CustomerRepo::NotFound) { |e|
 314			BLATHER << m.as_error("forbidden", :auth, e.message)
 315		}
 316end
 317
 318# Ignore groupchat messages
 319# Especially if we have the component join MUC for notifications
 320message(type: :groupchat) { true }
 321
 322def billable_message(m)
 323	b = m.body
 324	b && !b.empty? || m.find("ns:x", ns: OOB.registered_ns).first
 325end
 326
 327class OverLimit < StandardError
 328	def initialize(customer, usage)
 329		super("Please contact support")
 330		@customer = customer
 331		@usage = usage
 332	end
 333
 334	def notify_admin
 335		ExpiringLock.new("jmp_usage_notify-#{@customer.customer_id}").with do
 336			BLATHER.join(CONFIG[:notify_admin], "sgx-jmp")
 337			BLATHER.say(
 338				CONFIG[:notify_admin], "#{@customer.customer_id} has used " \
 339				"#{@usage} messages today", :groupchat
 340			)
 341		end
 342	end
 343end
 344
 345class CustomerExpired < StandardError; end
 346
 347CONFIG[:direct_targets].each do |(tel, jid)|
 348	customer_repo = CustomerRepo.new(
 349		sgx_repo: TrivialBackendSgxRepo.new(jid: jid),
 350		set_user: Sentry.method(:set_user)
 351	)
 352
 353	message to: /\A#{Regexp.escape(tel)}@#{CONFIG[:component][:jid]}\/?/ do |m|
 354		customer_repo.find_by_jid(m.from.stripped).then { |customer|
 355			customer.stanza_from(m)
 356		}.catch_only(CustomerRepo::NotFound) {
 357			# This should not happen, but let's still get the message
 358			# to support at least if it does
 359			m.from = ProxiedJID.proxy(m.from, CONFIG[:component][:jid])
 360			m.to = jid
 361			BLATHER << m
 362		}
 363	end
 364end
 365
 366CONFIG[:direct_sources].each do |(jid, tel)|
 367	customer_repo = CustomerRepo.new(
 368		sgx_repo: TrivialBackendSgxRepo.new(jid: jid),
 369		set_user: Sentry.method(:set_user)
 370	)
 371	message to: /\Acustomer_/, from: /\A#{Regexp.escape(jid)}\/?/ do |m|
 372		customer_repo.find(m.to.node.delete_prefix("customer_")).then { |customer|
 373			m.from = "#{tel}@sgx-jmp" # stanza_to will fix domain
 374			customer.stanza_to(m)
 375		}.catch_only(CustomerRepo::NotFound) { |e|
 376			BLATHER << m.as_error("item-not-found", :cancel, e.message)
 377		}
 378	end
 379end
 380
 381message do |m|
 382	StatsD.increment("message")
 383
 384	today = Time.now.utc.to_date
 385	CustomerRepo.new(set_user: Sentry.method(:set_user))
 386		.find_by_jid(m.from.stripped).then { |customer|
 387			next customer.stanza_from(m) unless billable_message(m)
 388
 389			if customer.plan_name && !customer.active?
 390				raise CustomerExpired, "Your account is expired, please top up"
 391			end
 392
 393			EMPromise.all([
 394				REDIS.exists("jmp_customer_spam_detected-#{customer.customer_id}"),
 395				TrustLevelRepo.new.find(customer),
 396				customer.message_usage((today..today))
 397			]).then { |(spam, tl, usage)|
 398				raise OverLimit.new(customer, "SPAM DETECTED") if spam.to_i == 1
 399				raise OverLimit.new(customer, usage) unless tl.send_message?(usage)
 400			}.then do
 401				EMPromise.all([customer.incr_message_usage, customer.stanza_from(m)])
 402			end
 403		}.catch_only(OverLimit) { |e|
 404			e.notify_admin
 405			BLATHER << m.as_error("policy-violation", :wait, e.message)
 406		}.catch_only(CustomerRepo::NotFound, CustomerExpired) { |e|
 407			BLATHER << m.as_error("forbidden", :auth, e.message)
 408		}
 409end
 410
 411disco_info to: Blather::JID.new(CONFIG[:component][:jid]) do |iq|
 412	reply = iq.reply
 413	reply.identities = [{
 414		name: "JMP.chat",
 415		type: "sms",
 416		category: "gateway"
 417	}]
 418	reply.features = [
 419		"http://jabber.org/protocol/disco#info",
 420		"http://jabber.org/protocol/commands"
 421	]
 422	form = Blather::Stanza::X.find_or_create(reply.query)
 423	form.type = "result"
 424	form.fields = [
 425		{
 426			var: "FORM_TYPE",
 427			type: "hidden",
 428			value: "http://jabber.org/network/serverinfo"
 429		}
 430	] + CONFIG[:xep0157]
 431	self << reply
 432end
 433
 434disco_info do |iq|
 435	reply = iq.reply
 436	reply.identities = [{
 437		name: "JMP.chat",
 438		type: "sms",
 439		category: "client"
 440	}]
 441	reply.features = [
 442		"urn:xmpp:receipts"
 443	]
 444	self << reply
 445end
 446
 447disco_items(
 448	to: Blather::JID.new(CONFIG[:component][:jid]),
 449	node: "http://jabber.org/protocol/commands"
 450) do |iq|
 451	StatsD.increment("command_list")
 452
 453	reply = iq.reply
 454	reply.node = "http://jabber.org/protocol/commands"
 455
 456	CustomerRepo.new(
 457		sgx_repo: Bwmsgsv2Repo.new,
 458		set_user: Sentry.method(:set_user)
 459	).find_by_jid(
 460		iq.from.stripped
 461	).catch {
 462		nil
 463	}.then { |customer|
 464		CommandList.for(customer, iq.from)
 465	}.then { |list|
 466		reply.items = list.map { |item|
 467			Blather::Stanza::DiscoItems::Item.new(
 468				iq.to,
 469				item[:node],
 470				item[:name]
 471			)
 472		}
 473		self << reply
 474	}
 475end
 476
 477iq "/iq/ns:services", ns: "urn:xmpp:extdisco:2" do |iq|
 478	StatsD.increment("extdisco")
 479
 480	reply = iq.reply
 481	reply << Nokogiri::XML::Builder.new {
 482		services(xmlns: "urn:xmpp:extdisco:2") do
 483			service(
 484				type: "sip",
 485				host: CONFIG[:sip_host]
 486			)
 487		end
 488	}.doc.root
 489
 490	self << reply
 491end
 492
 493Command.new(
 494	"jabber:iq:register",
 495	"Register",
 496	list_for: ->(*) { true },
 497	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 498) {
 499	google_play_userid = if Command.execution.iq.from.domain == "cheogram.com"
 500		Command.execution.iq.command.find(
 501			"./ns:userId", ns: "https://ns.cheogram.com/google-play"
 502		)&.first&.content
 503	end
 504	if Command.execution.iq.from.stripped.to_s == CONFIG[:web_register][:from]
 505		Customer.new(
 506			"__web_register", Command.execution.iq.from.stripped,
 507			sgx: TrivialBackendSgxRepo.new.get("__web_register")
 508				.with(registered?: false)
 509		)
 510	else
 511		Command.customer.catch_only(CustomerRepo::NotFound) {
 512			Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Customer.create"))
 513			Command.execution.customer_repo.create(Command.execution.iq.from.stripped)
 514		}
 515	end.then { |customer|
 516		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Registration.for"))
 517		Registration.for(customer, google_play_userid, TEL_SELECTIONS).then(&:write)
 518	}.then {
 519		StatsD.increment("registration.completed")
 520	}.catch_only(Command::Execution::FinalStanza) do |e|
 521		StatsD.increment("registration.completed")
 522		EMPromise.reject(e)
 523	end
 524}.register(self).then(&CommandList.method(:register))
 525
 526Command.new(
 527	"info",
 528	"👤 Show Account Info",
 529	list_for: ->(*) { true },
 530	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 531) {
 532	Command.customer.then(&CustomerInfo.method(:for)).then do |info|
 533		Command.finish do |reply|
 534			reply.command << info.form
 535		end
 536	end
 537}.register(self).then(&CommandList.method(:register))
 538
 539Command.new(
 540	"cdrs",
 541	"📲 Show Call Logs"
 542) {
 543	report_for = ((Date.today << 1)..Date.today)
 544
 545	Command.customer.then { |customer|
 546		CDRRepo.new.find_range(customer, report_for)
 547	}.then do |cdrs|
 548		Command.finish do |reply|
 549			reply.command << FormTemplate.render("customer_cdr", cdrs: cdrs)
 550		end
 551	end
 552}.register(self).then(&CommandList.method(:register))
 553
 554Command.new(
 555	"transactions",
 556	"🧾 Show Transactions",
 557	list_for: ->(customer:, **) { !!customer&.currency }
 558) {
 559	Command.customer.then(&:transactions).then do |txs|
 560		Command.finish do |reply|
 561			reply.command << FormTemplate.render("transactions", transactions: txs)
 562		end
 563	end
 564}.register(self).then(&CommandList.method(:register))
 565
 566Command.new(
 567	"configure calls",
 568	"📞 Configure Calls",
 569	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 570) {
 571	Command.customer.then do |customer|
 572		cc_form = ConfigureCallsForm.new(customer)
 573		Command.reply { |reply|
 574			reply.allowed_actions = [:next]
 575			reply.command << cc_form.render
 576		}.then { |iq|
 577			EMPromise.all(cc_form.parse(iq.form).map { |k, v|
 578				Command.execution.customer_repo.public_send("put_#{k}", customer, v)
 579			})
 580		}.then { Command.finish("Configuration saved!") }
 581	end
 582}.register(self).then(&CommandList.method(:register))
 583
 584Command.new(
 585	"ogm",
 586	"⏺️ Record Voicemail Greeting",
 587	list_for: ->(fwd: nil, **) { fwd&.voicemail_enabled? },
 588	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 589) {
 590	Command.customer.then do |customer|
 591		customer.fwd.create_call(CONFIG[:creds][:account]) do |cc|
 592			cc.from = customer.registered?.phone
 593			cc.application_id = CONFIG[:sip][:app]
 594			cc.answer_url = "#{CONFIG[:web_root]}/ogm/start?" \
 595			                "customer_id=#{customer.customer_id}"
 596		end
 597		Command.finish("You will now receive a call.")
 598	end
 599}.register(self).then(&CommandList.method(:register))
 600
 601Command.new(
 602	"migrate billing",
 603	"🏦 Switch to new billing",
 604	list_for: ->(tel:, customer:, **) { tel && !customer&.currency },
 605	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 606) {
 607	EMPromise.all([
 608		Command.customer,
 609		Command.reply do |reply|
 610			reply.allowed_actions = [:next]
 611			reply.command << FormTemplate.render("migrate_billing")
 612		end
 613	]).then do |(customer, iq)|
 614		plan_name = iq.form.field("plan_name").value.to_s
 615		customer = customer.with_plan(plan_name)
 616		customer.save_plan!.then {
 617			Registration::Payment.for(
 618				iq, customer, customer.registered?.phone,
 619				final_message: PaypalDone::MESSAGE,
 620				finish: PaypalDone
 621			)
 622		}.then(&:write).catch_only(Command::Execution::FinalStanza) do |s|
 623			BLATHER.join(CONFIG[:notify_admin], "sgx-jmp")
 624			BLATHER.say(
 625				CONFIG[:notify_admin],
 626				"#{customer.customer_id} migrated to #{customer.currency}",
 627				:groupchat
 628			)
 629			EMPromise.reject(s)
 630		end
 631	end
 632}.register(self).then(&CommandList.method(:register))
 633
 634Command.new(
 635	"credit cards",
 636	"💳 Credit Card Settings and Management"
 637) {
 638	Command.customer.then do |customer|
 639		url = CONFIG[:credit_card_url].call(
 640			customer.jid.to_s.gsub("\\", "%5C"),
 641			customer.customer_id
 642		)
 643		desc = "Manage credits cards and settings"
 644		Command.finish("#{desc}: #{url}") do |reply|
 645			oob = OOB.find_or_create(reply.command)
 646			oob.url = url
 647			oob.desc = desc
 648		end
 649	end
 650}.register(self).then(&CommandList.method(:register))
 651
 652Command.new(
 653	"top up",
 654	"💲 Buy Account Credit by Credit Card",
 655	list_for: ->(payment_methods: [], **) { !payment_methods.empty? },
 656	format_error: ->(e) { "Failed to buy credit, system said: #{e.message}" }
 657) {
 658	Command.customer.then { |customer|
 659		BuyAccountCreditForm.for(customer).then do |credit_form|
 660			Command.reply { |reply|
 661				reply.allowed_actions = [:complete]
 662				reply.command << credit_form.form
 663			}.then do |iq|
 664				CreditCardSale.create(customer, **credit_form.parse(iq.form))
 665			end
 666		end
 667	}.then { |transaction|
 668		Command.finish("#{transaction} added to your account balance.")
 669	}.catch_only(BuyAccountCreditForm::AmountValidationError) do |e|
 670		Command.finish(e.message, type: :error)
 671	end
 672}.register(self).then(&CommandList.method(:register))
 673
 674Command.new(
 675	"alt top up",
 676	"🪙 Buy Account Credit by Bitcoin, Mail, or Interac e-Transfer",
 677	list_for: ->(customer:, **) { !!customer&.currency }
 678) {
 679	Command.customer.then { |customer|
 680		AltTopUpForm.for(customer)
 681	}.then do |alt_form|
 682		Command.reply { |reply|
 683			reply.allowed_actions = [:complete]
 684			reply.command << alt_form.form
 685		}.then do |iq|
 686			Command.finish { |reply| alt_form.parse(iq.form).action(reply) }
 687		end
 688	end
 689}.register(self).then(&CommandList.method(:register))
 690
 691Command.new(
 692	"plan settings",
 693	"📝 Manage your plan, including overage limits",
 694	list_for: ->(customer:, **) { !!customer&.currency }
 695) {
 696	Command.customer.then { |customer|
 697		EMPromise.all([
 698			REDIS.get("jmp_customer_monthly_data_limit-#{customer.customer_id}"),
 699			SIMRepo.new.owned_by(customer)
 700		]).then { |(limit, sims)| [customer, sims, limit] }
 701	}.then do |(customer, sims, limit)|
 702		Command.reply { |reply|
 703			reply.allowed_actions = [:next]
 704			reply.command << FormTemplate.render(
 705				"plan_settings", customer: customer, sims: sims, data_limit: limit
 706			)
 707		}.then { |iq|
 708			kwargs = {
 709				monthly_overage_limit: iq.form.field("monthly_overage_limit")&.value,
 710				monthly_data_limit: iq.form.field("monthly_data_limit")&.value
 711			}.compact
 712			Command.execution.customer_repo.put_monthly_limits(customer, **kwargs)
 713		}.then { Command.finish("Configuration saved!") }
 714	end
 715}.register(self).then(&CommandList.method(:register))
 716
 717Command.new(
 718	"referral codes",
 719	"👥 Refer a friend for free credit"
 720) {
 721	repo = InvitesRepo.new
 722	Command.customer.then { |customer|
 723		EMPromise.all([
 724			repo.find_or_create_group_code(customer.customer_id),
 725			repo.unused_invites(customer.customer_id)
 726		])
 727	}.then do |(group_code, invites)|
 728		if invites.empty?
 729			Command.finish(
 730				"This code will provide credit equivalent to one month of service " \
 731				"to anyone after they sign up and pay: #{group_code}\n\n" \
 732				"You will receive credit equivalent to one month of service once " \
 733				"their payment clears."
 734			)
 735		else
 736			Command.finish do |reply|
 737				reply.command << FormTemplate.render(
 738					"codes",
 739					invites: invites,
 740					group_code: group_code
 741				)
 742			end
 743		end
 744	end
 745}.register(self).then(&CommandList.method(:register))
 746
 747# Assumes notify_from is a direct target
 748notify_to = CONFIG[:direct_targets].fetch(
 749	Blather::JID.new(CONFIG[:notify_from]).node.to_sym
 750)
 751
 752Command.new(
 753	"sims",
 754	"📶 (e)SIM Details",
 755	list_for: ->(customer:, **) { CONFIG[:keepgo] && !!customer&.currency },
 756	customer_repo: CustomerRepo.new(
 757		sgx_repo: TrivialBackendSgxRepo.new(jid: notify_to)
 758	)
 759) {
 760	Command.customer.then { |customer|
 761		EMPromise.all([customer, SIMRepo.new.owned_by(customer)])
 762	}.then do |(customer, sims)|
 763		Command.reply { |reply|
 764			reply.command << FormTemplate.render("sim_details", sims: sims)
 765		}.then { |iq|
 766			case iq.form.field("http://jabber.org/protocol/commands#actions")&.value
 767			when "order-sim"
 768				SIMOrder.for(customer, **CONFIG.dig(:sims, :sim, customer.currency))
 769			when "order-esim"
 770				SIMOrder::ESIM.for(
 771					customer, **CONFIG.dig(:sims, :esim, customer.currency)
 772				)
 773			else
 774				Command.finish
 775			end
 776		}.then { |order|
 777			Command.reply { |reply|
 778				reply.allowed_actions = [:complete]
 779				reply.command << order.form
 780			}.then(&order.method(:complete))
 781		}
 782	end
 783}.register(self).then(&CommandList.method(:register))
 784
 785Command.new(
 786	"subaccount",
 787	"➕️ Create a new phone number linked to this balance",
 788	list_for: lambda do |customer:, **|
 789		!!customer&.currency &&
 790		customer&.billing_customer_id == customer&.customer_id
 791	end
 792) {
 793	cheogram = Command.execution.iq.from.resource =~ /\ACheogram/
 794	Command.customer.then do |customer|
 795		ParentCodeRepo.new.find_or_create(customer.customer_id).then do |code|
 796			Command.finish { |reply|
 797				reply.command << FormTemplate.render(
 798					"subaccount", code: code, cheogram: cheogram
 799				)
 800			}
 801		end
 802	end
 803}.register(self).then(&CommandList.method(:register))
 804
 805Command.new(
 806	"reset sip account",
 807	"☎️ Create or Reset SIP Account",
 808	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 809) {
 810	Command.customer.then do |customer|
 811		sip_account = customer.reset_sip_account
 812		Command.reply { |reply|
 813			reply.allowed_actions = [:next]
 814			form = sip_account.form
 815			form.type = :form
 816			form.fields += [{
 817				type: :boolean, var: "change_fwd",
 818				label: "Should inbound calls forward to this SIP account?"
 819			}]
 820			reply.command << form
 821		}.then do |fwd|
 822			if ["1", "true"].include?(fwd.form.field("change_fwd")&.value.to_s)
 823				Command.execution.customer_repo.put_fwd(
 824					customer,
 825					customer.fwd.with(uri: sip_account.uri)
 826				).then { Command.finish("Inbound calls will now forward to SIP.") }
 827			else
 828				Command.finish
 829			end
 830		end
 831	end
 832}.register(self).then(&CommandList.method(:register))
 833
 834Command.new(
 835	"lnp",
 836	"#️⃣ Port in your number from another carrier",
 837	list_for: ->(**) { true }
 838) {
 839	EMPromise.all([
 840		Command.customer,
 841		Command.reply do |reply|
 842			reply.allowed_actions = [:next]
 843			reply.command << FormTemplate.render("lnp")
 844		end
 845	]).then { |(customer, iq)|
 846		PortInOrder.parse(customer, iq.form).complete_with do |form|
 847			Command.reply { |reply|
 848				reply.allowed_actions = [:next]
 849				reply.command << form
 850			}.then(&:form)
 851		end
 852	}.then do |order|
 853		order_id = BandwidthIris::PortIn.create(order.to_h)[:order_id]
 854		BLATHER.join(CONFIG[:notify_admin], "sgx-jmp")
 855		BLATHER.say(CONFIG[:notify_admin], order.message(order_id), :groupchat)
 856		Command.finish(
 857			"Your port-in request has been accepted, " \
 858			"support will contact you with next steps"
 859		)
 860	end
 861}.register(self).then(&CommandList.method(:register))
 862
 863Command.new(
 864	"terminate account",
 865	"❌ Cancel your account and terminate your phone number",
 866	list_for: ->(**) { false },
 867	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
 868) {
 869	Command.reply { |reply|
 870		reply.allowed_actions = [:next]
 871		reply.note_text = "Press next to confirm your account termination."
 872	}.then { Command.customer }.then { |customer|
 873		AdminAction::CancelCustomer.call(
 874			customer,
 875			customer_repo: Command.execution.customer_repo
 876		)
 877	}.then do
 878		Command.finish("Account cancelled")
 879	end
 880}.register(self).then(&CommandList.method(:register))
 881
 882Command.new(
 883	"customer info",
 884	"Show Customer Info",
 885	list_for: ->(customer: nil, **) { customer&.admin? }
 886) {
 887	Command.customer.then do |customer|
 888		raise AuthError, "You are not an admin" unless customer&.admin?
 889
 890		customer_repo = CustomerRepo.new(
 891			sgx_repo: Bwmsgsv2Repo.new,
 892			bandwidth_tn_repo: EmptyRepo.new(BandwidthTnRepo.new) # No CNAM in admin
 893		)
 894
 895		AdminCommand::NoUser.new(customer_repo).start
 896	end
 897}.register(self).then(&CommandList.method(:register))
 898
 899Command.new(
 900	"reachability",
 901	"Test Reachability",
 902	list_for: ->(customer: nil, **) { customer&.admin? }
 903) {
 904	Command.customer.then do |customer|
 905		raise AuthError, "You are not an admin" unless customer&.admin?
 906
 907		form = ReachabilityForm.new(CustomerRepo.new)
 908
 909		Command.reply { |reply|
 910			reply.allowed_actions = [:next]
 911			reply.command << form.render
 912		}.then { |response|
 913			form.parse(response.form)
 914		}.then { |result|
 915			result.repo.get_or_create(result.target).then { |v|
 916				result.target.stanza_from(result.prompt) if result.prompt
 917
 918				Command.finish { |reply|
 919					reply.command << form.render_result(v)
 920				}
 921			}
 922		}.catch_only(RuntimeError) { |e|
 923			Command.finish(e, type: :error)
 924		}
 925	end
 926}.register(self).then(&CommandList.method(:register))
 927
 928Command.new(
 929	"snikket",
 930	"Launch Snikket Instance",
 931	list_for: ->(customer: nil, **) { customer&.admin? }
 932) {
 933	Command.customer.then do |customer|
 934		raise AuthError, "You are not an admin" unless customer&.admin?
 935
 936		Command.reply { |reply|
 937			reply.allowed_actions = [:next]
 938			reply.command << FormTemplate.render("snikket_launch")
 939		}.then { |response|
 940			domain = response.form.field("domain").value.to_s
 941			IQ_MANAGER.write(Snikket::Launch.new(
 942				nil, CONFIG[:snikket_hosting_api],
 943				domain: domain
 944			)).then do |launched|
 945				Snikket::CustomerInstance.for(customer, domain, launched)
 946			end
 947		}.then { |instance|
 948			Command.finish do |reply|
 949				reply.command << FormTemplate.render(
 950					"snikket_launched",
 951					instance: instance
 952				)
 953			end
 954		}
 955	end
 956}.register(self).then(&CommandList.method(:register))
 957
 958Command.new(
 959	"stop snikket",
 960	"STOP Snikket Instance",
 961	list_for: ->(customer: nil, **) { customer&.admin? }
 962) {
 963	Command.customer.then do |customer|
 964		raise AuthError, "You are not an admin" unless customer&.admin?
 965
 966		Command.reply { |reply|
 967			reply.allowed_actions = [:next]
 968			reply.command << FormTemplate.render("snikket_stop")
 969		}.then { |response|
 970			instance_id = response.form.field("instance_id").value.to_s
 971			IQ_MANAGER.write(Snikket::Stop.new(
 972				nil, CONFIG[:snikket_hosting_api],
 973				instance_id: instance_id
 974			))
 975		}.then { |iq|
 976			Command.finish(iq.to_s)
 977		}
 978	end
 979}.register(self).then(&CommandList.method(:register))
 980
 981Command.new(
 982	"delete snikket",
 983	"DELETE Snikket Instance",
 984	list_for: ->(customer: nil, **) { customer&.admin? }
 985) {
 986	Command.customer.then do |customer|
 987		raise AuthError, "You are not an admin" unless customer&.admin?
 988
 989		Command.reply { |reply|
 990			reply.allowed_actions = [:next]
 991			reply.command << FormTemplate.render("snikket_delete")
 992		}.then { |response|
 993			instance_id = response.form.field("instance_id").value.to_s
 994			IQ_MANAGER.write(Snikket::Delete.new(
 995				nil, CONFIG[:snikket_hosting_api],
 996				instance_id: instance_id
 997			))
 998		}.then { |iq|
 999			Command.finish(iq.to_s)
1000		}
1001	end
1002}.register(self).then(&CommandList.method(:register))
1003
1004Command.new(
1005	"find snikket",
1006	"Lookup Snikket Instance",
1007	list_for: ->(customer: nil, **) { customer&.admin? }
1008) {
1009	Command.customer.then do |customer|
1010		raise AuthError, "You are not an admin" unless customer&.admin?
1011
1012		Command.reply { |reply|
1013			reply.allowed_actions = [:next]
1014			reply.command << FormTemplate.render("snikket_launch")
1015		}.then { |response|
1016			domain = response.form.field("domain").value.to_s
1017			IQ_MANAGER.write(Snikket::DomainInfo.new(
1018				nil, CONFIG[:snikket_hosting_api],
1019				domain: domain
1020			))
1021		}.then { |instance|
1022			Command.finish do |reply|
1023				reply.command << FormTemplate.render(
1024					"snikket_result",
1025					instance: instance
1026				)
1027			end
1028		}
1029	end
1030}.register(self).then(&CommandList.method(:register))
1031
1032def reply_with_note(iq, text, type: :info)
1033	reply = iq.reply
1034	reply.status = :completed
1035	reply.note_type = type
1036	reply.note_text = text
1037
1038	self << reply
1039end
1040
1041Command.new(
1042	"https://ns.cheogram.com/sgx/jid-switch",
1043	"Change JID",
1044	list_for: lambda { |customer: nil, from_jid: nil, **|
1045		customer || from_jid.to_s =~ /onboarding.cheogram.com/
1046	},
1047	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
1048) {
1049	Command.customer.then { |customer|
1050		Command.reply { |reply|
1051			reply.command << FormTemplate.render("jid_switch")
1052		}.then { |response|
1053			new_jid = response.form.field("jid").value
1054			repo = Command.execution.customer_repo
1055			repo.find_by_jid(new_jid).catch_only(CustomerRepo::NotFound) { nil }
1056				.then { |cust|
1057					next EMPromise.reject("Customer Already Exists") if cust
1058
1059					repo.change_jid(customer, new_jid)
1060				}
1061		}.then {
1062			StatsD.increment("changejid.completed")
1063			jid = ProxiedJID.new(customer.jid).unproxied
1064			if jid.domain == CONFIG[:onboarding_domain]
1065				CustomerRepo.new.find(customer.customer_id).then do |cust|
1066					WelcomeMessage.new(cust, customer.registered?.phone).welcome
1067				end
1068			end
1069			Command.finish { |reply|
1070				reply.note_type = :info
1071				reply.note_text = "Customer JID Changed"
1072			}
1073		}
1074	}
1075}.register(self).then(&CommandList.method(:register))
1076
1077Command.new(
1078	"web-register",
1079	"Initiate Register from Web",
1080	list_for: lambda { |from_jid: nil, **|
1081		from_jid&.stripped.to_s == CONFIG[:web_register][:from]
1082	}
1083) {
1084	if Command.execution.iq.from.stripped != CONFIG[:web_register][:from]
1085		next EMPromise.reject(
1086			Command::Execution::FinalStanza.new(iq.as_error("forbidden", :auth))
1087		)
1088	end
1089
1090	Command.reply { |reply|
1091		reply.command << FormTemplate.render("web_register")
1092	}.then do |iq|
1093		jid = iq.form.field("jid")&.value.to_s.strip
1094		tel = iq.form.field("tel")&.value.to_s.strip
1095		if jid !~ /\./ || jid =~ /\s/
1096			Command.finish("The Jabber ID you entered was not valid.", type: :error)
1097		elsif tel !~ /\A\+\d+\Z/
1098			Command.finish("Invalid telephone number", type: :error)
1099		else
1100			IQ_MANAGER.write(Blather::Stanza::Iq::Command.new.tap { |cmd|
1101				cmd.to = CONFIG[:web_register][:to]
1102				cmd.node = "push-register"
1103				cmd.form.fields = [{ var: "to", value: jid }]
1104				cmd.form.type = "submit"
1105			}).then { |result|
1106				TEL_SELECTIONS.set_tel(result.form.field("from")&.value.to_s.strip, tel)
1107			}.then { Command.finish }
1108		end
1109	end
1110}.register(self).then(&CommandList.method(:register))
1111
1112command sessionid: /./ do |iq|
1113	COMMAND_MANAGER.fulfill(iq)
1114	IQ_MANAGER.fulfill(iq)
1115	true
1116end
1117
1118iq type: [:result, :error] do |iq|
1119	IQ_MANAGER.fulfill(iq)
1120	true
1121end
1122
1123iq type: [:get, :set] do |iq|
1124	StatsD.increment("unknown_iq")
1125
1126	self << Blather::StanzaError.new(iq, "feature-not-implemented", :cancel)
1127end
1128
1129trap(:INT) { EM.stop }
1130trap(:TERM) { EM.stop }
1131EM.run { client.run }