Skip to content

Commit

Permalink
ui chat (#237)
Browse files Browse the repository at this point in the history
* ui chat

* working 2-way chat with and without beerchat mod

* chat page

* partial command support

* ux

* update readme

---------

Co-authored-by: BuckarooBanzay <[email protected]>
  • Loading branch information
BuckarooBanzay and BuckarooBanzay authored Oct 2, 2023
1 parent ab42634 commit 820faa2
Show file tree
Hide file tree
Showing 25 changed files with 432 additions and 39 deletions.
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,21 @@ Minetest web ui
# Features

* Account/Password management
* Remote console (chat, lua)
* Remote console (chat, lua-injection)
* Chat console
* World status
* Skin management
* Mail management
* XBan management
* Mediaserver (remote_media)
* OAuth provider
* Player event logging
* Event logging
* Mod/game/texturepack configuration and updates (cdb, git)
* Engine management (via Docker)
* File browser
* Minetest config management

Planned:
* mod/game/texturepack configuration and updates (cdb, git)
* Engine management (via Docker)
* Mapserver management (via Docker)
* Matterbridge management (via Docker)

Expand Down
37 changes: 37 additions & 0 deletions db/chat_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package db

import (
"mtui/types"
"time"

"github.com/google/uuid"
"github.com/minetest-go/dbutil"
)

type ChatLogRepository struct {
dbu *dbutil.DBUtil[*types.ChatLog]
}

func (r *ChatLogRepository) Insert(l *types.ChatLog) error {
if l.ID == "" {
l.ID = uuid.NewString()
}

if l.Timestamp == 0 {
l.Timestamp = time.Now().UnixMilli()
}

return r.dbu.Insert(l)
}

func (r *ChatLogRepository) Search(channel string, from, to int64) ([]*types.ChatLog, error) {
return r.dbu.SelectMulti("where channel = %s and timestamp > %s and timestamp < %s order by timestamp asc limit 1000", channel, from, to)
}

func (r *ChatLogRepository) GetLatest(channel string, limit int) ([]*types.ChatLog, error) {
return r.dbu.SelectMulti("where channel = %s order by timestamp asc limit %s", channel, limit)
}

func (r *ChatLogRepository) DeleteBefore(timestamp int64) error {
return r.dbu.Delete("where timestamp < %s", timestamp)
}
40 changes: 40 additions & 0 deletions db/chat_log_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package db_test

import (
"mtui/db"
"mtui/types"
"testing"

"github.com/stretchr/testify/assert"
)

func TestChatLogRepo(t *testing.T) {
_db := setupDB(t)
repo := db.NewRepositories(_db).ChatLogRepo

assert.NoError(t, repo.Insert(&types.ChatLog{Timestamp: 100, Channel: "main", Name: "player1", Message: "msg1"}))
assert.NoError(t, repo.Insert(&types.ChatLog{Timestamp: 200, Channel: "main", Name: "player2", Message: "msg2"}))
assert.NoError(t, repo.Insert(&types.ChatLog{Timestamp: 150, Channel: "other_chan", Name: "player1", Message: "msg1"}))

list, err := repo.GetLatest("main", 100)
assert.NoError(t, err)
assert.Equal(t, 2, len(list))
assert.Equal(t, "msg1", list[0].Message)
assert.Equal(t, "player1", list[0].Name)
assert.Equal(t, "msg2", list[1].Message)
assert.Equal(t, "player2", list[1].Name)

list, err = repo.Search("main", 99, 199)
assert.NoError(t, err)
assert.Equal(t, 1, len(list))
assert.Equal(t, "msg1", list[0].Message)
assert.Equal(t, "player1", list[0].Name)

assert.NoError(t, repo.DeleteBefore(199))

list, err = repo.GetLatest("main", 100)
assert.NoError(t, err)
assert.Equal(t, 1, len(list))
assert.Equal(t, "msg2", list[0].Message)
assert.Equal(t, "player2", list[0].Name)
}
10 changes: 10 additions & 0 deletions db/migrations/09_chatlog.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

create table chat_log(
id text(36) primary key not null, -- uuid
timestamp integer not null, -- unix milliseconds
channel text(32) not null, -- main
name text(64) not null, -- from-playername
message text(512) not null -- message
);

create index chat_log_index on chat_log(timestamp, channel);
2 changes: 2 additions & 0 deletions db/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Repositories struct {
ConfigRepo *ConfigRepository
FeatureRepository *FeatureRepository
LogRepository *LogRepository
ChatLogRepo *ChatLogRepository
MetricTypeRepository *MetricTypeRepository
MetricRepository *MetricRepository
OauthAppRepo *OauthAppRepository
Expand All @@ -22,6 +23,7 @@ func NewRepositories(db dbutil.DBTx) *Repositories {
ConfigRepo: &ConfigRepository{dbu: dbutil.New[*types.ConfigEntry](db, dbutil.DialectSQLite, func() *types.ConfigEntry { return &types.ConfigEntry{} })},
FeatureRepository: &FeatureRepository{dbu: dbutil.New[*types.Feature](db, dbutil.DialectSQLite, func() *types.Feature { return &types.Feature{} })},
LogRepository: &LogRepository{db: db, dbu: dbutil.New[*types.Log](db, dbutil.DialectSQLite, func() *types.Log { return &types.Log{} })},
ChatLogRepo: &ChatLogRepository{dbu: dbutil.New[*types.ChatLog](db, dbutil.DialectSQLite, func() *types.ChatLog { return &types.ChatLog{} })},
MetricTypeRepository: &MetricTypeRepository{dbu: dbutil.New[*types.MetricType](db, dbutil.DialectSQLite, func() *types.MetricType { return &types.MetricType{} })},
MetricRepository: &MetricRepository{dbu: dbutil.New[*types.Metric](db, dbutil.DialectSQLite, func() *types.Metric { return &types.Metric{} })},
OauthAppRepo: &OauthAppRepository{dbu: dbutil.New[*types.OauthApp](db, dbutil.DialectSQLite, func() *types.OauthApp { return &types.OauthApp{} })},
Expand Down
34 changes: 29 additions & 5 deletions events/chat.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
package events

import "mtui/eventbus"

const (
// DM
DirectChatMessageEvent eventbus.EventType = "direct_chat"
import (
"encoding/json"
"fmt"
"mtui/app"
"mtui/bridge"
"mtui/types"
"mtui/types/command"
)

func chatLoop(a *app.App, ch chan *bridge.CommandResponse) {
for cmd := range ch {
msg := &command.ChatMessage{}
err := json.Unmarshal(cmd.Data, msg)
if err != nil {
fmt.Printf("Chat notification payload error: %s\n", err.Error())
continue
}

err = a.Repos.ChatLogRepo.Insert(&types.ChatLog{
Channel: msg.Channel,
Name: msg.Name,
Message: msg.Message,
})
if err != nil {
fmt.Printf("Chat insert error: %s\n", err.Error())
continue
}

}
}
1 change: 1 addition & 0 deletions events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ func Setup(app *app.App) error {
go metricLoop(app, app.Bridge.AddHandler(command.COMMAND_METRICS))
go statsLoop(app.WSEvents, app.Bridge.AddHandler(command.COMMAND_STATS))
go logLoop(app, app.Bridge.AddHandler(command.COMMAND_LOG))
go chatLoop(app, app.Bridge.AddHandler(command.COMMAND_CHAT_NOTIFICATION))

return nil
}
4 changes: 2 additions & 2 deletions events/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ func logLoop(a *app.App, ch chan *bridge.CommandResponse) {
err := json.Unmarshal(cmd.Data, log)
if err != nil {
fmt.Printf("Payload error: %s\n", err.Error())
return
continue
}

a.GeoipResolver.ResolveLogGeoIP(log, nil)
err = a.Repos.LogRepository.Insert(log)
if err != nil {
fmt.Printf("DB error: %s\n", err.Error())
return
continue
}
}
}
2 changes: 1 addition & 1 deletion events/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func statsLoop(e *eventbus.EventBus, ch chan *bridge.CommandResponse) {
err := json.Unmarshal(cmd.Data, stats)
if err != nil {
fmt.Printf("Payload error: %s\n", err.Error())
return
continue
}

e.Emit(&eventbus.Event{
Expand Down
22 changes: 22 additions & 0 deletions jobs/chatlog_cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package jobs

import (
"fmt"
"mtui/app"
"time"
)

func chatlogCleanup(a *app.App) {
for {
if !a.MaintenanceMode.Load() {
ts := time.Now().AddDate(0, 0, -30)
err := a.Repos.ChatLogRepo.DeleteBefore(ts.UnixMilli())
if err != nil {
fmt.Printf("ChatLog cleanup error: %s\n", err.Error())
}
}

// re-schedule
time.Sleep(time.Second * 10)
}
}
1 change: 1 addition & 0 deletions jobs/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "mtui/app"

func Start(a *app.App) {
go logCleanup(a)
go chatlogCleanup(a)
go metricCleanup(a)
go mediaScan(a)
go modAutoUpdate(a)
Expand Down
6 changes: 3 additions & 3 deletions minetest.conf
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = mtui_dev
max_users = 13
server_name = mtui-xxx
mtui.url = http://ui:8080
mtui.key = mykey
server_announce = true
server_announce = false
secure.http_mods = mtui
name = mtui_dev
max_users = 13
9 changes: 9 additions & 0 deletions public/js/api/chat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const get_latest_chat_messages = channel => fetch(`api/chat/${channel}/latest`).then(r => r.json());

export const search_messages = (channel, from, to) => fetch(`api/chat/${channel}/${from}/${to}`).then(r => r.json());

export const send_message = msg => fetch("api/chat", {
method: "POST",
body: JSON.stringify(msg)
})
.then(r => r.json());
5 changes: 5 additions & 0 deletions public/js/components/NavBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export default {
<i class="fa-solid fa-terminal"></i> Shell
</router-link>
</li>
<li class="nav-item" v-if="has_priv('shout') && !maintenance">
<router-link to="/chat" class="nav-link">
<i class="fa-solid fa-comment"></i> Chat
</router-link>
</li>
<li class="nav-item">
<router-link to="/online-players" class="nav-link" v-if="!maintenance">
<i class="fa fa-users"></i> Online players
Expand Down
126 changes: 126 additions & 0 deletions public/js/components/pages/Chat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import DefaultLayout from "../layouts/DefaultLayout.js";

import { START } from "../Breadcrumb.js";
import { search_messages, send_message } from "../../api/chat.js";
import { execute_chatcommand } from "../../api/chatcommand.js";
import { get_claims } from "../../service/login.js";
import format_time from "../../util/format_time.js";

export default {
components: {
"default-layout": DefaultLayout
},
data: function() {
return {
history: [],
msg: "",
handle: null,
channel: "main",
from_timestamp: Date.now() - (3600*1000*24*7), //7 days back or 1000 messages
breadcrumb: [START, {
name: "Chat",
icon: "comment",
link: ""
}]
};
},
mounted: function() {
this.update();
this.handle = setInterval(() => this.update(), 1000);
},
unmounted: function() {
clearInterval(this.handle);
},
methods: {
format_time,
send: function() {
if (this.is_command) {
// send command
execute_chatcommand(get_claims().username, this.msg.substring(1))
.then(result => {
this.scroll();
this.history.push({
timestamp: +Date.now(),
name: "",
message: result.message,
success: result.success
});
this.msg = "";
});

} else {
// send message
send_message({
channel: this.channel,
message: this.msg
})
.then(() => {
this.update();
this.msg = "";
});
}
},
update: function() {
const later = Date.now() + (3600*1000);
search_messages(this.channel, this.from_timestamp, later)
.then(msgs => {
msgs.forEach(msg => {
if (msg.timestamp > this.from_timestamp) {
this.from_timestamp = msg.timestamp;
}
this.scroll();
this.history.push(msg);
});
});
},
scroll: function() {
const el = this.$refs.container;
if (el.scrollTop == el.scrollTopMax) {
// at the bottom, scroll further
setTimeout(() => el.scrollTop = el.scrollTopMax, 10);
}
}
},
computed: {
is_command: function() {
return this.msg.length > 0 && this.msg[0] == '/';
}
},
template: /*html*/`
<default-layout title="Chat" icon="comment" :breadcrumb="breadcrumb">
<div ref="container" style="height: 600px; overflow: scroll;">
<div v-for="msg in history" :key="msg.id"
v-bind:class="{'bg-success':msg.success==true, 'bg-warning':msg.success==false}"
style="display: flex;">
<div class="text-muted" style="width: 200px; flex: 0 0 auto;">
{{format_time(msg.timestamp/1000)}}
</div>
<div style="width: 200px; flex: 0 0 auto;">
<router-link :to="'/profile/' + msg.name" v-if="msg.name != ''">
{{msg.name}}
</router-link>
<span v-else>
{{msg.name}}
</span>
</div>
<div style="flex: 1 1 auto;">
{{msg.message}}
</div>
</div>
</div>
<form @submit.prevent="send" class="row">
<div class="input-group">
<input type="text" placeholder="Message" v-model="msg" class="form-control"/>
<button class="btn btn-success" type="submit" v-if="!is_command" :disabled="msg == ''">
<i class="fa-solid fa-paper-plane"></i>
Send
</button>
<button class="btn btn-warning" type="submit" v-if="is_command">
<i class="fa-solid fa-terminal"></i>
Execute command
</button>
</div>
</form>
</default-layout>
`
};
Loading

0 comments on commit 820faa2

Please sign in to comment.