diff --git a/app/controllers/Challenge.scala b/app/controllers/Challenge.scala index 7dde11bd29..93f2253a34 100644 --- a/app/controllers/Challenge.scala +++ b/app/controllers/Challenge.scala @@ -15,6 +15,7 @@ import lila.oauth.{ AccessToken, OAuthScope } import lila.setup.ApiConfig import lila.socket.Socket.SocketVersion import lila.user.{ User => UserModel } +import lila.common.Template final class Challenge( env: Env @@ -285,7 +286,8 @@ final class Challenge( env.user.repo enabledById userId.toLowerCase flatMap { destUser => val challenge = makeOauthChallenge(config, me, destUser) (destUser, config.acceptByToken) match { - case (Some(dest), Some(strToken)) => apiChallengeAccept(dest, challenge, strToken) + case (Some(dest), Some(strToken)) => + apiChallengeAccept(dest, challenge, strToken)(me, config.message) case _ => destUser ?? { env.challenge.granter(me.some, _, config.perfType) } flatMap { case Some(denied) => @@ -318,7 +320,11 @@ final class Challenge( .bindFromRequest() .fold( err => BadRequest(apiFormError(err)).fuccess, - config => acceptOauthChallenge(dest, makeOauthChallenge(config, orig, dest.some)) + config => + acceptOauthChallenge(dest, makeOauthChallenge(config, orig, dest.some))( + admin, + config.message + ) ) } } @@ -345,11 +351,14 @@ final class Challenge( ) } - private def acceptOauthChallenge(dest: UserModel, challenge: ChallengeModel) = - env.challenge.api.oauthAccept(dest, challenge) map { - case None => BadRequest(jsonError("Couldn't create game")) + private def acceptOauthChallenge( + dest: UserModel, + challenge: ChallengeModel + )(managedBy: lila.user.User, template: Option[Template]) = + env.challenge.api.oauthAccept(dest, challenge) flatMap { + case None => BadRequest(jsonError("Couldn't create game")).fuccess case Some(g) => - Ok( + env.challenge.msg.onApiPair(challenge)(managedBy, template) inject Ok( Json.obj( "game" -> { env.game.jsonView(g, challenge.initialFen) ++ Json.obj( @@ -364,7 +373,7 @@ final class Challenge( dest: UserModel, challenge: lila.challenge.Challenge, strToken: String - ) = + )(managedBy: lila.user.User, message: Option[Template]) = env.security.api.oauthScoped( lila.oauth.AccessToken.Id(strToken), List(lila.oauth.OAuthScope.Challenge.Write) @@ -372,7 +381,7 @@ final class Challenge( _.fold( err => BadRequest(jsonError(err.message)).fuccess, scoped => - if (scoped.user is dest) acceptOauthChallenge(dest, challenge) + if (scoped.user is dest) acceptOauthChallenge(dest, challenge)(managedBy, message) else BadRequest(jsonError("dest and accept user don't match")).fuccess ) } diff --git a/build.sbt b/build.sbt index 529c09cfeb..08d8715763 100644 --- a/build.sbt +++ b/build.sbt @@ -280,7 +280,7 @@ lazy val shutup = module("shutup", ) lazy val challenge = module("challenge", - Seq(common, db, hub, setup, game, relation, pref, socket, room), + Seq(common, db, hub, setup, game, relation, pref, socket, room, msg), Seq(scalatags, lettuce, specs2) ++ reactivemongo.bundle ) diff --git a/modules/challenge/src/main/ChallengeBulk.scala b/modules/challenge/src/main/ChallengeBulk.scala index 297f059dab..a8560a7e12 100644 --- a/modules/challenge/src/main/ChallengeBulk.scala +++ b/modules/challenge/src/main/ChallengeBulk.scala @@ -10,6 +10,7 @@ import scala.concurrent.duration._ import lila.common.Bus import lila.common.LilaStream +import lila.common.Template import lila.db.dsl._ import lila.game.{ Game, Player } import lila.hub.actorApi.map.TellMany @@ -20,6 +21,7 @@ import lila.user.User final class ChallengeBulkApi( colls: ChallengeColls, + msgApi: ChallengeMsg, gameRepo: lila.game.GameRepo, userRepo: lila.user.UserRepo, onStart: lila.round.OnStart @@ -33,6 +35,7 @@ final class ChallengeBulkApi( implicit private val gameHandler = Macros.handler[ScheduledGame] implicit private val variantHandler = variantByKeyHandler implicit private val clockHandler = clockConfigHandler + implicit private val messageHandler = stringAnyValHandler[Template](_.value, Template.apply) implicit private val bulkHandler = Macros.handler[ScheduledBulk] private val coll = colls.bulk @@ -92,8 +95,8 @@ final class ChallengeBulkApi( } } .mapConcat(_.toList) - .map[Game] { case (id, white, black) => - Game + .map { case (id, white, black) => + val game = Game .make( chess = chess.Game(situation = Situation(bulk.variant), clock = bulk.clock.toClock.some), whitePlayer = Player.make(chess.White, white.some, _(perfType)), @@ -104,9 +107,15 @@ final class ChallengeBulkApi( ) .withId(id) .start + (game, white, black) } - .mapAsyncUnordered(8) { game => - (gameRepo insertDenormalized game) >>- onStart(game.id) + .mapAsyncUnordered(8) { case (game, white, black) => + gameRepo.insertDenormalized(game) >>- onStart(game.id) inject { + (game, white, black) + } + } + .mapAsyncUnordered(8) { case (game, white, black) => + msgApi.onApiPair(game.id, white.light, black.light)(bulk.by, bulk.message) } .toMat(LilaStream.sinkCount)(Keep.right) .run() diff --git a/modules/challenge/src/main/ChallengeMsg.scala b/modules/challenge/src/main/ChallengeMsg.scala new file mode 100644 index 0000000000..1fc82c2a8a --- /dev/null +++ b/modules/challenge/src/main/ChallengeMsg.scala @@ -0,0 +1,34 @@ +package lila.challenge + +import scala.concurrent.ExecutionContext + +import lila.common.{ LightUser, Template } +import lila.user.{ LightUserApi, User } + +final class ChallengeMsg(msgApi: lila.msg.MsgApi, lightUserApi: LightUserApi)(implicit + ec: ExecutionContext +) { + + def onApiPair(challenge: Challenge)(managedBy: User, template: Option[Template]): Funit = + challenge.userIds.map(lightUserApi.async).sequenceFu.flatMap { + _.flatten match { + case List(u1, u2) => onApiPair(challenge.id, u1, u2)(managedBy.id, template) + case _ => funit + } + } + + def onApiPair(gameId: lila.game.Game.ID, u1: LightUser, u2: LightUser)( + managedById: User.ID, + template: Option[Template] + ): Funit = + List(u1 -> u2, u2 -> u1) + .map { case (u1, u2) => + val msg = template + .fold("Your game with {opponent} is ready: {game}.")(_.value) + .replace("{opponent}", s"@${u2.name}") + .replace("{game}", s"#${gameId}") + msgApi.post(managedById, u1.id, msg, multi = true) + } + .sequenceFu + .void +} diff --git a/modules/challenge/src/main/Env.scala b/modules/challenge/src/main/Env.scala index b031f37c09..9a8818a4ab 100644 --- a/modules/challenge/src/main/Env.scala +++ b/modules/challenge/src/main/Env.scala @@ -15,12 +15,14 @@ final class Env( onStart: lila.round.OnStart, gameCache: lila.game.Cached, lightUser: lila.common.LightUser.GetterSync, + lightUserApi: lila.user.LightUserApi, isOnline: lila.socket.IsOnline, db: lila.db.Db, cacheApi: lila.memo.CacheApi, prefApi: lila.pref.PrefApi, relationApi: lila.relation.RelationApi, remoteSocketApi: lila.socket.RemoteSocket, + msgApi: lila.msg.MsgApi, baseUrl: BaseUrl )(implicit ec: scala.concurrent.ExecutionContext, @@ -51,6 +53,8 @@ final class Env( lazy val bulk = wire[ChallengeBulkApi] + lazy val msg = wire[ChallengeMsg] + val forms = new ChallengeForm system.scheduler.scheduleWithFixedDelay(10 seconds, 3343 millis) { () => diff --git a/modules/common/src/main/model.scala b/modules/common/src/main/model.scala index 71fcab809e..6d58c8768a 100644 --- a/modules/common/src/main/model.scala +++ b/modules/common/src/main/model.scala @@ -130,3 +130,5 @@ case class Ints(value: List[Int]) extends AnyVal case class Every(value: FiniteDuration) extends AnyVal case class AtMost(value: FiniteDuration) extends AnyVal + +case class Template(value: String) extends AnyVal diff --git a/modules/msg/src/main/MsgPreset.scala b/modules/msg/src/main/MsgPreset.scala index b352ffac1c..07627d881b 100644 --- a/modules/msg/src/main/MsgPreset.scala +++ b/modules/msg/src/main/MsgPreset.scala @@ -1,9 +1,13 @@ package lila.msg +import lila.common.config.BaseUrl + case class MsgPreset(name: String, text: String) object MsgPreset { + type Username = String + lazy val sandbagAuto = MsgPreset( name = "Warning: possible sandbagging", text = @@ -20,7 +24,7 @@ Thank you for your understanding.""" This can be very annoying for your opponents. If this behavior continues to happen, we may be forced to terminate your account.""" ) - def maxFollow(username: String, max: Int) = + def maxFollow(username: Username, max: Int) = MsgPreset( name = "Follow limit reached!", text = s"""Sorry, you can't follow more than $max players on Lichess. diff --git a/modules/setup/src/main/ApiAiConfig.scala b/modules/setup/src/main/ApiAiConfig.scala index a5758a09f1..cb156405a1 100644 --- a/modules/setup/src/main/ApiAiConfig.scala +++ b/modules/setup/src/main/ApiAiConfig.scala @@ -20,8 +20,6 @@ final case class ApiAiConfig( val strictFen = false - def >> = (level, variant.key.some, clock, daysO, color.name.some, fen.map(_.value)).some - val days = ~daysO val increment = clock.??(_.increment.roundSeconds) val time = clock.??(_.limit.roundSeconds / 60) diff --git a/modules/setup/src/main/ApiConfig.scala b/modules/setup/src/main/ApiConfig.scala index 8475455b9e..29f43e6e02 100644 --- a/modules/setup/src/main/ApiConfig.scala +++ b/modules/setup/src/main/ApiConfig.scala @@ -9,6 +9,7 @@ import lila.game.PerfPicker import lila.lobby.Color import lila.rating.PerfType import chess.variant.Variant +import lila.common.Template final case class ApiConfig( variant: chess.variant.Variant, @@ -17,11 +18,10 @@ final case class ApiConfig( rated: Boolean, color: Color, position: Option[FEN] = None, - acceptByToken: Option[String] = None + acceptByToken: Option[String] = None, + message: Option[Template] ) { - def >> = (variant.key.some, clock, days, rated, color.name.some, position.map(_.value), acceptByToken).some - def perfType: Option[PerfType] = PerfPicker.perfType(chess.Speed(clock), variant, days) def validFen = ApiConfig.validFen(variant, position) @@ -49,7 +49,8 @@ object ApiConfig extends BaseHumanConfig { r: Boolean, c: Option[String], pos: Option[String], - tok: Option[String] + tok: Option[String], + msg: Option[String] ) = new ApiConfig( variant = chess.variant.Variant.orDefault(~v), @@ -58,7 +59,8 @@ object ApiConfig extends BaseHumanConfig { rated = r, color = Color.orDefault(~c), position = pos map FEN.apply, - acceptByToken = tok + acceptByToken = tok, + message = msg map Template ).autoVariant def validFen(variant: Variant, fen: Option[FEN]) = diff --git a/modules/setup/src/main/OpenConfig.scala b/modules/setup/src/main/OpenConfig.scala index 0ef073d822..71fae7f560 100644 --- a/modules/setup/src/main/OpenConfig.scala +++ b/modules/setup/src/main/OpenConfig.scala @@ -13,8 +13,6 @@ final case class OpenConfig( position: Option[FEN] = None ) { - def >> = (variant.key.some, clock, position.map(_.value)).some - def perfType: Option[PerfType] = PerfPicker.perfType(chess.Speed(clock), variant, none) def validFen = ApiConfig.validFen(variant, position) diff --git a/modules/setup/src/main/SetupBulk.scala b/modules/setup/src/main/SetupBulk.scala index 0d546c0b10..d2eb25461c 100644 --- a/modules/setup/src/main/SetupBulk.scala +++ b/modules/setup/src/main/SetupBulk.scala @@ -16,6 +16,7 @@ import lila.oauth.AccessToken import lila.oauth.OAuthScope import lila.oauth.OAuthServer import lila.user.User +import lila.common.Template object SetupBulk { @@ -27,7 +28,8 @@ object SetupBulk { clock: Clock.Config, rated: Boolean, pairAt: Option[DateTime], - startClocksAt: Option[DateTime] + startClocksAt: Option[DateTime], + message: Option[Template] ) private def timestampInNearFuture = longNumber( @@ -51,7 +53,8 @@ object SetupBulk { "clock" -> SetupForm.api.clockMapping, "rated" -> boolean, "pairAt" -> optional(timestampInNearFuture), - "startClocksAt" -> optional(timestampInNearFuture) + "startClocksAt" -> optional(timestampInNearFuture), + "message" -> SetupForm.api.message ) { ( tokens: String, @@ -59,7 +62,8 @@ object SetupBulk { clock: Clock.Config, rated: Boolean, pairTs: Option[Long], - clockTs: Option[Long] + clockTs: Option[Long], + message: Option[String] ) => BulkFormData( tokens, @@ -67,7 +71,8 @@ object SetupBulk { clock, rated, pairTs.map { new DateTime(_) }, - clockTs.map { new DateTime(_) } + clockTs.map { new DateTime(_) }, + message map Template ) }(_ => None) ) @@ -99,6 +104,7 @@ object SetupBulk { pairAt: DateTime, startClocksAt: Option[DateTime], scheduledAt: DateTime, + message: Option[Template], pairedAt: Option[DateTime] = None ) { def userSet = Set(games.flatMap(g => List(g.white, g.black))) @@ -210,6 +216,7 @@ final class SetupBulkApi(oauthServer: OAuthServer, idGenerator: IdGenerator)(imp Mode(data.rated), pairAt = data.pairAt | DateTime.now, startClocksAt = data.startClocksAt, + message = data.message, scheduledAt = DateTime.now ) } diff --git a/modules/setup/src/main/SetupForm.scala b/modules/setup/src/main/SetupForm.scala index 6368e56e52..dc58c5fedb 100644 --- a/modules/setup/src/main/SetupForm.scala +++ b/modules/setup/src/main/SetupForm.scala @@ -115,6 +115,13 @@ object SetupForm { lazy val variant = "variant" -> optional(text.verifying(Variant.byKey.contains _)) + lazy val message = optional( + nonEmptyText.verifying( + "The message must contain {game}, which will be replaced with the game URL.", + _ contains "{game}" + ) + ) + def user(from: User) = Form(challengeMapping.verifying("Invalid speed", _ validSpeed from.isBot)) @@ -128,8 +135,9 @@ object SetupForm { "rated" -> boolean, "color" -> optional(color), "fen" -> fenField, - "acceptByToken" -> optional(nonEmptyText) - )(ApiConfig.from)(_.>>) + "acceptByToken" -> optional(nonEmptyText), + "message" -> message + )(ApiConfig.from)(_ => none) .verifying("invalidFen", _.validFen) lazy val ai = Form( @@ -140,7 +148,7 @@ object SetupForm { "days" -> optional(days), "color" -> optional(color), "fen" -> fenField - )(ApiAiConfig.from)(_.>>).verifying("invalidFen", _.validFen) + )(ApiAiConfig.from)(_ => none).verifying("invalidFen", _.validFen) ) lazy val open = Form( @@ -148,7 +156,7 @@ object SetupForm { variant, clock, "fen" -> fenField - )(OpenConfig.from)(_.>>).verifying("invalidFen", _.validFen) + )(OpenConfig.from)(_ => none).verifying("invalidFen", _.validFen) ) } } diff --git a/ui/msg/src/view/convo.ts b/ui/msg/src/view/convo.ts index 9a2286146c..5f76ad6e41 100644 --- a/ui/msg/src/view/convo.ts +++ b/ui/msg/src/view/convo.ts @@ -1,11 +1,11 @@ -import { h } from 'snabbdom'; -import { VNode } from 'snabbdom/vnode'; -import { Convo } from '../interfaces'; -import { userName, bindMobileMousedown } from './util'; -import renderMsgs from './msgs'; +import MsgCtrl from '../ctrl'; import renderActions from './actions'; import renderInteract from './interact'; -import MsgCtrl from '../ctrl'; +import renderMsgs from './msgs'; +import { Convo } from '../interfaces'; +import { h } from 'snabbdom'; +import { userName, bindMobileMousedown } from './util'; +import { VNode } from 'snabbdom/vnode'; export default function renderConvo(ctrl: MsgCtrl, convo: Convo): VNode { const user = convo.user; diff --git a/ui/msg/src/view/enhance.ts b/ui/msg/src/view/enhance.ts index 5caa43a26d..0484f8bcdb 100644 --- a/ui/msg/src/view/enhance.ts +++ b/ui/msg/src/view/enhance.ts @@ -1,15 +1,22 @@ import { scroller } from './scroller'; -// looks like it has a @mention or a url.tld -export const isMoreThanText = (str: string) => /(\n|(@|\.)\w{2,})/.test(str); +// looks like it has a @mention or #gameid or a url.tld +export const isMoreThanText = (str: string) => /(\n|(@|#|\.)\w{2,})/.test(str); -export const enhance = (str: string) => expandMentions(expandUrls(lichess.escapeHtml(str))).replace(/\n/g, '
'); +export const enhance = (str: string) => + expandGameIds(expandMentions(expandUrls(lichess.escapeHtml(str)))).replace(/\n/g, '
'); const expandMentions = (html: string) => html.replace(/(^|[^\w@#/])@([\w-]{2,})/g, (orig: string, prefix: string, user: string) => user.length > 20 ? orig : `${prefix}${a('/@/' + user, '@' + user)}` ); +const expandGameIds = (html: string) => + html.replace( + /\s#([\w]{8})($|[^\w-])/g, + (_: string, id: string, suffix: string) => ' ' + a('/' + id, '#' + id, 'text') + suffix + ); + // ported from https://github.com/bryanwoods/autolink-js/blob/master/autolink.js const urlRegex = /(^|[\s\n]|<[A-Za-z]*\/?>)((?:(?:https?|ftp):\/\/|lichess\.org)[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi; const expandUrls = (html: string) => @@ -31,10 +38,10 @@ const expandImage = (url: string) => (/\.(jpg|jpeg|png|gif)$/.test(url) ? aImg(u const expandLink = (url: string) => a(url, url.replace(/^https?:\/\//, '')); -const a = (href: string, body: string) => +const a = (href: string, body: string, cls?: string) => `${body}`; + }"${cls ? ` class="${cls}"` : ''}>${body}`; const img = (src: string) => ``;