Skip to content

Commit 3e95fbf

Browse files
committed
feat: add quote command, update yap leaderboard to use timezone
1 parent 375648a commit 3e95fbf

File tree

9 files changed

+244
-16
lines changed

9 files changed

+244
-16
lines changed

Makefile

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build run clean test deps help docker-build
1+
.PHONY: build run clean test deps help docker-build pull push
22

33
# Detect OS and arch
44
UNAME_S := $(shell uname -s)
@@ -49,13 +49,18 @@ docker-build: ## Build using Docker for cross-compilation to Ubuntu
4949
docker build --platform linux/amd64 -t ash .
5050
docker run --rm -v $(PWD):/host ash cp /usr/local/bin/ash /host/ash-linux-amd64
5151

52-
# Pull configuration
52+
# Sync configuration
5353
RSYNC_OPTS ?= -avzhP --delete
54-
PULL_SRC ?= ark:ash/data/
54+
REMOTE_DATA ?= ark:ash/data/
5555

56-
pull: ## Pull the remote 'data' directory into local ./data/ (overwrites). Set PULL_SRC to change source.
56+
pull: ## Pull remote db into local ./data/ (stops remote bot first)
57+
ssh ark sudo systemctl stop ash.service
5758
@mkdir -p data
58-
rsync $(RSYNC_OPTS) $(PULL_SRC) ./data/
59+
rsync $(RSYNC_OPTS) $(REMOTE_DATA) ./data/
60+
61+
push: ## Push local ./data/ to remote (restarts remote bot after)
62+
rsync $(RSYNC_OPTS) ./data/ $(REMOTE_DATA)
63+
ssh ark sudo systemctl restart ash.service
5964

6065
.DEFAULT_GOAL := run
6166

bot.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@
138138
"command": "knockknock",
139139
"input_type": "text",
140140
"output_type": "text"
141+
},
142+
"quote": {
143+
"type": "builtin",
144+
"command": "quote",
145+
"input_type": "text",
146+
"output_type": "text"
141147
}
142148
}
143149
}

bot/bot.go

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"sync"
1313
"time"
1414

15+
"github.com/polarhive/ash/util"
16+
1517
"maunium.net/go/mautrix"
1618
"maunium.net/go/mautrix/event"
1719
"maunium.net/go/mautrix/id"
@@ -166,7 +168,17 @@ func (s *KnockKnockState) Delete(evID id.EventID) {
166168
// Yap leaderboard
167169
// ---------------------------------------------------------------------------
168170

169-
// QueryTopYappers returns the top N message senders in the last 24h for the
171+
// YapTimezone is the timezone used to determine "start of day" for the yap
172+
// leaderboard. Defaults to UTC. Set via config.json "TIMEZONE" field.
173+
var YapTimezone = time.UTC
174+
175+
// startOfToday returns midnight in the configured YapTimezone as Unix millis.
176+
func startOfToday() int64 {
177+
now := time.Now().In(YapTimezone)
178+
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, YapTimezone).UnixMilli()
179+
}
180+
181+
// QueryTopYappers returns the top N message senders since midnight for the
170182
// current room, excluding messages that start with the bot label (e.g. [BOT]).
171183
func QueryTopYappers(ctx context.Context, db *sql.DB, matrixClient *mautrix.Client, ev *event.Event, args string, replyLabel string, mention bool) (string, error) {
172184
if db == nil {
@@ -190,7 +202,7 @@ func QueryTopYappers(ctx context.Context, db *sql.DB, matrixClient *mautrix.Clie
190202
}
191203

192204
roomID := string(ev.RoomID)
193-
cutoff := time.Now().Add(-24 * time.Hour).UnixMilli()
205+
cutoff := startOfToday()
194206

195207
rows, err := db.QueryContext(ctx, `
196208
SELECT sender, SUM(LENGTH(body) - LENGTH(REPLACE(body, ' ', '')) + 1) as word_count
@@ -245,13 +257,13 @@ func QueryTopYappers(ctx context.Context, db *sql.DB, matrixClient *mautrix.Clie
245257
}
246258

247259
if len(entries) == 0 {
248-
return "no messages found in the last 24h", nil
260+
return "no messages found today", nil
249261
}
250262

251263
// Build plain text and HTML versions.
252264
var plain, html strings.Builder
253-
plain.WriteString(replyLabel + "top yappers (last 24h):\n")
254-
html.WriteString(replyLabel + "top yappers (last 24h):<br>")
265+
plain.WriteString(replyLabel + "top yappers (today):\n")
266+
html.WriteString(replyLabel + "top yappers (today):<br>")
255267
for i, e := range entries {
256268
plain.WriteString(fmt.Sprintf("%d. %s \u2014 %d words\n", i+1, e.display, e.count))
257269
if mention {
@@ -281,7 +293,7 @@ func QueryTopYappers(ctx context.Context, db *sql.DB, matrixClient *mautrix.Clie
281293
}
282294

283295
// queryYapGuess handles "/bot yap guess N". It looks up the caller's actual
284-
// position on the 24h word-count leaderboard and reports the difference.
296+
// position on today's (since midnight UTC) word-count leaderboard and reports the difference.
285297
func queryYapGuess(ctx context.Context, db *sql.DB, matrixClient *mautrix.Client, ev *event.Event, guessArg string, replyLabel string) (string, error) {
286298
guess := 1
287299
if guessArg != "" {
@@ -292,7 +304,7 @@ func queryYapGuess(ctx context.Context, db *sql.DB, matrixClient *mautrix.Client
292304

293305
roomID := string(ev.RoomID)
294306
senderID := string(ev.Sender)
295-
cutoff := time.Now().Add(-24 * time.Hour).UnixMilli()
307+
cutoff := startOfToday()
296308

297309
rows, err := db.QueryContext(ctx, `
298310
SELECT sender, SUM(LENGTH(body) - LENGTH(REPLACE(body, ' ', '')) + 1) as word_count
@@ -327,7 +339,7 @@ func queryYapGuess(ctx context.Context, db *sql.DB, matrixClient *mautrix.Client
327339
}
328340

329341
if actualPos == 0 {
330-
return replyLabel + "you have no messages in the last 24h!", nil
342+
return "you have no messages today!", nil
331343
}
332344

333345
diff := guess - actualPos
@@ -360,6 +372,82 @@ func queryYapGuess(ctx context.Context, db *sql.DB, matrixClient *mautrix.Client
360372
return msg, nil
361373
}
362374

375+
// ---------------------------------------------------------------------------
376+
// Random quote
377+
// ---------------------------------------------------------------------------
378+
379+
// QueryRandomQuote picks a random message from the room's history (excluding
380+
// bot messages and commands) and formats it as a quote.
381+
func QueryRandomQuote(ctx context.Context, db *sql.DB, matrixClient *mautrix.Client, ev *event.Event, args string, replyLabel string, mention bool) (string, error) {
382+
if db == nil {
383+
return "", fmt.Errorf("no database available")
384+
}
385+
386+
roomID := string(ev.RoomID)
387+
388+
// Parse duration argument (default 24h)
389+
durSec, err := util.ParseDurationArg(args)
390+
if err != nil {
391+
durSec = 24 * 3600 // fallback to 24h
392+
}
393+
cutoff := time.Now().Unix() - durSec
394+
395+
row := db.QueryRowContext(ctx, `
396+
SELECT sender, body, ts_ms
397+
FROM messages
398+
WHERE room_id = ?
399+
AND body NOT LIKE '[BOT]%'
400+
AND body NOT LIKE '/bot %'
401+
AND msgtype = 'm.text'
402+
AND LENGTH(body) > 5
403+
AND ts_ms >= ? * 1000
404+
ORDER BY RANDOM()
405+
LIMIT 1
406+
`, roomID, cutoff)
407+
408+
var sender, body string
409+
var tsMs int64
410+
if err := row.Scan(&sender, &body, &tsMs); err != nil {
411+
return "no messages found to quote", nil
412+
}
413+
414+
// Resolve display name.
415+
display := sender
416+
if matrixClient != nil {
417+
if resp, err := matrixClient.JoinedMembers(ctx, ev.RoomID); err == nil {
418+
if member, ok := resp.Joined[id.UserID(sender)]; ok && member.DisplayName != "" {
419+
display = member.DisplayName
420+
}
421+
}
422+
}
423+
if display == sender && strings.HasPrefix(sender, "@") {
424+
if idx := strings.Index(sender, ":"); idx > 0 {
425+
display = sender[1:idx]
426+
}
427+
}
428+
429+
ts := time.UnixMilli(tsMs).In(YapTimezone)
430+
date := ts.Format("02 Jan 2006")
431+
432+
plain := fmt.Sprintf("%s> %s\n> \u2014 %s, %s", replyLabel, body, display, date)
433+
html := fmt.Sprintf("%s<blockquote>%s<br>\u2014 <i>%s, %s</i></blockquote>", replyLabel, body, display, date)
434+
435+
if matrixClient != nil {
436+
content := event.MessageEventContent{
437+
MsgType: event.MsgText,
438+
Body: plain,
439+
Format: event.FormatHTML,
440+
FormattedBody: html,
441+
RelatesTo: &event.RelatesTo{InReplyTo: &event.InReplyTo{EventID: ev.ID}},
442+
}
443+
if _, err := matrixClient.SendMessageEvent(ctx, ev.RoomID, event.EventMessage, &content); err != nil {
444+
return "", fmt.Errorf("send quote reply: %w", err)
445+
}
446+
return "", nil
447+
}
448+
return plain, nil
449+
}
450+
363451
// ---------------------------------------------------------------------------
364452
// UwUify
365453
// ---------------------------------------------------------------------------

bot/bot_test.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func TestQueryTopYappers(t *testing.T) {
131131
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
132132
"bot-2", room, "@bot:example.com", now, "/bot help", "m.text")
133133

134-
// Old message — should be excluded (>24h ago).
134+
// Old message — should be excluded (before today UTC).
135135
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
136136
"old-1", room, "@old:example.com", now-100000000, "ancient msg", "m.text")
137137

@@ -270,3 +270,83 @@ func TestQueryYapGuess(t *testing.T) {
270270
t.Errorf("expected no messages for unknown sender, got: %s", result)
271271
}
272272
}
273+
274+
func TestQueryRandomQuote(t *testing.T) {
275+
db, err := sql.Open("sqlite3", ":memory:")
276+
if err != nil {
277+
t.Fatalf("open db: %v", err)
278+
}
279+
defer db.Close()
280+
281+
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS messages (
282+
id TEXT PRIMARY KEY,
283+
room_id TEXT,
284+
sender TEXT,
285+
ts_ms INTEGER,
286+
body TEXT,
287+
msgtype TEXT,
288+
raw_json TEXT
289+
)`)
290+
if err != nil {
291+
t.Fatalf("create table: %v", err)
292+
}
293+
294+
room := "!testroom:example.com"
295+
ev := &event.Event{RoomID: id.RoomID(room)}
296+
ctx := context.Background()
297+
298+
// Empty room — should return "no messages found".
299+
result, err := QueryRandomQuote(ctx, db, nil, ev, "", "", false)
300+
if err != nil {
301+
t.Fatalf("QueryRandomQuote empty: %v", err)
302+
}
303+
if !strings.Contains(result, "no messages") {
304+
t.Errorf("expected 'no messages' for empty room, got: %s", result)
305+
}
306+
307+
// Insert messages: one recent, one old.
308+
now := time.Now().UnixMilli()
309+
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
310+
"msg-1", room, "@alice:example.com", now, "the quick brown fox jumps", "m.text")
311+
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
312+
"msg-2", room, "@bob:example.com", now-3*86400000, "hello world from 3 days ago", "m.text")
313+
314+
// Should return only recent message for 1d.
315+
result, err = QueryRandomQuote(ctx, db, nil, ev, "1d", "", false)
316+
if err != nil {
317+
t.Fatalf("QueryRandomQuote 1d: %v", err)
318+
}
319+
if !strings.Contains(result, "fox jumps") {
320+
t.Errorf("expected recent quote, got: %s", result)
321+
}
322+
if strings.Contains(result, "3 days ago") {
323+
t.Errorf("should not quote old message, got: %s", result)
324+
}
325+
326+
// Should return either for 1w.
327+
result, err = QueryRandomQuote(ctx, db, nil, ev, "1w", "", false)
328+
if err != nil {
329+
t.Fatalf("QueryRandomQuote 1w: %v", err)
330+
}
331+
if !strings.Contains(result, "fox jumps") && !strings.Contains(result, "3 days ago") {
332+
t.Errorf("expected any quote, got: %s", result)
333+
}
334+
335+
// Bot messages should be excluded.
336+
_, _ = db.Exec(`INSERT INTO messages(id, room_id, sender, ts_ms, body, msgtype) VALUES (?, ?, ?, ?, ?, ?)`,
337+
"bot-1", room, "@bot:example.com", now, "[BOT] I am a bot message", "m.text")
338+
339+
result, err = QueryRandomQuote(ctx, db, nil, ev, "1d", "", false)
340+
if err != nil {
341+
t.Fatalf("QueryRandomQuote bot: %v", err)
342+
}
343+
if strings.Contains(result, "[BOT]") {
344+
t.Errorf("bot messages should be excluded, got: %s", result)
345+
}
346+
if !strings.Contains(result, "> ") || !strings.Contains(result, "\u2014") {
347+
t.Errorf("expected blockquote format, got: %s", result)
348+
}
349+
if !strings.Contains(result, "alice") && !strings.Contains(result, "bob") {
350+
t.Errorf("expected alice or bob in quote, got: %s", result)
351+
}
352+
}

bot/commands.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,8 @@ var builtinFuncs = map[string]func(string) string{
331331

332332
// builtinDBFuncs maps builtin command names that need DB access.
333333
var builtinDBFuncs = map[string]func(context.Context, *sql.DB, *mautrix.Client, *event.Event, string, string, bool) (string, error){
334-
"yap": QueryTopYappers,
334+
"yap": QueryTopYappers,
335+
"quote": QueryRandomQuote,
335336
}
336337

337338
// ---------------------------------------------------------------------------

cmd/ash/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"sync"
1010
"syscall"
11+
"time"
1112

1213
"github.com/rs/zerolog"
1314
"github.com/rs/zerolog/log"
@@ -93,6 +94,16 @@ func run(ctx context.Context, metaDB *sql.DB, messagesDB *sql.DB, cfg *config.Co
9394
log.Info().Str("path", botCfgPath).Msg("loaded bot config")
9495
}
9596

97+
// Set yap leaderboard timezone from config (defaults to UTC).
98+
if cfg.Timezone != "" {
99+
if tz, err := time.LoadLocation(cfg.Timezone); err != nil {
100+
log.Warn().Err(err).Str("tz", cfg.Timezone).Msg("invalid TIMEZONE in config, using UTC")
101+
} else {
102+
bot.YapTimezone = tz
103+
log.Info().Str("tz", cfg.Timezone).Msg("yap leaderboard timezone set")
104+
}
105+
}
106+
96107
readyChan := make(chan bool)
97108
var once sync.Once
98109
syncer.OnSync(func(_ context.Context, _ *mautrix.RespSync, _ string) bool {

config.json.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@
2424
"BOT_CONFIG_PATH": "./bot.json",
2525
"DEBUG": true,
2626
"DRY_RUN": false,
27-
"OPT_OUT_TAG": "#private"
27+
"OPT_OUT_TAG": "#private",
28+
"TIMEZONE": "Asia/Kolkata"
2829
}

config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Config struct {
3636
DryRun bool `json:"DRY_RUN"`
3737
DeviceName string `json:"MATRIX_DEVICE_NAME"`
3838
OptOutTag string `json:"OPT_OUT_TAG"`
39+
Timezone string `json:"TIMEZONE,omitempty"`
3940
}
4041

4142
// LoadConfig reads and parses the config.json file.

util/util.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,38 @@ func FormatPosts(posts []interface{}, linkstashURL string) string {
9696
sb.WriteString(fmt.Sprintf("\nSee full list: %s", linkstashURL))
9797
return sb.String()
9898
}
99+
100+
// ParseDurationArg parses duration strings like '1d', '2d', '1w', '1m', '24h' into seconds.
101+
func ParseDurationArg(arg string) (int64, error) {
102+
arg = strings.TrimSpace(arg)
103+
if arg == "" {
104+
return 24 * 3600, nil // default 24h
105+
}
106+
var n int64
107+
var unit string
108+
if _, err := fmt.Sscanf(arg, "%dd", &n); err == nil {
109+
return n * 86400, nil
110+
}
111+
if _, err := fmt.Sscanf(arg, "%dw", &n); err == nil {
112+
return n * 7 * 86400, nil
113+
}
114+
if _, err := fmt.Sscanf(arg, "%dm", &n); err == nil {
115+
return n * 30 * 86400, nil
116+
}
117+
if _, err := fmt.Sscanf(arg, "%dh", &n); err == nil {
118+
return n * 3600, nil
119+
}
120+
if _, err := fmt.Sscanf(arg, "%d%s", &n, &unit); err == nil {
121+
switch unit {
122+
case "d":
123+
return n * 86400, nil
124+
case "w":
125+
return n * 7 * 86400, nil
126+
case "m":
127+
return n * 30 * 86400, nil
128+
case "h":
129+
return n * 3600, nil
130+
}
131+
}
132+
return 0, fmt.Errorf("invalid duration: %s", arg)
133+
}

0 commit comments

Comments
 (0)