Implement proper anti-spam solution.
parent
4565d7deaa
commit
872537f722
|
@ -0,0 +1,279 @@
|
||||||
|
package antispam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeUser(from string) string {
|
||||||
|
from = strings.ToLower(from)
|
||||||
|
|
||||||
|
if strings.Contains(from, "!") {
|
||||||
|
split := strings.SplitN(from, "!", 2)
|
||||||
|
return fmt.Sprintf("*!%s", split[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCommand(command string) string {
|
||||||
|
return strings.ToLower(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AntiSpam struct {
|
||||||
|
userTrackingMap map[string][]time.Time
|
||||||
|
userTrackingLock sync.RWMutex
|
||||||
|
|
||||||
|
UserCooldownDuration time.Duration
|
||||||
|
|
||||||
|
UserRateLimitDuration time.Duration
|
||||||
|
UserRateLimitCount int
|
||||||
|
|
||||||
|
commandTrackingMap map[string][]time.Time
|
||||||
|
commandTrackingLock sync.RWMutex
|
||||||
|
|
||||||
|
CommandCooldownDuration time.Duration
|
||||||
|
|
||||||
|
CommandRateLimitDuration time.Duration
|
||||||
|
CommandRateLimitCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *AntiSpam {
|
||||||
|
as := new(AntiSpam)
|
||||||
|
as.init()
|
||||||
|
return as
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AntiSpam) init() {
|
||||||
|
m.userTrackingMap = map[string][]time.Time{}
|
||||||
|
m.commandTrackingMap = map[string][]time.Time{}
|
||||||
|
|
||||||
|
m.CommandCooldownDuration = 15 * time.Second
|
||||||
|
m.CommandRateLimitCount = 2
|
||||||
|
m.CommandRateLimitDuration = 1 * time.Minute
|
||||||
|
|
||||||
|
m.UserCooldownDuration = 3 * time.Second
|
||||||
|
m.UserRateLimitCount = 4
|
||||||
|
m.UserRateLimitDuration = 1 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AntiSpam) checkUserCooldown(from string) (duration time.Duration) {
|
||||||
|
from = normalizeUser(from)
|
||||||
|
|
||||||
|
m.userTrackingLock.RLock()
|
||||||
|
defer m.userTrackingLock.RUnlock()
|
||||||
|
|
||||||
|
// Are there any times registered at all for this entry?
|
||||||
|
if timestamps := m.userTrackingMap[from]; timestamps != nil {
|
||||||
|
|
||||||
|
var mostRecentTimestamp time.Time
|
||||||
|
|
||||||
|
// Figure out most recent time
|
||||||
|
for _, timestamp := range timestamps {
|
||||||
|
if mostRecentTimestamp.Before(timestamp) {
|
||||||
|
mostRecentTimestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
duration = mostRecentTimestamp.Add(m.UserCooldownDuration).Sub(time.Now())
|
||||||
|
if duration < 0 {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AntiSpam) checkCommandCooldown(command string) (duration time.Duration) {
|
||||||
|
command = normalizeCommand(command)
|
||||||
|
|
||||||
|
m.commandTrackingLock.RLock()
|
||||||
|
defer m.commandTrackingLock.RUnlock()
|
||||||
|
|
||||||
|
// Are there any times registered at all for this entry?
|
||||||
|
if timestamps := m.commandTrackingMap[command]; timestamps != nil {
|
||||||
|
|
||||||
|
var mostRecentTimestamp time.Time
|
||||||
|
|
||||||
|
// Figure out most recent time
|
||||||
|
for _, timestamp := range timestamps {
|
||||||
|
if mostRecentTimestamp.Before(timestamp) {
|
||||||
|
mostRecentTimestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
duration = mostRecentTimestamp.Add(m.CommandCooldownDuration).Sub(time.Now())
|
||||||
|
if duration < 0 {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AntiSpam) checkUserRateLimit(from string) (duration time.Duration) {
|
||||||
|
from = normalizeUser(from)
|
||||||
|
|
||||||
|
m.userTrackingLock.RLock()
|
||||||
|
defer m.userTrackingLock.RUnlock()
|
||||||
|
|
||||||
|
// Are there any times registered at all for this entry?
|
||||||
|
if timestamps := m.userTrackingMap[from]; timestamps != nil && len(timestamps) >= m.UserRateLimitCount {
|
||||||
|
leastRecentTimestamp := timestamps[len(timestamps)-m.UserRateLimitCount]
|
||||||
|
|
||||||
|
duration = leastRecentTimestamp.Add(m.UserRateLimitDuration).Sub(time.Now())
|
||||||
|
if duration < 0 {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AntiSpam) checkCommandRateLimit(command string) (duration time.Duration) {
|
||||||
|
command = normalizeCommand(command)
|
||||||
|
|
||||||
|
m.commandTrackingLock.RLock()
|
||||||
|
defer m.commandTrackingLock.RUnlock()
|
||||||
|
|
||||||
|
// Are there any times registered at all for this entry?
|
||||||
|
if timestamps := m.commandTrackingMap[command]; timestamps != nil && len(timestamps) >= m.CommandRateLimitCount {
|
||||||
|
leastRecentTimestamp := timestamps[len(timestamps)-m.CommandRateLimitCount]
|
||||||
|
|
||||||
|
duration = leastRecentTimestamp.Add(m.CommandRateLimitDuration).Sub(time.Now())
|
||||||
|
if duration < 0 {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AntiSpam) track(from string, command string) {
|
||||||
|
currentTime := time.Now()
|
||||||
|
|
||||||
|
command = normalizeCommand(command)
|
||||||
|
from = normalizeUser(from)
|
||||||
|
|
||||||
|
// User tracking
|
||||||
|
go func() {
|
||||||
|
// Register current time
|
||||||
|
m.userTrackingLock.Lock()
|
||||||
|
if m.userTrackingMap[from] == nil {
|
||||||
|
m.userTrackingMap[from] = []time.Time{}
|
||||||
|
}
|
||||||
|
m.userTrackingMap[from] = append(m.userTrackingMap[from], currentTime)
|
||||||
|
m.userTrackingLock.Unlock()
|
||||||
|
|
||||||
|
// Wait for time to run down
|
||||||
|
var waitDuration time.Duration
|
||||||
|
if m.UserCooldownDuration > m.UserRateLimitDuration {
|
||||||
|
waitDuration = m.UserCooldownDuration
|
||||||
|
} else {
|
||||||
|
waitDuration = m.UserRateLimitDuration
|
||||||
|
}
|
||||||
|
time.Sleep(waitDuration)
|
||||||
|
|
||||||
|
// Remove current time
|
||||||
|
m.userTrackingLock.Lock()
|
||||||
|
indexToDelete := -1
|
||||||
|
for i, time := range m.userTrackingMap[from] {
|
||||||
|
if time == currentTime {
|
||||||
|
indexToDelete = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if indexToDelete >= 0 {
|
||||||
|
m.userTrackingMap[from] = append(
|
||||||
|
m.userTrackingMap[from][:indexToDelete],
|
||||||
|
m.userTrackingMap[from][indexToDelete+1:]...)
|
||||||
|
if len(m.userTrackingMap) == 0 {
|
||||||
|
m.userTrackingMap[from] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.userTrackingLock.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Command tracking
|
||||||
|
go func() {
|
||||||
|
// Register current time
|
||||||
|
m.commandTrackingLock.Lock()
|
||||||
|
if m.commandTrackingMap[command] == nil {
|
||||||
|
m.commandTrackingMap[command] = []time.Time{}
|
||||||
|
}
|
||||||
|
m.commandTrackingMap[command] = append(m.commandTrackingMap[command], currentTime)
|
||||||
|
m.commandTrackingLock.Unlock()
|
||||||
|
|
||||||
|
// Wait for time to run down
|
||||||
|
var waitDuration time.Duration
|
||||||
|
if m.CommandCooldownDuration > m.CommandRateLimitDuration {
|
||||||
|
waitDuration = m.CommandCooldownDuration
|
||||||
|
} else {
|
||||||
|
waitDuration = m.CommandRateLimitDuration
|
||||||
|
}
|
||||||
|
time.Sleep(waitDuration)
|
||||||
|
|
||||||
|
// Remove current time
|
||||||
|
m.commandTrackingLock.Lock()
|
||||||
|
indexToDelete := -1
|
||||||
|
for i, time := range m.commandTrackingMap[command] {
|
||||||
|
if time == currentTime {
|
||||||
|
indexToDelete = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if indexToDelete >= 0 {
|
||||||
|
m.commandTrackingMap[command] = append(
|
||||||
|
m.commandTrackingMap[command][:indexToDelete],
|
||||||
|
m.commandTrackingMap[command][indexToDelete+1:]...)
|
||||||
|
if len(m.commandTrackingMap) == 0 {
|
||||||
|
m.commandTrackingMap[command] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.commandTrackingLock.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AntiSpam) Check(from string, command string) (allow bool, waitDuration time.Duration) {
|
||||||
|
allow = true
|
||||||
|
|
||||||
|
// if this user has run a command in the last N seconds (user cooldown)
|
||||||
|
if currentDuration := m.checkUserCooldown(from); currentDuration > 0 {
|
||||||
|
allow = false
|
||||||
|
if currentDuration > waitDuration {
|
||||||
|
waitDuration = currentDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this user has run X commands in the last N seconds (user rate limit)
|
||||||
|
if currentDuration := m.checkUserRateLimit(from); currentDuration > 0 {
|
||||||
|
allow = false
|
||||||
|
if currentDuration > waitDuration {
|
||||||
|
waitDuration = currentDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if command has been run just previously (command cooldown)
|
||||||
|
if currentDuration := m.checkCommandCooldown(command); currentDuration > 0 {
|
||||||
|
allow = false
|
||||||
|
if currentDuration > waitDuration {
|
||||||
|
waitDuration = currentDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if command has been run X (1?) times in the last N seconds (command rate limit)
|
||||||
|
if currentDuration := m.checkCommandRateLimit(command); currentDuration > 0 {
|
||||||
|
allow = false
|
||||||
|
if currentDuration > waitDuration {
|
||||||
|
waitDuration = currentDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allow {
|
||||||
|
m.track(from, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package antispam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_normalizeUser(t *testing.T) {
|
||||||
|
require.Equal(t, normalizeUser("aBc!dEf@gHi"), normalizeUser("Abby!dEf@GhI"))
|
||||||
|
require.Equal(t, "*!ident@host", normalizeUser("nick!ident@host"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_normalizeCommand(t *testing.T) {
|
||||||
|
require.Equal(t, normalizeCommand("!ping"), normalizeCommand("!PiNG"))
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Command struct {
|
||||||
|
Name string
|
||||||
|
Arguments []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCommand(msg string) *Command {
|
||||||
|
split := strings.Split(msg, " ")
|
||||||
|
return &Command{
|
||||||
|
Name: split[0],
|
||||||
|
Arguments: split[1:],
|
||||||
|
}
|
||||||
|
}
|
29
main.go
29
main.go
|
@ -12,12 +12,13 @@ import (
|
||||||
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/icedream/go-footballdata"
|
||||||
"github.com/olekukonko/tablewriter"
|
"github.com/olekukonko/tablewriter"
|
||||||
"github.com/thoj/go-ircevent"
|
"github.com/thoj/go-ircevent"
|
||||||
"gopkg.in/alecthomas/kingpin.v2"
|
"gopkg.in/alecthomas/kingpin.v2"
|
||||||
|
|
||||||
"github.com/icedream/embot/manager"
|
"git.icedream.tech/icedream/soccer-bot/antispam"
|
||||||
"github.com/icedream/go-footballdata"
|
"git.icedream.tech/icedream/soccer-bot/manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -96,7 +97,6 @@ func main() {
|
||||||
m := manager.NewManager()
|
m := manager.NewManager()
|
||||||
|
|
||||||
// IRC
|
// IRC
|
||||||
//conn := m.AntifloodIrcConn(irc.IRC(nickname, ident))
|
|
||||||
conn := irc.IRC(nickname, ident)
|
conn := irc.IRC(nickname, ident)
|
||||||
conn.Debug = debug
|
conn.Debug = debug
|
||||||
conn.VerboseCallbackHandler = conn.Debug
|
conn.VerboseCallbackHandler = conn.Debug
|
||||||
|
@ -109,6 +109,9 @@ func main() {
|
||||||
conn.PingFreq = pingFreq
|
conn.PingFreq = pingFreq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antispam
|
||||||
|
antispamInstance := antispam.New()
|
||||||
|
|
||||||
updateTopics := func() {
|
updateTopics := func() {
|
||||||
// Get football data
|
// Get football data
|
||||||
if r, err := footballData.FixturesOfSoccerSeason(Competition_Testing).Do(); err != nil {
|
if r, err := footballData.FixturesOfSoccerSeason(Competition_Testing).Do(); err != nil {
|
||||||
|
@ -388,7 +391,7 @@ func main() {
|
||||||
})
|
})
|
||||||
conn.AddCallback("PRIVMSG", func(e *irc.Event) {
|
conn.AddCallback("PRIVMSG", func(e *irc.Event) {
|
||||||
go func(event *irc.Event) {
|
go func(event *irc.Event) {
|
||||||
//sender := event.Nick
|
sender := event.Nick
|
||||||
target := event.Arguments[0]
|
target := event.Arguments[0]
|
||||||
isChannel := true
|
isChannel := true
|
||||||
if strings.EqualFold(target, conn.GetNick()) {
|
if strings.EqualFold(target, conn.GetNick()) {
|
||||||
|
@ -402,13 +405,23 @@ func main() {
|
||||||
log.Printf("BUG - Emergency switch, caught message from bot to bot: %s", event.Arguments)
|
log.Printf("BUG - Emergency switch, caught message from bot to bot: %s", event.Arguments)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := stripIrcFormatting(event.Message())
|
|
||||||
|
|
||||||
|
msg := stripIrcFormatting(event.Message())
|
||||||
log.Printf("<%s @ %s> %s", event.Nick, target, msg)
|
log.Printf("<%s @ %s> %s", event.Nick, target, msg)
|
||||||
|
|
||||||
|
cmd := parseCommand(msg)
|
||||||
|
log.Printf("Antispam check: %s, %s", event.Source, cmd.Name)
|
||||||
|
if ok, duration := antispamInstance.Check(event.Source, cmd.Name); !ok {
|
||||||
|
conn.Noticef(sender, "Sorry, you need to wait %s before you can use this command again!", duration)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case !isChannel && strings.HasPrefix(msg, "updatetopics"):
|
|
||||||
|
case !isChannel && strings.EqualFold(cmd.Name, "updatetopics"):
|
||||||
updateTopicsChan <- nil
|
updateTopicsChan <- nil
|
||||||
case strings.HasPrefix(msg, "!table"):
|
|
||||||
|
case strings.EqualFold(cmd.Name, "!table"):
|
||||||
if leagueTable, err := footballData.LeagueTableOfCompetition(Competition_Testing).Do(); err != nil {
|
if leagueTable, err := footballData.LeagueTableOfCompetition(Competition_Testing).Do(); err != nil {
|
||||||
if s, err := tplString("error", err); err != nil {
|
if s, err := tplString("error", err); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
|
@ -461,7 +474,7 @@ func main() {
|
||||||
conn.Privmsg(target, line)
|
conn.Privmsg(target, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case strings.HasPrefix(msg, "!match"):
|
case strings.EqualFold(cmd.Name, "!match"):
|
||||||
/*if r, err := footballData.FixturesOfSoccerSeason(SoccerSeason_EuropeanChampionShipsFrance2016).TimeFrame(1 * day).Do(); err != nil {
|
/*if r, err := footballData.FixturesOfSoccerSeason(SoccerSeason_EuropeanChampionShipsFrance2016).TimeFrame(1 * day).Do(); err != nil {
|
||||||
if s, err := tplString("error", err); err != nil {
|
if s, err := tplString("error", err); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
|
|
|
@ -1,86 +0,0 @@
|
||||||
package manager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha512"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/thoj/go-ircevent"
|
|
||||||
|
|
||||||
cache "github.com/patrickmn/go-cache"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *Manager) initAntiflood() {
|
|
||||||
m.cache = cache.New(1*time.Minute, 5*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) TrackOutput(target, t string) (shouldNotSend bool) {
|
|
||||||
key := normalizeTextAntiflood(target, t)
|
|
||||||
|
|
||||||
if _, ok := m.cache.Get(key); ok {
|
|
||||||
// The URL has been used recently, should ignore
|
|
||||||
shouldNotSend = true
|
|
||||||
} else {
|
|
||||||
m.cache.Add(key, nil, cache.DefaultExpiration)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) AntifloodIrcConn(c *irc.Connection) *ircConnectionProxy {
|
|
||||||
return &ircConnectionProxy{Connection: c, m: m}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeTextAntiflood(target, text string) string {
|
|
||||||
s := sha512.New()
|
|
||||||
s.Write([]byte(text))
|
|
||||||
return fmt.Sprintf("TEXT/%s/%X", strings.ToUpper(target), s.Sum([]byte{}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxies several methods of the IRC connection in order to drop repeated messages
|
|
||||||
type ircConnectionProxy struct {
|
|
||||||
*irc.Connection
|
|
||||||
|
|
||||||
m *Manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxy *ircConnectionProxy) Action(target, message string) {
|
|
||||||
if proxy.m.TrackOutput(target, message) {
|
|
||||||
log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.Connection.Action(target, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxy *ircConnectionProxy) Actionf(target, format string, a ...interface{}) {
|
|
||||||
proxy.Action(target, fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxy *ircConnectionProxy) Privmsg(target, message string) {
|
|
||||||
if proxy.m.TrackOutput(target, message) {
|
|
||||||
log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.Connection.Privmsg(target, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxy *ircConnectionProxy) Privmsgf(target, format string, a ...interface{}) {
|
|
||||||
proxy.Privmsg(target, fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxy *ircConnectionProxy) Notice(target, message string) {
|
|
||||||
if proxy.m.TrackOutput(target, message) {
|
|
||||||
log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.Connection.Notice(target, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxy *ircConnectionProxy) Noticef(target, format string, a ...interface{}) {
|
|
||||||
proxy.Notice(target, fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
|
@ -2,14 +2,9 @@ package manager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/patrickmn/go-cache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
// antiflood variables
|
|
||||||
cache *cache.Cache
|
|
||||||
|
|
||||||
// topic variables
|
// topic variables
|
||||||
topicStateLock sync.RWMutex
|
topicStateLock sync.RWMutex
|
||||||
topicMap map[string]string
|
topicMap map[string]string
|
||||||
|
@ -17,7 +12,6 @@ type Manager struct {
|
||||||
|
|
||||||
func NewManager() *Manager {
|
func NewManager() *Manager {
|
||||||
m := new(Manager)
|
m := new(Manager)
|
||||||
m.initAntiflood()
|
|
||||||
m.initTopic()
|
m.initTopic()
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue