chat moderation WIP
parent
ade9c162e5
commit
bb9d15ba05
|
@ -21,7 +21,8 @@ lines: @Html(J.stringify(lila.chat.JsonView(c))),
|
|||
i18n: @base.chatJsI18n(),
|
||||
userId: @Html(ctx.userId.fold("null")(id => s""""$id"""")),
|
||||
kobold: @ctx.troll,
|
||||
mod: @isGranted(_.MarkTroll)
|
||||
mod: @isGranted(_.MarkTroll),
|
||||
timeoutReasons: @Html(J.stringify(lila.chat.JsonView.timeoutReasons))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,7 +107,10 @@ donation {
|
|||
other_donors = [ bitchess, st0rmy, arka50, feeglood, eugene_kurmanin, mastertan, lance5500, crosky, medvezhonok ]
|
||||
}
|
||||
chat {
|
||||
collection.chat = chat
|
||||
collection {
|
||||
chat = chat
|
||||
timeout = chat_timeout
|
||||
}
|
||||
max_lines = 50
|
||||
net.domain = ${net.domain}
|
||||
actor.name = chat
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
package lila.chat
|
||||
|
||||
import lila.db.dsl._
|
||||
import lila.user.{ User, UserRepo }
|
||||
|
||||
import org.joda.time.DateTime
|
||||
import reactivemongo.bson._
|
||||
|
||||
final class ChatTimeout(
|
||||
chatColl: Coll,
|
||||
timeoutColl: Coll) {
|
||||
|
||||
import ChatTimeout._
|
||||
|
||||
private val minutes = 10
|
||||
|
||||
def add(chatId: ChatId, modId: String, username: String, reason: Reason): Funit =
|
||||
chatColl.byId[UserChat](chatId) zip UserRepo.named(modId) zip UserRepo.named(username) flatMap {
|
||||
case ((Some(chat), Some(mod)), Some(user)) if isMod(mod) => add(chat, mod, user, reason.pp)
|
||||
case _ => fuccess(none)
|
||||
}
|
||||
|
||||
private def add(chat: UserChat, mod: User, user: User, reason: Reason): Funit =
|
||||
isActive(chat, user).thenPp flatMap {
|
||||
case true => funit
|
||||
case false => timeoutColl.insert($doc(
|
||||
"_id" -> makeId,
|
||||
"chat" -> chat.id,
|
||||
"mod" -> mod.id,
|
||||
"user" -> user.id,
|
||||
"reason" -> reason,
|
||||
"createdAt" -> DateTime.now,
|
||||
"expiresAt" -> DateTime.now.plusMinutes(minutes))).void
|
||||
}
|
||||
|
||||
def isActive(chat: UserChat, user: User): Fu[Boolean] =
|
||||
timeoutColl.exists($doc(
|
||||
"chat" -> chat.id,
|
||||
"user" -> user.id,
|
||||
"expiresAt" $exists true))
|
||||
|
||||
def activeUserIds(chat: UserChat): Fu[List[String]] =
|
||||
timeoutColl.primitive[String]($doc(
|
||||
"chat" -> chat.id,
|
||||
"expiresAt" $exists true
|
||||
), "user")
|
||||
|
||||
def checkExpired: Funit = timeoutColl.primitive[String]($doc(
|
||||
"expiresAt" $lt DateTime.now
|
||||
), "_id") flatMap {
|
||||
case Nil => funit
|
||||
case ids => timeoutColl.unsetField($inIds(ids), "expiresAt", multi = true).void
|
||||
}
|
||||
|
||||
private val idSize = 8
|
||||
|
||||
private def makeId = scala.util.Random.alphanumeric take idSize mkString
|
||||
|
||||
private def isMod(user: User) = lila.security.Granter(_.MarkTroll.pp)(user.pp).pp
|
||||
}
|
||||
|
||||
object ChatTimeout {
|
||||
|
||||
sealed abstract class Reason(val key: String, val name: String)
|
||||
|
||||
object Reason {
|
||||
case object PublicShaming extends Reason("shaming", "Public shaming; please use lichess.org/report")
|
||||
case object Insult extends Reason("insult", "Disrespecting other players")
|
||||
case object Spam extends Reason("spam", "Spamming the chat")
|
||||
case object Other extends Reason("other", "Inappropriate behavior")
|
||||
val all = List(PublicShaming, Insult, Spam, Other)
|
||||
def apply(key: String) = all.find(_.key == key)
|
||||
}
|
||||
implicit val ReasonBSONHandler: BSONHandler[BSONString, Reason] = new BSONHandler[BSONString, Reason] {
|
||||
def read(b: BSONString) = Reason(b.value) err s"Invalid reason ${b.value}"
|
||||
def write(x: Reason) = BSONString(x.key)
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ final class Env(
|
|||
|
||||
private val settings = new {
|
||||
val CollectionChat = config getString "collection.chat"
|
||||
val CollectionTimeout = config getString "collection.timeout"
|
||||
val MaxLinesPerChat = config getInt "max_lines"
|
||||
val NetDomain = config getString "net.domain"
|
||||
val ActorName = config getString "actor.name"
|
||||
|
@ -27,12 +28,14 @@ final class Env(
|
|||
maxLinesPerChat = MaxLinesPerChat,
|
||||
netDomain = NetDomain)
|
||||
|
||||
private val tempBan = new TempBan(
|
||||
coll = chatColl)
|
||||
private val timeout = new ChatTimeout(
|
||||
chatColl = chatColl,
|
||||
timeoutColl = timeoutColl)
|
||||
|
||||
system.actorOf(Props(new FrontActor(api, tempBan)), name = ActorName)
|
||||
system.actorOf(Props(new FrontActor(api, timeout)), name = ActorName)
|
||||
|
||||
private[chat] lazy val chatColl = db(CollectionChat)
|
||||
private[chat] lazy val timeoutColl = db(CollectionTimeout)
|
||||
}
|
||||
|
||||
object Env {
|
||||
|
|
|
@ -4,10 +4,11 @@ import akka.actor._
|
|||
import chess.Color
|
||||
|
||||
import actorApi._
|
||||
import lila.common.PimpedJson._
|
||||
|
||||
private[chat] final class FrontActor(
|
||||
api: ChatApi,
|
||||
tempBan: TempBan) extends Actor {
|
||||
timeout: ChatTimeout) extends Actor {
|
||||
|
||||
def receive = {
|
||||
|
||||
|
@ -20,7 +21,12 @@ private[chat] final class FrontActor(
|
|||
case SystemTalk(chatId, text, replyTo) =>
|
||||
api.userChat.system(chatId, text) foreach publish(chatId, replyTo)
|
||||
|
||||
case TempBan(chatId, modId, userId) => tempBan.add(chatId, modId, userId)
|
||||
case Timeout(chatId, member, o) => for {
|
||||
data ← o obj "d"
|
||||
modId <- member.userId
|
||||
username <- data.str("username")
|
||||
reason <- data.str("reason") flatMap ChatTimeout.Reason.apply
|
||||
} timeout.add(chatId, modId, username, reason)
|
||||
}
|
||||
|
||||
def publish(chatId: String, replyTo: ActorRef)(lineOption: Option[Line]) {
|
||||
|
|
|
@ -11,6 +11,12 @@ object JsonView {
|
|||
|
||||
def apply(line: Line): JsValue = lineWriter writes line
|
||||
|
||||
lazy val timeoutReasons = Json toJson ChatTimeout.Reason.all
|
||||
|
||||
implicit val timeoutReasonWriter: Writes[ChatTimeout.Reason] = OWrites[ChatTimeout.Reason] { r =>
|
||||
Json.obj( "key" -> r.key, "name" -> r.name)
|
||||
}
|
||||
|
||||
implicit val mixedChatWriter: Writes[MixedChat] = Writes[MixedChat] { c =>
|
||||
JsArray(c.lines map lineWriter.writes)
|
||||
}
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
package lila.chat
|
||||
|
||||
import lila.db.dsl._
|
||||
import lila.user.{ User, UserRepo }
|
||||
|
||||
final class TempBan(coll: Coll) {
|
||||
|
||||
import Chat.userChatBSONHandler
|
||||
|
||||
def add(chatId: ChatId, modId: String, userId: String): Funit =
|
||||
coll.byId[UserChat](chatId) zip UserRepo.named(modId) zip UserRepo.named(userId) flatMap {
|
||||
case ((Some(chat), Some(mod)), Some(user)) => add(chat, mod, user)
|
||||
case _ => fuccess(none)
|
||||
}
|
||||
|
||||
def add(chat: UserChat, mod: User, user: User): Funit = ???
|
||||
}
|
|
@ -2,9 +2,10 @@ package lila.chat
|
|||
package actorApi
|
||||
|
||||
import akka.actor.ActorRef
|
||||
import play.api.libs.json.JsValue
|
||||
|
||||
case class UserTalk(chatId: String, userId: String, text: String, replyTo: ActorRef, public: Boolean = true)
|
||||
case class PlayerTalk(chatId: String, white: Boolean, text: String, replyTo: ActorRef)
|
||||
case class SystemTalk(chatId: String, text: String, replyTo: ActorRef)
|
||||
case class ChatLine(chatId: String, line: Line)
|
||||
case class TempBan(chatId: String, modId: String, userId: String)
|
||||
case class Timeout(chatId: String, member: lila.socket.SocketMember, data: JsValue)
|
||||
|
|
|
@ -78,8 +78,8 @@ trait CollExt { self: dsl with QueryBuilderExt =>
|
|||
def incFieldUnchecked(selector: BSONDocument, field: String, value: Int = 1) =
|
||||
coll.uncheckedUpdate(selector, $inc(field -> value))
|
||||
|
||||
def unsetField(selector: BSONDocument, field: String) =
|
||||
coll.update(selector, $unset(field))
|
||||
def unsetField(selector: BSONDocument, field: String, multi: Boolean = false) =
|
||||
coll.update(selector, $unset(field), multi = multi)
|
||||
|
||||
def fetchUpdate[D: BSONDocumentHandler](selector: BSONDocument)(update: D => BSONDocument): Funit =
|
||||
uno[D](selector) flatMap {
|
||||
|
|
|
@ -6,9 +6,9 @@ import akka.actor._
|
|||
import akka.pattern.ask
|
||||
|
||||
import actorApi._
|
||||
import akka.actor.ActorSelection
|
||||
import lila.common.PimpedJson._
|
||||
import lila.hub.actorApi.map._
|
||||
import akka.actor.ActorSelection
|
||||
import lila.socket.actorApi.{ Connected => _, _ }
|
||||
import lila.socket.Handler
|
||||
import lila.user.User
|
||||
|
@ -48,10 +48,7 @@ private[simul] final class SocketHandler(
|
|||
chat ! lila.chat.actorApi.UserTalk(simId, userId, text, socket)
|
||||
}
|
||||
}
|
||||
case ("temp-ban", o) => o str "d" foreach { userId =>
|
||||
member.userId foreach { modId =>
|
||||
chat ! lila.chat.actorApi.TempBan(simId, modId, userId)
|
||||
}
|
||||
}
|
||||
case ("timeout", o) =>
|
||||
chat ! lila.chat.actorApi.Timeout(simId, member, o)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -148,7 +148,7 @@ object ApplicationBuild extends Build {
|
|||
libraryDependencies ++= provided(play.api)
|
||||
)
|
||||
|
||||
lazy val chat = project("chat", Seq(common, db, user, security, i18n)).settings(
|
||||
lazy val chat = project("chat", Seq(common, db, user, security, i18n, socket)).settings(
|
||||
libraryDependencies ++= provided(play.api, RM)
|
||||
)
|
||||
|
||||
|
|
|
@ -114,6 +114,7 @@ lichess.shepherd = function(f) {
|
|||
});
|
||||
};
|
||||
lichess.makeChat = function(id, data) {
|
||||
if (data.mod) lichess.loadCss('/assets/stylesheets/chat.mod.css');
|
||||
lichess.loadScript('/assets/compiled/lichess.chat.js').then(function() {
|
||||
lichess.chat = LichessChat(document.getElementById(id), data);
|
||||
});
|
||||
|
|
|
@ -1016,7 +1016,7 @@ div.under_chat .watchers:hover {
|
|||
#chat div.top {
|
||||
position: relative;
|
||||
}
|
||||
#chat div.top input.toggle_chat {
|
||||
#chat div.top .toggle_chat {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
@ -1058,9 +1058,9 @@ body.no-select #chat li {
|
|||
opacity: 1;
|
||||
}
|
||||
#chat .messages li {
|
||||
padding-left: 3px;
|
||||
padding: 0.4em 0 0.4em 3px;
|
||||
line-height: 14px;
|
||||
margin: 0.8em 0 0.8em 3px;
|
||||
margin: 0;
|
||||
}
|
||||
#chat .messages li.system {
|
||||
padding: 2px 0;
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
div.mchat.mod li {
|
||||
position: relative;
|
||||
}
|
||||
div.mchat.mod i.mod {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
margin-right: 3px;
|
||||
padding: 4px 5px;
|
||||
opacity: 0.4;
|
||||
background: #fff;
|
||||
color: #dc322f;
|
||||
}
|
||||
div.mchat.mod li:hover {
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
body.dark div.mchat.mod li:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
div.mchat.mod li:hover i.mod {
|
||||
display: block;
|
||||
}
|
||||
div.mchat.mod i.mod:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
div.mchat.mod .timeout {
|
||||
padding: 10px;
|
||||
}
|
||||
div.mchat.mod .timeout h2 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 5px 0 10px 0;
|
||||
}
|
||||
div.mchat.mod .timeout a {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #3893E8;
|
||||
padding: 2px 0;
|
||||
transition: 0.3s;
|
||||
margin-left: -10px;
|
||||
}
|
||||
div.mchat.mod .timeout a::before {
|
||||
opacity: 0;
|
||||
transition: 0.3s;
|
||||
}
|
||||
div.mchat.mod .timeout a:hover {
|
||||
margin-left: 0;
|
||||
}
|
||||
div.mchat.mod .timeout a:hover::before {
|
||||
opacity: 1;
|
||||
margin-right: 2px;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
var m = require('mithril');
|
||||
var makeSocket = require('./socket');
|
||||
var makeModeration = require('./moderation').ctrl;
|
||||
|
||||
module.exports = function(opts) {
|
||||
|
||||
|
@ -8,10 +9,16 @@ module.exports = function(opts) {
|
|||
var vm = {
|
||||
isTroll: opts.kobold,
|
||||
isMod: opts.mod,
|
||||
placeholderKey: 'talkInChat'
|
||||
placeholderKey: 'talkInChat',
|
||||
moderating: m.prop(null),
|
||||
loading: m.prop(false)
|
||||
};
|
||||
|
||||
var socket = makeSocket(opts.socketSend);
|
||||
var socket = makeSocket(lichess.socket.send);
|
||||
var moderation = vm.isMod ? makeModeration({
|
||||
reasons: opts.timeoutReasons,
|
||||
send: socket.send
|
||||
}) : null;
|
||||
|
||||
return {
|
||||
lines: lines,
|
||||
|
@ -23,7 +30,7 @@ module.exports = function(opts) {
|
|||
alert('Max length: 140 chars. ' + text.length + ' chars used.');
|
||||
return false;
|
||||
}
|
||||
lichess.socket.send('talk', text);
|
||||
socket.send('talk', text);
|
||||
return false;
|
||||
},
|
||||
newLine: function(line) {
|
||||
|
@ -31,6 +38,7 @@ module.exports = function(opts) {
|
|||
lines.push(line);
|
||||
m.redraw();
|
||||
},
|
||||
moderation: moderation,
|
||||
trans: lichess.trans(opts.i18n),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
var m = require('mithril');
|
||||
|
||||
module.exports = {
|
||||
ctrl: function(opts) {
|
||||
var vm = {
|
||||
data: m.prop(null),
|
||||
// data: m.prop({
|
||||
// username: 'Foobidoo'
|
||||
// }),
|
||||
loading: m.prop(false)
|
||||
};
|
||||
var close = function() {
|
||||
vm.data(null);
|
||||
};
|
||||
return {
|
||||
vm: vm,
|
||||
reasons: opts.reasons,
|
||||
open: function(username) {
|
||||
vm.data({
|
||||
username: username
|
||||
});
|
||||
m.redraw();
|
||||
},
|
||||
close: close,
|
||||
timeout: function(reason) {
|
||||
opts.send('timeout', {
|
||||
username: vm.data().username,
|
||||
reason: reason
|
||||
});
|
||||
close();
|
||||
}
|
||||
}
|
||||
},
|
||||
view: {
|
||||
lineAction: m('i', {
|
||||
class: 'mod',
|
||||
'data-icon': '',
|
||||
title: 'Moderation'
|
||||
}),
|
||||
ui: function(ctrl) {
|
||||
if (!ctrl) return;
|
||||
var data = ctrl.vm.data();
|
||||
if (!data) return;
|
||||
return m('div.moderation', [
|
||||
m('div.top', [
|
||||
m('span.toggle_chat', {
|
||||
'data-icon': 'L',
|
||||
onclick: ctrl.close
|
||||
}),
|
||||
m('span.text', {
|
||||
'data-icon': '',
|
||||
}, data.username)
|
||||
]),
|
||||
m('div.timeout', [
|
||||
m('h2', 'Timeout 10 minutes for:'),
|
||||
ctrl.reasons.map(function(r) {
|
||||
return m('a.text[data-icon=p]', {
|
||||
onclick: function() {
|
||||
ctrl.timeout(r.key)
|
||||
}
|
||||
}, r.name);
|
||||
})
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,16 +1,18 @@
|
|||
var m = require('mithril');
|
||||
var moderationView = require('./moderation').view;
|
||||
|
||||
function renderLine(ctrl) {
|
||||
return function(line) {
|
||||
return m('li', [
|
||||
ctrl.vm.isMod ? moderationView.lineAction : null,
|
||||
m.trust($.userLinkLimit(line.u, 14)),
|
||||
line.t
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function(ctrl) {
|
||||
return m('div.mchat', [
|
||||
function discussion(ctrl) {
|
||||
return m('div.discussion', [
|
||||
m('div.top', [
|
||||
m('span', ctrl.trans('chatRoom')),
|
||||
m('input', {
|
||||
|
@ -21,6 +23,9 @@ module.exports = function(ctrl) {
|
|||
]),
|
||||
m('ol.messages.content.scroll-shadow-soft', {
|
||||
config: function(el, isUpdate, ctx) {
|
||||
if (!isUpdate && ctrl.moderation) $(el).on('click', 'i.mod', function(e) {
|
||||
ctrl.moderation.open($(e.target).parent().find('.user_link').text());
|
||||
});
|
||||
var autoScroll = (el.scrollTop === 0 || (el.scrollTop > (el.scrollHeight - el.clientHeight - 150)));
|
||||
el.scrollTop = 999999;
|
||||
if (autoScroll) setTimeout(function() {
|
||||
|
@ -44,5 +49,12 @@ module.exports = function(ctrl) {
|
|||
});
|
||||
}
|
||||
})
|
||||
]);
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = function(ctrl) {
|
||||
return m('div', {
|
||||
class: 'mchat' + (ctrl.vm.isMod ? ' mod' : '')
|
||||
},
|
||||
moderationView.ui(ctrl.moderation) || discussion(ctrl));
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue