diff --git a/client/common/configuration/templates/channel.ts b/client/common/configuration/templates/channel.ts
index 4550b023..474ccbf8 100644
--- a/client/common/configuration/templates/channel.ts
+++ b/client/common/configuration/templates/channel.ts
@@ -47,9 +47,9 @@ async function renderConfigurationChannel (
name="bot"
id="peertube-livechat-bot"
value="1"
- {{#channelConfiguration.configuration.bot}}
+ {{#channelConfiguration.configuration.bot.enabled}}
checked="checked"
- {{/channelConfiguration.configuration.bot}}
+ {{/channelConfiguration.configuration.bot.enabled}}
/>
{{enableBot}}
@@ -61,7 +61,7 @@ async function renderConfigurationChannel (
name="bot_nickname"
class="form-control"
id="peertube-livechat-bot-nickname"
- value="{{channelConfiguration.configuration.botNickname}}"
+ value="{{channelConfiguration.configuration.bot.nickname}}"
/>
@@ -84,11 +84,7 @@ async function renderConfigurationChannel (
name="forbidden_words_{{fieldNumber}}"
id="peertube-livechat-forbidden-words-{{fieldNumber}}"
class="form-control"
- >{{
- #channelConfiguration.configuration.forbiddenWords
- }}{{.}}\n{{
- /channelConfiguration.configuration.forbiddenWords
- }}
+ >{{entries}}
{{forbiddenWordsDesc2}}
diff --git a/client/common/configuration/templates/logic/channel.ts b/client/common/configuration/templates/logic/channel.ts
index d1b4eff4..ce57d4aa 100644
--- a/client/common/configuration/templates/logic/channel.ts
+++ b/client/common/configuration/templates/logic/channel.ts
@@ -33,15 +33,23 @@ async function getConfigurationChannelViewData (
throw new Error('Invalid channel configuration options.')
}
+ const forbiddenWordsArray = []
+ for (let i = 0; i < channelConfiguration.configuration.bot.forbiddenWords.length; i++) {
+ const fw = channelConfiguration.configuration.bot.forbiddenWords[i]
+ forbiddenWordsArray.push({
+ displayNumber: i + 1,
+ fieldNumber: i,
+ displayHelp: i === 0,
+ entries: fw.entries.join('\n'),
+ regexp: !!fw.regexp,
+ applyToModerators: fw.applyToModerators,
+ reason: fw.reason
+ })
+ }
+
return {
channelConfiguration,
- forbiddenWordsArray: [0, 1, 2].map(count => {
- return {
- displayNumber: count + 1,
- fieldNumber: count,
- displayHelp: count === 0
- }
- }),
+ forbiddenWordsArray,
quotesArray: [0].map(count => {
return {
displayNumber: count + 1,
@@ -89,12 +97,36 @@ async function vivifyConfigurationChannel (
const submitForm: Function = async () => {
const data = new FormData(form)
const channelConfigurationOptions: ChannelConfigurationOptions = {
- bot: data.get('bot') === '1',
- botNickname: data.get('bot_nickname')?.toString() ?? '',
- // bannedJIDs: (data.get('banned_jids')?.toString() ?? '').split(/\r?\n|\r|\n/g),
- forbiddenWords: (data.get('forbidden_words')?.toString() ?? '').split(/\r?\n|\r|\n/g)
+ bot: {
+ enabled: data.get('bot') === '1',
+ nickname: data.get('bot_nickname')?.toString() ?? '',
+ // TODO bannedJIDs
+ forbiddenWords: [],
+ quotes: [],
+ commands: []
+ }
}
+ // TODO: handle form errors.
+
+ for (let i = 0; data.has('forbidden_words_' + i.toString()); i++) {
+ const entries = (data.get('forbidden_words_' + i.toString())?.toString() ?? '').split(/\r?\n|\r|\n/g)
+ const regexp = data.get('forbidden_words_regexp_' + i.toString())
+ const applyToModerators = data.get('forbidden_words_applytomoderators_' + i.toString())
+ const reason = data.get('forbidden_words_reason_' + i.toString())?.toString()
+ const fw: ChannelConfigurationOptions['bot']['forbiddenWords'][0] = {
+ entries,
+ applyToModerators: !!applyToModerators,
+ regexp: !!regexp
+ }
+ if (reason) {
+ fw.reason = reason
+ }
+ channelConfigurationOptions.bot.forbiddenWords.push(fw)
+ }
+
+ // TODO: quotes and commands.
+
const headers: any = clientOptions.peertubeHelpers.getAuthHeader() ?? {}
headers['content-type'] = 'application/json;charset=UTF-8'
diff --git a/server/lib/configuration/channel/sanitize.ts b/server/lib/configuration/channel/sanitize.ts
index 13f38978..abcf6cec 100644
--- a/server/lib/configuration/channel/sanitize.ts
+++ b/server/lib/configuration/channel/sanitize.ts
@@ -18,11 +18,20 @@ async function sanitizeChannelConfigurationOptions (
throw new Error('Invalid data type')
}
+ const botData = data.bot
+ if (typeof botData !== 'object') {
+ throw new Error('Invalid data.bot data type')
+ }
+
const result: ChannelConfigurationOptions = {
- bot: _readBoolean(data, 'bot'),
- botNickname: _readSimpleInput(data, 'botNickname'),
- // bannedJIDs: await _readRegExpArray(data, 'bannedJIDs'),
- forbiddenWords: await _readRegExpArray(data, 'forbiddenWords')
+ bot: {
+ enabled: _readBoolean(botData, 'enabled'),
+ nickname: _readSimpleInput(botData, 'nickname', true),
+ forbiddenWords: await _readForbiddenWords(botData),
+ quotes: _readQuotes(botData),
+ commands: _readCommands(botData)
+ // TODO: bannedJIDs
+ }
}
return result
@@ -38,15 +47,59 @@ function _readBoolean (data: any, f: string): boolean {
return data[f]
}
-function _readSimpleInput (data: any, f: string): string {
+function _readInteger (data: any, f: string, min: number, max: number): number {
+ if (!(f in data)) {
+ throw new Error('Missing integer value for field ' + f)
+ }
+ const v = parseInt(data[f])
+ if (isNaN(v)) {
+ throw new Error('Invalid value type for field ' + f)
+ }
+ if (v < min) {
+ throw new Error('Invalid value type ( max) {
+ throw new Error('Invalid value type (>max) for field ' + f)
+ }
+ return v
+}
+
+function _readSimpleInput (data: any, f: string, strict?: boolean): string {
if (!(f in data)) {
return ''
}
if (typeof data[f] !== 'string') {
throw new Error('Invalid data type for field ' + f)
}
- // Replacing all invalid characters, no need to throw an error..
- return (data[f] as string).replace(/[^\p{L}\p{N}\p{Z}_-]$/gu, '')
+ // Removing control characters.
+ // eslint-disable-next-line no-control-regex
+ let s = (data[f] as string).replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
+ if (strict) {
+ // Replacing all invalid characters, no need to throw an error..
+ s = s.replace(/[^\p{L}\p{N}\p{Z}_-]$/gu, '')
+ }
+ return s
+}
+
+function _readStringArray (data: any, f: string): string[] {
+ if (!(f in data)) {
+ return []
+ }
+ if (!Array.isArray(data[f])) {
+ throw new Error('Invalid data type for field ' + f)
+ }
+ const result: string[] = []
+ for (const v of data[f]) {
+ if (typeof v !== 'string') {
+ throw new Error('Invalid data type in a value of field ' + f)
+ }
+ if (v === '' || /^\s+$/.test(v)) {
+ // ignore empty values
+ continue
+ }
+ result.push(v)
+ }
+ return result
}
async function _readRegExpArray (data: any, f: string): Promise {
@@ -82,6 +135,66 @@ async function _readRegExpArray (data: any, f: string): Promise {
return result
}
+async function _readForbiddenWords (botData: any): Promise {
+ if (!Array.isArray(botData.forbiddenWords)) {
+ throw new Error('Invalid forbiddenWords data')
+ }
+ const result: ChannelConfigurationOptions['bot']['forbiddenWords'] = []
+ for (const fw of botData.forbiddenWords) {
+ const regexp = !!fw.regexp
+ let entries
+ if (regexp) {
+ entries = await _readRegExpArray(fw, 'entries')
+ } else {
+ entries = _readStringArray(fw, 'entries')
+ }
+ const applyToModerators = _readBoolean(fw, 'applyToModerators')
+ const reason = fw.reason ? _readSimpleInput(fw, 'reason') : undefined
+
+ result.push({
+ regexp,
+ entries,
+ applyToModerators,
+ reason
+ })
+ }
+ return result
+}
+
+function _readQuotes (botData: any): ChannelConfigurationOptions['bot']['quotes'] {
+ if (!Array.isArray(botData.quotes)) {
+ throw new Error('Invalid quotes data')
+ }
+ const result: ChannelConfigurationOptions['bot']['quotes'] = []
+ for (const fw of botData.quotes) {
+ const messages = _readStringArray(fw, 'message')
+ const delay = _readInteger(fw, 'delay', 1, 6000)
+
+ result.push({
+ messages,
+ delay
+ })
+ }
+ return result
+}
+
+function _readCommands (botData: any): ChannelConfigurationOptions['bot']['commands'] {
+ if (!Array.isArray(botData.commands)) {
+ throw new Error('Invalid commands data')
+ }
+ const result: ChannelConfigurationOptions['bot']['commands'] = []
+ for (const fw of botData.commands) {
+ const message = _readSimpleInput(fw, 'message')
+ const command = _readSimpleInput(fw, 'command')
+
+ result.push({
+ message,
+ command
+ })
+ }
+ return result
+}
+
export {
sanitizeChannelConfigurationOptions
}
diff --git a/server/lib/configuration/channel/storage.ts b/server/lib/configuration/channel/storage.ts
index d0ceb3cd..abc51e4d 100644
--- a/server/lib/configuration/channel/storage.ts
+++ b/server/lib/configuration/channel/storage.ts
@@ -36,10 +36,43 @@ async function getChannelConfigurationOptions (
function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions {
return {
- bot: false,
- botNickname: 'Sepia',
- // bannedJIDs: [],
- forbiddenWords: []
+ bot: {
+ enabled: false,
+ nickname: 'Sepia',
+ // Note: we are instanciating several data for forbiddenWords, quotes and commands.
+ // This will be used by the frontend to instanciates requires fields
+ forbiddenWords: [
+ {
+ entries: []
+ },
+ {
+ entries: []
+ },
+ {
+ entries: []
+ }
+ ],
+ quotes: [
+ {
+ messages: [],
+ delay: 5 * 60 // seconds to minutes
+ }
+ ],
+ commands: [
+ {
+ command: '',
+ message: ''
+ },
+ {
+ command: '',
+ message: ''
+ },
+ {
+ command: '',
+ message: ''
+ }
+ ]
+ }
}
}
@@ -86,25 +119,26 @@ function channelConfigurationOptionsToBotRoomConf (
// If we want the bot to correctly enable/disable the handlers,
// we must always define all handlers, even if not used.
const handlers: ConfigHandlers = []
- handlers.push(_getForbiddenWordsHandler(
- 'forbidden_words_0',
- channelConfigurationOptions.forbiddenWords
- ))
+ channelConfigurationOptions.bot.forbiddenWords.forEach((v, i) => {
+ handlers.push(_getForbiddenWordsHandler(
+ 'forbidden_words_' + i.toString(),
+ channelConfigurationOptions.bot.forbiddenWords[i]
+ ))
+ })
const roomConf: ChannelCommonRoomConf = {
- enabled: channelConfigurationOptions.bot,
+ enabled: channelConfigurationOptions.bot.enabled,
handlers
}
- if (channelConfigurationOptions.botNickname && channelConfigurationOptions.botNickname !== '') {
- roomConf.nick = channelConfigurationOptions.botNickname
+ if (channelConfigurationOptions.bot.nickname && channelConfigurationOptions.bot.nickname !== '') {
+ roomConf.nick = channelConfigurationOptions.bot.nickname
}
return roomConf
}
function _getForbiddenWordsHandler (
id: string,
- forbiddenWords: string[],
- reason?: string
+ forbiddenWords: ChannelConfigurationOptions['bot']['forbiddenWords'][0]
): ConfigHandler {
const handler: ConfigHandler = {
type: 'moderate',
@@ -114,26 +148,63 @@ function _getForbiddenWordsHandler (
rules: []
}
}
- if (forbiddenWords.length === 0) {
+ if (forbiddenWords.entries.length === 0) {
return handler
}
handler.enabled = true
- // Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords
- // is an array of RegExp definition (strings).
- // They are validated one by bone.
- // To increase the bot performance, we will join them all (hopping the bot will optimize them).
const rule: any = {
- name: id,
- regexp: '(?:' + forbiddenWords.join(')|(?:') + ')'
+ name: id
}
- if (reason) {
- rule.reason = reason
+
+ if (forbiddenWords.regexp) {
+ // Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords
+ // is an array of RegExp definition (strings).
+ // They are validated one by bone.
+ // To increase the bot performance, we will join them all (hopping the bot will optimize them).
+ rule.regexp = '(?:' + forbiddenWords.entries.join(')|(?:') + ')'
+ } else {
+ // Here we must add word-breaks and escape entries.
+ // We join all entries in one Regexp (for the same reason as above).
+ rule.regexp = '(?:' +
+ forbiddenWords.entries.map(s => {
+ s = _stringToWordRegexp(s)
+ // Must add the \b...
+ // ... but... won't work if the first (or last) char is an emoji.
+ // So, doing this trick:
+ if (/^\w/.test(s)) {
+ s = '\\b' + s
+ }
+ if (/\w$/.test(s)) {
+ s = s + '\\b'
+ }
+ // FIXME: this solution wont work for non-latin charsets.
+ return s
+ }).join(')|(?:') + ')'
+ }
+
+ if (forbiddenWords.reason) {
+ rule.reason = forbiddenWords.reason
}
handler.options.rules.push(rule)
+
+ handler.options.applyToModerators = !!forbiddenWords.applyToModerators
return handler
}
+const stringToWordRegexpSpecials = [
+ // order matters for these
+ '-', '[', ']',
+ // order doesn't matter for any of these
+ '/', '{', '}', '(', ')', '*', '+', '?', '.', '\\', '^', '$', '|'
+]
+// I choose to escape every character with '\'
+// even though only some strictly require it when inside of []
+const stringToWordRegexp = RegExp('[' + stringToWordRegexpSpecials.join('\\') + ']', 'g')
+function _stringToWordRegexp (s: string): string {
+ return s.replace(stringToWordRegexp, '\\$&')
+}
+
function _getFilePath (
options: RegisterServerOptions,
channelId: number | string
diff --git a/server/lib/routers/api/configuration.ts b/server/lib/routers/api/configuration.ts
index 5eca59af..9b176747 100644
--- a/server/lib/routers/api/configuration.ts
+++ b/server/lib/routers/api/configuration.ts
@@ -55,6 +55,16 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
// Note: the front-end should do some input validation.
// If there is any invalid value, we just return a 400 error.
// The frontend should have prevented to post invalid data.
+
+ // Note: if !bot.enabled, we wont try to save hidden fields values, to minimize the risk of error
+ if (req.body.bot?.enabled === false) {
+ logger.debug('Bot disabled, loading the previous bot conf to not override hidden fields')
+ const channelOptions =
+ await getChannelConfigurationOptions(options, channelInfos.id) ??
+ getDefaultChannelConfigurationOptions(options)
+ req.body.bot = channelOptions.bot
+ req.body.bot.enable = false
+ }
channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body)
} catch (err) {
logger.warn(err)
diff --git a/shared/lib/types.ts b/shared/lib/types.ts
index eb6d57e6..e75c5205 100644
--- a/shared/lib/types.ts
+++ b/shared/lib/types.ts
@@ -53,10 +53,25 @@ interface ChannelInfos {
}
interface ChannelConfigurationOptions {
- bot: boolean
- botNickname?: string
- forbiddenWords: string[]
- // TODO: bannedJIDs: string[]
+ bot: {
+ enabled: boolean
+ nickname?: string
+ forbiddenWords: Array<{
+ entries: string[]
+ regexp?: boolean
+ applyToModerators?: boolean
+ reason?: string
+ }>
+ quotes: Array<{
+ messages: string[]
+ delay: number
+ }>
+ commands: Array<{
+ command: string
+ message: string
+ }>
+ // TODO: bannedJIDs: string[]
+ }
}
interface ChannelConfiguration {