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) => ``;